feat(maestro): resume a dormant session from its sidebar card

Two integration-review fixes plus the card's own resume action
(#168):

- dataset.status and the aria-label read a two-way running/dead test,
  so a dormant card announced itself as "exited" while its visible
  dormant flag disagreed. Both now read the session's own three-way
  status.
- A dormant card is a muted variant (docs/SIDEBAR.md): spawn-similar
  is replaced by a resume action (button, context menu) that calls
  POST /api/sessions/:id/resume — there is nothing running to clone
  from a dormant session, but resuming it in place makes sense. Kill
  is unchanged: it already dismisses a dormant session by removing
  its sidecar.
This commit is contained in:
Miguel Palhas
2026-09-04 09:19:13 +01:00
parent 938dd62648
commit c29d6f4339
4 changed files with 109 additions and 19 deletions
+12
View File
@@ -91,6 +91,18 @@ export async function killSession(id: Uuid, force = false): Promise<void> {
); );
} }
/** 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<Session> {
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 /** 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 * merely highlighted in a sidebar. The daemon uses this to clear an
* `AwaitingInput` glow once the pane has been looked at long enough * `AwaitingInput` glow once the pane has been looked at long enough
+38
View File
@@ -427,6 +427,44 @@ describe("session cards (#107)", () => {
expect(line2?.textContent).not.toContain("exited"); 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<HTMLButtonElement>(".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", () => { it("carries the state word only in tooltips and aria labels, never as text", () => {
const s = session({ const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000019", id: "aaaaaaaa-0000-0000-0000-000000000019",
+49 -15
View File
@@ -1,5 +1,5 @@
import { escapeHtml } from "./html.js"; 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 { openForceKillDialog, openKillConfirmDialog } from "./killDialog.js";
import { openContextMenu } from "./contextMenu.js"; import { openContextMenu } from "./contextMenu.js";
import { toggleHelp } from "./help.js"; import { toggleHelp } from "./help.js";
@@ -104,6 +104,21 @@ const STATE_WORDS: Record<string, string> = {
error: "Error", 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, /** 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 * full height. The row's own padding (`renderNode`) shifts outward with
* nesting depth, carrying the rail with it. */ * 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.style.paddingLeft = `${depth * 16 + 8}px`;
row.dataset.sessionId = s.id; row.dataset.sessionId = s.id;
row.dataset.navKey = `session:${s.id}`; row.dataset.navKey = `session:${s.id}`;
const running = s.status === "Running"; row.dataset.status = statusSlug(s.status);
row.dataset.status = running ? "running" : "dead"; // 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 name = sessionName(s);
const slug = activityColorSlug(s.activity ?? "Idle"); const slug = activityColorSlug(s.activity ?? "Idle");
const stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle; const stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle;
row.setAttribute( row.setAttribute("aria-label", `${name}, ${statusWord(s.status)}, ${stateWord}`);
"aria-label", const cloneOrResumeAction = dormant
`${name}, ${running ? "running" : "exited"}, ${stateWord}`, ? `<button class="leaf-act leaf-resume" type="button" title="Resume session" aria-label="Resume ${escapeHtml(name)}">↻</button>`
); : `<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${escapeHtml(name)}">⧉</button>`;
// docs/SIDEBAR.md: line 1 reserves action width, and line 2 is unconditional. // docs/SIDEBAR.md: line 1 reserves action width, and line 2 is unconditional.
row.innerHTML = ` row.innerHTML = `
${activityRailMarkup(slug, stateWord)} ${activityRailMarkup(slug, stateWord)}
<span class="card-line1"> <span class="card-line1">
<span class="leaf-name${isProvisionalName(s) ? " is-provisional" : ""}">${escapeHtml(name)}</span> <span class="leaf-name${isProvisionalName(s) ? " is-provisional" : ""}">${escapeHtml(name)}</span>
<span class="leaf-actions"> <span class="leaf-actions">
<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${escapeHtml(name)}">⧉</button> ${cloneOrResumeAction}
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button> <button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
</span> </span>
</span> </span>
${metaLine(s, now)} ${metaLine(s, now)}
`; `;
const similarBtn = row.querySelector(".leaf-clone") as HTMLButtonElement; const similarBtn = row.querySelector<HTMLButtonElement>(".leaf-clone");
similarBtn.addEventListener("click", (ev) => { similarBtn?.addEventListener("click", (ev) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur })); document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur }));
}); });
const resumeBtn = row.querySelector<HTMLButtonElement>(".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; const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
killBtn.addEventListener("click", (ev) => { killBtn.addEventListener("click", (ev) => {
ev.preventDefault(); ev.preventDefault();
@@ -244,7 +272,15 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
ev.stopPropagation(); ev.stopPropagation();
openContextMenu(ev.clientX, ev.clientY, [ openContextMenu(ev.clientX, ev.clientY, [
{ label: "Rename…", onSelect: () => beginRename(row, cur) }, { label: "Rename…", onSelect: () => beginRename(row, cur) },
{ cur.status === "Dormant"
? {
label: "Resume",
onSelect: () =>
void resumeSession(cur.id).catch((e: Error) =>
alert(`Failed to resume: ${e.message}`),
),
}
: {
label: "Spawn similar", label: "Spawn similar",
onSelect: () => onSelect: () =>
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur })), document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur })),
@@ -260,10 +296,8 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
cur = next; cur = next;
const nextSlug = activityColorSlug(next.activity ?? "Idle"); const nextSlug = activityColorSlug(next.activity ?? "Idle");
const nextWord = STATE_WORDS[nextSlug] ?? STATE_WORDS.idle; const nextWord = STATE_WORDS[nextSlug] ?? STATE_WORDS.idle;
row.setAttribute( row.dataset.status = statusSlug(next.status);
"aria-label", row.setAttribute("aria-label", `${sessionName(next)}, ${statusWord(next.status)}, ${nextWord}`);
`${sessionName(next)}, ${next.status === "Running" ? "running" : "exited"}, ${nextWord}`,
);
const rail = row.querySelector(".activity-rail"); const rail = row.querySelector(".activity-rail");
if (rail) rail.outerHTML = activityRailMarkup(nextSlug, nextWord); if (rail) rail.outerHTML = activityRailMarkup(nextSlug, nextWord);
const meta = row.querySelector(".card-line2"); const meta = row.querySelector(".card-line2");
+6
View File
@@ -37,6 +37,12 @@ export function sessionFocus(id: Uuid): string {
return `${session(id)}/focus`; 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. */ /** Append the `path` query parameter `DIRS` and `GITROOT` accept. */
export function withPathQuery(route: string, value: string): string { export function withPathQuery(route: string, value: string): string {
return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`; return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`;