feat(maestro): force-kill prompt in the web client
The kill button now sends the unforced DELETE immediately instead of asking first. Only the daemon's guard refusal raises a prompt, and it is a real dialog rather than confirm(), so it can offer the override the daemon already implements. The API error envelope is decoded into ApiRequestError, keeping the code and the unsaved-work breakdown so the dialog names what is in the way.
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* 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<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 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;
|
||||
}
|
||||
Reference in New Issue
Block a user