diff --git a/components/answered-card.tsx b/components/answered-card.tsx index fb9c013..d444290 100644 --- a/components/answered-card.tsx +++ b/components/answered-card.tsx @@ -41,9 +41,9 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) { {answer.questionText} - {answer.options && answer.options.length > 0 && ( + {((answer.options?.length ?? 0) > 0 || answer.other) && (
- {answer.options.map((opt) => { + {answer.options?.map((opt) => { const sel = selectedIdx.has(opt.index); const key = isQuiz && correctIdx.has(opt.index); return ( @@ -70,20 +70,38 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
); })} + {/* The learner's own typed option is an OPTION — same container, + same selected styling as its siblings, not a loose quote + floating outside the list. Quotes keep the learner's words. */} + {answer.other && ( +
+ “{answer.other}” +
+ )} )} - {answer.other &&
“{answer.other}”
} {answer.text &&
“{answer.text}”
} {answer.dontKnow && isQuiz &&
Answered “I don't know”.
} + {answer.note && ( +
+ + Your thinking + + {answer.note} +
+ )} + {isQuiz && answer.explanation && (
Why: {answer.explanation}
)} - {answer.note &&
Note: {answer.note}
} ); } diff --git a/components/chat.tsx b/components/chat.tsx index 3ec736f..9415f7d 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -24,7 +24,7 @@ import { QuestionDock } from "@/components/question-dock"; import { SidebarToggle } from "@/components/sidebar"; import { SubAgentsPanel } from "@/components/sub-agents-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 { findPendingToolCall, phaseAt3, phaseTimeline } from "@/src/core"; @@ -399,6 +399,7 @@ export function Chat({ ; /** Teach-phase pending question: arrives collapsed behind the reveal button (W5). */ requiresReveal: boolean; + /** Session phase at the pending question — scales the thinking field. */ + phase: Phase3; composer: React.ReactNode; onSubmitAnswer: (output: QuestionAnswerData) => void; }) { @@ -610,6 +614,7 @@ function DockOverlay({ input={pending.input as never} expanded={expanded} reveal={revealPending} + phase={phase} onExpand={expandAndReveal} onSubmit={onSubmitAnswer} /> diff --git a/components/composer.tsx b/components/composer.tsx index 353c158..df58d36 100644 --- a/components/composer.tsx +++ b/components/composer.tsx @@ -42,7 +42,10 @@ export function Composer({ value={value} onChange={(e) => onChange(e.target.value)} onKeyDown={(e) => { - if (e.key === "Enter" && !e.shiftKey) { + // Chat convention: Enter sends, Shift+Enter is a newline. + // ⌘/Ctrl+Enter also sends — the universal submit shortcut + // across every input in the app (thinking field, answers). + if (e.key === "Enter" && (e.metaKey || e.ctrlKey || !e.shiftKey)) { e.preventDefault(); if (busy) onStop(); else if (canSend) onSend(); diff --git a/components/question-dock.tsx b/components/question-dock.tsx index 836ac06..daa18fe 100644 --- a/components/question-dock.tsx +++ b/components/question-dock.tsx @@ -18,13 +18,22 @@ import { 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. One unit, one action: - * the bottom field (note for select modes, the answer for free-text asks, - * "Other" text when Other is picked) plus the Submit button send together. + * 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 @@ -47,9 +56,21 @@ export interface QuestionDockProps { 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 }: QuestionDockProps) { +export function QuestionDock({ + toolName, + toolCallId, + input, + expanded, + reveal = false, + onExpand, + onSubmit, + phase = "probe", +}: QuestionDockProps) { return ( <> {!expanded && ( @@ -65,6 +86,7 @@ export function QuestionDock({ toolName, toolCallId, input, expanded, reveal = f toolName={toolName} toolCallId={toolCallId} input={input} + phase={phase} onSubmit={onSubmit} /> @@ -140,7 +162,13 @@ export function QuestionDockBar({ } /** The expanded question: label, question, options, and the unified bottom row. */ -function QuestionDockForm({ toolName, toolCallId, input, onSubmit }: Omit) { +function QuestionDockForm({ + toolName, + toolCallId, + input, + phase = "probe", + onSubmit, +}: Omit) { const kind = toolName === "quiz" ? ("quiz" as const) : ("ask" as const); // Canonical QuestionData, built once per question. Quiz options are @@ -185,15 +213,18 @@ function QuestionDockForm({ toolName, toolCallId, input, onSubmit }: Omit>(new Set()); // 0-based indices const [dontKnow, setDontKnow] = useState(false); - const [showOther, setShowOther] = useState(false); - // The single free-text slot holds one of two values by mode. - const [note, setNote] = useState(""); + // "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); - setShowOther(false); + setOtherSelected(false); setSelected((prev) => { if (qd.mode === "multi-select") { const next = new Set(prev); @@ -208,27 +239,27 @@ function QuestionDockForm({ toolName, toolCallId, input, onSubmit }: Omit 0 - : dontKnow || (showOther && other.trim().length > 0) || selected.size > 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: showOther ? other : undefined, + other: otherSelected ? other : 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)); }; - // The bottom field: answer (text mode) / own answer (Other) / note. - const field = - qd.mode === "text" - ? { value: text, set: setText, placeholder: "Type your answer…" } - : showOther - ? { value: other, set: setOther, placeholder: "Type your own answer…" } - : { value: note, set: setNote, placeholder: "Add a note (optional) — anything you're unsure about" }; + // 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 (
@@ -289,7 +320,7 @@ function QuestionDockForm({ toolName, toolCallId, input, onSubmit }: Omit { setSelected(new Set()); - setShowOther(false); + setOtherSelected(false); setDontKnow((v) => !v); }} aria-pressed={dontKnow} @@ -303,55 +334,119 @@ function QuestionDockForm({ toolName, toolCallId, input, onSubmit }: Omit { - setSelected(new Set()); - setDontKnow(false); - setShowOther((v) => !v); - }} - aria-pressed={showOther} +
- Other… - + + {otherSelected && ( + + {qd.mode === "multi-select" ? "✓" : ""} + + )} + + { + 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()} + /> +
)}
)} - {/* The unified bottom row — same slot and radii as the chat composer: - the text field plus Submit, which sends text + selection together. */} + {/* 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. */}
{ e.preventDefault(); submit(); }} > -