From 4762029ac8144a5abf9e52efe1d4eb9e240852cd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 29 Aug 2026 20:41:03 +0000 Subject: [PATCH 1/2] fix(app): keep session status ahead of title Put the sidebar session phase slot at the leading edge so titles share one flexible column, and expose waiting, recovery, and crash states to assistive technology instead of color alone. --- .../features/projects/project-session-row.tsx | 2 +- .../session-status-indicator.test.tsx | 83 +++++++++++++++++++ .../projects/session-status-indicator.tsx | 31 ++++--- 3 files changed, 105 insertions(+), 11 deletions(-) create mode 100644 apps/app/src/features/projects/session-status-indicator.test.tsx diff --git a/apps/app/src/features/projects/project-session-row.tsx b/apps/app/src/features/projects/project-session-row.tsx index e23803360..8cb608cb4 100644 --- a/apps/app/src/features/projects/project-session-row.tsx +++ b/apps/app/src/features/projects/project-session-row.tsx @@ -29,8 +29,8 @@ export function ProjectSessionRow({ }) } > - {session.title ?? "New chat"} + {session.title ?? "New chat"} diff --git a/apps/app/src/features/projects/session-status-indicator.test.tsx b/apps/app/src/features/projects/session-status-indicator.test.tsx new file mode 100644 index 000000000..fee6d070b --- /dev/null +++ b/apps/app/src/features/projects/session-status-indicator.test.tsx @@ -0,0 +1,83 @@ +// @vitest-environment jsdom +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vitest"; + +import { SessionStatusIndicator } from "./session-status-indicator"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let root: Root | undefined; +let container: HTMLDivElement | undefined; + +const renderIndicator = (phase: Parameters[0]["phase"]) => { + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + act(() => { + root?.render(createElement(SessionStatusIndicator, { phase })); + }); + return container; +}; + +afterEach(() => { + act(() => root?.unmount()); + container?.remove(); + root = undefined; + container = undefined; +}); + +describe("SessionStatusIndicator", () => { + it("shows an accessible spinner while running", () => { + const node = renderIndicator("running"); + const slot = node.querySelector("[data-slot=session-status]"); + const loader = node.querySelector("[role=status]"); + expect(slot?.dataset.state).toBe("loading"); + expect(slot?.getAttribute("title")).toBe("A turn is running in this session"); + expect(loader).not.toBeNull(); + expect(loader?.getAttribute("aria-label")).toBe("A turn is running in this session"); + }); + + it("shows an accessible amber dot while waiting for user action", () => { + const node = renderIndicator("requires_action"); + const slot = node.querySelector("[data-slot=session-status]"); + const dot = slot?.querySelector("span"); + expect(slot?.getAttribute("role")).toBe("img"); + expect(slot?.getAttribute("aria-label")).toBe("Waiting for your action"); + expect(slot?.getAttribute("title")).toBe("Waiting for your action"); + expect(dot?.className).toContain("bg-warning"); + expect(dot?.getAttribute("aria-hidden")).toBe("true"); + }); + + it("shows an accessible amber dot when recovery is required", () => { + const node = renderIndicator("recovery_required"); + const slot = node.querySelector("[data-slot=session-status]"); + const dot = slot?.querySelector("span"); + expect(slot?.getAttribute("role")).toBe("img"); + expect(slot?.getAttribute("aria-label")).toBe("This session needs recovery"); + expect(slot?.getAttribute("title")).toBe("This session needs recovery"); + expect(dot?.className).toContain("bg-warning"); + expect(dot?.getAttribute("aria-hidden")).toBe("true"); + }); + + it("shows an accessible red dot when the session crashed", () => { + const node = renderIndicator("crashed"); + const slot = node.querySelector("[data-slot=session-status]"); + const dot = slot?.querySelector("span"); + expect(slot?.getAttribute("role")).toBe("img"); + expect(slot?.getAttribute("aria-label")).toBe("Session crashed"); + expect(slot?.getAttribute("title")).toBe("Session crashed"); + expect(dot?.className).toContain("bg-destructive"); + expect(dot?.getAttribute("aria-hidden")).toBe("true"); + }); + + it("reserves a fixed slot for idle or missing status", () => { + for (const phase of ["idle", undefined] as const) { + const node = renderIndicator(phase); + const slot = node.querySelector("span"); + expect(slot?.className).toContain("size-[1em]"); + expect(slot?.className).toContain("shrink-0"); + expect(slot?.querySelector("span")).toBeNull(); + } + }); +}); diff --git a/apps/app/src/features/projects/session-status-indicator.tsx b/apps/app/src/features/projects/session-status-indicator.tsx index 7dfb53bca..051af4f3d 100644 --- a/apps/app/src/features/projects/session-status-indicator.tsx +++ b/apps/app/src/features/projects/session-status-indicator.tsx @@ -1,9 +1,9 @@ import type { SessionPhase } from "@vibest/contract"; import { Spinner } from "@vibest/ui/components/spinner"; -const SLOT_CLASS = "ms-auto inline-flex size-3 shrink-0 items-center justify-center"; +const SLOT_CLASS = "inline-flex size-[1em] shrink-0 items-center justify-center"; -/** Server-derived session phase after the title. Idle rows omit the slot. */ +/** Server-derived session phase before the title; slot is 1em so it matches the title. */ export function SessionStatusIndicator({ phase }: { readonly phase: SessionPhase | undefined }) { switch (phase) { case "running": @@ -14,40 +14,51 @@ export function SessionStatusIndicator({ phase }: { readonly phase: SessionPhase data-state="loading" title="A turn is running in this session" > - + ); case "requires_action": return ( - + ); case "recovery_required": return ( - + ); case "crashed": return ( - - + + ); case "idle": case undefined: - return null; + return ; default: { const exhaustive: never = phase; return exhaustive; From acbab6cca5d96a05b0e8e946426fb930053e6745 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 29 Aug 2026 20:43:01 +0000 Subject: [PATCH 2/2] test(app): transform TSX in the app vitest config Session status tests render the indicator; the app runner had no JSX plugin, so add the existing React Vite plugin there. --- apps/app/vitest.config.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/app/vitest.config.ts b/apps/app/vitest.config.ts index 0d35ca79d..1392dde70 100644 --- a/apps/app/vitest.config.ts +++ b/apps/app/vitest.config.ts @@ -1,8 +1,10 @@ import url from "node:url"; +import react from "@vitejs/plugin-react"; import { defineConfig } from "vitest/config"; export default defineConfig({ + plugins: [react()], resolve: { alias: { "@": url.fileURLToPath(new URL("./src", import.meta.url)) }, },