diff --git a/components/answered-card.tsx b/components/answered-card.tsx index 5639fe1..59c1bcb 100644 --- a/components/answered-card.tsx +++ b/components/answered-card.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState } from "react"; import { ConfidenceRail } from "@/components/confidence-rail"; import { GlyphMark } from "@/components/ui/glyph-mark"; import { cn } from "@/lib/utils"; @@ -17,6 +18,7 @@ import type { QuestionAnswerData } from "@/src/core"; * the explanation, with NO verdict chips (owner ruling 2026-09-27). */ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) { + const [whyOpen, setWhyOpen] = useState(false); // the tucked explanation (predictions, owner 2026-10-03) if (answer.dismissed) { return (
@@ -33,7 +35,7 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) { return (
- {isQuiz ? "Quiz" : isExplain ? "Explain" : "Question"} + {isQuiz ? (answer.questionType === "predict" ? "Prediction" : "Quiz") : isExplain ? "Explain" : "Question"} {isQuiz && (answer.dontKnow ? ( didn't know @@ -122,26 +124,62 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
Answered “I don't know”.
)} - {answer.note && ( + {(answer.thinking ?? answer.note) && (
Your thinking - {answer.note} + {answer.thinking ?? answer.note} +
+ )} + + {/* The reading channel (owner 2026-10-03): thoughts captured while + reading the teaching prose — a separate block from the question + thinking, same quiet voice. */} + {answer.readingThinking && ( +
+ + Your thinking while reading + + {answer.readingThinking}
)} {/* px-3→removed: card p-4 + box px-3 double-inset sat the "Why" label deeper than the option box edges (owner, round 3). Sides flush with everything else; py-2 stays for vertical air. */} - {isQuiz && answer.explanation && ( -
- Why: - {/* The markdown wrapper must go truly inline — a block

after the - "Why:" label created the oversized leading gap (owner's fix list). */} - {answer.explanation} + {isQuiz && answer.explanation && answer.questionType === "predict" ? ( + /* The TUCKED explanation (owner 2026-10-03): a prediction's + verdict shows, but the explanation sits collapsed — viewable, + never center stage. The teaching that follows resolves the + prediction; THAT is the real explanation. */ +

+ + {whyOpen && ( +
+ Why: + {answer.explanation} +
+ )}
+ ) : ( + isQuiz && + answer.explanation && ( +
+ Why: + {/* The markdown wrapper must go truly inline — a block

after the + "Why:" label created the oversized leading gap (owner's fix list). */} + {answer.explanation} +

+ ) )} + {/* The explain reveal (owner ruling 2026-09-27): the committed answer — one field, reasoning included — shown on submit; no verdict, the model's next reply IS the grading. */} diff --git a/components/chat.tsx b/components/chat.tsx index a66bd8b..6561989 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -41,7 +41,8 @@ import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TurnUsag 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 } from "@/src/core"; +import { 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"; @@ -331,7 +332,10 @@ export function Chat({ const p = pendingRef.current; if (!p) return; const question = (p.input as { question?: string } | null)?.question ?? ""; - void dismissQuestion(p.toolCallId, dismissedAnswer(toolQuestionKind(p.tool), question)); + const reading = readingThinkingRef.current.trim() || undefined; + clearReadingThinking(); + const answer = dismissedAnswer(toolQuestionKind(p.tool), question); + void dismissQuestion(p.toolCallId, reading ? { ...answer, readingThinking: reading } : answer); }, [abortInFlightTurn, dismissQuestion], ); @@ -393,7 +397,28 @@ export function Chat({ // never precede its phase's seam in the transcript (the teacher's turn // ends at the question call), so the session's current phase IS the // question's phase. - const requiresReveal = pending !== null && pending.tool === "quiz" && phaseAt3(messages) === "teach"; + const requiresReveal = pending !== null && quizNeedsReveal(pending.tool, pending.input, phaseAt3(messages)); + + /* The READING-THINKING channel (owner 2026-10-03): a draft field for + * thinking while reading the teaching prose, mounted while a check waits + * behind its reveal, riding the check answer (or its dismissal) as the + * separate `readingThinking` field. Reset per pending question. */ + const [readingThinking, setReadingThinking] = useState(""); + const readingThinkingRef = useRef(""); + const applyReadingThinking = useCallback((v: string) => { + readingThinkingRef.current = v; + setReadingThinking(v); + }, []); + const clearReadingThinking = useCallback(() => { + readingThinkingRef.current = ""; + setReadingThinking(""); + }, []); + const pendingQuestionId = pending?.toolCallId ?? null; + useEffect(() => { + // A new pending question never inherits the previous one's draft. + readingThinkingRef.current = ""; + setReadingThinking(""); + }, [pendingQuestionId]); // The phase at every message (one linear fold): the transcript draws a // color-coded dotted divider at each boundary; the header chip reads the @@ -1241,6 +1266,8 @@ export function Chat({ plan={hasPlan ? : undefined} onOpenPlan={() => setPlanOpen(true)} requiresReveal={requiresReveal} + readingThinking={readingThinking} + onReadingThinking={applyReadingThinking} phase={phases.at(-1) ?? "probe"} turns={turns} onReserve={setDockReserve} @@ -1288,8 +1315,12 @@ export function Chat({ // the optimistic graded result (the AnsweredCard shows at // once) and the server's write-ahead persists it before the // turn; the teacher's reaction streams below it. Dismissals - // ride the same callback (QuestionAnswerData.dismissed). - submitAnswer(pending.tool, pending.toolCallId, output); + // ride the same callback (QuestionAnswerData.dismissed). The + // reading-thinking draft rides along (owner 2026-10-03) — the + // check answer is its only carrier. + const reading = readingThinkingRef.current.trim() || undefined; + clearReadingThinking(); + submitAnswer(pending.tool, pending.toolCallId, reading ? { ...output, readingThinking: reading } : output); }} /> @@ -1398,6 +1429,8 @@ export function shouldStampGeometryMute(nextH: number, prevH: number, pending: b function DockOverlay({ pending, requiresReveal, + readingThinking, + onReadingThinking, phase, turns, composer, @@ -1414,6 +1447,10 @@ function DockOverlay({ pending: ReturnType; /** 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 + * the submit + dismissal seams (outside this overlay) can merge it. */ + readingThinking: string; + onReadingThinking: (v: string) => void; /** Session phase at the pending question — scales the thinking field. */ phase: Phase3; /** The session's usage log — the stats bar beneath the composer folds it. */ @@ -1730,6 +1767,18 @@ function DockOverlay({ rounding starts, truncated, no background, no container. A line dies when its card lands (visibleQueuedSteers). */} + {/* The reading-thinking field (owner 2026-10-03): mounted while a + teach-phase check waits — the same cue that collapses it. It + claims dictated speech until the learner reveals the check + (the dock reclaims then). Rides the check answer on submit. */} + {pending && requiresReveal && ( + + )}
{pending ? ( @@ -1743,6 +1792,7 @@ function DockOverlay({ onExpand={expandAndReveal} onSubmit={onSubmitAnswer} onEscape={dictationCancel} + dictationClaim={!revealPending} /> ) : ( composer diff --git a/components/question-dock.tsx b/components/question-dock.tsx index d45772a..98736e7 100644 --- a/components/question-dock.tsx +++ b/components/question-dock.tsx @@ -9,6 +9,7 @@ import { cn } from "@/lib/utils"; import { buildAnswer, coerceCorrectAnswer, + normalizeQuizType, CONFIDENCE_STOPS, DEFAULT_CONFIDENCE_INDEX, emptyDockSelection, @@ -79,6 +80,11 @@ export interface QuestionDockProps { /** Session phase at the question — scales the thinking field: probing * stays quick (one row), teaching invites dumping (three rows). */ phase?: Phase3; + /** Dictation claim (owner 2026-10-03): false while a teach-phase check + * sits UNREVEALED — the reading-thinking field owns dictated speech + * during the reading; the dock reclaims on reveal. Default true (probe + * questions, predictions, old callers). */ + dictationClaim?: boolean; } export function QuestionDock({ @@ -91,6 +97,7 @@ export function QuestionDock({ onSubmit, onEscape, phase = "probe", + dictationClaim = true, }: QuestionDockProps) { // Parse, don't cast (src/core/tool-parts.ts): the model authored this // input, and only the schema the tool declared vouches for its shape. @@ -115,6 +122,11 @@ export function QuestionDock({ question={parsed.question} onExpand={onExpand} variant={reveal ? "reveal" : "collapsed"} + label={ + toolName === "quiz" && normalizeQuizType((parsed as { type?: unknown }).type) === "predict" + ? "Prediction" + : undefined + } /> )}
@@ -126,6 +138,7 @@ export function QuestionDock({ active={expanded} onSubmit={onSubmit} onEscape={onEscape} + dictationClaim={dictationClaim} />
@@ -154,11 +167,16 @@ export function QuestionDockBar({ question, onExpand, variant = "collapsed", + label, }: { toolName: "quiz" | "ask_user_question" | "explain"; question: string; onExpand: () => void; variant?: "collapsed" | "reveal"; + /** The kind chip's text (owner 2026-10-03): "Prediction" when a + * predict-type quiz minimizes by scroll — predictions never use the + * reveal variant, but the scroll-collapse strip can still hide them. */ + label?: string; }) { if (variant === "reveal") { return ( @@ -189,7 +207,7 @@ export function QuestionDockBar({ className="dock-variant flex w-full items-center gap-2.5 px-4 py-2.5 text-left text-sm transition-colors hover:bg-accent/40" > - {toolName === "quiz" ? "Quiz" : toolName === "explain" ? "Explain" : "Question"} + {label ?? (toolName === "quiz" ? "Quiz" : toolName === "explain" ? "Explain" : "Question")} {teaserText(question)} @@ -208,6 +226,7 @@ function QuestionDockForm({ active, onSubmit, onEscape, + dictationClaim = true, }: Omit) { const kind = toolQuestionKind(toolName); @@ -237,6 +256,9 @@ function QuestionDockForm({ const q: QuestionData = { id: toolCallId, kind, + // The role (owner 2026-10-03): predict questions gate by it in the + // view (never collapsed) and the answered card tucks the explanation. + questionType: kind === "quiz" ? normalizeQuizType((input as { type?: unknown }).type) : undefined, question: input.question, context: input.details, mode, @@ -304,7 +326,9 @@ function QuestionDockForm({ }, [qd.mode], ), - true, + // Released while a teach check sits unrevealed (owner 2026-10-03): + // the reading-thinking field owns dictated speech during the reading. + dictationClaim, ); const toggle = (idx: number) => setSel(toggleOption(selectMode, sel, idx)); @@ -335,7 +359,7 @@ function QuestionDockForm({ values: [...sel.selected].map((i) => qd.options[i]!.value), other: sel.otherSelected ? other : undefined, text: qd.mode === "text" ? answerTextRef.current : undefined, - note: qd.mode !== "text" ? thinkingTextRef.current.trim() || undefined : undefined, + thinking: qd.mode !== "text" ? thinkingTextRef.current.trim() || undefined : undefined, // Always sent on quiz answers (owner: 50 must mean 50, never // "untouched"); buildAnswer drops it on "I don't know". confidence: kind === "quiz" ? CONFIDENCE_STOPS[confIdx] : undefined, @@ -489,7 +513,7 @@ function QuestionDockForm({ {/* Question area — scrolls internally if very tall; the bottom row stays put. */}
- {kind === "quiz" ? "Quiz" : kind === "explain" ? "Explain" : "Question"} + {kind === "quiz" ? (qd.questionType === "predict" ? "Prediction" : "Quiz") : kind === "explain" ? "Explain" : "Question"} {qd.mode === "multi-select" && " · select all that apply"}
diff --git a/components/reading-thinking.tsx b/components/reading-thinking.tsx new file mode 100644 index 0000000..061be15 --- /dev/null +++ b/components/reading-thinking.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useCallback } from "react"; +import { joinDictation, useDictationTarget } from "@/components/dictation"; + +/** + * The reading-thinking field (owner design 2026-10-03, "two questions per + * teaching unit"): a quiet textarea for thinking WHILE READING the + * teaching prose — the same transfer channel the question dock's "Your + * thinking" gives the answer, opened one phase earlier. It mounts while a + * teach-phase check waits behind its reveal (the same cue that collapses + * the check), rides the check answer as the SEPARATE `readingThinking` + * channel (never conflated with the quiz thinking), and disappears with + * the question. + * + * Dictation: while the check sits UNREVEALED this field is the primary + * target (the dock releases its claim — dictated reading-thoughts land + * here, not in the hidden quiz field); once the learner reveals the + * check, the dock reclaims and this field is plain typing. + */ +export function ReadingThinkingField({ + value, + onChange, + onEscape, + claim, +}: { + value: string; + onChange: (v: string) => void; + /** Esc in the field stops dictation (threaded from the page's hook). */ + onEscape?: () => void; + /** Primary dictation claim — true while the check is behind its reveal. */ + claim: boolean; +}) { + useDictationTarget( + useCallback( + (t: string) => { + onChange(joinDictation(value, t)); + }, + [value, onChange], + ), + claim, + ); + return ( +
+
+ Thinking while reading +
+