From 1a1b39aac5dcef8621ebb89a0cf612a1a933e617 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sat, 5 Sep 2026 21:29:17 +0300 Subject: [PATCH 1/4] Plan graph: fit scales small graphs UP to use the panel (wave-2 item 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fit zoom was capped at 1×, so any graph smaller than the panel rendered at natural size, centered, with big empty margins above and below — the common case for early-session chains in the 384px panel. The fit now scales up to fill (FIT_ZOOM_MAX 1.5 — a lone node doesn't become a billboard), floor-clamped as before. Math extracted into fitZoomFor() and unit-tested (scale-up, cap, width/height-bound, floor, unmeasured viewport). --- components/tree-panel.tsx | 40 +++++++++++++++++++++++--------- next-env.d.ts | 4 ++-- tests/render-tree-panel.test.tsx | 37 +++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 13 deletions(-) diff --git a/components/tree-panel.tsx b/components/tree-panel.tsx index 393bc1a..1de4af3 100644 --- a/components/tree-panel.tsx +++ b/components/tree-panel.tsx @@ -541,6 +541,13 @@ const GRAPH_ZOOM_BTN = * nothing — fully zoomed out IS the fit), never above 2×. */ const ZOOM_MAX = 2; const ZOOM_FLOOR = 0.15; +/** The fit view's breathing room. The fit is the default view — slim + * margins fit more graph. */ +const FIT_PAD = 16; +/** The fit may scale a SMALL graph up to fill the panel (wave-2 fix: the + * old ≤1× cap left small diagrams tiny with big empty margins), but + * never past this — a lone node shouldn't become a billboard. */ +const FIT_ZOOM_MAX = 1.5; /** The graph's view transform: zoom + translation (viewport px). */ export interface GraphView { @@ -549,6 +556,24 @@ export interface GraphView { y: number; } +/** The zoom that fits a bw×bh graph into a w×h viewport: scales DOWN + * large graphs (floor-clamped) and UP small ones (FIT_ZOOM_MAX-capped) + * so the panel is used, not padded with empty margins. */ +export function fitZoomFor( + box: { w: number; h: number }, + viewport: { w: number; h: number }, +): number { + if (!viewport.w || !viewport.h || box.w === 0) return 1; + return Math.max( + ZOOM_FLOOR, + Math.min( + (viewport.w - FIT_PAD) / box.w, + (viewport.h - FIT_PAD) / box.h, + FIT_ZOOM_MAX, + ), + ); +} + /** The fit-centered view: the whole bw×bh graph centered in the w×h * viewport — fully zoomed out is EXACTLY this, nothing to pan. */ export function fitGraphView( @@ -607,20 +632,13 @@ export function TreeGraph({ tree }: { tree: PlanTree }) { return () => ro.disconnect(); }, []); - // The zoom floor: fully zoomed out, the whole diagram fits the panel. - // Pad 16: the fit view IS the default — slim margins fit more graph. - const fitZoom = useMemo(() => { - const pad = 16; - if (!size.w || !size.h || layout.width === 0) return 1; - return Math.max( - ZOOM_FLOOR, - Math.min((size.w - pad) / layout.width, (size.h - pad) / layout.height, 1), - ); - }, [size, layout]); + // The zoom floor: fully zoomed out, the whole diagram fills the panel + // (small graphs scale UP to use the space, capped at FIT_ZOOM_MAX). + const box = useMemo(() => ({ w: layout.width, h: layout.height }), [layout]); + const fitZoom = useMemo(() => fitZoomFor(box, size), [box, size]); const [view, setView] = useState({ x: 0, y: 0, zoom: 1 }); const userAdjustedRef = useRef(false); - const box = useMemo(() => ({ w: layout.width, h: layout.height }), [layout]); // Epsilon: zoom is float; "at the floor" needs a tolerance. const atFloor = view.zoom <= fitZoom + 1e-9; diff --git a/next-env.d.ts b/next-env.d.ts index ce4e94a..a419cbe 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,7 +1,7 @@ /// /// -import "./.next/types/routes.d.ts"; -import "./.next/types/root-params.d.ts"; +import "./.next/dev/types/routes.d.ts"; +import "./.next/dev/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/tests/render-tree-panel.test.tsx b/tests/render-tree-panel.test.tsx index 9986d65..3014e35 100644 --- a/tests/render-tree-panel.test.tsx +++ b/tests/render-tree-panel.test.tsx @@ -459,3 +459,40 @@ describe("fitGraphView / zoomedGraphView", () => { assert.equal(next.zoom, 2); }); }); + +// ─── fit sizing (wave-2: small graphs must USE the panel, not float tiny) ───── + +import { fitZoomFor } from "../components/tree-panel.tsx"; + +describe("fitZoomFor", () => { + it("a graph smaller than the panel scales UP to use it — no tiny diagram with empty margins", () => { + // 5-node chain: 176 wide × 448 tall in a 384×600 panel: height-bound. + // Old bug: the ≤1× cap rendered such graphs at natural size, centered, + // tiny, with big empty areas above and below. + const z = fitZoomFor({ w: 176, h: 448 }, { w: 384, h: 600 }); + assert.equal(z, (600 - 16) / 448, "height-bound: fills the tall panel"); + assert.ok(z > 1, "scales UP past 1×"); + }); + + it("the scale-up is capped: a lone node doesn't become a billboard", () => { + const z = fitZoomFor({ w: 176, h: 48 }, { w: 384, h: 600 }); + assert.equal(z, 1.5, "capped at FIT_ZOOM_MAX"); + }); + + it("a wide graph is width-bound; a tall graph is height-bound", () => { + assert.equal(fitZoomFor({ w: 1000, h: 100 }, { w: 384, h: 600 }), (384 - 16) / 1000); + assert.equal(fitZoomFor({ w: 100, h: 1000 }, { w: 384, h: 600 }), (600 - 16) / 1000); + }); + + it("a graph larger than the panel scales DOWN, floor-clamped", () => { + assert.equal(fitZoomFor({ w: 1500, h: 1500 }, { w: 384, h: 600 }), (384 - 16) / 1500); + // absurdly large → clamped at the floor, never disappears + assert.equal(fitZoomFor({ w: 5000, h: 5000 }, { w: 384, h: 600 }), 0.15); + assert.equal(fitZoomFor({ w: 1e6, h: 1e6 }, { w: 384, h: 600 }), 0.15); + }); + + it("an unmeasured viewport (SSR / first paint) stays at 1×", () => { + assert.equal(fitZoomFor({ w: 1000, h: 800 }, { w: 0, h: 0 }), 1); + assert.equal(fitZoomFor({ w: 0, h: 800 }, { w: 384, h: 600 }), 1); + }); +}); From d057a5a61645d5055b940759be1de9c3dc92aa5b Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 9 Sep 2026 01:57:44 +0300 Subject: [PATCH 2/4] "Your thinking": the learner's voice, first-class in the question dock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- components/answered-card.tsx | 10 +- components/chat.tsx | 7 +- components/question-dock.tsx | 188 +++++++++++++++++++++++++---------- tests/render-cards.test.tsx | 67 ++++++++++++- 4 files changed, 211 insertions(+), 61 deletions(-) diff --git a/components/answered-card.tsx b/components/answered-card.tsx index fb9c013..cae329d 100644 --- a/components/answered-card.tsx +++ b/components/answered-card.tsx @@ -77,13 +77,21 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) { {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 6f089b8..49bd7b2 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"; @@ -376,6 +376,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; }) { @@ -587,6 +591,7 @@ function DockOverlay({ input={pending.input as never} expanded={expanded} reveal={revealPending} + phase={phase} onExpand={expandAndReveal} onSubmit={onSubmitAnswer} /> diff --git a/components/question-dock.tsx b/components/question-dock.tsx index 836ac06..c084fd5 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,104 @@ 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(); }} > -