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).
439 lines
15 KiB
TypeScript
439 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo, useState } from "react";
|
|
import { ArrowDownIcon, ChevronRightIcon, PlayIcon } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { MessageResponse } from "@/components/ai-elements/message";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
buildAnswer,
|
|
coerceCorrectAnswer,
|
|
normalizeOptions,
|
|
resolveCorrect,
|
|
seededRandom,
|
|
shuffleOptions,
|
|
type AnswerRequest,
|
|
type AskToolInput,
|
|
type QuestionAnswerData,
|
|
type QuestionData,
|
|
type QuestionMode,
|
|
type QuizToolInput,
|
|
type Phase3,
|
|
} from "@/src/core";
|
|
|
|
/**
|
|
* The question dock — a pending quiz/ask REPLACES the chat composer: the
|
|
* persistent bottom blob morphs into the question. Two voices meet here:
|
|
* the teacher's (question, options, explanation) and the learner's —
|
|
* "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
|
|
* chat view, which also owns the persistent shell and its height morph. This
|
|
* component is content only. The expanded form stays MOUNTED while collapsed
|
|
* (hidden, not unmounted) so a half-made selection survives scrolling.
|
|
*/
|
|
|
|
export interface QuestionDockProps {
|
|
toolName: "quiz" | "ask_user_question";
|
|
toolCallId: string;
|
|
input: QuizToolInput | AskToolInput;
|
|
/** Collapse state, owned by the DockOverlay (it drives the shell's height morph). */
|
|
expanded: boolean;
|
|
/**
|
|
* Teach-phase check awaiting its reveal (vision §2, W5): while collapsed,
|
|
* the bar is the "Check your understanding" invitation instead of the
|
|
* scroll-collapse strip. Owned by the DockOverlay; false once the learner
|
|
* has revealed (afterwards the normal collapse machine rules apply).
|
|
*/
|
|
reveal?: boolean;
|
|
onExpand: () => 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,
|
|
phase = "probe",
|
|
}: QuestionDockProps) {
|
|
return (
|
|
<>
|
|
{!expanded && (
|
|
<QuestionDockBar
|
|
toolName={toolName}
|
|
question={input.question}
|
|
onExpand={onExpand}
|
|
variant={reveal ? "reveal" : "collapsed"}
|
|
/>
|
|
)}
|
|
<div className={expanded ? "dock-variant" : "invisible h-0 overflow-hidden"}>
|
|
<QuestionDockForm
|
|
toolName={toolName}
|
|
toolCallId={toolCallId}
|
|
input={input}
|
|
phase={phase}
|
|
onSubmit={onSubmit}
|
|
/>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** A plain-text teaser of a question for the collapsed bar: unwrap math, flatten common LaTeX. */
|
|
function teaserText(q: string): string {
|
|
return q
|
|
.replace(/\\\(([\s\S]*?)\\\)/g, "$1") // \(...\) inline math
|
|
.replace(/\\\[([\s\S]*?)\\\]/g, "$1") // \[...\] display math
|
|
.replace(/\$\$?([^$]*)\$\$?/g, "$1") // legacy $...$ (pre-switch sessions)
|
|
.replace(/\\frac\{([^{}]*)\}\{([^{}]*)\}/g, "$1/$2")
|
|
.replace(/\\([a-zA-Z]+)/g, "$1")
|
|
.replace(/[{}]/g, "")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
/** The collapsed strip. Two species (W5): "collapsed" is the scroll minimization
|
|
* (kind chip + one-liner + Answer ↓); "reveal" is the teach-check invitation —
|
|
* a distinct, more inviting affordance, because it asks the learner to start
|
|
* something, not to resume something. */
|
|
export function QuestionDockBar({
|
|
toolName,
|
|
question,
|
|
onExpand,
|
|
variant = "collapsed",
|
|
}: {
|
|
toolName: "quiz" | "ask_user_question";
|
|
question: string;
|
|
onExpand: () => void;
|
|
variant?: "collapsed" | "reveal";
|
|
}) {
|
|
if (variant === "reveal") {
|
|
return (
|
|
<button
|
|
type="button"
|
|
data-testid="teach-check-reveal"
|
|
onClick={onExpand}
|
|
className="dock-variant flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-accent/40"
|
|
>
|
|
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-primary/15 text-primary">
|
|
<PlayIcon className="size-3.5" />
|
|
</span>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block text-sm font-medium">Check your understanding</span>
|
|
<span className="block truncate text-xs text-muted-foreground">{teaserText(question)}</span>
|
|
</span>
|
|
<span className="flex shrink-0 items-center gap-1 text-xs font-medium text-primary">
|
|
Open <ChevronRightIcon className="size-3.5" />
|
|
</span>
|
|
</button>
|
|
);
|
|
}
|
|
return (
|
|
<button
|
|
type="button"
|
|
data-testid="question-dock-bar"
|
|
onClick={onExpand}
|
|
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" : "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">
|
|
Answer <ArrowDownIcon className="size-3.5" />
|
|
</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
/** The expanded question: label, question, options, and the unified bottom row. */
|
|
function QuestionDockForm({
|
|
toolName,
|
|
toolCallId,
|
|
input,
|
|
phase = "probe",
|
|
onSubmit,
|
|
}: Omit<QuestionDockProps, "expanded" | "onExpand">) {
|
|
const kind = toolName === "quiz" ? ("quiz" as const) : ("ask" as const);
|
|
|
|
// Canonical QuestionData, built once per question. Quiz options are
|
|
// deterministically shuffled (seeded by toolCallId) so position never leaks
|
|
// correctness AND server/client render the same order (hydration-safe);
|
|
// the display order is snapshotted into the answer record at submit time.
|
|
const qd = useMemo<QuestionData>(() => {
|
|
const quizInput = kind === "quiz" ? (input as QuizToolInput) : null;
|
|
const askInput = kind === "ask" ? (input as AskToolInput) : null;
|
|
const multi = input.multiSelect === true;
|
|
const mode: QuestionMode =
|
|
kind === "quiz"
|
|
? multi
|
|
? "multi-select"
|
|
: "single-select"
|
|
: askInput?.options?.length
|
|
? multi
|
|
? "multi-select"
|
|
: "single-select"
|
|
: "text";
|
|
const options = normalizeOptions(
|
|
(quizInput?.options ?? askInput?.options ?? []) as { label: string; value?: string }[],
|
|
);
|
|
const ordered = kind === "quiz" && options.length > 0 ? shuffleOptions(options, seededRandom(toolCallId)) : options;
|
|
const q: QuestionData = {
|
|
id: toolCallId,
|
|
kind,
|
|
question: input.question,
|
|
context: input.details,
|
|
mode,
|
|
options: ordered,
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
if (quizInput) {
|
|
const resolved = resolveCorrect(coerceCorrectAnswer(quizInput.correctAnswer), ordered);
|
|
if (resolved.error) console.error("[quiz] correctAnswer did not resolve:", resolved.error);
|
|
q.correctIndices = resolved.indices;
|
|
q.explanation = quizInput.explanation;
|
|
}
|
|
return q;
|
|
}, [toolCallId, kind, input]);
|
|
|
|
const [selected, setSelected] = useState<Set<number>>(new Set()); // 0-based indices
|
|
const [dontKnow, setDontKnow] = useState(false);
|
|
// "Other" is selected by typing into its inline input (or clicking its
|
|
// circle). Picking a sibling option deselects it, but the typed text
|
|
// 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 [text, setText] = useState("");
|
|
|
|
const toggle = (idx: number) => {
|
|
setDontKnow(false);
|
|
setOtherSelected(false);
|
|
setSelected((prev) => {
|
|
if (qd.mode === "multi-select") {
|
|
const next = new Set(prev);
|
|
if (next.has(idx)) next.delete(idx);
|
|
else next.add(idx);
|
|
return next;
|
|
}
|
|
return new Set([idx]);
|
|
});
|
|
};
|
|
|
|
const canSubmit =
|
|
qd.mode === "text"
|
|
? text.trim().length > 0
|
|
: dontKnow || (otherSelected && other.trim().length > 0) || selected.size > 0;
|
|
|
|
const submit = () => {
|
|
if (!canSubmit) return;
|
|
const req: AnswerRequest = {
|
|
dontKnow,
|
|
values: [...selected].map((i) => qd.options[i]!.value),
|
|
other: otherSelected ? other : undefined,
|
|
text: qd.mode === "text" ? text : undefined,
|
|
note: qd.mode !== "text" ? thinking.trim() || undefined : undefined,
|
|
};
|
|
onSubmit(buildAnswer(qd, req));
|
|
};
|
|
|
|
// Selecting Other never clears the thinking field — two channels, the
|
|
// answer and the reasoning behind it, side by side.
|
|
const selectOther = () => {
|
|
setDontKnow(false);
|
|
setSelected(new Set());
|
|
setOtherSelected(true);
|
|
};
|
|
|
|
return (
|
|
<div data-testid="question-dock">
|
|
{/* 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" : "Question"}
|
|
{qd.mode === "multi-select" && " · select all that apply"}
|
|
</div>
|
|
<div className="mb-3 font-medium">
|
|
<MessageResponse>{qd.question}</MessageResponse>
|
|
</div>
|
|
{qd.context && (
|
|
<div className="mb-3 text-sm text-muted-foreground">
|
|
<MessageResponse>{qd.context}</MessageResponse>
|
|
</div>
|
|
)}
|
|
|
|
{qd.mode !== "text" && (
|
|
<div className="flex flex-col gap-2 pb-1" role={qd.mode === "multi-select" ? "group" : "radiogroup"}>
|
|
{qd.options.map((opt, i) => {
|
|
const isSel = selected.has(i);
|
|
return (
|
|
<button
|
|
key={opt.value}
|
|
type="button"
|
|
role={qd.mode === "multi-select" ? "checkbox" : "radio"}
|
|
aria-checked={isSel}
|
|
onClick={() => toggle(i)}
|
|
className={cn(
|
|
"flex items-start gap-3 rounded-lg border px-3 py-2 text-left text-sm transition-colors",
|
|
isSel ? "border-primary bg-accent" : "hover:bg-accent/50",
|
|
)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
"mt-0.5 flex size-4 shrink-0 items-center justify-center border",
|
|
qd.mode === "multi-select" ? "rounded-sm" : "rounded-full",
|
|
isSel ? "border-primary bg-primary text-primary-foreground" : "border-muted-foreground/40",
|
|
)}
|
|
>
|
|
{isSel && (
|
|
<span className={cn(qd.mode === "multi-select" ? "text-[10px]" : "size-1.5 rounded-full bg-current")}>
|
|
{qd.mode === "multi-select" ? "✓" : ""}
|
|
</span>
|
|
)}
|
|
</span>
|
|
<span>
|
|
<MessageResponse>{opt.label}</MessageResponse>
|
|
{opt.description && <span className="block text-xs text-muted-foreground">{opt.description}</span>}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
|
|
{kind === "quiz" && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setSelected(new Set());
|
|
setOtherSelected(false);
|
|
setDontKnow((v) => !v);
|
|
}}
|
|
aria-pressed={dontKnow}
|
|
className={cn(
|
|
"rounded-lg border border-dashed px-3 py-2 text-left text-sm text-muted-foreground transition-colors",
|
|
dontKnow && "border-primary text-foreground",
|
|
)}
|
|
>
|
|
I don't know
|
|
</button>
|
|
)}
|
|
|
|
{kind === "ask" && (
|
|
<div
|
|
role={qd.mode === "multi-select" ? "checkbox" : "radio"}
|
|
aria-checked={otherSelected}
|
|
data-testid="other-option"
|
|
onClick={selectOther}
|
|
className={cn(
|
|
"flex items-start gap-3 rounded-lg border px-3 py-2 text-left text-sm transition-colors",
|
|
otherSelected ? "border-primary bg-accent" : "border-dashed hover:bg-accent/50",
|
|
)}
|
|
>
|
|
<span
|
|
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>
|
|
|
|
{/* The learner's half of the dock — a composer TWIN: a labeled, bordered
|
|
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
|
|
className="border-t border-border/60 p-1.5"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
submit();
|
|
}}
|
|
>
|
|
<div
|
|
data-testid={qd.mode === "text" ? "answer-field" : "thinking-field"}
|
|
className="rounded-xl border border-border/70 bg-background/60 transition-colors focus-within:border-primary/50"
|
|
>
|
|
<div className="px-2.5 pt-2 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
|
{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>
|
|
</div>
|
|
);
|
|
}
|