diff --git a/web/src/barActions.test.ts b/web/src/barActions.test.ts new file mode 100644 index 0000000..0720de6 --- /dev/null +++ b/web/src/barActions.test.ts @@ -0,0 +1,131 @@ +// @vitest-environment happy-dom +// +// The sidebar's half of the command bar's action page (#151): the bar picks +// kill or rename and raises the flow the card already has, so what the page +// runs is what the ✕ button and the `r` key run. +import { afterEach, beforeAll, describe, expect, it } from "vitest"; + +import { KILL_SESSION_EVENT, RENAME_SESSION_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: () => {} }, + }); +}); + +let mounted: { el: HTMLElement; dispose: () => void } | null = null; + +afterEach(() => { + document.querySelector(".kill-dialog")?.remove(); + mounted?.dispose(); + mounted = null; + document.body.replaceChildren(); +}); + +function session(overrides: Partial = {}): Session { + return { + id: "aaaaaaaa-0000-0000-0000-0000000000ff", + cwd: "/home/naps/tea/maestro", + command: "claude", + env: {}, + metadata: { name: "amber-falcon" }, + 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, + subscribe: (fn: CatalogListener) => { + fn(state); + return () => {}; + }, + } as unknown as CatalogStore; +} + +function mount(s: Session): HTMLElement { + const handle = renderSidebar(storeWith([s])); + mounted = handle; + document.body.appendChild(handle.el); + return handle.el; +} + +function raise(name: string, s: Session): void { + document.dispatchEvent(new CustomEvent(name, { detail: s })); +} + +/** Let the kill chain's awaits (fetch, its json body) settle. */ +async function flush(): Promise { + for (let i = 0; i < 8; i++) await Promise.resolve(); +} + +describe("kill from the action page", () => { + it("goes through the daemon, without a first confirmation", 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 s = session(); + mount(s); + raise(KILL_SESSION_EVENT, s); + await flush(); + expect(calls).toContain(`DELETE /api/sessions/${s.id}`); + expect(document.querySelector(".kill-dialog")).toBeNull(); + }); + + it("raises the existing confirm-and-blockers dialog on a guarded refusal", async () => { + window.fetch = ((url: string) => { + if (String(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 s = session({ blocked_work: { untracked_files: 1 } }); + mount(s); + raise(KILL_SESSION_EVENT, s); + await flush(); + const dialog = document.querySelector(".kill-dialog"); + expect(dialog?.getAttribute("aria-label")).toBe("Kill amber-falcon"); + expect(dialog?.textContent).toContain("1 untracked file"); + }); +}); + +describe("rename from the action page", () => { + it("expands the folder hiding the card, since the prompt lives in the card", () => { + window.fetch = (() => Promise.reject(new Error("offline"))) as typeof fetch; + const s = session(); + const el = mount(s); + const folder = el.querySelector(".tree-folder-header"); + folder?.click(); + expect(el.querySelector(`[data-session-id="${s.id}"]`)).toBeNull(); + raise(RENAME_SESSION_EVENT, s); + expect(el.querySelector(".leaf-rename")?.value).toBe("amber-falcon"); + }); + + it("raises the card's own prompt, pre-filled with the session's name", () => { + window.fetch = (() => Promise.reject(new Error("offline"))) as typeof fetch; + const s = session(); + const el = mount(s); + raise(RENAME_SESSION_EVENT, s); + const input = el.querySelector(".leaf-rename"); + expect(input?.value).toBe("amber-falcon"); + expect(input?.getAttribute("aria-label")).toBe("Rename amber-falcon"); + }); +}); diff --git a/web/src/catalog.ts b/web/src/catalog.ts index d248295..55674e9 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -7,6 +7,7 @@ import { CommandSet, defineCommands } from "./commands.js"; import { CatalogStore } from "./events.js"; import type { InTheWay, Session } from "./types.js"; import { + abbreviateCwd, abbreviateFolderName, blockerStats, customSessionName, @@ -16,6 +17,7 @@ import { sessionBlockers, isProvisionalName, sessionName, + sessionTreePath, } from "./types.js"; import { splitCommand, toolIconSvg } from "./toolIcon.js"; import { @@ -50,6 +52,17 @@ export const FOCUS_SIDEBAR_EVENT = "catalog-focus-sidebar"; * ("Spawn similar"). Detail is the session being cloned. */ export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar"; +/** Ask the sidebar to kill a session — the command bar's action page picking + * `kill` (#151). Detail is the session. Handled here, so the kill goes + * through the same confirm-and-blockers flow the card's ✕ does + * (`docs/WORKTREES.md`); the bar never reimplements it. */ +export const KILL_SESSION_EVENT = "catalog-kill-session"; + +/** Ask the sidebar to rename a session — the command bar's action page picking + * `rename` (#151). Detail is the session. The prompt is the card's own inline + * field, pre-filled with the session's name (`docs/SIDEBAR.md`). */ +export const RENAME_SESSION_EVENT = "catalog-rename-session"; + /** A session row was clicked (issue #133). The row's `href` drives normal * hash navigation, but a split/drag can move focus without ever touching * `location.hash` — so a click back onto a session whose hash still @@ -745,6 +758,40 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S }; document.addEventListener("catalog-rerender", rerenderHandler); + // The command bar's action page (#151) picks an action and hands it here: + // both flows are the ones the card already has, reached by session rather + // than by row. + const killHandler = (ev: Event) => { + const s = (ev as CustomEvent).detail; + void requestKill(s, sessionName(s)); + }; + const renameHandler = (ev: Event) => { + const s = (ev as CustomEvent).detail; + const row = rowForSession(s); + if (row) beginRename(row, s); + }; + document.addEventListener(KILL_SESSION_EVENT, killHandler); + document.addEventListener(RENAME_SESSION_EVENT, renameHandler); + + /** The card a session is rendered as, expanding whatever folder hides it + * first: the rename prompt lives inside the card, so a collapsed folder + * would otherwise swallow the request. */ + function rowForSession(s: Session): HTMLElement | null { + const find = () => treeHost.querySelector(`.tree-leaf[data-session-id="${s.id}"]`); + const found = find(); + if (found) return found; + // Folder paths are the abbreviated directory's segments joined back up + // (see buildTree), so the session's own path is normalised the same way + // before it is compared against them. + const cwd = sessionTreePath(s); + const path = (cwd ? abbreviateCwd(cwd, homeDir) : "").split("/").filter(Boolean).join("/"); + for (const folder of [...collapsed]) { + if (path === folder || path.startsWith(`${folder}/`)) collapsed.delete(folder); + } + rerenderHandler(); + return find(); + } + // "Spawn similar" (#28) is dispatched here and handled in main.ts, where the // multi-view controller can seed the spawn modal with the session's dir/command. @@ -794,6 +841,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S // refusing to refresh. renamingId = null; document.removeEventListener("catalog-rerender", rerenderHandler); + document.removeEventListener(KILL_SESSION_EVENT, killHandler); + document.removeEventListener(RENAME_SESSION_EVENT, renameHandler); unsubscribe(); }, }; diff --git a/web/src/commandBar.test.ts b/web/src/commandBar.test.ts index bf4ebd4..abb8bd9 100644 --- a/web/src/commandBar.test.ts +++ b/web/src/commandBar.test.ts @@ -1,7 +1,12 @@ // @vitest-environment happy-dom import { afterEach, describe, expect, it, vi } from "vitest"; -import { SESSION_SELECT_EVENT } from "./catalog.js"; +import { + KILL_SESSION_EVENT, + RENAME_SESSION_EVENT, + SESSION_SELECT_EVENT, + SPAWN_SIMILAR_EVENT, +} from "./catalog.js"; import { barGroups, barRows, @@ -15,6 +20,7 @@ import { orderSessionsForBar, parseBarQuery, sessionBarCandidates, + sessionActionCandidates, sessionCandidate, SPAWN_DIRECTORY_EVENT, } from "./commandBar.js"; @@ -542,3 +548,176 @@ describe("the focus round-trip over a pane (#152)", () => { } }); }); + +describe("the session action page (#151)", () => { + const sessions = [ + session("api", { + metadata: { name: "api" }, + current_cwd: "/home/me/dev/api", + last_focused_at: "2026-01-01T11:00:00Z", + }), + session("worker", { + metadata: { name: "worker" }, + current_cwd: "/home/me/dev/worker", + last_focused_at: "2026-01-01T10:00:00Z", + }), + ]; + + function open(over: Partial[0]> = {}) { + return openCommandBar({ + sessions, + homeDir: "/home/me", + commands: [], + onOpenSession: () => {}, + ...over, + }); + } + + function headerText(): string | undefined { + return document.querySelector(".cmdbar-page-header")?.textContent ?? undefined; + } + + it("lists kill, rename and spawn similar for the highlighted session", () => { + open(); + press("Tab"); + expect(rowNames()).toEqual(["Kill session", "Rename session", "Spawn similar"]); + expect(activeName()).toBe("Kill session"); + }); + + it("names the session being acted on", () => { + open(); + press("ArrowDown"); + press("Tab"); + expect(headerText()).toContain("worker"); + }); + + it("drops the kind headers, since the page is one kind", () => { + open(); + press("Tab"); + expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0); + }); + + it("stays fuzzy-searchable over the three actions", () => { + open(); + press("Tab"); + type("ren"); + expect(rowNames()).toEqual(["Rename session"]); + type("spwn"); + expect(rowNames()).toEqual(["Spawn similar"]); + type("zzz"); + expect(document.querySelector(".cmdbar-empty")?.textContent).toBe("No matches"); + }); + + it("does nothing on a command row", () => { + openCommandBar({ + sessions, + homeDir: "/home/me", + commands: [{ ...COMMAND_SPECS[0], run: () => {} } as Command], + onOpenSession: () => {}, + }); + type(">"); + const before = rowNames(); + press("Tab"); + expect(rowNames()).toEqual(before); + expect(headerText()).toBeUndefined(); + }); + + it("does nothing on a directory row", async () => { + open({ loadDirectories: () => Promise.resolve(["/home/me/dev/api"]) }); + type("~"); + await Promise.resolve(); + press("Tab"); + expect(rowNames()).toEqual(["~/dev/api"]); + expect(headerText()).toBeUndefined(); + }); + + it("returns to the result list on Escape, with the previous query intact", () => { + open(); + type("wor"); + press("Tab"); + expect(headerText()).toContain("worker"); + press("Escape"); + expect(isCommandBarOpen()).toBe(true); + expect(headerText()).toBeUndefined(); + expect((document.querySelector(".cmdbar-input") as HTMLInputElement).value).toBe("wor"); + expect(rowNames()).toEqual(["worker"]); + }); + + it("returns to the result list on backspace at an empty query", () => { + open(); + type("wor"); + press("Tab"); + type(""); + press("Backspace"); + expect(headerText()).toBeUndefined(); + expect((document.querySelector(".cmdbar-input") as HTMLInputElement).value).toBe("wor"); + expect(rowNames()).toEqual(["worker"]); + }); + + it("keeps backspace ordinary while the page's query has text", () => { + open(); + press("Tab"); + type("ren"); + press("Backspace"); + expect(headerText()).toContain("api"); + expect(rowNames()).toEqual(["Rename session"]); + }); + + it("closes the bar when the page's Escape has already been used", () => { + open(); + press("Tab"); + press("Escape"); + press("Escape"); + expect(isCommandBarOpen()).toBe(false); + }); + + it("raises kill, rename and spawn similar with the session, closing the bar", () => { + const seen: string[] = []; + for (const [hook, key] of [ + ["onKillSession", "Kill session"], + ["onRenameSession", "Rename session"], + ["onSpawnSimilar", "Spawn similar"], + ] as const) { + open({ [hook]: (s: Session) => seen.push(`${hook}:${s.id}`) }); + type("wor"); + press("Tab"); + type(key); + press("Enter"); + expect(isCommandBarOpen()).toBe(false); + } + expect(seen).toEqual([ + "onKillSession:worker", + "onRenameSession:worker", + "onSpawnSimilar:worker", + ]); + }); + + it("hands each action to the flow it already has, by default", () => { + const seen: string[] = []; + const listeners: Array<[string, EventListener]> = [ + [KILL_SESSION_EVENT, (e) => seen.push(`kill:${(e as CustomEvent).detail.id}`)], + [RENAME_SESSION_EVENT, (e) => seen.push(`rename:${(e as CustomEvent).detail.id}`)], + [SPAWN_SIMILAR_EVENT, (e) => seen.push(`similar:${(e as CustomEvent).detail.id}`)], + ]; + for (const [name, fn] of listeners) document.addEventListener(name, fn); + try { + for (const action of ["Kill session", "Rename session", "Spawn similar"]) { + open(); + press("Tab"); + type(action); + press("Enter"); + } + } finally { + for (const [name, fn] of listeners) document.removeEventListener(name, fn); + } + expect(seen).toEqual(["kill:api", "rename:api", "similar:api"]); + }); + + it("offers no action beyond the three", () => { + expect(sessionActionCandidates().map((c) => c.action.id)).toEqual([ + "kill", + "rename", + "spawn-similar", + ]); + }); +}); diff --git a/web/src/commandBar.ts b/web/src/commandBar.ts index a0ad6d5..cd37d06 100644 --- a/web/src/commandBar.ts +++ b/web/src/commandBar.ts @@ -2,11 +2,16 @@ // list, opened from the sidebar, closed with Escape. // // Three kinds live here: sessions and commands, ranked together by default, -// and directories, which need the `~` sigil. The action page and the -// terminal-focus binding land in #151-#152. +// and directories, which need the `~` sigil. `tab` on a session drills into +// its action page (#151), which is the same overlay over three actions. import { listDir, projects as fetchProjects, recentDirs } from "./api.js"; -import { SESSION_SELECT_EVENT } from "./catalog.js"; +import { + KILL_SESSION_EVENT, + RENAME_SESSION_EVENT, + SESSION_SELECT_EVENT, + SPAWN_SIMILAR_EVENT, +} from "./catalog.js"; import { keyCaps, publishedCommands, type Command } from "./commands.js"; import { matchAndRank, @@ -30,7 +35,13 @@ export type CommandBarCandidate = CommandCandidate & { command: Command }; * ranks; `path` is the one a spawn is made in. */ export type DirectoryBarCandidate = DirectoryCandidate & { path: string }; -export type BarCandidate = SessionBarCandidate | CommandBarCandidate | DirectoryBarCandidate; +/** One row of a session's action page. It is a `command` to the matcher — a + * single `label` field, ranked exactly like a registry command — and carries + * the action it stands for instead of a registry entry. */ +export type ActionBarCandidate = CommandCandidate & { action: SessionAction }; + +export type BarCandidate = + SessionBarCandidate | CommandBarCandidate | ActionBarCandidate | DirectoryBarCandidate; export type BarResult = MatchResult; /** Raised when Enter lands on a directory row. The bar picks the directory; the @@ -73,6 +84,40 @@ export function parseBarQuery(raw: string): BarQuery { return { sigil: null, query: raw }; } +// ---- the session action page ---- + +/** The actions a session's action page offers, and no others: the bar picks + * one and hands off to the flow that already implements it + * (docs/COMMAND-BAR.md, "Drill-in"). */ +export type SessionActionId = "kill" | "rename" | "spawn-similar"; + +export interface SessionAction { + id: SessionActionId; + label: string; +} + +export const SESSION_ACTIONS: readonly SessionAction[] = [ + { id: "kill", label: "Kill session" }, + { id: "rename", label: "Rename session" }, + { id: "spawn-similar", label: "Spawn similar" }, +]; + +/** The action page's candidates, in the order above — which is the order an + * empty query lists them in, since the matcher keeps input order. */ +export function sessionActionCandidates(): ActionBarCandidate[] { + return SESSION_ACTIONS.map((action) => ({ + kind: "command", + action, + fields: { label: action.label }, + })); +} + +/** Narrow a `command` candidate to an action row. Both share the matcher's + * `command` kind, so what separates them is what they carry. */ +export function isActionCandidate(candidate: BarCandidate): candidate is ActionBarCandidate { + return candidate.kind === "command" && "action" in candidate; +} + // ---- candidates ---- function focusedAt(s: Session): number | null { @@ -306,6 +351,8 @@ function renderRow(result: BarResult, index: number): HTMLElement { if (candidate.kind === "session") { row.dataset.sessionId = candidate.session.id; if (candidate.session.status === "Dead") row.classList.add("is-dead"); + } else if (isActionCandidate(candidate)) { + row.dataset.actionId = candidate.action.id; } else if (candidate.kind === "command") { row.dataset.commandId = candidate.command.id; } else { @@ -322,7 +369,7 @@ function renderRow(result: BarResult, index: number): HTMLElement { // The chord is teaching material, not a match target: it is rendered from // the live registry entry and never fed to the matcher. - if (candidate.kind === "command") { + if (candidate.kind === "command" && !isActionCandidate(candidate)) { const keys = document.createElement("span"); keys.className = "cmdbar-row-keys"; for (const cap of keyCaps(candidate.command.keys[0])) { @@ -374,6 +421,13 @@ export interface CommandBarOptions { * `SPAWN_DIRECTORY_EVENT`, which the shell turns into a fast-forward * spawn. */ onSpawnDirectory?: (path: string) => void; + /** The action page's three actions (#151). Each defaults to raising the + * event the flow that already implements it listens on — the sidebar's + * confirm-and-blockers kill, its inline rename prompt, the spawn form + * seeded from the session. The bar picks the action and nothing more. */ + onKillSession?: (session: Session) => void; + onRenameSession?: (session: Session) => void; + onSpawnSimilar?: (session: Session) => void; } interface OpenBar { @@ -406,6 +460,14 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement { opts.onSpawnDirectory ?? ((path: string) => document.dispatchEvent(new CustomEvent(SPAWN_DIRECTORY_EVENT, { detail: path }))); + const sessionEvent = + (name: string) => + (session: Session): void => { + document.dispatchEvent(new CustomEvent(name, { detail: session })); + }; + const killSession = opts.onKillSession ?? sessionEvent(KILL_SESSION_EVENT); + const renameSession = opts.onRenameSession ?? sessionEvent(RENAME_SESSION_EVENT); + const spawnSimilar = opts.onSpawnSimilar ?? sessionEvent(SPAWN_SIMILAR_EVENT); const loadDirectories = opts.loadDirectories ?? loadBarDirectories; const listEntries = opts.listDirEntries ?? ((dir: string) => listDir(dir).then((listing) => listing.entries)); @@ -450,6 +512,13 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement { let rows: BarResult[] = []; let highlighted = 0; + // The action page (#151). `page` is the session being acted on, `stashed` + // the result-list query it was drilled into from — backing out restores it, + // so the round trip costs the operator nothing. + let page: Session | null = null; + let stashed = ""; + let header: HTMLElement | null = null; + // `~`'s candidates: the recents-and-projects list, fetched once, plus // whatever `/api/dirs` has filled in for the directory being typed. let directories: DirectoryBarCandidate[] = []; @@ -516,6 +585,7 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement { * list is mixed — sessions and commands together — except on an empty query, * where the bar is a session switcher. */ function candidatesFor(parsed: BarQuery): BarCandidate[] { + if (page) return sessionActionCandidates(); switch (parsed.sigil) { case ">": return [...commandCandidates]; @@ -547,9 +617,27 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement { if (rows.length === 0) input.removeAttribute("aria-activedescendant"); } + /** The line naming the session being acted on, so the target is never + * ambiguous. Present only while the action page is up. */ + function paintHeader(): void { + header?.remove(); + header = null; + if (!page) return; + header = document.createElement("div"); + header.className = "cmdbar-page-header"; + header.setAttribute("role", "presentation"); + header.textContent = `Actions for "${sessionName(page)}"`; + panel.insertBefore(header, results); + } + function render(): void { - const parsed = parseBarQuery(input.value); - input.placeholder = PLACEHOLDERS[parsed.sigil ?? "mixed"]; + // On the action page the query line is over the three actions, so there is + // no sigil to read: `>` and `~` are ordinary characters there. + const parsed = page ? { sigil: null, query: input.value } : parseBarQuery(input.value); + input.placeholder = page + ? `Act on ${sessionName(page)}…` + : PLACEHOLDERS[parsed.sigil ?? "mixed"]; + paintHeader(); rows = barRows(parsed.query, candidatesFor(parsed)); // The highlight starts on the globally best row every time the list // changes: an old index would point at a different result. @@ -567,8 +655,9 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement { let index = 0; for (const group of barGroups(rows)) { - // While a sigil is in force there is one kind, so the header is dropped. - if (parsed.sigil === null) { + // While a sigil is in force there is one kind, so the header is + // dropped — and the action page is one kind too. + if (parsed.sigil === null && !page) { const header = document.createElement("div"); header.className = "cmdbar-group-title"; header.setAttribute("role", "presentation"); @@ -593,10 +682,50 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement { paintHighlight(); } + /** `tab`: drill into the highlighted session's action page. No effect on any + * other kind, and none on the page itself, which has no session rows. */ + function drillIn(): void { + const candidate = rows[highlighted]?.candidate; + if (page || candidate?.kind !== "session") return; + page = candidate.session; + stashed = input.value; + input.value = ""; + render(); + } + + /** Escape, or backspace at an empty query: back to the result list with the + * query the drill-in was made from. */ + function leavePage(): void { + page = null; + input.value = stashed; + stashed = ""; + render(); + } + + function runAction(action: SessionAction, session: Session): void { + close(); + switch (action.id) { + case "kill": + killSession(session); + return; + case "rename": + renameSession(session); + return; + case "spawn-similar": + spawnSimilar(session); + return; + } + } + function activate(index: number): void { const result = rows[index]; if (!result) return; const candidate = result.candidate; + if (isActionCandidate(candidate)) { + // The page cannot be up without a session to act on. + if (page) runAction(candidate.action, page); + return; + } close(); switch (candidate.kind) { case "session": @@ -615,7 +744,20 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement { switch (e.key) { case "Escape": e.preventDefault(); - close(); + // On an action page Escape is one level up, not the way out. + if (page) leavePage(); + else close(); + return; + case "Tab": + e.preventDefault(); + drillIn(); + return; + case "Backspace": + // Ordinary editing, except at an empty query on the action page. + if (page && input.value === "") { + e.preventDefault(); + leavePage(); + } return; case "Enter": e.preventDefault(); diff --git a/web/src/styles.css b/web/src/styles.css index 4b293b4..238365c 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1472,6 +1472,15 @@ input.leaf-rename:focus { padding: var(--sp-2) 0; } +/* The action page's header (#151): names the session being acted on, so the + target of kill/rename/spawn-similar is never ambiguous. */ +.cmdbar-page-header { + padding: var(--sp-2) var(--sp-4); + border-bottom: 1px solid var(--border); + font-size: var(--fs-12); + color: var(--muted); +} + .cmdbar-group-title { padding: var(--sp-2) var(--sp-4) var(--sp-1); font-size: var(--fs-11);