// @vitest-environment happy-dom import { afterEach, beforeAll, describe, expect, it } from "vitest"; import { renderSidebar } from "./catalog.js"; import type { CatalogListener, CatalogStore } from "./events.js"; import type { InTheWay, Session } from "./types.js"; // happy-dom runs without a backing store; renderSidebar reads the persisted // sidebar width on mount. beforeAll(() => { Object.defineProperty(window, "localStorage", { value: { getItem: () => null, setItem: () => {} }, }); // No daemon here; renderSidebar treats a failed info fetch as "absolute // paths are a fine fallback". window.fetch = () => Promise.reject(new Error("offline")); }); afterEach(() => { document.querySelector(".kill-dialog")?.remove(); }); function session(overrides: Partial): Session { return { id: "11111111-2222-3333-4444-555555555555", cwd: "/home/naps/tea/maestro", command: "claude", env: {}, metadata: {}, status: "Running", created_at: "2026-08-24T10:00:00Z", ...overrides, }; } function storeWith(sessions: Session[]): CatalogStore { const state = { sessions: new Map(sessions.map((s) => [s.id, s])) }; return { snapshot: () => state, // Like the real store, deliver an immediate snapshot so the sidebar // renders its rows during construction. subscribe: (fn: CatalogListener) => { fn(state); return () => {}; }, } as unknown as CatalogStore; } function rowFor(el: HTMLElement, id: string): HTMLElement | null { return el.querySelector(`[data-session-id="${id}"]`); } /** Let the kill chain's awaits (fetch, its json body) settle. */ async function flush(): Promise { for (let i = 0; i < 8; i++) await Promise.resolve(); } describe("blocked-work indicator (#83)", () => { it("marks the row of a blocked session in diff notation", () => { const blocked = session({ id: "aaaaaaaa-0000-0000-0000-000000000001", blocked_work: { uncommitted_files: 3, unpushed_commits: 2, insertions: 124, deletions: 18, }, }); const el = renderSidebar(storeWith([blocked])).el; const badge = rowFor(el, blocked.id)?.querySelector(".leaf-blocked"); expect(badge?.textContent).toBe("+124-18↑2"); // The spelled-out breakdown survives in the tooltip. expect(badge?.getAttribute("title")).toContain( "3 uncommitted files, 2 unpushed commits", ); }); it("falls back to the file count when tracked changes carry no lines", () => { const blocked = session({ id: "aaaaaaaa-0000-0000-0000-00000000001a", blocked_work: { uncommitted_files: 2, untracked_files: 1 }, }); const el = renderSidebar(storeWith([blocked])).el; const badge = rowFor(el, blocked.id)?.querySelector(".leaf-blocked"); expect(badge?.textContent).toBe("~2?1"); }); it("does not mark clear or unprobed sessions", () => { const clear = session({ id: "aaaaaaaa-0000-0000-0000-000000000002", blocked_work: {} as InTheWay, }); const unprobed = session({ id: "aaaaaaaa-0000-0000-0000-000000000003", }); const el = renderSidebar(storeWith([clear, unprobed])).el; expect(rowFor(el, clear.id)?.querySelector(".leaf-blocked")).toBeNull(); expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull(); }); it("confirms before an unguarded kill (#160)", async () => { const calls: string[] = []; window.fetch = ((url: string, init?: RequestInit) => { calls.push(`${init?.method ?? "GET"} ${url}`); return Promise.resolve(new Response(null, { status: 204 })); }) as typeof fetch; const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000005" }); const el = renderSidebar(storeWith([plain])).el; rowFor(el, plain.id)?.querySelector(".leaf-kill")?.click(); await flush(); const dialog = document.querySelector(".kill-dialog"); expect(dialog?.getAttribute("aria-label")).toBe("Kill claude"); expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0); dialog?.querySelector(".kill-dialog-kill")?.click(); await flush(); expect(calls).toContain(`DELETE /api/sessions/${plain.id}`); expect(document.querySelector(".kill-dialog")).toBeNull(); }); it("kills nothing when the confirmation is cancelled (#160)", async () => { const calls: string[] = []; window.fetch = ((url: string, init?: RequestInit) => { calls.push(`${init?.method ?? "GET"} ${url}`); return Promise.resolve(new Response(null, { status: 204 })); }) as typeof fetch; const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000007" }); const el = renderSidebar(storeWith([plain])).el; rowFor(el, plain.id)?.querySelector(".leaf-kill")?.click(); await flush(); document.querySelector(".kill-dialog-cancel")?.click(); await flush(); expect(document.querySelector(".kill-dialog")).toBeNull(); expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0); }); it("names the blockers in the confirmation, before the daemon refuses (#160)", async () => { window.fetch = (() => Promise.reject(new Error("never called"))) as typeof fetch; const blocked = session({ id: "aaaaaaaa-0000-0000-0000-000000000008", blocked_work: { untracked_files: 1 }, }); const el = renderSidebar(storeWith([blocked])).el; rowFor(el, blocked.id)?.querySelector(".leaf-kill")?.click(); await flush(); const dialog = document.querySelector(".kill-dialog"); expect(dialog?.textContent).toContain("1 untracked file"); expect(dialog?.textContent).toContain("refused unless it is forced"); }); it("offers the override when the daemon refuses a guarded kill", async () => { const calls: string[] = []; window.fetch = ((url: string, init?: RequestInit) => { calls.push(`${init?.method ?? "GET"} ${url}`); if (url.includes("force=true")) return Promise.resolve(new Response(null, { status: 204 })); return Promise.resolve( new Response( JSON.stringify({ error: { code: "kill_guarded", message: "worktree has unsaved work", unsaved_work: { untracked_files: 1 }, }, }), { status: 409, headers: { "content-type": "application/json" } }, ), ); }) as typeof fetch; const blocked = session({ id: "aaaaaaaa-0000-0000-0000-000000000004", command: "bash", blocked_work: { untracked_files: 1 }, }); const el = renderSidebar(storeWith([blocked])).el; rowFor(el, blocked.id)?.querySelector(".leaf-kill")?.click(); await flush(); document.querySelector(".kill-dialog-kill")?.click(); await flush(); const dialog = document.querySelector(".kill-dialog"); expect(dialog?.textContent).toContain("1 untracked file"); dialog?.querySelector(".kill-dialog-force")?.click(); await flush(); expect(calls).toContain(`DELETE /api/sessions/${blocked.id}?force=true`); expect(document.querySelector(".kill-dialog")).toBeNull(); }); it("closes the override prompt without killing on cancel", async () => { const calls: string[] = []; window.fetch = ((url: string, init?: RequestInit) => { calls.push(`${init?.method ?? "GET"} ${url}`); return Promise.resolve( new Response( JSON.stringify({ error: { code: "kill_guarded", message: "worktree has unsaved work" }, }), { status: 409, headers: { "content-type": "application/json" } }, ), ); }) as typeof fetch; const blocked = session({ id: "aaaaaaaa-0000-0000-0000-000000000006", blocked_work: { unpushed_commits: 2 }, }); const el = renderSidebar(storeWith([blocked])).el; rowFor(el, blocked.id)?.querySelector(".leaf-kill")?.click(); await flush(); document.querySelector(".kill-dialog-kill")?.click(); await flush(); document.querySelector(".kill-dialog-cancel")?.click(); expect(document.querySelector(".kill-dialog")).toBeNull(); expect(calls.filter((c) => c.includes("force=true"))).toHaveLength(0); }); }); describe("agent-reported detail (#112)", () => { const ago = (ms: number): string => new Date(Date.now() - ms).toISOString(); it("renders the reported detail right after the duration on line 2", () => { const s = session({ id: "aaaaaaaa-0000-0000-0000-000000000020", status: "Dead", activity: "Error", activity_since: ago(90_000), activity_detail: "cargo build failed", }); const el = renderSidebar(storeWith([s])).el; const line2 = rowFor(el, s.id)?.querySelector(".card-line2"); expect(line2?.querySelector(".activity-detail")?.textContent).toBe("cargo build failed"); const order = [...line2!.children].map((c) => c.className); expect(order.indexOf("activity-detail")).toBeGreaterThan(order.indexOf("activity-duration")); expect(order.indexOf("activity-detail")).toBeLessThan( order.findIndex((c) => c.startsWith("card-flag")), ); }); it("omits the detail when no report is in force", () => { const s = session({ id: "aaaaaaaa-0000-0000-0000-000000000021", activity: "Active", activity_since: ago(90_000), }); const el = renderSidebar(storeWith([s])).el; expect(rowFor(el, s.id)?.querySelector(".activity-detail")).toBeNull(); }); }); describe("session cards (#107)", () => { const ago = (ms: number): string => new Date(Date.now() - ms).toISOString(); it("keeps the name and the actions in the same flow line, actions reserving their width", () => { const s = session({ id: "aaaaaaaa-0000-0000-0000-000000000010" }); const el = renderSidebar(storeWith([s])).el; const line1 = rowFor(el, s.id)?.querySelector(".card-line1"); expect(line1).not.toBeNull(); // Same flex line ⇒ the name truncates before it can ever reach the buttons. expect(line1?.querySelector(".leaf-name")).not.toBeNull(); expect(line1?.querySelector(".leaf-actions")).toBe(line1?.lastElementChild); }); it("always renders line 2 for a freshly spawned session", () => { const s = session({ id: "aaaaaaaa-0000-0000-0000-000000000011", activity: "Active", activity_since: ago(5_000), }); const el = renderSidebar(storeWith([s])).el; const row = rowFor(el, s.id); const line2 = row?.querySelector(".card-line2"); expect(line2).not.toBeNull(); expect(row?.querySelector(".activity-rail")).not.toBeNull(); expect(line2?.querySelector(".activity-duration")).toBeNull(); expect(line2?.querySelector(".leaf-cmd")).not.toBeNull(); }); it("shows dot + floored duration once the state has been held past 30s", () => { const s = session({ id: "aaaaaaaa-0000-0000-0000-000000000012", activity: "Active", activity_since: ago(4 * 60_000 + 30_000), }); const el = renderSidebar(storeWith([s])).el; const row = rowFor(el, s.id); const line2 = row?.querySelector(".card-line2"); expect(line2?.dataset.activity).toBe("active"); expect(row?.querySelector(".activity-rail")).not.toBeNull(); expect(line2?.querySelector(".activity-duration")?.textContent).toBe("4m"); }); it("dims a name the daemon generated, until it settles (#131)", () => { const generated = session({ id: "aaaaaaaa-0000-0000-0000-0000000000a1", metadata: { name: "amber-falcon", name_source: "Generated" }, }); const haiku = session({ id: "aaaaaaaa-0000-0000-0000-0000000000a2", metadata: { name: "sidebar-rail", name_source: "Haiku" }, }); const chosen = session({ id: "aaaaaaaa-0000-0000-0000-0000000000a3", metadata: { name: "my-session", name_source: "User" }, }); // Named before `name_source` existed: settled, never dimmed. const legacy = session({ id: "aaaaaaaa-0000-0000-0000-0000000000a4", metadata: { name: "older-session" }, }); const el = renderSidebar(storeWith([generated, haiku, chosen, legacy])).el; const provisional = (s: Session) => rowFor(el, s.id)?.querySelector(".leaf-name")?.classList.contains("is-provisional"); expect(provisional(generated)).toBe(true); expect(provisional(haiku)).toBe(false); expect(provisional(chosen)).toBe(false); expect(provisional(legacy)).toBe(false); }); it("renders the command as its tool icon when the tool is known", () => { const s = session({ id: "aaaaaaaa-0000-0000-0000-00000000001b", command: "claude", metadata: { name: "claude" }, }); const el = renderSidebar(storeWith([s])).el; const cmd = rowFor(el, s.id)?.querySelector(".card-line2 .leaf-cmd"); expect(cmd?.querySelector(".tool-icon")?.getAttribute("data-tool")).toBe("claude"); expect(cmd?.getAttribute("title")).toBe("claude"); // The icon said it all: no text left over. expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull(); }); it("drops the command's arguments, keeping them in the tooltip", () => { const s = session({ id: "aaaaaaaa-0000-0000-0000-00000000001c", command: "claude --dangerously-skip-permissions", }); const el = renderSidebar(storeWith([s])).el; const cmd = rowFor(el, s.id)?.querySelector(".card-line2 .leaf-cmd"); expect(cmd?.querySelector(".tool-icon")?.getAttribute("data-tool")).toBe("claude"); expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull(); expect(cmd?.getAttribute("title")).toBe("claude --dangerously-skip-permissions"); }); it("keeps an unknown command as plain text", () => { const s = session({ id: "aaaaaaaa-0000-0000-0000-00000000001d", command: "hx src/main.rs", }); const el = renderSidebar(storeWith([s])).el; const cmd = rowFor(el, s.id)?.querySelector(".card-line2 .leaf-cmd"); expect(cmd?.querySelector(".tool-icon")).toBeNull(); expect(cmd?.querySelector(".leaf-cmd-rest")?.textContent).toBe("hx"); expect(cmd?.getAttribute("title")).toBe("hx src/main.rs"); }); it("maps each activity state to its contract slug", () => { const mk = (id: string, activity: string) => session({ id, activity: activity as Session["activity"], status: "Running", activity_since: ago(90_000), }); const idle = mk("aaaaaaaa-0000-0000-0000-000000000013", "Idle"); const wait = mk("aaaaaaaa-0000-0000-0000-000000000014", "AwaitingInput"); const error = mk("aaaaaaaa-0000-0000-0000-000000000015", "Error"); const el = renderSidebar(storeWith([idle, wait, error])).el; expect(rowFor(el, idle.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe( "idle", ); expect(rowFor(el, wait.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe( "awaiting-input", ); expect(rowFor(el, error.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe( "error", ); }); it("treats an unknown activity variant as idle instead of failing to render", () => { const s = session({ id: "aaaaaaaa-0000-0000-0000-000000000016", activity: "Teleported" as Session["activity"], activity_since: ago(120_000), }); const el = renderSidebar(storeWith([s])).el; const line2 = rowFor(el, s.id)?.querySelector(".card-line2"); expect(line2?.getAttribute("data-activity")).toBe("idle"); expect(line2?.querySelector(".activity-duration")?.textContent).toBe("2m"); }); it("moves blocked work onto line 2 as diff notation", () => { const s = session({ id: "aaaaaaaa-0000-0000-0000-000000000017", blocked_work: { uncommitted_files: 3, unpushed_commits: 2, insertions: 124, deletions: 18, }, }); const el = renderSidebar(storeWith([s])).el; const line2 = rowFor(el, s.id)?.querySelector(".card-line2"); expect(line2?.textContent).toContain("+124"); expect(line2?.textContent).toContain("-18"); // Line 1 carries no badges. expect(rowFor(el, s.id)?.querySelector(".leaf-badge")).toBeNull(); }); it("shows exited on line 2 for dead sessions", () => { const s = session({ id: "aaaaaaaa-0000-0000-0000-000000000018", status: "Dead", }); const el = renderSidebar(storeWith([s])).el; const line2 = rowFor(el, s.id)?.querySelector(".card-line2"); expect(line2?.textContent).toContain("exited"); }); it("carries the state word only in tooltips and aria labels, never as text", () => { const s = session({ id: "aaaaaaaa-0000-0000-0000-000000000019", command: "claude", metadata: { name: "worker" }, activity: "AwaitingInput", activity_since: ago(90_000), }); const el = renderSidebar(storeWith([s])).el; const row = rowFor(el, s.id) as HTMLElement; const rail = row.querySelector(".activity-rail"); expect(rail?.getAttribute("title")).toContain("Awaiting input"); expect(rail?.getAttribute("aria-label")).toContain("Awaiting input"); expect(row.textContent).not.toMatch(/awaiting.?input/i); }); it("keeps the attach navigation and depth indentation", () => { const s = session({ id: "aaaaaaaa-0000-0000-0000-00000000001a" }); const el = renderSidebar(storeWith([s])).el; const row = rowFor(el, s.id) as HTMLAnchorElement; expect(row.getAttribute("href")).toBe("#/attach/aaaaaaaa-0000-0000-0000-00000000001a"); expect(row.style.paddingLeft).toMatch(/^\d+(\.\d+)?px$/); }); });