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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user