/** * Forced-kill prompt (#81). * * The daemon refuses to kill a session whose worktree still holds uncommitted * or unpushed work. `confirm()` can only ask the same question twice, so the * refusal gets its own dialog: it names what is in the way and offers the * override the daemon's `force=true` already implements. A kill the daemon * accepts never reaches here — there is nothing to warn about. */ import { formatInTheWay, type InTheWay } from "./types.js"; 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 the prompt. Opening while one is up replaces it, so a second refusal * can never stack two dialogs. */ export function openForceKillDialog(p: ForceKillPrompt): 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.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.`; 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 force = document.createElement("button"); force.type = "button"; force.className = "btn btn-danger kill-dialog-force"; force.textContent = "Force kill"; force.addEventListener("click", () => { force.disabled = true; void p .onForce() .then(closeForceKillDialog) .catch((e: unknown) => { force.disabled = false; error.hidden = false; error.textContent = `Failed to kill: ${(e as Error).message}`; }); }); actions.append(cancel, force); 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.focus(); return el; }