);
})}
+ {/* 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 (