The TUI has always raised `KillConfirm` before `x` kills anything; the web sent `DELETE /api/sessions/:id` on the first press and only prompted when the daemon refused. The command bar puts both behind the same three keystrokes, so the difference now reads as a bug (#160). The web follows the TUI: a kill is destructive and unattended agents live in these sessions. `killDialog.ts` grows a confirmation that shares the force prompt's shell, names the blockers the card already knows about, and keeps the focus on Cancel. The guarded refusal still opens its own dialog behind it.
441 lines
18 KiB
TypeScript
441 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");
|
|
});
|
|
|
|
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$/);
|
|
});
|
|
});
|