quick-fixes round: quiz-only teach-check reveal, ESC-abort confirm popup + question dismissal, confidence prompt diet

- requiresReveal gates on pending.tool === 'quiz' (owner: only graded quizzes
  arrive collapsed; ask/explain always open)
- ESC-abort chain opens AbortTurnDialog everywhere (owner ruling); from a
  pending question abort IS dismissal — dismissedAnswer rides the answer
  path, the teacher acknowledges the skip in its next turn; popup-Esc is
  claimed via the state gate (two window listeners fire per Esc)
- 25-confidence.md: stripped the slider/JSON/calibration mechanics — pure
  reading doctrine remains
- toolQuestionKind named once in core (dock parity), dock switched to it
This commit is contained in:
amosblomqvist
2026-10-03 01:03:29 +03:00
parent 60971ca6fe
commit 3e5b857c63
8 changed files with 271 additions and 10 deletions
+124
View File
@@ -0,0 +1,124 @@
"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 (QuestionAnswerData.dismissed), and the
* teacher acknowledges the skip in its next turn like any answer.
*/
/** What Esc is aborting — drives copy and the confirm action. */
export type AbortMode = "turn" | "question";
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",
},
};
/** 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,
);
}
+42 -8
View File
@@ -30,6 +30,7 @@ import { isProviderKeyRefusal, needsContinuation, type ChatInput, type ChatSetti
import { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block";
import { PlanMarginalia, planExists } from "@/components/plan-window";
import { MermaidOverlay, SessionMermaidContext } from "@/components/session-overlay";
import { AbortTurnDialog, type AbortMode } from "@/components/abort-turn-dialog";
import { WaypointsIcon } from "lucide-react";
import type { PlanBlockData } from "@/src/core";
import { QuestionDock } from "@/components/question-dock";
@@ -38,7 +39,7 @@ import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TurnUsag
import type { LhViewMessage } from "@/src/core";
import { PhaseChip, PhaseDivider } from "@/components/phase-divider";
import { CoverageRing } from "@/components/extraction-coverage";
import { findPendingToolCall, phaseAt3, phaseTimeline } from "@/src/core";
import { dismissedAnswer, findPendingToolCall, phaseAt3, phaseTimeline, toolQuestionKind } from "@/src/core";
import { slotsConfigured, type ModelsSettings } from "@/src/core";
import { useSettingsSaved } from "@/components/settings-signal";
@@ -298,13 +299,39 @@ export function Chat({
// 409 not_running is the benign raced-settle: the stream's finish
// already settled the view (or the poll fallback adopts) — nothing owed.
}, [chatId, stop]);
// ESC-abort guard (owner, 2026-10-02): the abort chain opens a confirm
// popup EVERYWHERE (streams and pending questions alike). Both window
// listeners fire per Esc, so the state gate here — not stopPropagation
// ordering — is what keeps the popup's own Esc from chaining.
const [abortAsk, setAbortAsk] = useState<AbortMode | null>(null);
const abortAskRef = useRef<AbortMode | null>(null);
abortAskRef.current = abortAsk;
useEffect(() => {
const onAbort = () => {
if (busyRef.current) abortInFlightTurn();
if (abortAskRef.current !== null) return; // the popup's Esc — claimed
if (pendingRef.current) setAbortAsk("question");
else if (busyRef.current) setAbortAsk("turn");
};
window.addEventListener("lh:kbd-abort-turn", onAbort);
return () => window.removeEventListener("lh:kbd-abort-turn", onAbort);
}, [abortInFlightTurn]);
}, []);
const confirmAbort = useCallback(
(mode: AbortMode) => {
setAbortAsk(null);
if (mode === "turn") {
abortInFlightTurn();
return;
}
// From a pending question there is no turn to kill — abort IS
// dismissal, riding the same answer path as any answer (the
// teacher acknowledges the skip in its next turn).
const p = pendingRef.current;
if (!p) return;
const question = (p.input as { question?: string } | null)?.question ?? "";
void submitAnswer(p.tool, p.toolCallId, dismissedAnswer(toolQuestionKind(p.tool), question));
},
[abortInFlightTurn, submitAnswer],
);
// Mount continuation: if the stored transcript ends with a fulfilled tool
// (a job result landed while no client was open), resume the lesson — the
// SDK's sendAutomaticallyWhen only fires inside addToolOutput, so reloads
@@ -323,6 +350,10 @@ export function Chat({
}, []);
const pending = findPendingToolCall(messages);
// The abort handler reads the pending question from a ref (the window
// listener is mount-scoped; the value it needs is render-scoped).
const pendingRef = useRef(pending);
pendingRef.current = pending;
const localStream = status === "submitted" || status === "streaming";
const busy = localStream || turnInFlight;
const busyRef = useRef(busy);
@@ -351,13 +382,15 @@ export function Chat({
void resumeStream();
}, [resumeStream]);
// Teach-phase check (W5, vision §2): a pending question that lands once a
// Teach-phase check (W5, vision §2): a pending QUIZ that lands once a
// plan node is OCCUPIED (phaseAt3 "teach") starts collapsed behind a
// "Check your understanding" reveal button; probe- and plan-phase
// questions auto-open as today. A pending question can never precede its
// phase's seam in the transcript (the teacher's turn ends at the question
// call), so the session's current phase IS the question's phase.
const requiresReveal = pending !== null && phaseAt3(messages) === "teach";
// questions auto-open as today. Graded quizzes only (owner, 2026-10-02)
// — ask/explain questions always arrive open. A pending question can
// never precede its phase's seam in the transcript (the teacher's turn
// ends at the question call), so the session's current phase IS the
// question's phase.
const requiresReveal = pending !== null && pending.tool === "quiz" && phaseAt3(messages) === "teach";
// The phase at every message (one linear fold): the transcript draws a
// color-coded dotted divider at each boundary; the header chip reads the
@@ -1228,6 +1261,7 @@ export function Chat({
<MermaidOverlay chart={mermaidFocus} onClose={() => setMermaidFocus(null)} />
)}
{planOpen && <PlanPopup messages={messages} onClose={() => setPlanOpen(false)} />}
<AbortTurnDialog mode={abortAsk} onClose={() => setAbortAsk(null)} onConfirm={confirmAbort} />
</div>
</div>
</div>
+2 -1
View File
@@ -16,6 +16,7 @@ import {
resolveCorrect,
seededRandom,
shuffleOptions,
toolQuestionKind,
parseAskInput,
parseQuizInput,
parseExplainInput,
@@ -208,7 +209,7 @@ function QuestionDockForm({
onSubmit,
onEscape,
}: Omit<QuestionDockProps, "expanded" | "onExpand" | "reveal">) {
const kind = toolName === "quiz" ? ("quiz" as const) : toolName === "explain" ? ("explain" as const) : ("ask" as const);
const kind = toolQuestionKind(toolName);
// Canonical QuestionData, built once per question. Quiz options are
// deterministically shuffled (seeded by toolCallId) so position never leaks