A host reboot kills the tmux server, so `SessionManager::recover` iterated an empty list and every session vanished from the API while its sidecar sat untouched on disk. A second recovery pass now reads the sidecars tmux had nothing to adopt and registers each as `SessionStatus::Dormant` — a record of what was running, with no pane and no PTY bridge behind it. `SessionStatus` deserializes leniently now, like `ActivityState`: an unknown spelling reads as `Dead` rather than failing the whole `Session`. Refs #162 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
454 lines
18 KiB
TypeScript
454 lines
18 KiB
TypeScript
// @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>): 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<HTMLElement>(`[data-session-id="${id}"]`);
|
|
}
|
|
|
|
/** Let the kill chain's awaits (fetch, its json body) settle. */
|
|
async function flush(): Promise<void> {
|
|
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<HTMLButtonElement>(".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<HTMLButtonElement>(".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<HTMLButtonElement>(".leaf-kill")?.click();
|
|
await flush();
|
|
document.querySelector<HTMLButtonElement>(".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<HTMLButtonElement>(".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<HTMLButtonElement>(".leaf-kill")?.click();
|
|
await flush();
|
|
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
|
await flush();
|
|
const dialog = document.querySelector(".kill-dialog");
|
|
expect(dialog?.textContent).toContain("1 untracked file");
|
|
dialog?.querySelector<HTMLButtonElement>(".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<HTMLButtonElement>(".leaf-kill")?.click();
|
|
await flush();
|
|
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
|
await flush();
|
|
document.querySelector<HTMLButtonElement>(".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<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".card-line2");
|
|
expect(line2?.textContent).toContain("exited");
|
|
});
|
|
|
|
// #162: a session recovered with no tmux behind it is known but not running,
|
|
// and never reads as something that exited.
|
|
it("shows dormant on line 2 for dormant sessions", () => {
|
|
const s = session({
|
|
id: "aaaaaaaa-0000-0000-0000-000000000118",
|
|
status: "Dormant",
|
|
});
|
|
const el = renderSidebar(storeWith([s])).el;
|
|
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
|
|
expect(line2?.textContent).toContain("dormant");
|
|
expect(line2?.textContent).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",
|
|
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<HTMLElement>(".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$/);
|
|
});
|
|
});
|