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.
208 lines
7.4 KiB
TypeScript
208 lines
7.4 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import { beforeAll, describe, expect, it } from "vitest";
|
|
|
|
import { FOCUS_PANE_EVENT, renderSidebar } from "./catalog.js";
|
|
import type { CatalogListener, CatalogStore } from "./events.js";
|
|
import type { Session } from "./types.js";
|
|
|
|
beforeAll(() => {
|
|
Object.defineProperty(window, "localStorage", {
|
|
value: { getItem: () => null, setItem: () => {} },
|
|
});
|
|
window.fetch = () => Promise.reject(new Error("offline"));
|
|
});
|
|
|
|
function session(id: string, cwd: string): Session {
|
|
return {
|
|
id,
|
|
cwd,
|
|
command: "claude",
|
|
env: {},
|
|
metadata: {},
|
|
status: "Running",
|
|
created_at: "2026-08-24T10:00:00Z",
|
|
} as Session;
|
|
}
|
|
|
|
function storeWith(sessions: Session[]): CatalogStore {
|
|
const state = { sessions: new Map(sessions.map((s) => [s.id, s])) };
|
|
return {
|
|
snapshot: () => state,
|
|
subscribe: (fn: CatalogListener) => {
|
|
fn(state);
|
|
return () => {};
|
|
},
|
|
} as unknown as CatalogStore;
|
|
}
|
|
|
|
const A = "aaaaaaaa-0000-0000-0000-00000000000a";
|
|
const B = "aaaaaaaa-0000-0000-0000-00000000000b";
|
|
|
|
/** Two sessions in sibling directories: two folder headers, two cards. */
|
|
function twoFolders(opts: Parameters<typeof renderSidebar>[1] = {}): {
|
|
el: HTMLElement;
|
|
handle: ReturnType<typeof renderSidebar>;
|
|
} {
|
|
const handle = renderSidebar(storeWith([session(A, "/one"), session(B, "/two")]), opts);
|
|
// Mounted: `focusList` moves real DOM focus, which needs a live document.
|
|
document.body.replaceChildren(handle.el);
|
|
return { el: handle.el, handle };
|
|
}
|
|
|
|
function press(el: HTMLElement, key: string, init: KeyboardEventInit = {}): void {
|
|
el.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, ...init }));
|
|
}
|
|
|
|
function highlighted(el: HTMLElement): string | null {
|
|
return el.querySelector<HTMLElement>(".is-highlighted")?.dataset.navKey ?? null;
|
|
}
|
|
|
|
function tree(el: HTMLElement): HTMLElement {
|
|
return el.querySelector<HTMLElement>(".tree")!;
|
|
}
|
|
|
|
describe("sidebar keyboard navigation", () => {
|
|
it("walks the cards and never stops on an expanded folder", () => {
|
|
const { el } = twoFolders();
|
|
// The folders are there — the walk just refuses to land on them.
|
|
expect(el.querySelectorAll(".tree-folder-header")).toHaveLength(2);
|
|
|
|
press(tree(el), "j");
|
|
expect(highlighted(el)).toBe(`session:${A}`);
|
|
press(tree(el), "j");
|
|
expect(highlighted(el)).toBe(`session:${B}`);
|
|
// Wraps.
|
|
press(tree(el), "j");
|
|
expect(highlighted(el)).toBe(`session:${A}`);
|
|
press(tree(el), "k");
|
|
expect(highlighted(el)).toBe(`session:${B}`);
|
|
});
|
|
|
|
it("accepts the arrow keys as well as j/k", () => {
|
|
const { el } = twoFolders();
|
|
press(tree(el), "ArrowDown");
|
|
expect(highlighted(el)).toBe(`session:${A}`);
|
|
press(tree(el), "ArrowUp");
|
|
// Backwards from the first card wraps to the last.
|
|
expect(highlighted(el)).toBe(`session:${B}`);
|
|
});
|
|
|
|
it("stops on a collapsed folder, since its cards are gone", () => {
|
|
const { el } = twoFolders();
|
|
const header = el.querySelectorAll<HTMLElement>(".tree-folder-header")[0];
|
|
header.click(); // collapse /one
|
|
|
|
press(tree(el), "j");
|
|
expect(highlighted(el)).toBe("folder:one");
|
|
press(tree(el), "j");
|
|
expect(highlighted(el)).toBe(`session:${B}`);
|
|
|
|
// Enter expands it again and lands on the card it was hiding.
|
|
press(tree(el), "j");
|
|
expect(highlighted(el)).toBe("folder:one");
|
|
press(tree(el), "Enter");
|
|
expect(highlighted(el)).toBe(`session:${A}`);
|
|
});
|
|
|
|
it("focusList starts from the session on screen", () => {
|
|
const { el, handle } = twoFolders();
|
|
handle.setHighlights({ open: new Set([B]), focused: B });
|
|
handle.focusList();
|
|
expect(highlighted(el)).toBe(`session:${B}`);
|
|
expect(handle.hasListFocus()).toBe(true);
|
|
});
|
|
|
|
it("Tab asks the shell for the pane back", () => {
|
|
const { el } = twoFolders();
|
|
let asked = 0;
|
|
document.addEventListener(FOCUS_PANE_EVENT, () => (asked += 1));
|
|
press(tree(el), "Tab");
|
|
expect(asked).toBe(1);
|
|
});
|
|
|
|
it("c collapses and expands the highlighted folder", () => {
|
|
const { el } = twoFolders();
|
|
const header = el.querySelectorAll<HTMLElement>(".tree-folder-header")[0];
|
|
header.click(); // collapse /one, so the walk stops on its header
|
|
|
|
press(tree(el), "j");
|
|
expect(highlighted(el)).toBe("folder:one");
|
|
press(tree(el), "c");
|
|
// Expanded: the card it was hiding is back in the walk.
|
|
expect(el.querySelector(`[data-nav-key="session:${A}"]`)).not.toBeNull();
|
|
});
|
|
|
|
it("r renames the highlighted session in place", () => {
|
|
const { el } = twoFolders();
|
|
press(tree(el), "j");
|
|
press(tree(el), "r");
|
|
const field = el.querySelector<HTMLInputElement>(".leaf-rename");
|
|
expect(field).not.toBeNull();
|
|
// Escape settles the rename, so the module-level "a row is being renamed"
|
|
// flag doesn't leak into the next test.
|
|
field!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
|
});
|
|
|
|
it("x kills the highlighted session, once confirmed (#160)", async () => {
|
|
const { el } = twoFolders();
|
|
const killed: string[] = [];
|
|
window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
|
|
killed.push(`${init?.method ?? "GET"} ${String(input)}`);
|
|
return Promise.resolve(new Response(null, { status: 204 }));
|
|
}) as typeof window.fetch;
|
|
|
|
press(tree(el), "j");
|
|
press(tree(el), "x");
|
|
expect(killed.some((r) => r.startsWith("DELETE"))).toBe(false);
|
|
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
|
await Promise.resolve();
|
|
expect(killed.some((r) => r.startsWith("DELETE") && r.includes(A))).toBe(true);
|
|
});
|
|
|
|
it("leaves the keyboard alone while a row is being renamed", () => {
|
|
const { el } = twoFolders();
|
|
press(tree(el), "j");
|
|
const input = document.createElement("input");
|
|
tree(el).appendChild(input);
|
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "j", bubbles: true }));
|
|
expect(highlighted(el)).toBe(`session:${A}`);
|
|
});
|
|
});
|
|
|
|
describe("sidebar preview requests (docs/PREVIEW.md)", () => {
|
|
it("asks for a preview on every keyboard step, and for none once the list blurs", () => {
|
|
const seen: (string | null)[] = [];
|
|
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
|
|
|
|
press(tree(el), "j");
|
|
press(tree(el), "j");
|
|
expect(seen).toEqual([A, B]);
|
|
|
|
tree(el).dispatchEvent(new FocusEvent("blur"));
|
|
expect(seen.at(-1)).toBe(null);
|
|
});
|
|
|
|
it("asks for a preview of the hovered card, and clears it on the way out", () => {
|
|
const seen: (string | null)[] = [];
|
|
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
|
|
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
|
|
|
|
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
|
|
expect(seen).toEqual([A]);
|
|
// Moving within the same card is not a new preview.
|
|
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
|
|
expect(seen).toHaveLength(1);
|
|
|
|
tree(el).dispatchEvent(new PointerEvent("pointerleave", { pointerType: "mouse" }));
|
|
expect(seen.at(-1)).toBe(null);
|
|
});
|
|
|
|
it("ignores touch, where a tap already opens the session", () => {
|
|
const seen: (string | null)[] = [];
|
|
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
|
|
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
|
|
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "touch" }));
|
|
expect(seen).toEqual([]);
|
|
});
|
|
});
|