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; 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)) }, },