145 lines
4.7 KiB
TypeScript
145 lines
4.7 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { useBackdropDismiss } from "@/components/backdrop-dismiss";
|
|
|
|
/**
|
|
* 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<AbortMode, { title: string; line: string; action: string }> = {
|
|
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 backdropDismiss = useBackdropDismiss(onClose);
|
|
const copy = COPY[mode];
|
|
return (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={copy.title}
|
|
data-testid="abort-turn-dialog"
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm"
|
|
// A genuine click on the scrim cancels — the cautious default, like Esc.
|
|
{...backdropDismiss}
|
|
>
|
|
<div
|
|
className="mx-4 w-full max-w-sm rounded-xl border bg-card p-5 shadow-lg"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<h2 className="text-sm font-semibold text-card-foreground" data-testid="abort-turn-title">
|
|
{copy.title}
|
|
</h2>
|
|
<p className="mt-1.5 text-xs text-muted-foreground" data-testid="abort-turn-line">
|
|
{copy.line}
|
|
{busy && mode !== "turn" ? " The working turn stops too." : ""}
|
|
</p>
|
|
<div className="mt-4 flex justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
data-testid="abort-turn-cancel"
|
|
onClick={onClose}
|
|
className="rounded-sm px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
data-testid="abort-turn-confirm"
|
|
onClick={onConfirm}
|
|
// Destructive red — the app's established destructive pair.
|
|
className="rounded-sm border border-destructive/50 bg-destructive/10 px-3 py-1.5 text-xs text-destructive transition-colors hover:bg-destructive/20"
|
|
>
|
|
{copy.action}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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(
|
|
<AbortTurnBody mode={mode} onClose={onClose} onConfirm={() => onConfirm(mode)} busy={busy} />,
|
|
document.body,
|
|
);
|
|
}
|