"Your thinking": the learner's voice, first-class in the question dock

The note was the transfer channel of the whole product presented as an
afterthought: an unlabeled one-line slot whispering "optional". Now:

- A labeled composer-TWIN field ("Your thinking") between the options
  and Submit, docked with the submit button inside it exactly like the
  chat composer's send. The placeholder invites dumping ("Why this
  answer? What are you unsure about? Say as much as you like — the
  teacher reads all of this."); the word "optional" is banished.
- Enter makes a NEWLINE in the thinking field (it is for dumping, soon
  dictated); the button submits. Text-mode answers keep Enter-to-submit.
- Phase-scaled prominence: teaching = a generous dump area, probing =
  one quick row (threaded from the session phase via DockOverlay).
- "Other…" (ask questions) is now an editable OPTION ROW — radio circle
  that fills on selection + inline input, styled like its siblings — so
  picking Other never displaces the thinking field; typed text survives
  deselection.
- The answered card promotes the thinking to a quoted first-class
  block above the verdict, labeled "Your thinking" — the transcript
  reads as a dialogue of thinking, not a buried "Note:" footnote.

The frozen core is untouched (the teacher-facing "Note:" transcript
line in question-core.ts is protocol text, flagged to master).
This commit is contained in:
amosblomqvist
2026-09-09 01:57:44 +03:00
parent c061d11fd7
commit d057a5a616
4 changed files with 211 additions and 61 deletions
+9 -1
View File
@@ -77,13 +77,21 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
{answer.text && <div className="mt-2 whitespace-pre-wrap text-sm">“{answer.text}”</div>} {answer.text && <div className="mt-2 whitespace-pre-wrap text-sm">“{answer.text}”</div>}
{answer.dontKnow && isQuiz && <div className="mt-2 text-sm text-muted-foreground">Answered “I don't know”.</div>} {answer.dontKnow && isQuiz && <div className="mt-2 text-sm text-muted-foreground">Answered “I don't know”.</div>}
{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-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Your thinking
</span>
<span className="block whitespace-pre-wrap text-sm text-foreground/90">{answer.note}</span>
</div>
)}
{isQuiz && answer.explanation && ( {isQuiz && answer.explanation && (
<div className="mt-3 rounded-lg bg-muted px-3 py-2 text-sm text-muted-foreground"> <div className="mt-3 rounded-lg bg-muted px-3 py-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">Why: </span> <span className="font-medium text-foreground">Why: </span>
<MessageResponse className="inline">{answer.explanation}</MessageResponse> <MessageResponse className="inline">{answer.explanation}</MessageResponse>
</div> </div>
)} )}
{answer.note && <div className="mt-2 text-xs text-muted-foreground">Note: {answer.note}</div>}
</div> </div>
); );
} }
+6 -1
View File
@@ -24,7 +24,7 @@ import { QuestionDock } from "@/components/question-dock";
import { SidebarToggle } from "@/components/sidebar"; import { SidebarToggle } from "@/components/sidebar";
import { SubAgentsPanel } from "@/components/sub-agents-panel"; import { SubAgentsPanel } from "@/components/sub-agents-panel";
import { TreePanel } from "@/components/tree-panel"; import { TreePanel } from "@/components/tree-panel";
import type { ArtifactPartProps, JobRecord, QuestionAnswerData, TeacherUIMessage } from "@/src/core"; import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TeacherUIMessage } from "@/src/core";
import { PhaseChip, PhaseDivider, planNodeCount } from "@/components/phase-divider"; import { PhaseChip, PhaseDivider, planNodeCount } from "@/components/phase-divider";
import { findPendingToolCall, phaseAt3, phaseTimeline } from "@/src/core"; import { findPendingToolCall, phaseAt3, phaseTimeline } from "@/src/core";
@@ -376,6 +376,7 @@ export function Chat({
<DockOverlay <DockOverlay
pending={pending} pending={pending}
requiresReveal={requiresReveal} requiresReveal={requiresReveal}
phase={phases.at(-1) ?? "probe"}
composer={ composer={
<Composer <Composer
// Stop applies only to a LOCAL stream: a server-side turn has no // Stop applies only to a LOCAL stream: a server-side turn has no
@@ -428,12 +429,15 @@ export function Chat({
function DockOverlay({ function DockOverlay({
pending, pending,
requiresReveal, requiresReveal,
phase,
composer, composer,
onSubmitAnswer, onSubmitAnswer,
}: { }: {
pending: ReturnType<typeof findPendingToolCall>; pending: ReturnType<typeof findPendingToolCall>;
/** 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;
/** Session phase at the pending question — scales the thinking field. */
phase: Phase3;
composer: React.ReactNode; composer: React.ReactNode;
onSubmitAnswer: (output: QuestionAnswerData) => void; onSubmitAnswer: (output: QuestionAnswerData) => void;
}) { }) {
@@ -587,6 +591,7 @@ function DockOverlay({
input={pending.input as never} input={pending.input as never}
expanded={expanded} expanded={expanded}
reveal={revealPending} reveal={revealPending}
phase={phase}
onExpand={expandAndReveal} onExpand={expandAndReveal}
onSubmit={onSubmitAnswer} onSubmit={onSubmitAnswer}
/> />
+134 -54
View File
@@ -18,13 +18,22 @@ import {
type QuestionData, type QuestionData,
type QuestionMode, type QuestionMode,
type QuizToolInput, type QuizToolInput,
type Phase3,
} from "@/src/core"; } from "@/src/core";
/** /**
* The question dock — a pending quiz/ask REPLACES the chat composer: the * The question dock — a pending quiz/ask REPLACES the chat composer: the
* persistent bottom blob morphs into the question. One unit, one action: * persistent bottom blob morphs into the question. Two voices meet here:
* the bottom field (note for select modes, the answer for free-text asks, * the teacher's (question, options, explanation) and the learner's —
* "Other" text when Other is picked) plus the Submit button send together. * "Your thinking", a labeled composer-twin field for dumping reasoning,
* with Submit docked inside it exactly like the chat composer's send.
*
* The thinking field is the transfer channel of the whole product (wave-2):
* never called "optional", Enter makes a NEWLINE (it is for dumping — soon
* dictated, not typed), submission is the button only, and its size scales
* with the phase (probing stays quick; teaching invites depth). The
* "Other…" option is an editable option ROW — circle + inline input,
* styled like its siblings — so picking Other never displaces thinking.
* *
* Collapse behavior (scroll-up → slim bar) is owned by the DockOverlay in the * Collapse behavior (scroll-up → slim bar) is owned by the DockOverlay in the
* chat view, which also owns the persistent shell and its height morph. This * chat view, which also owns the persistent shell and its height morph. This
@@ -47,9 +56,21 @@ export interface QuestionDockProps {
reveal?: boolean; reveal?: boolean;
onExpand: () => void; onExpand: () => void;
onSubmit: (output: QuestionAnswerData) => void; onSubmit: (output: QuestionAnswerData) => void;
/** Session phase at the question — scales the thinking field: probing
* stays quick (one row), teaching invites dumping (three rows). */
phase?: Phase3;
} }
export function QuestionDock({ toolName, toolCallId, input, expanded, reveal = false, onExpand, onSubmit }: QuestionDockProps) { export function QuestionDock({
toolName,
toolCallId,
input,
expanded,
reveal = false,
onExpand,
onSubmit,
phase = "probe",
}: QuestionDockProps) {
return ( return (
<> <>
{!expanded && ( {!expanded && (
@@ -65,6 +86,7 @@ export function QuestionDock({ toolName, toolCallId, input, expanded, reveal = f
toolName={toolName} toolName={toolName}
toolCallId={toolCallId} toolCallId={toolCallId}
input={input} input={input}
phase={phase}
onSubmit={onSubmit} onSubmit={onSubmit}
/> />
</div> </div>
@@ -140,7 +162,13 @@ export function QuestionDockBar({
} }
/** The expanded question: label, question, options, and the unified bottom row. */ /** The expanded question: label, question, options, and the unified bottom row. */
function QuestionDockForm({ toolName, toolCallId, input, onSubmit }: Omit<QuestionDockProps, "expanded" | "onExpand">) { function QuestionDockForm({
toolName,
toolCallId,
input,
phase = "probe",
onSubmit,
}: Omit<QuestionDockProps, "expanded" | "onExpand">) {
const kind = toolName === "quiz" ? ("quiz" as const) : ("ask" as const); const kind = toolName === "quiz" ? ("quiz" as const) : ("ask" as const);
// Canonical QuestionData, built once per question. Quiz options are // Canonical QuestionData, built once per question. Quiz options are
@@ -185,15 +213,18 @@ function QuestionDockForm({ toolName, toolCallId, input, onSubmit }: Omit<Questi
const [selected, setSelected] = useState<Set<number>>(new Set()); // 0-based indices const [selected, setSelected] = useState<Set<number>>(new Set()); // 0-based indices
const [dontKnow, setDontKnow] = useState(false); const [dontKnow, setDontKnow] = useState(false);
const [showOther, setShowOther] = useState(false); // "Other" is selected by typing into its inline input (or clicking its
// The single free-text slot holds one of two values by mode. // circle). Picking a sibling option deselects it, but the typed text
const [note, setNote] = useState(""); // survives — the learner's words are never thrown away.
const [otherSelected, setOtherSelected] = useState(false);
// The learner's transfer channel — thinking, never "a note".
const [thinking, setThinking] = useState("");
const [other, setOther] = useState(""); const [other, setOther] = useState("");
const [text, setText] = useState(""); const [text, setText] = useState("");
const toggle = (idx: number) => { const toggle = (idx: number) => {
setDontKnow(false); setDontKnow(false);
setShowOther(false); setOtherSelected(false);
setSelected((prev) => { setSelected((prev) => {
if (qd.mode === "multi-select") { if (qd.mode === "multi-select") {
const next = new Set(prev); const next = new Set(prev);
@@ -208,27 +239,27 @@ function QuestionDockForm({ toolName, toolCallId, input, onSubmit }: Omit<Questi
const canSubmit = const canSubmit =
qd.mode === "text" qd.mode === "text"
? text.trim().length > 0 ? text.trim().length > 0
: dontKnow || (showOther && other.trim().length > 0) || selected.size > 0; : dontKnow || (otherSelected && other.trim().length > 0) || selected.size > 0;
const submit = () => { const submit = () => {
if (!canSubmit) return; if (!canSubmit) return;
const req: AnswerRequest = { const req: AnswerRequest = {
dontKnow, dontKnow,
values: [...selected].map((i) => qd.options[i]!.value), values: [...selected].map((i) => qd.options[i]!.value),
other: showOther ? other : undefined, other: otherSelected ? other : undefined,
text: qd.mode === "text" ? text : undefined, text: qd.mode === "text" ? text : undefined,
note: qd.mode !== "text" ? note.trim() || undefined : undefined, note: qd.mode !== "text" ? thinking.trim() || undefined : undefined,
}; };
onSubmit(buildAnswer(qd, req)); onSubmit(buildAnswer(qd, req));
}; };
// The bottom field: answer (text mode) / own answer (Other) / note. // Selecting Other never clears the thinking field — two channels, the
const field = // answer and the reasoning behind it, side by side.
qd.mode === "text" const selectOther = () => {
? { value: text, set: setText, placeholder: "Type your answer…" } setDontKnow(false);
: showOther setSelected(new Set());
? { value: other, set: setOther, placeholder: "Type your own answer…" } setOtherSelected(true);
: { value: note, set: setNote, placeholder: "Add a note (optional) — anything you're unsure about" }; };
return ( return (
<div data-testid="question-dock"> <div data-testid="question-dock">
@@ -289,7 +320,7 @@ function QuestionDockForm({ toolName, toolCallId, input, onSubmit }: Omit<Questi
type="button" type="button"
onClick={() => { onClick={() => {
setSelected(new Set()); setSelected(new Set());
setShowOther(false); setOtherSelected(false);
setDontKnow((v) => !v); setDontKnow((v) => !v);
}} }}
aria-pressed={dontKnow} aria-pressed={dontKnow}
@@ -303,55 +334,104 @@ function QuestionDockForm({ toolName, toolCallId, input, onSubmit }: Omit<Questi
)} )}
{kind === "ask" && ( {kind === "ask" && (
<button <div
type="button" role={qd.mode === "multi-select" ? "checkbox" : "radio"}
onClick={() => { aria-checked={otherSelected}
setSelected(new Set()); data-testid="other-option"
setDontKnow(false); onClick={selectOther}
setShowOther((v) => !v);
}}
aria-pressed={showOther}
className={cn( className={cn(
"rounded-lg border border-dashed px-3 py-2 text-left text-sm text-muted-foreground transition-colors", "flex items-start gap-3 rounded-lg border px-3 py-2 text-left text-sm transition-colors",
showOther && "border-primary text-foreground", otherSelected ? "border-primary bg-accent" : "border-dashed hover:bg-accent/50",
)} )}
> >
Other… <span
</button> className={cn(
"mt-0.5 flex size-4 shrink-0 items-center justify-center border",
qd.mode === "multi-select" ? "rounded-sm" : "rounded-full",
otherSelected
? "border-primary bg-primary text-primary-foreground"
: "border-muted-foreground/40",
)}
>
{otherSelected && (
<span className={cn(qd.mode === "multi-select" ? "text-[10px]" : "size-1.5 rounded-full bg-current")}>
{qd.mode === "multi-select" ? "✓" : ""}
</span>
)}
</span>
<input
value={other}
onChange={(e) => {
setOther(e.target.value);
if (!otherSelected) setOtherSelected(true);
}}
placeholder="Type your own answer…"
className="w-full min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
onClick={(e) => e.stopPropagation()}
/>
</div>
)} )}
</div> </div>
)} )}
</div> </div>
{/* The unified bottom row — same slot and radii as the chat composer: {/* The learner's half of the dock — a composer TWIN: a labeled, bordered
the text field plus Submit, which sends text + selection together. */} field with Submit docked inside, exactly like the chat composer's
send. Select modes get "Your thinking" (Enter makes a newline — it
is for dumping, soon dictated; the button submits); text mode
keeps "Your answer" with Enter-to-submit. */}
<form <form
className="flex items-end gap-1.5 border-t border-border/60 p-1.5" className="border-t border-border/60 p-1.5"
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
submit(); submit();
}} }}
> >
<textarea <div
value={field.value} data-testid={qd.mode === "text" ? "answer-field" : "thinking-field"}
onChange={(e) => field.set(e.target.value)} className="rounded-xl border border-border/70 bg-background/60 transition-colors focus-within:border-primary/50"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
}}
placeholder={field.placeholder}
rows={1}
className="field-sizing-content max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 text-sm outline-none placeholder:text-muted-foreground"
/>
<Button
type="submit"
disabled={!canSubmit}
className="h-10 shrink-0 rounded-[10px] px-3.5 text-sm font-medium"
> >
Submit answer <div className="px-2.5 pt-2 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
</Button> {qd.mode === "text" ? "Your answer" : "Your thinking"}
</div>
{qd.mode === "text" ? (
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
}}
placeholder="Type your answer…"
rows={1}
className="field-sizing-content max-h-48 min-h-10 w-full resize-none bg-transparent px-2.5 py-2 text-sm outline-none placeholder:text-muted-foreground"
/>
) : (
<textarea
value={thinking}
onChange={(e) => setThinking(e.target.value)}
placeholder="Why this answer? What are you unsure about? Say as much as you like — the teacher reads all of this."
rows={1}
className={cn(
"field-sizing-content max-h-48 w-full resize-none bg-transparent px-2.5 py-2 text-sm outline-none placeholder:text-muted-foreground/70",
// Phase-scaled prominence: teaching invites depth,
// probing stays quick.
phase === "teach" ? "min-h-[4.5rem]" : "min-h-10",
)}
/>
)}
<div className="flex items-center justify-end p-1">
<Button
type="submit"
disabled={!canSubmit}
className="h-9 rounded-[10px] px-3.5 text-sm font-medium"
>
Submit answer
</Button>
</div>
</div>
</form> </form>
</div> </div>
); );
+62 -5
View File
@@ -56,8 +56,12 @@ describe("render smoke tests", () => {
assert.match(html, /Which planet is closest to the sun\?/); assert.match(html, /Which planet is closest to the sun\?/);
assert.match(html, /Mercury/); assert.match(html, /Mercury/);
assert.match(html, /I don&#x27;t know|I don't know/); assert.match(html, /I don&#x27;t know|I don't know/);
// The unified bottom row: note field + Submit answer live INSIDE the dock. // The learner's voice: a labeled "Your thinking" composer-twin lives
assert.match(html, /Add a note/, "note field is the dock's bottom row"); // INSIDE the dock — never called optional, inviting the dump.
assert.ok(html.includes('data-testid="thinking-field"'), "thinking field present");
assert.match(html, /Your thinking/, "labeled as the learner's voice");
assert.match(html, /Why this answer\? What are you unsure about\?/, "placeholder invites, not apologizes");
assert.ok(!html.includes("optional"), "the field is never called optional");
assert.match(html, /Submit answer/, "send becomes Submit answer"); assert.match(html, /Submit answer/, "send becomes Submit answer");
// The v1 desync bug: options are buttons with ARIA roles, not native inputs. // The v1 desync bug: options are buttons with ARIA roles, not native inputs.
assert.ok(!html.includes('type="radio"'), "no native radio inputs"); assert.ok(!html.includes('type="radio"'), "no native radio inputs");
@@ -69,15 +73,50 @@ describe("render smoke tests", () => {
assert.ok(!html.includes("innermost"), "explanation must not render pre-answer"); assert.ok(!html.includes("innermost"), "explanation must not render pre-answer");
}); });
it("ask dock renders Other + note field, no grading affordance", () => { it("ask dock renders the editable Other row + thinking field, no grading affordance", () => {
const html = renderDock({ const html = renderDock({
toolName: "ask_user_question", toolName: "ask_user_question",
toolCallId: "a1", toolCallId: "a1",
input: { question: "How confident are you?", options: [{ label: "Very" }, { label: "Somewhat" }] } as never, input: { question: "How confident are you?", options: [{ label: "Very" }, { label: "Somewhat" }] } as never,
}); });
assert.match(html, /Other/);
assert.ok(!html.includes("I don&#x27;t know"), "ask has no 'I don't know'"); assert.ok(!html.includes("I don&#x27;t know"), "ask has no 'I don't know'");
assert.match(html, /Add a note/, "ask select-mode also gets the note row"); // Other is an editable OPTION ROW — a sibling of the options, with the
// radio circle + an inline input; picking it never displaces thinking.
assert.ok(html.includes('data-testid="other-option"'), "Other is an option row");
assert.match(html, /Type your own answer/, "type directly into the option");
assert.ok(html.includes('role="radio"'), "Other row keeps the option role");
assert.match(html, /aria-checked="false"/, "circle starts unfilled");
assert.ok(html.includes('data-testid="thinking-field"'), "thinking field coexists with Other");
assert.match(html, /Your thinking/, "ask select-mode also gets the learner's voice");
});
it("phase scales the thinking field: teaching invites depth, probing stays quick", () => {
const teach = renderToStaticMarkup(
<QuestionDock
toolName="quiz"
toolCallId="t1"
input={quizInput as never}
expanded={true}
phase="teach"
onExpand={() => {}}
onSubmit={() => {}}
/>,
);
assert.ok(teach.includes("min-h-[4.5rem]"), "teach: a generous dump area");
const probe = renderDock({ toolName: "quiz", toolCallId: "q1", input: quizInput as never });
assert.ok(!probe.includes("min-h-[4.5rem]"), "probe (default): stays compact");
assert.ok(probe.includes("min-h-10"), "probe: one quiet row");
});
it("text-mode asks keep Enter-to-submit and the answer label", () => {
const html = renderDock({
toolName: "ask_user_question",
toolCallId: "a2",
input: { question: "Explain the chain rule in your own words." } as never,
});
assert.ok(html.includes('data-testid="answer-field"'), "text mode is the answer field");
assert.match(html, /Your answer/, "labeled as the answer");
assert.ok(!html.includes('data-testid="thinking-field"'), "no thinking field in text mode (v1)");
}); });
it("AnsweredCard grades quiz answers (✓/✗ + explanation)", () => { it("AnsweredCard grades quiz answers (✓/✗ + explanation)", () => {
@@ -88,6 +127,24 @@ describe("render smoke tests", () => {
assert.ok(html.includes("✓") && html.includes("✗")); assert.ok(html.includes("✓") && html.includes("✗"));
}); });
it("AnsweredCard promotes the learner's thinking to a first-class block", () => {
const answer = buildAnswer(quizQD(), {
values: ["Venus"],
note: "I picked Venus because it looks brightest — but now I realize brightness isn't distance.",
});
const html = renderToStaticMarkup(<AnsweredCard answer={answer} />);
assert.ok(html.includes('data-testid="your-thinking"'), "the thinking block exists");
assert.match(html, /Your thinking/, "labeled as the learner's voice");
assert.match(html, /brightness isn&#x27;t distance/, "the thinking itself renders");
assert.ok(!html.includes("Note:"), "no longer a muted 'Note:' footnote");
// The thinking sits ABOVE the verdict's explanation — the dialogue of
// thinking, not an afterthought.
assert.ok(
html.indexOf("your-thinking") < html.indexOf("innermost"),
"thinking before the explanation",
);
});
it("AnsweredCard does NOT grade ask answers", () => { it("AnsweredCard does NOT grade ask answers", () => {
const qd: QuestionData = { const qd: QuestionData = {
id: "a1", id: "a1",