"use client"; import { useEffect } from "react"; import { createPortal } from "react-dom"; /** * The ESC-abort guard (owner, 2026-10-02): the global Esc → abort chain * opens this confirm popup EVERYWHERE — mid-stream aborts and pending- * question dismissals alike. Esc on the popup itself cancels (claimed — * it must never chain into another abort), which is also why Chat's * abort handler ignores events while this dialog is open: two window * listeners fire per Esc and stopPropagation alone cannot order them. * * MINIMAL, the delete-node dialog's tone: title, one line, Cancel + * action. No busy state — both actions are local (a fetch that fires * and forgets, an answer submit whose own machinery shows progress). * * What "abort" means is mode-dependent: from a running turn it kills * the turn (partial stays); from a pending question there is no turn to * kill — abort IS dismissal (A2, 2026-10-03): the silent route writes * the skipped toolResult and wakes NOTHING; the session pauses until * the user's next plain message, which carries the dismissal to the * model in its context. */ /** What Esc is aborting — drives copy and the confirm action. */ export type AbortMode = "turn" | "question" | "problem"; const COPY: Record = { turn: { title: "Abort this turn?", line: "The partial answer stays in the transcript.", action: "Abort", }, question: { title: "Dismiss this question?", line: "Skipped — the teacher moves on without it.", action: "Dismiss", }, problem: { title: "Discard this problem?", line: "The problem is dropped — no solution is revealed.", action: "Discard", }, }; /** The dialog's pure body — exported for SSR pins (the delete-node * pattern). The wrapper owns effects + the portal. */ export function AbortTurnBody({ mode, onClose, onConfirm, busy = false, }: { mode: AbortMode; onClose: () => void; onConfirm: () => void; /** The dismiss doubles as the abort when a turn is still running * (owner, 2026-10-04): the line says so. */ busy?: boolean; }) { const copy = COPY[mode]; return (
{ if (e.target === e.currentTarget) onClose(); }} >
e.stopPropagation()} >

{copy.title}

{copy.line} {busy && mode !== "turn" ? " The working turn stops too." : ""}

); } export function AbortTurnDialog({ mode, onClose, onConfirm, busy = false, }: { /** What Esc is aborting; null = closed (nothing rendered). */ mode: AbortMode | null; onClose: () => void; onConfirm: (mode: AbortMode) => void; busy?: boolean; }) { // Esc cancels the popup (claimed — never a chained abort). useEffect(() => { if (mode === null) return; const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { // THE ONE-ESC LAW (keyboard round 2026-10-06): claim at the // document level — a window-level stopPropagation cannot stop // the earlier-registered global window listener. e.preventDefault(); e.stopPropagation(); onClose(); } }; document.addEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown); }, [mode, onClose]); if (mode === null || typeof document === "undefined") return null; return createPortal( onConfirm(mode)} busy={busy} />, document.body, ); }