From 59bf5bf219719b80392c2107e4f1ffb8bbd4b537 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sun, 4 Oct 2026 21:55:54 +0300 Subject: [PATCH] practice UI: the dock, the transcript cards, layered-Esc discard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- components/abort-turn-dialog.tsx | 7 +- components/artifacts/index.ts | 6 + components/chat.tsx | 113 ++++++++++++++--- components/practice-cards.tsx | 138 ++++++++++++++++++++ components/practice-dock.tsx | 164 ++++++++++++++++++++++++ components/session-core.ts | 30 ++++- components/session.ts | 5 + src/core/view.ts | 32 ++++- tests/esc-layering.test.ts | 8 +- tests/render-practice-surface.test.tsx | 166 +++++++++++++++++++++++++ 10 files changed, 650 insertions(+), 19 deletions(-) create mode 100644 components/practice-cards.tsx create mode 100644 components/practice-dock.tsx create mode 100644 tests/render-practice-surface.test.tsx diff --git a/components/abort-turn-dialog.tsx b/components/abort-turn-dialog.tsx index 64c6265..b65671b 100644 --- a/components/abort-turn-dialog.tsx +++ b/components/abort-turn-dialog.tsx @@ -24,7 +24,7 @@ import { createPortal } from "react-dom"; */ /** What Esc is aborting — drives copy and the confirm action. */ -export type AbortMode = "turn" | "question"; +export type AbortMode = "turn" | "question" | "problem"; const COPY: Record = { turn: { @@ -37,6 +37,11 @@ const COPY: Record = 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 diff --git a/components/artifacts/index.ts b/components/artifacts/index.ts index 2e22eb3..48fb80d 100644 --- a/components/artifacts/index.ts +++ b/components/artifacts/index.ts @@ -5,6 +5,7 @@ import { JobCard, PdfJobCard } from "@/components/artifacts/job-card"; import { VisualCard } from "@/components/artifacts/visual-card"; import { ProbeDoneCard } from "@/components/artifacts/probe-done-card"; import { ToolCallCard } from "@/components/artifacts/tool-call-card"; +import { ProblemCard, ConcludeCard } from "@/components/practice-cards"; /** * Artifact renderer registry — the extension point for tool-part UI. @@ -37,6 +38,11 @@ export const artifactRenderers: Record> ls: ToolCallCard, find: ToolCallCard, grep: ToolCallCard, + // The practice pair (2026-10-04): the pose renders the statement ONLY + // (the commitment lives in the call's arguments and never renders); + // the conclusion carries the outcome + verdict + the reveal. + problem: ProblemCard, + conclude: ConcludeCard, // The prober's graph tools (wave 7): same quiet expandable card — // collapsed one-liner (tool name + the session id for // list_propositions), the tool's own compact row-per-line output diff --git a/components/chat.tsx b/components/chat.tsx index e625613..cd0e596 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -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 ? : 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 ( + + ); 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. */} : 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; + /** 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 ? ( + { + setExpanded(true); + void suppressFoldDuringReveal(); + }} + onSubmit={onSubmitThinking} + onEscape={dictationCancel} + /> ) : ( composer )} diff --git a/components/practice-cards.tsx b/components/practice-cards.tsx new file mode 100644 index 0000000..fce3a3e --- /dev/null +++ b/components/practice-cards.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useState } from "react"; +import { ChevronRightIcon } from "lucide-react"; +import { MessageResponse } from "@/components/ai-elements/message"; +import type { ArtifactPartProps } from "@/src/core"; +import { cn } from "@/lib/utils"; + +/** + * The practice transcript cards (practice sessions, owner design + * 2026-10-04): three quiet records in the stream — + * + * - ProblemCard — a posed problem: the statement, compact-expandable + * (like every card here; the dock is the working surface, this is the + * record). The commitment NEVER renders — only the call's statement. + * - ConcludeCard — the conclusion: outcome badge + verdict + the reveal + * (the committed solution, shown when the problem ended). A dismissal + * synthesis (outcome:"dismissed") renders as one muted line. + * - ThinkingCard — the learner's own submission, user-side: the register + * is the two buttons' (Nudge = working trace, Commit = answer). + */ + +/** The practice-thinking marker's details (native-messages.ts). */ +export interface PracticeThinkingData { + problemCallId: string; + commit: boolean; + text: string; +} + +export function ProblemCard({ part }: ArtifactPartProps) { + const [open, setOpen] = useState(false); + const input = (part.input ?? {}) as { statement?: string }; + const statement = typeof input.statement === "string" ? input.statement : ""; + return ( +
+ + {open && ( +
+ {statement} +
+ )} +
+ ); +} + +const OUTCOME_LABEL: Record = { + solved: "Solved", + solvedWithHints: "Solved with hints", + movedOn: "Moved on", + surrendered: "Surrendered", + dismissed: "Dismissed", +}; + +export function ConcludeCard({ part }: ArtifactPartProps) { + const [open, setOpen] = useState(false); + const input = (part.input ?? {}) as { + outcome?: string; + verdict?: string; + solutionReveal?: string; + }; + const outcome = typeof input.outcome === "string" ? input.outcome : ""; + const verdict = typeof input.verdict === "string" ? input.verdict : ""; + const reveal = typeof input.solutionReveal === "string" ? input.solutionReveal : ""; + + // The dismissal synthesis: no verdict, no reveal — one muted line (the + // learner clicked; the record needs only the fact). + if (outcome === "dismissed") { + return ( +
+ Problem dismissed. +
+ ); + } + + return ( +
+ + {open && ( +
+ {verdict.length > 0 && ( +
+ {verdict} +
+ )} + {reveal.length > 0 && ( +
+
+ The solution +
+
+ {reveal} +
+
+ )} +
+ )} +
+ ); +} + +export function ThinkingCard({ data }: { data: PracticeThinkingData }) { + const [open, setOpen] = useState(true); + return ( +
+ + {open && ( +
{data.text}
+ )} +
+ ); +} diff --git a/components/practice-dock.tsx b/components/practice-dock.tsx new file mode 100644 index 0000000..9d4775f --- /dev/null +++ b/components/practice-dock.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import { ChevronRightIcon } from "lucide-react"; +import { MessageResponse } from "@/components/ai-elements/message"; +import { joinDictation, useDictationTarget } from "@/components/dictation"; +import { cn } from "@/lib/utils"; + +/** + * The practice dock (practice sessions, owner design 2026-10-04): an + * ACTIVE problem replaces the chat composer — the persistent bottom blob + * morphs into the problem. The agent's statement above; the learner's + * half below — "Your thinking", a labeled composer-twin field (the + * question dock's discipline: Enter makes a newline, it is for dumping) + * with TWO submit buttons: + * + * - **Nudge** — submit the thinking WITHOUT a committed answer: working + * trace, hint request (the ladder answers, one rung per request). + * - **Commit** — the thinking contains an arrived answer. + * + * ⌘/Ctrl+Enter commits (the universal submit chord). One problem may + * carry many submissions; each starts a turn. + * + * Collapse (scroll-up → slim bar) is owned by the DockOverlay; this + * component is content only, and the expanded form stays MOUNTED while + * collapsed (hidden) so a half-written trace survives scrolling. + * + * The commitment (modelSolution, decisionPoints, …) rides the tool call's + * arguments and is NOT rendered here — only the statement is the + * learner's. Esc dismisses the problem (the layered-Esc chain; the + * confirm dialog then the silent route) — claimed by the page, not here. + */ + +export interface PracticeDockProps { + /** The active problem's tool-call id (submissions target it). */ + problemCallId: string; + /** The pose's arguments — only the statement is the learner's (the + * mode badge would edge toward announcing the target; dropped). */ + input: { statement: string }; + /** Collapse state, owned by the DockOverlay. */ + expanded: boolean; + onExpand: () => void; + /** (commit, text) — POST {type:"thinking"}; commit=false is the nudge. */ + onSubmit: (commit: boolean, text: string) => void; + /** Escape in the thinking field stops dictation (threaded from the page). */ + onEscape?: () => void; +} + +export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmit, onEscape }: PracticeDockProps) { + const [thinking, setThinking] = useState(""); + // Ref MIRRORS the field ("nothing gets lost", the question-dock + // discipline): a submit resuming after `await finishDictation()` reads + // the tail, never a stale closure. + const thinkingRef = useRef(""); + + useDictationTarget( + useCallback( + (t: string) => { + const v = joinDictation(thinkingRef.current, t); + thinkingRef.current = v; + setThinking(v); + }, + [], + ), + true, + ); + + const submit = (commit: boolean) => { + const text = thinkingRef.current.trim(); + if (text.length === 0) return; + thinkingRef.current = ""; + setThinking(""); + onSubmit(commit, text); + }; + + /* ── the collapsed bar ── */ + if (!expanded) { + return ( + + ); + } + + /* ── the expanded form ── */ + return ( +
+ {/* The agent's half: the statement, markdown + LaTeX rendered. */} +
+
+ {input.statement} +
+
+ + {/* The learner's half — a composer TWIN (the question dock's + discipline): Enter makes a newline; the buttons submit. */} +
+
+ Your thinking +
+