Merge feat/ui-polish: plan-graph fit scaling, Your-thinking voice, Other option row, universal submit shortcut

This commit is contained in:
amosblomqvist
2026-09-09 06:47:11 +03:00
7 changed files with 336 additions and 76 deletions
+22 -4
View File
@@ -41,9 +41,9 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
<MessageResponse>{answer.questionText}</MessageResponse> <MessageResponse>{answer.questionText}</MessageResponse>
</div> </div>
{answer.options && answer.options.length > 0 && ( {((answer.options?.length ?? 0) > 0 || answer.other) && (
<div className="flex flex-col gap-1.5 text-sm"> <div className="flex flex-col gap-1.5 text-sm">
{answer.options.map((opt) => { {answer.options?.map((opt) => {
const sel = selectedIdx.has(opt.index); const sel = selectedIdx.has(opt.index);
const key = isQuiz && correctIdx.has(opt.index); const key = isQuiz && correctIdx.has(opt.index);
return ( return (
@@ -70,20 +70,38 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
</div> </div>
); );
})} })}
{/* 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 && (
<div
data-testid="answered-other"
className="flex items-start gap-2 rounded-lg border border-primary bg-accent px-3 py-1.5"
>
<span>“{answer.other}”</span>
</div>
)}
</div> </div>
)} )}
{answer.other && <div className="mt-2 text-sm">“{answer.other}”</div>}
{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";
@@ -399,6 +399,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
@@ -451,12 +452,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;
}) { }) {
@@ -610,6 +614,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}
/> />
+4 -1
View File
@@ -42,7 +42,10 @@ export function Composer({
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => { 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(); e.preventDefault();
if (busy) onStop(); if (busy) onStop();
else if (canSend) onSend(); else if (canSend) onSend();
+136 -41
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,119 @@ 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();
}} }}
> >
<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 <textarea
value={field.value} value={text}
onChange={(e) => field.set(e.target.value)} onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) { // Chat convention: Enter submits, Shift+Enter is a newline;
// ⌘/Ctrl+Enter also submits (universal across the app).
if (e.key === "Enter" && (e.metaKey || e.ctrlKey || !e.shiftKey)) {
e.preventDefault(); e.preventDefault();
submit(); submit();
} }
}} }}
placeholder={field.placeholder} placeholder="Type your answer…"
rows={1} 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" 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)}
onKeyDown={(e) => {
// ⌘↵ on macOS, Ctrl↵ elsewhere — the Gmail convention; plain
// Enter stays a newline (this field is for dumping).
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
submit();
}
}}
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-between gap-2 p-1">
{qd.mode !== "text" && (
<span aria-hidden className="pl-1.5 text-[10px] text-muted-foreground">
⌘/Ctrl + ↵ submits
</span>
)}
<Button <Button
type="submit" type="submit"
disabled={!canSubmit} disabled={!canSubmit}
className="h-10 shrink-0 rounded-[10px] px-3.5 text-sm font-medium" className="ml-auto h-9 rounded-[10px] px-3.5 text-sm font-medium"
> >
Submit answer Submit answer
</Button> </Button>
</div>
</div>
</form> </form>
</div> </div>
); );
+29 -11
View File
@@ -541,6 +541,13 @@ const GRAPH_ZOOM_BTN =
* nothing — fully zoomed out IS the fit), never above 2×. */ * nothing — fully zoomed out IS the fit), never above 2×. */
const ZOOM_MAX = 2; const ZOOM_MAX = 2;
const ZOOM_FLOOR = 0.15; 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). */ /** The graph's view transform: zoom + translation (viewport px). */
export interface GraphView { export interface GraphView {
@@ -549,6 +556,24 @@ export interface GraphView {
y: number; 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 /** The fit-centered view: the whole bw×bh graph centered in the w×h
* viewport — fully zoomed out is EXACTLY this, nothing to pan. */ * viewport — fully zoomed out is EXACTLY this, nothing to pan. */
export function fitGraphView( export function fitGraphView(
@@ -607,20 +632,13 @@ export function TreeGraph({ tree }: { tree: PlanTree }) {
return () => ro.disconnect(); return () => ro.disconnect();
}, []); }, []);
// The zoom floor: fully zoomed out, the whole diagram fits the panel. // The zoom floor: fully zoomed out, the whole diagram fills the panel
// Pad 16: the fit view IS the default — slim margins fit more graph. // (small graphs scale UP to use the space, capped at FIT_ZOOM_MAX).
const fitZoom = useMemo(() => { const box = useMemo(() => ({ w: layout.width, h: layout.height }), [layout]);
const pad = 16; const fitZoom = useMemo(() => fitZoomFor(box, size), [box, size]);
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]);
const [view, setView] = useState<GraphView>({ x: 0, y: 0, zoom: 1 }); const [view, setView] = useState<GraphView>({ x: 0, y: 0, zoom: 1 });
const userAdjustedRef = useRef(false); const userAdjustedRef = useRef(false);
const box = useMemo(() => ({ w: layout.width, h: layout.height }), [layout]);
// Epsilon: zoom is float; "at the floor" needs a tolerance. // Epsilon: zoom is float; "at the floor" needs a tolerance.
const atFloor = view.zoom <= fitZoom + 1e-9; const atFloor = view.zoom <= fitZoom + 1e-9;
+89 -5
View File
@@ -41,6 +41,18 @@ function quizQD(): QuestionData {
}; };
} }
function askQD(): QuestionData {
const options = normalizeOptions([{ label: "Very" }, { label: "Somewhat" }]);
return {
id: "a1",
kind: "ask",
question: "How confident are you?",
mode: "single-select",
options,
createdAt: new Date().toISOString(),
};
}
function renderDock(props: { toolName: "quiz" | "ask_user_question"; toolCallId: string; input: never; onSubmit?: () => void }) { function renderDock(props: { toolName: "quiz" | "ask_user_question"; toolCallId: string; input: never; onSubmit?: () => void }) {
// expanded=true renders the full form (the collapsed bar is covered by the // expanded=true renders the full form (the collapsed bar is covered by the
// QuestionDockBar test below). No scroll context needed — the DockOverlay // QuestionDockBar test below). No scroll context needed — the DockOverlay
@@ -56,9 +68,14 @@ 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");
assert.match(html, /⌘\/Ctrl \+ ↵ submits/, "the ⌘/Ctrl+Enter shortcut is discoverable");
// 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");
assert.ok(html.includes('role="radio"'), "button-based radio role present"); assert.ok(html.includes('role="radio"'), "button-based radio role present");
@@ -69,15 +86,51 @@ 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)");
assert.ok(!html.includes("⌘/Ctrl"), "no dump shortcut hint — Enter already submits");
}); });
it("AnsweredCard grades quiz answers (✓/✗ + explanation)", () => { it("AnsweredCard grades quiz answers (✓/✗ + explanation)", () => {
@@ -88,6 +141,37 @@ describe("render smoke tests", () => {
assert.ok(html.includes("✓") && html.includes("✗")); assert.ok(html.includes("✓") && html.includes("✗"));
}); });
it("AnsweredCard renders the learner's own Other answer AS an option row", () => {
const answer = buildAnswer(askQD(), { other: "Fairly confident — but shaky on applications" });
const html = renderToStaticMarkup(<AnsweredCard answer={answer} />);
assert.ok(html.includes('data-testid="answered-other"'), "the Other answer is an option row");
assert.match(html, /border-primary bg-accent/, "styled like its selected siblings");
assert.match(html, /shaky on applications/, "the typed words render");
// Inside the options container — same list, not a loose quote outside it.
assert.ok(
html.indexOf("Very") < html.indexOf("answered-other"),
"the Other row sits with the listed options",
);
});
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",
+37
View File
@@ -459,3 +459,40 @@ describe("fitGraphView / zoomedGraphView", () => {
assert.equal(next.zoom, 2); 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);
});
});