Files
learnharness/components/abort-turn-dialog.tsx
T
amosblomqvist 59bf5bf219 practice UI: the dock, the transcript cards, layered-Esc discard
- PracticeDock (components/practice-dock.tsx): the active problem
  replaces the composer — statement (markdown+LaTeX) above, the 'Your
  thinking' composer-twin below with the two submit lanes (Nudge =
  thinking without a commit; Commit = answer commit; ⌘/Ctrl+Enter
  commits). Dictation claims the thinking field.
- Transcript cards (practice-cards.tsx + the artifact registry): the
  problem card (compact-expandable statement — the commitment NEVER
  renders), the conclude card (outcome + verdict + reveal; the
  dismissal synthesis is one muted line), and the user-side thinking
  register cards (Your thinking / Your solution).
- view.ts: the problem pose joins the placeholder fork (the ack never
  flips the part); practice-thinking markers project as data parts;
  problem-dismissed markers project nothing.
- chat.tsx: activeProblemOf drives the dock branch (question > problem
  > composer), the esc layering gains the problem rung (below question,
  above turn) with its discard confirm + the silent marker route; the
  thinking submissions POST through the session's optimistic appends.
2026-10-04 21:55:54 +03:00

132 lines
4.1 KiB
TypeScript

"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<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,
}: {
mode: AbortMode;
onClose: () => void;
onConfirm: () => void;
}) {
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 click on the scrim cancels — the cautious default, like Esc.
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<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">{copy.line}</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,
}: {
/** What Esc is aborting; null = closed (nothing rendered). */
mode: AbortMode | null;
onClose: () => void;
onConfirm: (mode: AbortMode) => void;
}) {
// Esc cancels the popup (claimed — never a chained abort).
useEffect(() => {
if (mode === null) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
onClose();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [mode, onClose]);
if (mode === null || typeof document === "undefined") return null;
return createPortal(
<AbortTurnBody mode={mode} onClose={onClose} onConfirm={() => onConfirm(mode)} />,
document.body,
);
}