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. */
|
/** 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 }> = {
|
const COPY: Record<AbortMode, { title: string; line: string; action: string }> = {
|
||||||
turn: {
|
turn: {
|
||||||
@@ -37,6 +37,11 @@ const COPY: Record<AbortMode, { title: string; line: string; action: string }> =
|
|||||||
line: "Skipped — the teacher moves on without it.",
|
line: "Skipped — the teacher moves on without it.",
|
||||||
action: "Dismiss",
|
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
|
/** 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 { VisualCard } from "@/components/artifacts/visual-card";
|
||||||
import { ProbeDoneCard } from "@/components/artifacts/probe-done-card";
|
import { ProbeDoneCard } from "@/components/artifacts/probe-done-card";
|
||||||
import { ToolCallCard } from "@/components/artifacts/tool-call-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.
|
* Artifact renderer registry — the extension point for tool-part UI.
|
||||||
@@ -37,6 +38,11 @@ export const artifactRenderers: Record<string, ComponentType<ArtifactPartProps>>
|
|||||||
ls: ToolCallCard,
|
ls: ToolCallCard,
|
||||||
find: ToolCallCard,
|
find: ToolCallCard,
|
||||||
grep: 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 —
|
// The prober's graph tools (wave 7): same quiet expandable card —
|
||||||
// collapsed one-liner (tool name + the session id for
|
// collapsed one-liner (tool name + the session id for
|
||||||
// list_propositions), the tool's own compact row-per-line output
|
// 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 { WaypointsIcon } from "lucide-react";
|
||||||
import type { PlanBlockData } from "@/src/core";
|
import type { PlanBlockData } from "@/src/core";
|
||||||
import { QuestionDock } from "@/components/question-dock";
|
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 { TopBar } from "@/components/top-bar";
|
||||||
import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TurnUsage } from "@/src/core";
|
import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TurnUsage } from "@/src/core";
|
||||||
import type { LhViewMessage } from "@/src/core";
|
import type { LhViewMessage } from "@/src/core";
|
||||||
import { PhaseChip, PhaseDivider } from "@/components/phase-divider";
|
import { PhaseChip, PhaseDivider } from "@/components/phase-divider";
|
||||||
import { CoverageRing } from "@/components/extraction-coverage";
|
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 { ReadingThinkingField } from "@/components/reading-thinking";
|
||||||
import { slotsConfigured, type ModelsSettings } from "@/src/core";
|
import { slotsConfigured, type ModelsSettings } from "@/src/core";
|
||||||
import { useSettingsSaved } from "@/components/settings-signal";
|
import { useSettingsSaved } from "@/components/settings-signal";
|
||||||
@@ -190,7 +200,7 @@ export function Chat({
|
|||||||
// (core/view.ts projectView); sends are ONE explicit input event each
|
// (core/view.ts projectView); sends are ONE explicit input event each
|
||||||
// ({chatId, input} — the stash indirection died with the SDK), and the
|
// ({chatId, input} — the stash indirection died with the SDK), and the
|
||||||
// turn stream is the VERBATIM AgentEvent passthrough the server emits.
|
// 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 });
|
useChatSession({ chatId, initialMessages, initiallyInFlight });
|
||||||
|
|
||||||
// The reattach settled terminal → the sync-and-continue seam (below).
|
// The reattach settled terminal → the sync-and-continue seam (below).
|
||||||
@@ -321,9 +331,11 @@ export function Chat({
|
|||||||
planOpen: planOpenRef.current,
|
planOpen: planOpenRef.current,
|
||||||
setupOpen: setupOpenRef.current,
|
setupOpen: setupOpenRef.current,
|
||||||
pending: pendingRef.current !== null,
|
pending: pendingRef.current !== null,
|
||||||
|
problem: activeProblemRef.current !== null,
|
||||||
busy: busyRef.current,
|
busy: busyRef.current,
|
||||||
});
|
});
|
||||||
if (target === "question") setAbortAsk("question");
|
if (target === "question") setAbortAsk("question");
|
||||||
|
else if (target === "problem") setAbortAsk("problem");
|
||||||
else if (target === "turn") setAbortAsk("turn");
|
else if (target === "turn") setAbortAsk("turn");
|
||||||
else if (target === "mermaid") setMermaidFocus(null);
|
else if (target === "mermaid") setMermaidFocus(null);
|
||||||
else if (target === "plan") setPlanOpen(false);
|
else if (target === "plan") setPlanOpen(false);
|
||||||
@@ -344,14 +356,21 @@ export function Chat({
|
|||||||
// SILENT route — no turn, no wake, nothing generates. The session
|
// SILENT route — no turn, no wake, nothing generates. The session
|
||||||
// pauses at the user's choice; the next plain send is the wake.
|
// pauses at the user's choice; the next plain send is the wake.
|
||||||
const p = pendingRef.current;
|
const p = pendingRef.current;
|
||||||
if (!p) return;
|
if (p) {
|
||||||
const question = (p.input as { question?: string } | null)?.question ?? "";
|
const question = (p.input as { question?: string } | null)?.question ?? "";
|
||||||
const reading = readingThinkingRef.current.trim() || undefined;
|
const reading = readingThinkingRef.current.trim() || undefined;
|
||||||
clearReadingThinking();
|
clearReadingThinking();
|
||||||
const answer = dismissedAnswer(toolQuestionKind(p.tool), question);
|
const answer = dismissedAnswer(toolQuestionKind(p.tool), question);
|
||||||
void dismissQuestion(p.toolCallId, reading ? { ...answer, readingThinking: reading } : answer);
|
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
|
// Mount continuation: if the stored transcript ends with a fulfilled tool
|
||||||
// (a job result landed while no client was open), resume the lesson — the
|
// (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).
|
// listener is mount-scoped; the value it needs is render-scoped).
|
||||||
const pendingRef = useRef(pending);
|
const pendingRef = useRef(pending);
|
||||||
pendingRef.current = 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 localStream = status === "submitted" || status === "streaming";
|
||||||
const busy = localStream || turnInFlight;
|
const busy = localStream || turnInFlight;
|
||||||
const busyRef = useRef(busy);
|
const busyRef = useRef(busy);
|
||||||
@@ -403,6 +431,20 @@ export function Chat({
|
|||||||
void resumeStream();
|
void resumeStream();
|
||||||
}, [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
|
// Teach-phase check (W5, vision §2): a pending QUIZ that lands once a
|
||||||
// plan node is OCCUPIED (phaseAt3 "teach") starts collapsed behind a
|
// plan node is OCCUPIED (phaseAt3 "teach") starts collapsed behind a
|
||||||
// "Check your understanding" reveal button; probe- and plan-phase
|
// "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
|
// a pending question still owns the interaction (the dock replaces the
|
||||||
// composer, and there is no live turn to steer anyway — questions end
|
// composer, and there is no live turn to steer anyway — questions end
|
||||||
// turns).
|
// 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 syncFromServer = useCallback(async () => {
|
||||||
const res = await fetch(`/api/chats/${chatId}`);
|
const res = await fetch(`/api/chats/${chatId}`);
|
||||||
@@ -1194,6 +1236,13 @@ export function Chat({
|
|||||||
);
|
);
|
||||||
case "step-start":
|
case "step-start":
|
||||||
return pi > 0 ? <Separator key={pi} className="my-3" /> : null;
|
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":
|
case "data-plan-block":
|
||||||
// The plan in the chat (wave 4): a frozen snapshot
|
// The plan in the chat (wave 4): a frozen snapshot
|
||||||
// block — rendered as carried, never re-derived.
|
// block — rendered as carried, never re-derived.
|
||||||
@@ -1285,6 +1334,8 @@ export function Chat({
|
|||||||
fold) and NEVER overlaps the composer. */}
|
fold) and NEVER overlaps the composer. */}
|
||||||
<DockOverlay
|
<DockOverlay
|
||||||
pending={pending}
|
pending={pending}
|
||||||
|
practice={activeProblem}
|
||||||
|
onSubmitThinking={submitThinking}
|
||||||
chatId={chatId}
|
chatId={chatId}
|
||||||
plan={hasPlan ? <PlanMarginalia messages={messages} /> : undefined}
|
plan={hasPlan ? <PlanMarginalia messages={messages} /> : undefined}
|
||||||
onOpenPlan={() => setPlanOpen(true)}
|
onOpenPlan={() => setPlanOpen(true)}
|
||||||
@@ -1426,13 +1477,19 @@ export function Chat({
|
|||||||
* then the session overlays (mermaid paints above plan — later in DOM),
|
* then the session overlays (mermaid paints above plan — later in DOM),
|
||||||
* then setup (self-closing — the gate only must not fire), then the
|
* then setup (self-closing — the gate only must not fire), then the
|
||||||
* 2026-10-02 abort chain proper (question before turn). */
|
* 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: {
|
export function escAbortTarget(s: {
|
||||||
abortAsk: boolean;
|
abortAsk: boolean;
|
||||||
mermaidFocus: boolean;
|
mermaidFocus: boolean;
|
||||||
planOpen: boolean;
|
planOpen: boolean;
|
||||||
setupOpen: boolean;
|
setupOpen: boolean;
|
||||||
pending: 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;
|
busy: boolean;
|
||||||
}): EscAbortTarget {
|
}): EscAbortTarget {
|
||||||
if (s.abortAsk) return null; // the popup's own Esc — claimed by the dialog
|
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.planOpen) return "plan";
|
||||||
if (s.setupOpen) return "setup";
|
if (s.setupOpen) return "setup";
|
||||||
if (s.pending) return "question";
|
if (s.pending) return "question";
|
||||||
|
if (s.problem) return "problem";
|
||||||
if (s.busy) return "turn";
|
if (s.busy) return "turn";
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -1477,6 +1535,8 @@ export function shouldStampGeometryMute(nextH: number, prevH: number, pending: b
|
|||||||
|
|
||||||
function DockOverlay({
|
function DockOverlay({
|
||||||
pending,
|
pending,
|
||||||
|
practice,
|
||||||
|
onSubmitThinking,
|
||||||
requiresReveal,
|
requiresReveal,
|
||||||
readingThinking,
|
readingThinking,
|
||||||
onReadingThinking,
|
onReadingThinking,
|
||||||
@@ -1494,6 +1554,12 @@ function DockOverlay({
|
|||||||
queuedSteers,
|
queuedSteers,
|
||||||
}: {
|
}: {
|
||||||
pending: ReturnType<typeof findPendingToolCall>;
|
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). */
|
/** Teach-phase pending question: arrives collapsed behind the reveal button (W5). */
|
||||||
requiresReveal: boolean;
|
requiresReveal: boolean;
|
||||||
/** The reading-thinking draft (owner 2026-10-03) — owned by ChatView so
|
/** 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.
|
// again → re-open. The dead band keeps trackpad drift from chattering.
|
||||||
const expandedRef = useRef(expanded);
|
const expandedRef = useRef(expanded);
|
||||||
expandedRef.current = expanded;
|
expandedRef.current = expanded;
|
||||||
const pendingRef = useRef(!!pending);
|
// Dock ownership (the fold machine, the reserve immunity, the arrival
|
||||||
pendingRef.current = !!pending;
|
// 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);
|
const requiresRevealRef = useRef(requiresReveal);
|
||||||
requiresRevealRef.current = requiresReveal;
|
requiresRevealRef.current = requiresReveal;
|
||||||
const revealedRef = useRef(revealed);
|
const revealedRef = useRef(revealed);
|
||||||
@@ -1765,7 +1834,11 @@ function DockOverlay({
|
|||||||
if (b) offer(b.getBoundingClientRect().height);
|
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
|
// The opening variant of a session appears instantly — the fade/rise is
|
||||||
// reserved for mid-session transitions.
|
// reserved for mid-session transitions.
|
||||||
const initialModeRef = useRef(mode);
|
const initialModeRef = useRef(mode);
|
||||||
@@ -1843,6 +1916,18 @@ function DockOverlay({
|
|||||||
onEscape={dictationCancel}
|
onEscape={dictationCancel}
|
||||||
dictationClaim={!revealPending}
|
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
|
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 {
|
import {
|
||||||
needsContinuation,
|
needsContinuation,
|
||||||
newAnswerResult,
|
newAnswerResult,
|
||||||
|
newProblemDismissedMarker,
|
||||||
|
newThinkingMessage,
|
||||||
newUserMessage,
|
newUserMessage,
|
||||||
projectView,
|
projectView,
|
||||||
type ChatInput,
|
type ChatInput,
|
||||||
@@ -503,7 +505,12 @@ export class SessionModel {
|
|||||||
// adoption can tell them from the record's absorbed copies.
|
// adoption can tell them from the record's absorbed copies.
|
||||||
const optimistic: LhMessage[] = [];
|
const optimistic: LhMessage[] = [];
|
||||||
if (input.type === "text") optimistic.push({ ...newUserMessage(input.text), id: localId() });
|
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";
|
const tool = pendingCallTool([...this.base, ...this.tail], input.toolCallId) ?? "quiz";
|
||||||
optimistic.push({ ...newAnswerResult(input.toolCallId, tool, input.answer), id: localId() });
|
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
|
/** Local detach: cancel the consume, keep the partial tail. The page's
|
||||||
* settle watch adopts the record when the server turn finishes. */
|
* settle watch adopts the record when the server turn finishes. */
|
||||||
stop(): void {
|
stop(): void {
|
||||||
|
|||||||
@@ -98,6 +98,10 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U
|
|||||||
(toolCallId: string, answer: QuestionAnswerData) => void model.dismissQuestion(toolCallId, answer),
|
(toolCallId: string, answer: QuestionAnswerData) => void model.dismissQuestion(toolCallId, answer),
|
||||||
[model],
|
[model],
|
||||||
);
|
);
|
||||||
|
const dismissProblem = useCallback(
|
||||||
|
(problemCallId: string) => void model.dismissProblem(problemCallId),
|
||||||
|
[model],
|
||||||
|
);
|
||||||
const setMessages = useCallback((record: LhMessage[]) => model.setMessages(record), [model]);
|
const setMessages = useCallback((record: LhMessage[]) => model.setMessages(record), [model]);
|
||||||
const adoptRecord = useCallback((record: LhMessage[]) => model.adoptRecord(record), [model]);
|
const adoptRecord = useCallback((record: LhMessage[]) => model.adoptRecord(record), [model]);
|
||||||
const stop = useCallback(() => model.stop(), [model]);
|
const stop = useCallback(() => model.stop(), [model]);
|
||||||
@@ -114,6 +118,7 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U
|
|||||||
steer,
|
steer,
|
||||||
submitAnswer,
|
submitAnswer,
|
||||||
dismissQuestion,
|
dismissQuestion,
|
||||||
|
dismissProblem,
|
||||||
setMessages,
|
setMessages,
|
||||||
adoptRecord,
|
adoptRecord,
|
||||||
stop,
|
stop,
|
||||||
|
|||||||
+30
-2
@@ -220,6 +220,18 @@ export function projectView(messages: readonly LhMessage[], live?: LiveTurnState
|
|||||||
return out;
|
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. */
|
/** A tool call block → its view part, with the live execution layer. */
|
||||||
function toolPartOf(
|
function toolPartOf(
|
||||||
block: { id: string; name: string; arguments?: unknown },
|
block: { id: string; name: string; arguments?: unknown },
|
||||||
@@ -231,7 +243,7 @@ function toolPartOf(
|
|||||||
input: block.arguments,
|
input: block.arguments,
|
||||||
};
|
};
|
||||||
const exec = live?.executions.get(block.id);
|
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) {
|
||||||
if (exec.isError) {
|
if (exec.isError) {
|
||||||
// An invalid call NEVER becomes input-available — the dock's mount
|
// 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
|
// Flipping the part to output-available on the placeholder would hand
|
||||||
// AnsweredCard a null and crash the render (the live crash, 2026-09-26).
|
// 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 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;
|
if (isQuestion && !result.isError && result.details == null) return;
|
||||||
applyResult(part, result);
|
applyResult(part, result);
|
||||||
}
|
}
|
||||||
@@ -313,6 +325,22 @@ function projectMarker(marker: LhMarkerMessage, out: LhViewMessage[]): void {
|
|||||||
});
|
});
|
||||||
return;
|
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": {
|
case "plan-block": {
|
||||||
// No message of its own: splice into the preceding assistant
|
// No message of its own: splice into the preceding assistant
|
||||||
// group, IN FLOW after the last successful plan_init call.
|
// 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.
|
* pure) is the defense. Priority = stacking order.
|
||||||
*/
|
*/
|
||||||
describe("escAbortTarget (the layered-Esc ruling)", () => {
|
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)", () => {
|
it("no overlays: the 2026-10-02 chain stands (question before turn)", () => {
|
||||||
assert.equal(escAbortTarget({ ...none, pending: true }), "question");
|
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");
|
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", () => {
|
it("an open overlay claims Esc over a pending question — the owner's report", () => {
|
||||||
for (const overlay of ["mermaidFocus", "planOpen", "setupOpen"] as const) {
|
for (const overlay of ["mermaidFocus", "planOpen", "setupOpen"] as const) {
|
||||||
const target = escAbortTarget({ ...none, [overlay]: true, pending: true });
|
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