/** * The two kill prompts (#81, #160). * * A kill is destructive and unattended agents live in these sessions, so both * clients ask before one goes through — the TUI's `KillConfirm`, and * `openKillConfirmDialog` here (#160). Where the session's worktree still * holds uncommitted or unpushed work, the confirmation says so up front, the * same way the TUI's does. * * The daemon decides the rest: it refuses a kill over such a worktree, and * that refusal needs an answer the confirmation cannot give — a forced kill. * `openForceKillDialog` is that second prompt. A kill the daemon accepts never * reaches it. */ import { formatInTheWay, type InTheWay } from "./types.js"; /** What both prompts need: a target to name and a button that acts on it. */ interface KillDialogSpec { /** Session name, as the sidebar shows it. */ name: string; /** Sentence under the title. */ body: string; /** Label on the acting button. */ confirmLabel: string; /** Extra class on the acting button, so tests and styles can find it. */ confirmClass: string; /** Run it. A rejection is rendered in the dialog, which stays open so the * press can be retried. */ onConfirm: () => Promise; } export interface KillConfirmPrompt { name: string; /** What the card last saw in the way, when anything was. Shown before the * attempt, so "this will be refused" is not a surprise. */ blockers: InTheWay | null; /** Run the plain kill. Opening a further dialog from here is allowed: the * confirmation only closes itself if it is still the one on screen. */ onKill: () => Promise; } export interface ForceKillPrompt { /** Session name, as the sidebar shows it. */ name: string; /** What the daemon reported in the way, when it reported anything. */ blockers: InTheWay | null; /** Message from the daemon's refusal, shown when there is no breakdown. */ reason: string; /** Run the forced kill. A rejection is rendered in the dialog, which stays * open so the override can be retried. */ onForce: () => Promise; } let open: { el: HTMLElement; onKeydown: (ev: KeyboardEvent) => void } | null = null; export function closeForceKillDialog(): void { if (!open) return; window.removeEventListener("keydown", open.onKeydown, true); open.el.remove(); open = null; } /** Mount one of the prompts. Opening while one is up replaces it, so the * confirmation and a refusal that follows it can never stack. */ function mount(p: KillDialogSpec): HTMLElement { closeForceKillDialog(); const el = document.createElement("div"); el.className = "kill-dialog"; el.setAttribute("role", "dialog"); el.setAttribute("aria-modal", "true"); el.setAttribute("aria-label", `Kill ${p.name}`); const panel = document.createElement("div"); panel.className = "kill-dialog-panel"; const title = document.createElement("h2"); title.className = "kill-dialog-title"; title.textContent = `Kill "${p.name}"?`; const body = document.createElement("p"); body.className = "kill-dialog-body"; body.textContent = p.body; const error = document.createElement("p"); error.className = "kill-dialog-error"; error.hidden = true; const actions = document.createElement("div"); actions.className = "kill-dialog-actions"; const cancel = document.createElement("button"); cancel.type = "button"; cancel.className = "btn btn-ghost kill-dialog-cancel"; cancel.textContent = "Cancel"; cancel.addEventListener("click", closeForceKillDialog); const confirm = document.createElement("button"); confirm.type = "button"; confirm.className = `btn btn-danger ${p.confirmClass}`; confirm.textContent = p.confirmLabel; confirm.addEventListener("click", () => { confirm.disabled = true; void p .onConfirm() .then(() => { // A handler that opened the refusal prompt has already replaced this // dialog; closing here would take that one down with it. if (open?.el === el) closeForceKillDialog(); }) .catch((e: unknown) => { if (open?.el !== el) return; confirm.disabled = false; error.hidden = false; error.textContent = `Failed to kill: ${(e as Error).message}`; }); }); actions.append(cancel, confirm); panel.append(title, body, error, actions); // A press on the scrim cancels; one inside the panel is left alone. el.addEventListener("click", (ev) => { if (!panel.contains(ev.target as Node)) closeForceKillDialog(); }); el.appendChild(panel); document.body.appendChild(el); const onKeydown = (ev: KeyboardEvent): void => { if (ev.key !== "Escape") return; ev.preventDefault(); ev.stopPropagation(); closeForceKillDialog(); }; window.addEventListener("keydown", onKeydown, true); open = { el, onKeydown }; // Cancel takes the focus in both prompts: a kill is never one keystroke // away from whatever asked for the dialog. cancel.focus(); return el; } /** Ask before a kill the daemon has not seen yet (#160). */ export function openKillConfirmDialog(p: KillConfirmPrompt): HTMLElement { return mount({ name: p.name, body: p.blockers ? `Its worktree still has ${formatInTheWay(p.blockers)}. The kill will be refused unless it is forced.` : "The session and everything running in it go away. Its worktree stays on disk.", confirmLabel: "Kill", confirmClass: "kill-dialog-kill", onConfirm: p.onKill, }); } /** Offer the override after the daemon refuses a guarded kill (#81). */ export function openForceKillDialog(p: ForceKillPrompt): HTMLElement { return mount({ name: p.name, body: p.blockers ? `Its worktree still has ${formatInTheWay(p.blockers)}. Forcing the kill removes the session and leaves the worktree on disk.` : `${p.reason} Forcing the kill removes the session and leaves the worktree on disk.`, confirmLabel: "Force kill", confirmClass: "kill-dialog-force", onConfirm: p.onForce, }); }