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