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>
|
<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
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
+149
-54
@@ -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();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<textarea
|
<div
|
||||||
value={field.value}
|
data-testid={qd.mode === "text" ? "answer-field" : "thinking-field"}
|
||||||
onChange={(e) => field.set(e.target.value)}
|
className="rounded-xl border border-border/70 bg-background/60 transition-colors focus-within:border-primary/50"
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
Submit answer
|
<div className="px-2.5 pt-2 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
</Button>
|
{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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+29
-11
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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't know|I don't know/);
|
assert.match(html, /I don'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't know"), "ask has no 'I don't know'");
|
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)", () => {
|
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'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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user