diff --git a/web/src/api.ts b/web/src/api.ts index a5b63a8..f0fe3b9 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -91,6 +91,18 @@ export async function killSession(id: Uuid, force = false): Promise { ); } +/** Respawn a dormant session under its own uuid and sidecar (#166/#168). + * Refused with `409` (`already_running` / `cwd_missing`) when there is + * nothing a respawn could fix. */ +export async function resumeSession(id: Uuid): Promise { + return jsonOrError( + await fetch(routes.sessionResume(id), { + method: "POST", + credentials: "same-origin", + }), + ); +} + /** Report whether this client's pane is the one currently showing `id` — not * merely highlighted in a sidebar. The daemon uses this to clear an * `AwaitingInput` glow once the pane has been looked at long enough diff --git a/web/src/catalog.test.ts b/web/src/catalog.test.ts index 47dde0d..fecb26e 100644 --- a/web/src/catalog.test.ts +++ b/web/src/catalog.test.ts @@ -427,6 +427,44 @@ describe("session cards (#107)", () => { expect(line2?.textContent).not.toContain("exited"); }); + // #168: a dormant card is a muted variant with a resume action in place of + // "spawn similar", since there is nothing running to clone from live state. + it("renders a dormant card muted, with a resume action instead of spawn-similar", async () => { + const calls: string[] = []; + window.fetch = ((url: string, init?: RequestInit) => { + calls.push(`${init?.method ?? "GET"} ${url}`); + return Promise.resolve( + new Response(JSON.stringify({ id: "aaaaaaaa-0000-0000-0000-000000000120", status: "Running" }), { + status: 200, + }), + ); + }) as typeof fetch; + const s = session({ id: "aaaaaaaa-0000-0000-0000-000000000120", status: "Dormant" }); + const el = renderSidebar(storeWith([s])).el; + const row = rowFor(el, s.id) as HTMLElement; + expect(row.classList.contains("is-dormant")).toBe(true); + expect(row.querySelector(".leaf-clone")).toBeNull(); + const resumeBtn = row.querySelector(".leaf-resume"); + expect(resumeBtn).toBeTruthy(); + resumeBtn?.click(); + await flush(); + expect(calls).toContain(`POST /api/sessions/${s.id}/resume`); + }); + + // #168: a two-way running/dead test made a dormant card lie to screen + // readers — the visible flag says dormant, the aria-label said "exited". + it("marks a dormant row's status and aria-label distinctly from dead", () => { + const s = session({ + id: "aaaaaaaa-0000-0000-0000-000000000119", + status: "Dormant", + }); + const el = renderSidebar(storeWith([s])).el; + const row = rowFor(el, s.id) as HTMLElement; + expect(row.dataset.status).toBe("dormant"); + expect(row.getAttribute("aria-label")).toContain("dormant"); + expect(row.getAttribute("aria-label")).not.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", diff --git a/web/src/catalog.ts b/web/src/catalog.ts index 1a4e4c7..9ce3cf7 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -1,5 +1,5 @@ import { escapeHtml } from "./html.js"; -import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js"; +import { ApiRequestError, getDaemonInfo, killSession, renameSession, resumeSession } from "./api.js"; import { openForceKillDialog, openKillConfirmDialog } from "./killDialog.js"; import { openContextMenu } from "./contextMenu.js"; import { toggleHelp } from "./help.js"; @@ -104,6 +104,21 @@ const STATE_WORDS: Record = { error: "Error", }; +// #168: dormancy is not an exit (docs/SIDEBAR.md), so `dataset.status` and +// the aria-label read the session's own three-way `status` rather than a +// two-way running/dead test that folded `Dormant` into "dead"/"exited". +function statusSlug(status: Session["status"]): string { + if (status === "Running") return "running"; + if (status === "Dormant") return "dormant"; + return "dead"; +} + +function statusWord(status: Session["status"]): string { + if (status === "Running") return "running"; + if (status === "Dormant") return "dormant"; + return "exited"; +} + /** docs/SIDEBAR.md: the activity rail is pinned to the row's own left edge, * full height. The row's own padding (`renderNode`) shifts outward with * nesting depth, carrying the rail with it. */ @@ -203,33 +218,46 @@ function renderNode(node: TreeNode, depth: number): HTMLElement { row.style.paddingLeft = `${depth * 16 + 8}px`; row.dataset.sessionId = s.id; row.dataset.navKey = `session:${s.id}`; - const running = s.status === "Running"; - row.dataset.status = running ? "running" : "dead"; + row.dataset.status = statusSlug(s.status); + // docs/SIDEBAR.md: a dormant card is a muted variant — nothing running + // behind it, so it earns no activity colour or spawn-similar affordance. + const dormant = s.status === "Dormant"; + row.classList.toggle("is-dormant", dormant); const name = sessionName(s); const slug = activityColorSlug(s.activity ?? "Idle"); const stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle; - row.setAttribute( - "aria-label", - `${name}, ${running ? "running" : "exited"}, ${stateWord}`, - ); + row.setAttribute("aria-label", `${name}, ${statusWord(s.status)}, ${stateWord}`); + const cloneOrResumeAction = dormant + ? `` + : ``; // docs/SIDEBAR.md: line 1 reserves action width, and line 2 is unconditional. row.innerHTML = ` ${activityRailMarkup(slug, stateWord)} ${escapeHtml(name)} - + ${cloneOrResumeAction} ${metaLine(s, now)} `; - const similarBtn = row.querySelector(".leaf-clone") as HTMLButtonElement; - similarBtn.addEventListener("click", (ev) => { + const similarBtn = row.querySelector(".leaf-clone"); + similarBtn?.addEventListener("click", (ev) => { ev.preventDefault(); ev.stopPropagation(); document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur })); }); + const resumeBtn = row.querySelector(".leaf-resume"); + resumeBtn?.addEventListener("click", (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + resumeBtn.disabled = true; + void resumeSession(cur.id).catch((e: Error) => { + resumeBtn.disabled = false; + alert(`Failed to resume: ${e.message}`); + }); + }); const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement; killBtn.addEventListener("click", (ev) => { ev.preventDefault(); @@ -244,11 +272,19 @@ function renderNode(node: TreeNode, depth: number): HTMLElement { ev.stopPropagation(); openContextMenu(ev.clientX, ev.clientY, [ { label: "Rename…", onSelect: () => beginRename(row, cur) }, - { - label: "Spawn similar", - onSelect: () => - document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur })), - }, + cur.status === "Dormant" + ? { + label: "Resume", + onSelect: () => + void resumeSession(cur.id).catch((e: Error) => + alert(`Failed to resume: ${e.message}`), + ), + } + : { + label: "Spawn similar", + onSelect: () => + document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur })), + }, { label: "Kill session", danger: true, @@ -260,10 +296,8 @@ function renderNode(node: TreeNode, depth: number): HTMLElement { cur = next; const nextSlug = activityColorSlug(next.activity ?? "Idle"); const nextWord = STATE_WORDS[nextSlug] ?? STATE_WORDS.idle; - row.setAttribute( - "aria-label", - `${sessionName(next)}, ${next.status === "Running" ? "running" : "exited"}, ${nextWord}`, - ); + row.dataset.status = statusSlug(next.status); + row.setAttribute("aria-label", `${sessionName(next)}, ${statusWord(next.status)}, ${nextWord}`); const rail = row.querySelector(".activity-rail"); if (rail) rail.outerHTML = activityRailMarkup(nextSlug, nextWord); const meta = row.querySelector(".card-line2"); diff --git a/web/src/routes.ts b/web/src/routes.ts index 090b388..1c3a917 100644 --- a/web/src/routes.ts +++ b/web/src/routes.ts @@ -37,6 +37,12 @@ export function sessionFocus(id: Uuid): string { return `${session(id)}/focus`; } +/** `POST /api/sessions/:id/resume` (#166/#168): respawn a dormant session + * under its own uuid and sidecar. */ +export function sessionResume(id: Uuid): string { + return `${session(id)}/resume`; +} + /** Append the `path` query parameter `DIRS` and `GITROOT` accept. */ export function withPathQuery(route: string, value: string): string { return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`;