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>
</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
View File
@@ -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}
/>
+4 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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;