Enter now confirms the kill the operator just asked for; Escape and the scrim still cancel.
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 };
|
|
// The acting button takes the focus in both prompts: the operator already
|
|
// asked for the kill, so Enter confirms it. Escape and the scrim cancel.
|
|
confirm.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,
|
|
});
|
|
}
|