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:
amosblomqvist
2026-10-04 21:55:54 +03:00
parent 4bea0b46df
commit 59bf5bf219
10 changed files with 650 additions and 19 deletions
+6 -1
View File
@@ -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<AbortMode, { title: string; line: string; action: string }> = {
turn: {
@@ -37,6 +37,11 @@ const COPY: Record<AbortMode, { title: string; line: string; action: string }> =
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
+6
View File
@@ -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<string, ComponentType<ArtifactPartProps>>
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
+99 -14
View File
@@ -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
)}
+138
View File
@@ -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 (
<div data-testid="problem-card" className="my-1">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full cursor-pointer items-center gap-2 rounded-lg border border-border/60 bg-card/60 px-3 py-2 text-left text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<ChevronRightIcon className={cn("size-4 shrink-0 transition-transform", open && "rotate-90")} />
<span className="font-medium">Problem</span>
<span className="min-w-0 flex-1 truncate">{statement}</span>
</button>
{open && (
<div className="lh-prose mt-1 rounded-lg border border-border/60 bg-card/60 px-4 py-3 text-[15px]">
<MessageResponse>{statement}</MessageResponse>
</div>
)}
</div>
);
}
const OUTCOME_LABEL: Record<string, string> = {
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 (
<div data-testid="conclude-card" className="my-1 px-1 text-sm text-muted-foreground/70">
Problem dismissed.
</div>
);
}
return (
<div data-testid="conclude-card" className="my-1">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full cursor-pointer items-center gap-2 rounded-lg border border-border/60 bg-card/60 px-3 py-2 text-left text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<ChevronRightIcon className={cn("size-4 shrink-0 transition-transform", open && "rotate-90")} />
<span className="font-medium">{OUTCOME_LABEL[outcome] ?? "Concluded"}</span>
<span className="min-w-0 flex-1 truncate">{verdict}</span>
</button>
{open && (
<div className="mt-1 space-y-2 rounded-lg border border-border/60 bg-card/60 px-4 py-3 text-[15px]">
{verdict.length > 0 && (
<div className="text-sm text-foreground">
<MessageResponse>{verdict}</MessageResponse>
</div>
)}
{reveal.length > 0 && (
<div className="border-t border-border/50 pt-2">
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
The solution
</div>
<div className="lh-prose">
<MessageResponse>{reveal}</MessageResponse>
</div>
</div>
)}
</div>
)}
</div>
);
}
export function ThinkingCard({ data }: { data: PracticeThinkingData }) {
const [open, setOpen] = useState(true);
return (
<div
data-testid="thinking-card"
data-commit={data.commit ? "true" : "false"}
className="ml-auto max-w-[85%] rounded-2xl border border-border/60 bg-muted/40 px-4 py-3"
>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full cursor-pointer items-center gap-2 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground"
>
{data.commit ? "Your solution" : "Your thinking"}
<ChevronRightIcon className={cn("size-3.5 shrink-0 transition-transform", open && "rotate-90")} />
</button>
{open && (
<div className="mt-1.5 whitespace-pre-wrap text-[15px]">{data.text}</div>
)}
</div>
);
}
+164
View File
@@ -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 (
<button
type="button"
data-testid="practice-dock-bar"
onClick={onExpand}
className="flex w-full cursor-pointer items-center gap-2 px-4 py-3 text-left text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<ChevronRightIcon className="size-4 shrink-0 rotate-90" />
<span className="font-medium">Problem</span>
<span className="min-w-0 flex-1 truncate">{input.statement}</span>
</button>
);
}
/* ── the expanded form ── */
return (
<div data-testid="practice-dock" data-problem={problemCallId}>
{/* The agent's half: the statement, markdown + LaTeX rendered. */}
<div className="max-h-[40vh] overflow-y-auto px-4 pt-3 pb-2">
<div className="lh-prose text-[15px]">
<MessageResponse>{input.statement}</MessageResponse>
</div>
</div>
{/* The learner's half — a composer TWIN (the question dock's
discipline): Enter makes a newline; the buttons submit. */}
<div
data-testid="practice-thinking-field"
className="m-1.5 rounded-xl border border-border/70 bg-background/60 transition-colors focus-within:border-primary/50"
>
<div className="px-2.5 pt-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Your thinking
</div>
<textarea
value={thinking}
onChange={(e) => {
thinkingRef.current = e.target.value;
setThinking(e.target.value);
}}
onKeyDown={(e) => {
// Esc ends dictation — stopPropagation claims it (the
// keyboard machine) so the window chain doesn't also fire.
if (e.key === "Escape" && onEscape) {
e.preventDefault();
e.stopPropagation();
onEscape();
return;
}
// ⌘↵ on macOS, Ctrl↵ elsewhere — the universal submit
// chord; here it COMMITS (the stronger of the two acts).
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
submit(true);
}
}}
placeholder="Work here — say as much of your thinking as you have. Nudge asks for a hint without committing; Commit submits an answer."
rows={4}
className={cn(
"field-sizing-content lh-input max-h-64 min-h-16 w-full resize-none bg-transparent px-2.5 pb-2 outline-none placeholder:text-muted-foreground",
)}
/>
{/* The two submit lanes — docked inside the field like the
composer's send, right-aligned. */}
<div className="flex items-center justify-end gap-1.5 px-1.5 pb-1.5">
<button
type="button"
data-testid="practice-nudge"
disabled={thinking.trim().length === 0}
onClick={() => submit(false)}
className="rounded-lg border border-border/70 px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
>
Nudge
</button>
<button
type="button"
data-testid="practice-commit"
disabled={thinking.trim().length === 0}
onClick={() => submit(true)}
className="rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:pointer-events-none disabled:opacity-40"
>
Commit
</button>
</div>
</div>
</div>
);
}
+29 -1
View File
@@ -18,6 +18,8 @@ import type { ToolResultMessage } from "@earendil-works/pi-ai";
import {
needsContinuation,
newAnswerResult,
newProblemDismissedMarker,
newThinkingMessage,
newUserMessage,
projectView,
type ChatInput,
@@ -503,7 +505,12 @@ export class SessionModel {
// adoption can tell them from the record's absorbed copies.
const optimistic: LhMessage[] = [];
if (input.type === "text") optimistic.push({ ...newUserMessage(input.text), id: localId() });
else if (input.type === "answer") {
else if (input.type === "thinking") {
// The practice submissions (2026-10-04): the marker lands
// optimistically — the register (trace / commit) renders from
// its details the moment the button fires.
optimistic.push({ ...newThinkingMessage(input.problemCallId, input.commit, input.text), id: localId() });
} else if (input.type === "answer") {
const tool = pendingCallTool([...this.base, ...this.tail], input.toolCallId) ?? "quiz";
optimistic.push({ ...newAnswerResult(input.toolCallId, tool, input.answer), id: localId() });
}
@@ -605,6 +612,27 @@ export class SessionModel {
}
}
/** The problem discard (practice sessions, owner 2026-10-04): the
* ask-style dismissal — the UI-only marker lands optimistically, then
* the SILENT route persists it (no turn, no wake). The composer
* returns; the conclude synthesis rides the user's next input. */
async dismissProblem(problemCallId: string): Promise<void> {
if (!this.readyRef) return;
this.mutate(() => {
this.tail = [...this.tail, { ...newProblemDismissedMarker(problemCallId), id: localId() }];
});
try {
const res = await this.fetch(`/api/chats/${this.chatId}/dismiss`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ input: { type: "dismiss-problem", problemCallId } }),
});
if (!res.ok) throw new Error(`The dismissal failed (${res.status}).`);
} catch (err) {
this.setError((err as Error).message);
}
}
/** Local detach: cancel the consume, keep the partial tail. The page's
* settle watch adopts the record when the server turn finishes. */
stop(): void {
+5
View File
@@ -98,6 +98,10 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U
(toolCallId: string, answer: QuestionAnswerData) => void model.dismissQuestion(toolCallId, answer),
[model],
);
const dismissProblem = useCallback(
(problemCallId: string) => void model.dismissProblem(problemCallId),
[model],
);
const setMessages = useCallback((record: LhMessage[]) => model.setMessages(record), [model]);
const adoptRecord = useCallback((record: LhMessage[]) => model.adoptRecord(record), [model]);
const stop = useCallback(() => model.stop(), [model]);
@@ -114,6 +118,7 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U
steer,
submitAnswer,
dismissQuestion,
dismissProblem,
setMessages,
adoptRecord,
stop,
+30 -2
View File
@@ -220,6 +220,18 @@ export function projectView(messages: readonly LhMessage[], live?: LiveTurnState
return out;
}
/** The dock-owned tools: question tools (client-owned results — the dock
* mounts on the dangling call) and the practice pose (its ack is FINAL —
* details: null, never replaced — and the card + dock read the call's
* input; the placeholder must never flip the part to output-available).
* PRACTICE (2026-10-04): problem joins the placeholder fork; conclude is
* a NORMAL tool part (its result acks with no payload, details null —
* but the ConcludeCard reads the call's input, and output-available with
* output undefined renders fine). */
function isPlaceholderResultTool(name: string): boolean {
return (QUESTION_TOOLS as readonly string[]).includes(name) || name === "problem";
}
/** A tool call block → its view part, with the live execution layer. */
function toolPartOf(
block: { id: string; name: string; arguments?: unknown },
@@ -231,7 +243,7 @@ function toolPartOf(
input: block.arguments,
};
const exec = live?.executions.get(block.id);
const isQuestion = (QUESTION_TOOLS as readonly string[]).includes(block.name);
const isQuestion = isPlaceholderResultTool(block.name);
if (exec) {
if (exec.isError) {
// An invalid call NEVER becomes input-available — the dock's mount
@@ -267,7 +279,7 @@ function applyResultTo(part: LhViewPart | undefined, result: ToolResultMessage):
// Flipping the part to output-available on the placeholder would hand
// AnsweredCard a null and crash the render (the live crash, 2026-09-26).
const tool = part.type.startsWith("tool-") ? part.type.slice("tool-".length) : part.type;
const isQuestion = (QUESTION_TOOLS as readonly string[]).includes(tool);
const isQuestion = isPlaceholderResultTool(tool);
if (isQuestion && !result.isError && result.details == null) return;
applyResult(part, result);
}
@@ -313,6 +325,22 @@ function projectMarker(marker: LhMarkerMessage, out: LhViewMessage[]): void {
});
return;
}
case "practice-thinking": {
// The learner's working trace / committed answer (practice
// sessions): a user-side card keyed on the structured details
// (problemCallId, commit, text) — the register lives in the card,
// not re-parsed from content.
out.push({
id: marker.id,
role: "user",
parts: [{ type: "data-practice-thinking", id: `pt_${marker.id}`, data: marker.details }],
});
return;
}
case "problem-dismissed":
// UI-only (the ask-style dismissal): the synthesized conclude
// pair — which rides the NEXT input — carries the record's face.
return;
case "plan-block": {
// No message of its own: splice into the preceding assistant
// group, IN FLOW after the last successful plan_init call.
+7 -1
View File
@@ -12,7 +12,7 @@ import { escAbortTarget } from "../components/chat.tsx";
* pure) is the defense. Priority = stacking order.
*/
describe("escAbortTarget (the layered-Esc ruling)", () => {
const none = { abortAsk: false, mermaidFocus: false, planOpen: false, setupOpen: false, pending: false, busy: false };
const none = { abortAsk: false, mermaidFocus: false, planOpen: false, setupOpen: false, pending: false, problem: false, busy: false };
it("no overlays: the 2026-10-02 chain stands (question before turn)", () => {
assert.equal(escAbortTarget({ ...none, pending: true }), "question");
@@ -21,6 +21,12 @@ describe("escAbortTarget (the layered-Esc ruling)", () => {
assert.equal(escAbortTarget({ ...none, pending: true, busy: true }), "question", "a pending question outranks the turn");
});
it("an active practice problem sits between question and turn (2026-10-04)", () => {
assert.equal(escAbortTarget({ ...none, problem: true }), "problem");
assert.equal(escAbortTarget({ ...none, problem: true, busy: true }), "problem", "the problem card outranks the turn abort");
assert.equal(escAbortTarget({ ...none, planOpen: true, problem: true }), "plan", "an open popup still outranks the problem");
});
it("an open overlay claims Esc over a pending question — the owner's report", () => {
for (const overlay of ["mermaidFocus", "planOpen", "setupOpen"] as const) {
const target = escAbortTarget({ ...none, [overlay]: true, pending: true });
+166
View File
@@ -0,0 +1,166 @@
/**
* THE PRACTICE SURFACE — SSR render pins (the render-suite discipline:
* pure bodies, renderToStaticMarkup, testids). The dock (the active
* problem replaces the composer: statement above, "Your thinking"
* composer-twin below, the TWO submit lanes — Nudge without a commit,
* Commit with one; ⌘/Ctrl+Enter commits), the transcript cards (the
* problem's compact-expandable statement — commitment NEVER renders;
* the conclusion's outcome + verdict + reveal; the learner's thinking
* register cards), and the view projection (the thinking marker's
* data part; the dismissal's silence).
*/
import React from "react";
(globalThis as Record<string, unknown>).React = React;
import assert from "node:assert/strict";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, it } from "node:test";
import { PracticeDock } from "../components/practice-dock.tsx";
import { ConcludeCard, ProblemCard, ThinkingCard } from "../components/practice-cards.tsx";
import { projectView } from "../src/core/view.ts";
import type { ArtifactPartProps } from "../src/core";
import {
newThinkingMessage,
PRACTICE_PROBLEM_TOOL,
type LhMessage,
} from "../src/core/native-messages.ts";
const part = (over: Partial<ArtifactPartProps["part"]>): ArtifactPartProps["part"] => ({
type: "tool-problem",
state: "input-available",
toolCallId: "call_p1",
...over,
});
// ── the dock ─────────────────────────────────────────────────────────────────
describe("PracticeDock", () => {
it("expanded: statement renders, the two lanes sit in the thinking field", () => {
const html = renderToStaticMarkup(
<PracticeDock
problemCallId="call_p1"
input={{ statement: "Show that the sum is even." }}
expanded={true}
onExpand={() => {}}
onSubmit={() => {}}
/>,
);
assert.match(html, /Show that the sum is even\./);
assert.match(html, /Your thinking/);
assert.match(html, /data-testid="practice-nudge"/);
assert.match(html, /data-testid="practice-commit"/);
// The buttons carry their lane in the disabled gate; both start
// disabled (empty field).
assert.match(html, /disabled/);
});
it("collapsed: the slim bar carries the statement excerpt", () => {
const html = renderToStaticMarkup(
<PracticeDock
problemCallId="call_p1"
input={{ statement: "Show that the sum is even." }}
expanded={false}
onExpand={() => {}}
onSubmit={() => {}}
/>,
);
assert.match(html, /data-testid="practice-dock-bar"/);
assert.match(html, /Show that the sum is even\./);
});
});
// ── the transcript cards ─────────────────────────────────────────────────────
describe("the practice transcript cards", () => {
it("ProblemCard: the statement renders — the commitment NEVER does", () => {
const html = renderToStaticMarkup(
<ProblemCard
part={part({
input: {
statement: "Prove the parity claim.",
mode: "recall",
modelSolution: "Pair them.",
decisionPoints: ["pairing"],
expectedWrongMoves: [{ move: "induction", resolution: "pairing is direct" }],
strategy: "near-twin",
rationale: "weakest target",
},
})}
/>,
);
assert.match(html, /data-testid="problem-card"/);
assert.match(html, /Prove the parity claim\./);
for (const hidden of ["Pair them.", "induction", "near-twin", "weakest target", "pairing"]) {
assert.doesNotMatch(html, new RegExp(hidden.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), `commitment leaked: ${hidden}`);
}
});
it("ConcludeCard: outcome badge + verdict headline; the reveal expands", () => {
const html = renderToStaticMarkup(
<ConcludeCard
part={part({
type: "tool-conclude",
input: { outcome: "solvedWithHints", verdict: "The pairing came after R2.", solutionReveal: "Pair them; each pair is even." },
})}
/>,
);
assert.match(html, /data-testid="conclude-card"/);
assert.match(html, /Solved with hints/);
assert.match(html, /The pairing came after R2\./);
});
it("ConcludeCard: the dismissal synthesis is one muted line — no reveal card", () => {
const html = renderToStaticMarkup(
<ConcludeCard part={part({ type: "tool-conclude", input: { outcome: "dismissed" } })} />,
);
assert.match(html, /Problem dismissed\./);
assert.doesNotMatch(html, /The solution/);
});
it("ThinkingCard: the register is the title (trace vs solution)", () => {
const trace = renderToStaticMarkup(<ThinkingCard data={{ problemCallId: "c", commit: false, text: "stuck at step two" }} />);
assert.match(trace, /Your thinking/);
assert.doesNotMatch(trace, /Your solution/);
const commit = renderToStaticMarkup(<ThinkingCard data={{ problemCallId: "c", commit: true, text: "It is 42." }} />);
assert.match(commit, /Your solution/);
});
});
// ── the view projection ──────────────────────────────────────────────────────
describe("the practice view projection", () => {
it("the thinking marker projects as a user-side data part (register in details)", () => {
const marker = newThinkingMessage("call_p1", true, "It is 42.");
const view = projectView([marker as LhMessage]);
assert.equal(view.length, 1);
assert.equal(view[0]!.role, "user");
const dataPart = view[0]!.parts[0]!;
assert.equal(dataPart.type, "data-practice-thinking");
assert.deepEqual(dataPart.data, { problemCallId: "call_p1", commit: true, text: "It is 42." });
});
it("the problem pose's ack keeps the part input-available (the dock's mount)", () => {
// Pose call + ack result (details: null — the placeholder fork).
const messages = [
{
id: "a1",
role: "assistant",
content: [{ type: "toolCall", id: "call_p1", name: PRACTICE_PROBLEM_TOOL, arguments: { statement: "S." } }],
},
{
id: "r1",
role: "toolResult",
toolCallId: "call_p1",
toolName: PRACTICE_PROBLEM_TOOL,
content: [{ type: "text", text: "Problem posed" }],
details: null,
isError: false,
},
] as unknown as LhMessage[];
const view = projectView(messages);
const toolPart = view[0]!.parts.find((p) => p.type === `tool-${PRACTICE_PROBLEM_TOOL}`)!;
assert.equal(toolPart.state, "input-available");
});
});