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.
166 lines
5.9 KiB
TypeScript
166 lines
5.9 KiB
TypeScript
/**
|
|
* 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<void>;
|
|
}
|
|
|
|
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<void>;
|
|
}
|
|
|
|
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<void>;
|
|
}
|
|
|
|
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,
|
|
});
|
|
}
|