Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/app/src/features/projects/project-session-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ export function ProjectSessionRow({
})
}
>
<span className="truncate">{session.title ?? "New chat"}</span>
<SessionStatusIndicator phase={session.status?.phase} />
<span className="min-w-0 flex-1 truncate">{session.title ?? "New chat"}</span>
</SidebarMenuButton>
<SessionActionsMenu isActive={isActive} session={session} />
</SidebarMenuItem>
Expand Down
83 changes: 83 additions & 0 deletions apps/app/src/features/projects/session-status-indicator.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof SessionStatusIndicator>[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<HTMLSpanElement>("[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<HTMLSpanElement>("[data-slot=session-status]");
const dot = slot?.querySelector<HTMLSpanElement>("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<HTMLSpanElement>("[data-slot=session-status]");
const dot = slot?.querySelector<HTMLSpanElement>("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<HTMLSpanElement>("[data-slot=session-status]");
const dot = slot?.querySelector<HTMLSpanElement>("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();
}
});
});
31 changes: 21 additions & 10 deletions apps/app/src/features/projects/session-status-indicator.tsx
Original file line number Diff line number Diff line change
@@ -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":
Expand All @@ -14,40 +14,51 @@ export function SessionStatusIndicator({ phase }: { readonly phase: SessionPhase
data-state="loading"
title="A turn is running in this session"
>
<Spinner className="size-3" aria-label="A turn is running in this session" />
<Spinner className="size-[1em]" aria-label="A turn is running in this session" />
</span>
);
case "requires_action":
return (
<span
className={SLOT_CLASS}
aria-hidden
aria-label="Waiting for your action"
data-slot="session-status"
data-state="requires-action"
role="img"
title="Waiting for your action"
>
<span className="bg-warning size-2 rounded-full" title="Waiting for your action" />
<span className="bg-warning size-2 rounded-full" aria-hidden />
</span>
);
case "recovery_required":
return (
<span
className={SLOT_CLASS}
aria-hidden
aria-label="This session needs recovery"
data-slot="session-status"
data-state="recovery-required"
role="img"
title="This session needs recovery"
>
<span className="bg-warning size-2 rounded-full" title="This session needs recovery" />
<span className="bg-warning size-2 rounded-full" aria-hidden />
</span>
);
case "crashed":
return (
<span className={SLOT_CLASS} aria-hidden data-slot="session-status" data-state="crashed">
<span className="bg-destructive size-2 rounded-full" title="Session crashed" />
<span
className={SLOT_CLASS}
aria-label="Session crashed"
data-slot="session-status"
data-state="crashed"
role="img"
title="Session crashed"
>
<span className="bg-destructive size-2 rounded-full" aria-hidden />
</span>
);
case "idle":
case undefined:
return null;
return <span className={SLOT_CLASS} aria-hidden data-slot="session-status" />;
default: {
const exhaustive: never = phase;
return exhaustive;
Expand Down
2 changes: 2 additions & 0 deletions apps/app/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -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)) },
},
Expand Down