two questions per teaching unit: predict → teach → check (owner 2026-10-03)
The teaching cycle gains its motivation question. The teach quiz schema now carries a REQUIRED type field — predict (posed before the teaching, arrives open, the learner predicts the next step) or check (posed after, arrives collapsed behind the reveal). The prober's quiz stays role-less. - core: quizInputObject.type union + normalizeQuizType + the reveal law as pure quizNeedsReveal; QuestionData.questionType; note → thinking rename (legacy reads fold back); readingThinking channel on answers; compactAnswerForModel emits thinking / thinkingWhileReading - tools: descriptions stripped to mechanics — QUIZ_OPTION_CRAFT and EXPLAIN_DOCTRINE deleted, doctrine moved to the prompt layer (reverses the 2026-09-27 placement, owner 2026-10-03); shared quizDescription/explainDescription builders differ only in deltas - prompts: shared/20-question-tools.md rewritten (option craft, transcribed-speech leniency, the two thinking channels); new teacher/28-predictions.md (cycle, roles, skip rule, resolution law); 20-teaching + 30-question-principles scoped to the new taxonomy - UI: predictions render open with a Prediction eyebrow, verdict shows but the explanation tucks behind a toggle; the reading-thinking field mounts while a check waits (dictation claims it until reveal); answered card separates thinking vs thinking-while-reading blocks
This commit is contained in:
@@ -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 (
|
||||
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
|
||||
@@ -33,7 +35,7 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="answered-card">
|
||||
<div className="mb-1 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{isQuiz ? "Quiz" : isExplain ? "Explain" : "Question"}
|
||||
{isQuiz ? (answer.questionType === "predict" ? "Prediction" : "Quiz") : isExplain ? "Explain" : "Question"}
|
||||
{isQuiz &&
|
||||
(answer.dontKnow ? (
|
||||
<span className="rounded bg-muted px-1.5 py-0.5">didn't know</span>
|
||||
@@ -122,26 +124,62 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
|
||||
<div className="mt-2 text-sm text-muted-foreground">Answered “I don't know”.</div>
|
||||
)}
|
||||
|
||||
{answer.note && (
|
||||
{(answer.thinking ?? answer.note) && (
|
||||
<div data-testid="your-thinking" className="mt-3 border-l-2 border-primary/40 pl-3">
|
||||
<span className="mb-0.5 block text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Your thinking
|
||||
</span>
|
||||
<span className="lh-prose block whitespace-pre-wrap text-foreground/90">{answer.note}</span>
|
||||
<span className="lh-prose block whitespace-pre-wrap text-foreground/90">{answer.thinking ?? answer.note}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<div data-testid="your-reading-thinking" className="mt-3 border-l-2 border-muted-foreground/30 pl-3">
|
||||
<span className="mb-0.5 block text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Your thinking while reading
|
||||
</span>
|
||||
<span className="lh-prose block whitespace-pre-wrap text-foreground/90">{answer.readingThinking}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<div className="mt-3 rounded-lg bg-muted py-2 text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">Why: </span>
|
||||
{/* The markdown wrapper must go truly inline — a block <p> after the
|
||||
"Why:" label created the oversized leading gap (owner's fix list). */}
|
||||
<MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.explanation}</MessageResponse>
|
||||
{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. */
|
||||
<div className="mt-3" data-testid="tucked-explanation">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWhyOpen((v) => !v)}
|
||||
className="text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
{whyOpen ? "Hide why" : "Why"}
|
||||
</button>
|
||||
{whyOpen && (
|
||||
<div className="mt-1.5 rounded-lg bg-muted py-2 text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">Why: </span>
|
||||
<MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.explanation}</MessageResponse>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
isQuiz &&
|
||||
answer.explanation && (
|
||||
<div className="mt-3 rounded-lg bg-muted py-2 text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">Why: </span>
|
||||
{/* The markdown wrapper must go truly inline — a block <p> after the
|
||||
"Why:" label created the oversized leading gap (owner's fix list). */}
|
||||
<MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.explanation}</MessageResponse>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* 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. */}
|
||||
|
||||
+55
-5
@@ -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 ? <PlanMarginalia messages={messages} /> : 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);
|
||||
}}
|
||||
/>
|
||||
</Conversation>
|
||||
@@ -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<typeof findPendingToolCall>;
|
||||
/** 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). */}
|
||||
<QueuedSteersBody steers={queuedSteers} />
|
||||
{/* 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 && (
|
||||
<ReadingThinkingField
|
||||
value={readingThinking}
|
||||
onChange={onReadingThinking}
|
||||
onEscape={dictationCancel}
|
||||
claim={revealPending}
|
||||
/>
|
||||
)}
|
||||
<div ref={shellRef} className="pointer-events-auto overflow-hidden rounded-2xl border bg-card shadow-lg">
|
||||
<div key={mode} className={mode === initialModeRef.current ? undefined : "dock-variant"}>
|
||||
{pending ? (
|
||||
@@ -1743,6 +1792,7 @@ function DockOverlay({
|
||||
onExpand={expandAndReveal}
|
||||
onSubmit={onSubmitAnswer}
|
||||
onEscape={dictationCancel}
|
||||
dictationClaim={!revealPending}
|
||||
/>
|
||||
) : (
|
||||
composer
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className={expanded ? "dock-variant" : "invisible h-0 overflow-hidden"}>
|
||||
@@ -126,6 +138,7 @@ export function QuestionDock({
|
||||
active={expanded}
|
||||
onSubmit={onSubmit}
|
||||
onEscape={onEscape}
|
||||
dictationClaim={dictationClaim}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -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"
|
||||
>
|
||||
<span className="shrink-0 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{toolName === "quiz" ? "Quiz" : toolName === "explain" ? "Explain" : "Question"}
|
||||
{label ?? (toolName === "quiz" ? "Quiz" : toolName === "explain" ? "Explain" : "Question")}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-muted-foreground">{teaserText(question)}</span>
|
||||
<span className="flex shrink-0 items-center gap-1 text-xs font-medium">
|
||||
@@ -208,6 +226,7 @@ function QuestionDockForm({
|
||||
active,
|
||||
onSubmit,
|
||||
onEscape,
|
||||
dictationClaim = true,
|
||||
}: Omit<QuestionDockProps, "expanded" | "onExpand" | "reveal">) {
|
||||
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. */}
|
||||
<div className="max-h-[55dvh] overflow-y-auto px-4 pt-3">
|
||||
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{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"}
|
||||
</div>
|
||||
<div className="mb-3 font-medium">
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
data-testid="reading-thinking-field"
|
||||
className="pointer-events-auto mb-2 rounded-2xl border bg-card/60 px-3 py-2 transition-colors focus-within:border-primary/50"
|
||||
>
|
||||
<div className="pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Thinking while reading
|
||||
</div>
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
// Esc stops dictation (the strip's rule) — claimed so the
|
||||
// window chain doesn't also read it as abort-turn.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onEscape?.();
|
||||
return;
|
||||
}
|
||||
// The dock's dumping convention: plain Enter is a newline.
|
||||
// No submit button here — the check's own submit is this
|
||||
// field's carrier — so ⌘↵ just blurs.
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
placeholder="What are you thinking as you read? Say as much as you like — it rides your next check answer to the teacher."
|
||||
rows={1}
|
||||
className="field-sizing-content lh-input max-h-40 w-full resize-none bg-transparent outline-none placeholder:text-muted-foreground/70"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user