diff --git a/web/src/catalog.test.ts b/web/src/catalog.test.ts new file mode 100644 index 0000000..b502fd7 --- /dev/null +++ b/web/src/catalog.test.ts @@ -0,0 +1,106 @@ +// @vitest-environment happy-dom +import { 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")); +}); + +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}"]`); +} + +describe("blocked-work indicator (#83)", () => { + it("marks the row of a blocked session with the shared phrasing", () => { + const blocked = session({ + id: "aaaaaaaa-0000-0000-0000-000000000001", + blocked_work: { uncommitted_files: 3, unpushed_commits: 2 }, + }); + const el = renderSidebar(storeWith([blocked])).el; + const badge = rowFor(el, blocked.id)?.querySelector(".leaf-blocked"); + expect(badge?.textContent).toContain("⚠"); + expect(badge?.textContent).toContain("3 uncommitted files, 2 unpushed commits"); + }); + + 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("restates the warning at the kill confirm", async () => { + const confirmations: string[] = []; + window.confirm = (msg?: string) => { + confirmations.push(msg ?? ""); + return false; + }; + const blocked = session({ + id: "aaaaaaaa-0000-0000-0000-000000000004", + command: "bash", + blocked_work: { untracked_files: 1 }, + }); + const el = renderSidebar(storeWith([blocked])).el; + const kill = rowFor(el, blocked.id)?.querySelector(".leaf-kill"); + kill?.click(); + await Promise.resolve(); + expect(confirmations).toHaveLength(1); + expect(confirmations[0]).toContain("1 untracked file"); + expect(confirmations[0]).toContain("refused unless forced"); + }); + + it("confirms plainly when nothing blocks", async () => { + const confirmations: string[] = []; + window.confirm = (msg?: string) => { + confirmations.push(msg ?? ""); + return false; + }; + const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000005" }); + const el = renderSidebar(storeWith([plain])).el; + rowFor(el, plain.id)?.querySelector(".leaf-kill")?.click(); + await Promise.resolve(); + expect(confirmations).toEqual(['Kill session "claude"?']); + }); +}); diff --git a/web/src/catalog.ts b/web/src/catalog.ts index 3fad212..b9d4bf0 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -3,7 +3,7 @@ import { getDaemonInfo, killSession } from "./api.js"; import { HUD_TOGGLE_EVENT, isHudEnabled, toggleHud } from "./debug.js"; import { CatalogStore } from "./events.js"; import type { Session } from "./types.js"; -import { abbreviateFolderName, sessionName } from "./types.js"; +import { abbreviateFolderName, formatInTheWay, sessionBlockers, sessionName } from "./types.js"; import type { TreeNode } from "./tree.js"; import { buildTree } from "./tree.js"; import { createThemeToggle } from "./theme.js"; @@ -64,10 +64,14 @@ function renderNode(node: TreeNode, depth: number): HTMLElement { // The directory is the enclosing folder, so only surface the command — and // only when it isn't already what the name shows. const showCmd = s.command !== name; + // #83: warn on the row itself, in `formatInTheWay`'s shared phrasing, so + // "this will be refused" is visible before the kill is attempted. + const blockers = sessionBlockers(s); row.innerHTML = ` ${escapeHtml(name)} ${showCmd ? `${escapeHtml(s.command)}` : ""} ${running ? "" : `exited`} + ${blockers ? `⚠ ${escapeHtml(formatInTheWay(blockers))}` : ""} @@ -83,7 +87,12 @@ function renderNode(node: TreeNode, depth: number): HTMLElement { killBtn.addEventListener("click", async (ev) => { ev.preventDefault(); ev.stopPropagation(); - if (!confirm(`Kill session "${sessionName(s)}"?`)) return; + // #83: the confirm restates the warning at the moment of decision. + const blockers = sessionBlockers(s); + const question = blockers + ? `Kill session "${name}"? Its worktree still has ${formatInTheWay(blockers)} — the kill will be refused unless forced.` + : `Kill session "${name}"?`; + if (!confirm(question)) return; try { await killSession(s.id); } catch (e) { diff --git a/web/src/styles.css b/web/src/styles.css index 5ed77a1..065d7e1 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -831,6 +831,13 @@ textarea { flex: 0 0 auto; } +/* #83: the session row's blocked-work warning — an unforced kill will be + refused, and the operator should see that before pressing the button. */ +.leaf-badge.leaf-blocked { + color: var(--warn, #b45309); + border-color: var(--warn, #b45309); +} + .leaf-actions { position: absolute; right: var(--sp-1); diff --git a/web/src/types.ts b/web/src/types.ts index 48040cf..b036b5b 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -31,6 +31,11 @@ export interface Session { * observed it (`#{pane_current_path}`) — distinct from `cwd` (the spawn dir). * Drives the directory-based session tree. Absent until first observed. */ current_cwd?: string | null; + /** What currently blocks an unforced kill of this session (#83), as the + * daemon's watcher last probed it. Absent until probed and for sessions + * without a worktree; all counts default to 0 on the wire for older + * daemons. */ + blocked_work?: InTheWay | null; } /** One directory's immediate subdirectories (NAP-329). */ @@ -180,6 +185,19 @@ export function reasonLabel(reason: RetainedReason): string { return reason; } +/** #83: this session's blocked-work breakdown when something actually blocks + * an unforced kill; `null` while clear or not yet probed. Render it with + * {@link formatInTheWay} so every client phrases it identically. */ +export function sessionBlockers(s: Session): InTheWay | null { + const w = s.blocked_work; + if (!w) return null; + const blocked = + (w.uncommitted_files ?? 0) > 0 || + (w.untracked_files ?? 0) > 0 || + (w.unpushed_commits ?? 0) > 0; + return blocked ? w : null; +} + export interface CreateSessionRequest { cwd: string; command: string;