Merge feat/ui-polish: plan-graph fit scaling, Your-thinking voice, Other option row, universal submit shortcut
This commit is contained in:
@@ -41,9 +41,9 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
|
||||
<MessageResponse>{answer.questionText}</MessageResponse>
|
||||
</div>
|
||||
|
||||
{answer.options && answer.options.length > 0 && (
|
||||
{((answer.options?.length ?? 0) > 0 || answer.other) && (
|
||||
<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 key = isQuiz && correctIdx.has(opt.index);
|
||||
return (
|
||||
@@ -70,20 +70,38 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
|
||||
</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>
|
||||
)}
|
||||
|
||||
{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.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 && (
|
||||
<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>
|
||||
<MessageResponse className="inline">{answer.explanation}</MessageResponse>
|
||||
</div>
|
||||
)}
|
||||
{answer.note && <div className="mt-2 text-xs text-muted-foreground">Note: {answer.note}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+6
-1
@@ -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({
|
||||
<DockOverlay
|
||||
pending={pending}
|
||||
requiresReveal={requiresReveal}
|
||||
phase={phases.at(-1) ?? "probe"}
|
||||
composer={
|
||||
<Composer
|
||||
// Stop applies only to a LOCAL stream: a server-side turn has no
|
||||
@@ -451,12 +452,15 @@ export function Chat({
|
||||
function DockOverlay({
|
||||
pending,
|
||||
requiresReveal,
|
||||
phase,
|
||||
composer,
|
||||
onSubmitAnswer,
|
||||
}: {
|
||||
pending: ReturnType<typeof findPendingToolCall>;
|
||||
/** 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}
|
||||
/>
|
||||
|
||||
@@ -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();
|
||||
|
||||
+149
-54
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
@@ -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<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);
|
||||
|
||||
// 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 [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<Questi
|
||||
const canSubmit =
|
||||
qd.mode === "text"
|
||||
? text.trim().length > 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 (
|
||||
<div data-testid="question-dock">
|
||||
@@ -289,7 +320,7 @@ function QuestionDockForm({ toolName, toolCallId, input, onSubmit }: Omit<Questi
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelected(new Set());
|
||||
setShowOther(false);
|
||||
setOtherSelected(false);
|
||||
setDontKnow((v) => !v);
|
||||
}}
|
||||
aria-pressed={dontKnow}
|
||||
@@ -303,55 +334,119 @@ function QuestionDockForm({ toolName, toolCallId, input, onSubmit }: Omit<Questi
|
||||
)}
|
||||
|
||||
{kind === "ask" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelected(new Set());
|
||||
setDontKnow(false);
|
||||
setShowOther((v) => !v);
|
||||
}}
|
||||
aria-pressed={showOther}
|
||||
<div
|
||||
role={qd.mode === "multi-select" ? "checkbox" : "radio"}
|
||||
aria-checked={otherSelected}
|
||||
data-testid="other-option"
|
||||
onClick={selectOther}
|
||||
className={cn(
|
||||
"rounded-lg border border-dashed px-3 py-2 text-left text-sm text-muted-foreground transition-colors",
|
||||
showOther && "border-primary text-foreground",
|
||||
"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",
|
||||
)}
|
||||
>
|
||||
Other…
|
||||
</button>
|
||||
<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 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. */}
|
||||
<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) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
value={field.value}
|
||||
onChange={(e) => field.set(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
placeholder={field.placeholder}
|
||||
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"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="h-10 shrink-0 rounded-[10px] px-3.5 text-sm font-medium"
|
||||
<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"
|
||||
>
|
||||
Submit answer
|
||||
</Button>
|
||||
<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) => {
|
||||
// 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();
|
||||
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)}
|
||||
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
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="ml-auto h-9 rounded-[10px] px-3.5 text-sm font-medium"
|
||||
>
|
||||
Submit answer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
+29
-11
@@ -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<GraphView>({ 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;
|
||||
|
||||
|
||||
@@ -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 }) {
|
||||
// expanded=true renders the full form (the collapsed bar is covered by the
|
||||
// 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, /Mercury/);
|
||||
assert.match(html, /I don't know|I don't know/);
|
||||
// The unified bottom row: note field + Submit answer live INSIDE the dock.
|
||||
assert.match(html, /Add a note/, "note field is the dock's bottom row");
|
||||
// The learner's voice: a labeled "Your thinking" composer-twin lives
|
||||
// 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, /⌘\/Ctrl \+ ↵ submits/, "the ⌘/Ctrl+Enter shortcut is discoverable");
|
||||
// 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('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");
|
||||
});
|
||||
|
||||
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({
|
||||
toolName: "ask_user_question",
|
||||
toolCallId: "a1",
|
||||
input: { question: "How confident are you?", options: [{ label: "Very" }, { label: "Somewhat" }] } as never,
|
||||
});
|
||||
assert.match(html, /Other/);
|
||||
assert.ok(!html.includes("I don'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)", () => {
|
||||
@@ -88,6 +141,37 @@ describe("render smoke tests", () => {
|
||||
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'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", () => {
|
||||
const qd: QuestionData = {
|
||||
id: "a1",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user