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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user