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.
This commit is contained in:
+99
-14
@@ -36,12 +36,22 @@ 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";
|
||||
import { PracticeDock } from "@/components/practice-dock";
|
||||
import { ThinkingCard, type PracticeThinkingData } from "@/components/practice-cards";
|
||||
import { TopBar } from "@/components/top-bar";
|
||||
import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TurnUsage } from "@/src/core";
|
||||
import type { LhViewMessage } from "@/src/core";
|
||||
import { PhaseChip, PhaseDivider } from "@/components/phase-divider";
|
||||
import { CoverageRing } from "@/components/extraction-coverage";
|
||||
import { dismissedAnswer, findPendingToolCall, phaseAt3, phaseTimeline, toolQuestionKind, quizNeedsReveal } from "@/src/core";
|
||||
import {
|
||||
activeProblemOf,
|
||||
dismissedAnswer,
|
||||
findPendingToolCall,
|
||||
phaseAt3,
|
||||
phaseTimeline,
|
||||
toolQuestionKind,
|
||||
quizNeedsReveal,
|
||||
} from "@/src/core";
|
||||
import { ReadingThinkingField } from "@/components/reading-thinking";
|
||||
import { slotsConfigured, type ModelsSettings } from "@/src/core";
|
||||
import { useSettingsSaved } from "@/components/settings-signal";
|
||||
@@ -190,7 +200,7 @@ export function Chat({
|
||||
// (core/view.ts projectView); sends are ONE explicit input event each
|
||||
// ({chatId, input} — the stash indirection died with the SDK), and the
|
||||
// turn stream is the VERBATIM AgentEvent passthrough the server emits.
|
||||
const { messages, nativeMessages, send, steer, steerQueue, submitAnswer, dismissQuestion, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } =
|
||||
const { messages, nativeMessages, send, steer, steerQueue, submitAnswer, dismissQuestion, dismissProblem, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } =
|
||||
useChatSession({ chatId, initialMessages, initiallyInFlight });
|
||||
|
||||
// The reattach settled terminal → the sync-and-continue seam (below).
|
||||
@@ -321,9 +331,11 @@ export function Chat({
|
||||
planOpen: planOpenRef.current,
|
||||
setupOpen: setupOpenRef.current,
|
||||
pending: pendingRef.current !== null,
|
||||
problem: activeProblemRef.current !== null,
|
||||
busy: busyRef.current,
|
||||
});
|
||||
if (target === "question") setAbortAsk("question");
|
||||
else if (target === "problem") setAbortAsk("problem");
|
||||
else if (target === "turn") setAbortAsk("turn");
|
||||
else if (target === "mermaid") setMermaidFocus(null);
|
||||
else if (target === "plan") setPlanOpen(false);
|
||||
@@ -344,14 +356,21 @@ export function Chat({
|
||||
// SILENT route — no turn, no wake, nothing generates. The session
|
||||
// pauses at the user's choice; the next plain send is the wake.
|
||||
const p = pendingRef.current;
|
||||
if (!p) return;
|
||||
const question = (p.input as { question?: string } | null)?.question ?? "";
|
||||
const reading = readingThinkingRef.current.trim() || undefined;
|
||||
clearReadingThinking();
|
||||
const answer = dismissedAnswer(toolQuestionKind(p.tool), question);
|
||||
void dismissQuestion(p.toolCallId, reading ? { ...answer, readingThinking: reading } : answer);
|
||||
if (p) {
|
||||
const question = (p.input as { question?: string } | null)?.question ?? "";
|
||||
const reading = readingThinkingRef.current.trim() || undefined;
|
||||
clearReadingThinking();
|
||||
const answer = dismissedAnswer(toolQuestionKind(p.tool), question);
|
||||
void dismissQuestion(p.toolCallId, reading ? { ...answer, readingThinking: reading } : answer);
|
||||
return;
|
||||
}
|
||||
// The practice discard (owner, 2026-10-04): ask-style — the
|
||||
// silent marker route; the conclude synthesis rides the user's
|
||||
// next input. The composer returns immediately.
|
||||
const problem = activeProblemRef.current;
|
||||
if (problem) void dismissProblem(problem.toolCallId);
|
||||
},
|
||||
[abortInFlightTurn, dismissQuestion],
|
||||
[abortInFlightTurn, dismissQuestion, dismissProblem],
|
||||
);
|
||||
// Mount continuation: if the stored transcript ends with a fulfilled tool
|
||||
// (a job result landed while no client was open), resume the lesson — the
|
||||
@@ -375,6 +394,15 @@ export function Chat({
|
||||
// listener is mount-scoped; the value it needs is render-scoped).
|
||||
const pendingRef = useRef(pending);
|
||||
pendingRef.current = pending;
|
||||
|
||||
// THE ACTIVE PRACTICE PROBLEM (practice sessions, 2026-10-04): the
|
||||
// same dock-ownership rule as a pending question — an active problem
|
||||
// replaces the composer. Computed from the NATIVE record (the fold is
|
||||
// the gate the tools themselves read), not the projected view.
|
||||
const activeProblem = activeProblemOf(nativeMessages);
|
||||
const activeProblemRef = useRef(activeProblem);
|
||||
activeProblemRef.current = activeProblem;
|
||||
|
||||
const localStream = status === "submitted" || status === "streaming";
|
||||
const busy = localStream || turnInFlight;
|
||||
const busyRef = useRef(busy);
|
||||
@@ -403,6 +431,20 @@ export function Chat({
|
||||
void resumeStream();
|
||||
}, [resumeStream]);
|
||||
|
||||
// The practice dock's two lanes (owner, 2026-10-04): both POST one
|
||||
// thinking input against the ACTIVE problem — commit=false is the
|
||||
// nudge (hint request), commit=true the answer commit. Each starts a
|
||||
// turn; the dock stays mounted (the problem is still active until the
|
||||
// agent concludes).
|
||||
const submitThinking = useCallback(
|
||||
(commit: boolean, text: string) => {
|
||||
const problem = activeProblemRef.current;
|
||||
if (!problem) return;
|
||||
void send({ type: "thinking", problemCallId: problem.toolCallId, commit, text });
|
||||
},
|
||||
[send],
|
||||
);
|
||||
|
||||
// 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
|
||||
@@ -553,7 +595,7 @@ export function Chat({
|
||||
// a pending question still owns the interaction (the dock replaces the
|
||||
// composer, and there is no live turn to steer anyway — questions end
|
||||
// turns).
|
||||
const canSend = (text.trim().length > 0 || dictation.active) && !pending;
|
||||
const canSend = (text.trim().length > 0 || dictation.active) && !pending && !activeProblem;
|
||||
|
||||
const syncFromServer = useCallback(async () => {
|
||||
const res = await fetch(`/api/chats/${chatId}`);
|
||||
@@ -1194,6 +1236,13 @@ export function Chat({
|
||||
);
|
||||
case "step-start":
|
||||
return pi > 0 ? <Separator key={pi} className="my-3" /> : null;
|
||||
case "data-practice-thinking":
|
||||
// The learner's practice submission (2026-10-04):
|
||||
// user-side — the register (thinking trace /
|
||||
// committed answer) is the card's title.
|
||||
return (
|
||||
<ThinkingCard key={pi} data={part.data as PracticeThinkingData} />
|
||||
);
|
||||
case "data-plan-block":
|
||||
// The plan in the chat (wave 4): a frozen snapshot
|
||||
// block — rendered as carried, never re-derived.
|
||||
@@ -1285,6 +1334,8 @@ export function Chat({
|
||||
fold) and NEVER overlaps the composer. */}
|
||||
<DockOverlay
|
||||
pending={pending}
|
||||
practice={activeProblem}
|
||||
onSubmitThinking={submitThinking}
|
||||
chatId={chatId}
|
||||
plan={hasPlan ? <PlanMarginalia messages={messages} /> : undefined}
|
||||
onOpenPlan={() => setPlanOpen(true)}
|
||||
@@ -1426,13 +1477,19 @@ export function Chat({
|
||||
* then the session overlays (mermaid paints above plan — later in DOM),
|
||||
* then setup (self-closing — the gate only must not fire), then the
|
||||
* 2026-10-02 abort chain proper (question before turn). */
|
||||
export type EscAbortTarget = "mermaid" | "plan" | "setup" | "question" | "turn" | null;
|
||||
export type EscAbortTarget = "mermaid" | "plan" | "setup" | "question" | "problem" | "turn" | null;
|
||||
export function escAbortTarget(s: {
|
||||
abortAsk: boolean;
|
||||
mermaidFocus: boolean;
|
||||
planOpen: boolean;
|
||||
setupOpen: boolean;
|
||||
pending: boolean;
|
||||
/** An active practice problem (practice sessions, 2026-10-04): Esc
|
||||
* opens the discard confirm — below a pending question (the two
|
||||
* never coexist, but the layering stays strict), above the turn
|
||||
* abort (a live turn can be running BEHIND an idle problem only in
|
||||
* teaching sessions — practice problems end their pose turns). */
|
||||
problem: boolean;
|
||||
busy: boolean;
|
||||
}): EscAbortTarget {
|
||||
if (s.abortAsk) return null; // the popup's own Esc — claimed by the dialog
|
||||
@@ -1440,6 +1497,7 @@ export function escAbortTarget(s: {
|
||||
if (s.planOpen) return "plan";
|
||||
if (s.setupOpen) return "setup";
|
||||
if (s.pending) return "question";
|
||||
if (s.problem) return "problem";
|
||||
if (s.busy) return "turn";
|
||||
return null;
|
||||
}
|
||||
@@ -1477,6 +1535,8 @@ export function shouldStampGeometryMute(nextH: number, prevH: number, pending: b
|
||||
|
||||
function DockOverlay({
|
||||
pending,
|
||||
practice,
|
||||
onSubmitThinking,
|
||||
requiresReveal,
|
||||
readingThinking,
|
||||
onReadingThinking,
|
||||
@@ -1494,6 +1554,12 @@ function DockOverlay({
|
||||
queuedSteers,
|
||||
}: {
|
||||
pending: ReturnType<typeof findPendingToolCall>;
|
||||
/** The ACTIVE practice problem (practice sessions, 2026-10-04):
|
||||
* {toolCallId, input} — dock ownership exactly like a pending
|
||||
* question (the composer is replaced); null in teaching sessions. */
|
||||
practice: { toolCallId: string; input: unknown } | null;
|
||||
/** (commit, text) — the practice dock's two submit lanes. */
|
||||
onSubmitThinking: (commit: boolean, text: string) => void;
|
||||
/** Teach-phase pending question: arrives collapsed behind the reveal button (W5). */
|
||||
requiresReveal: boolean;
|
||||
/** The reading-thinking draft (owner 2026-10-03) — owned by ChatView so
|
||||
@@ -1592,8 +1658,11 @@ function DockOverlay({
|
||||
// again → re-open. The dead band keeps trackpad drift from chattering.
|
||||
const expandedRef = useRef(expanded);
|
||||
expandedRef.current = expanded;
|
||||
const pendingRef = useRef(!!pending);
|
||||
pendingRef.current = !!pending;
|
||||
// Dock ownership (the fold machine, the reserve immunity, the arrival
|
||||
// states): a pending question OR an active practice problem — either
|
||||
// replaces the composer and owns the band.
|
||||
const pendingRef = useRef(!!pending || !!practice);
|
||||
pendingRef.current = !!pending || !!practice;
|
||||
const requiresRevealRef = useRef(requiresReveal);
|
||||
requiresRevealRef.current = requiresReveal;
|
||||
const revealedRef = useRef(revealed);
|
||||
@@ -1765,7 +1834,11 @@ function DockOverlay({
|
||||
if (b) offer(b.getBoundingClientRect().height);
|
||||
};
|
||||
|
||||
const mode = pending ? `question:${pending.toolCallId}` : "chat";
|
||||
const mode = pending
|
||||
? `question:${pending.toolCallId}`
|
||||
: practice
|
||||
? `practice:${practice.toolCallId}`
|
||||
: "chat";
|
||||
// The opening variant of a session appears instantly — the fade/rise is
|
||||
// reserved for mid-session transitions.
|
||||
const initialModeRef = useRef(mode);
|
||||
@@ -1843,6 +1916,18 @@ function DockOverlay({
|
||||
onEscape={dictationCancel}
|
||||
dictationClaim={!revealPending}
|
||||
/>
|
||||
) : practice ? (
|
||||
<PracticeDock
|
||||
problemCallId={practice.toolCallId}
|
||||
input={practice.input as { statement: string }}
|
||||
expanded={expanded}
|
||||
onExpand={() => {
|
||||
setExpanded(true);
|
||||
void suppressFoldDuringReveal();
|
||||
}}
|
||||
onSubmit={onSubmitThinking}
|
||||
onEscape={dictationCancel}
|
||||
/>
|
||||
) : (
|
||||
composer
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user