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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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");
|
||||||
|
|||||||
@@ -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)}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user