Merge feat/ui-polish: plan graph rendering (dagre engine, owner skin), stats-control polish, overlay dock scroll

# Conflicts:
#	package.json
This commit is contained in:
amosblomqvist
2026-09-14 08:36:52 +03:00
8 changed files with 677 additions and 166 deletions
+164 -65
View File
@@ -40,6 +40,15 @@ import { findPendingToolCall, phaseAt3, phaseTimeline } from "@/src/core";
*/ */
const COLUMN = "mx-auto w-full max-w-none px-6"; const COLUMN = "mx-auto w-full max-w-none px-6";
const DOCK = "mx-auto w-full max-w-[46rem] px-4"; const DOCK = "mx-auto w-full max-w-[46rem] px-4";
// The dock overlay's gradient band (h-24) — part of the reserved tail.
const GRADIENT_H = 96;
// Fold thresholds (owner, scroll round 1, tuned round 3): pixel distance
// from the bottom — position, not velocity. Leave the bottom → the box
// folds (visual only); reach it again → it re-opens. 96 (was 64, owner
// round 3) tolerates a small peek upward without folding; the dead band
// vs EXPAND_DIST absorbs trackpad drift.
const COLLAPSE_DIST = 96;
const EXPAND_DIST = 16;
/** /**
* Server-sync channel for artifact renderers: the CENTRAL jobs poll in Chat * Server-sync channel for artifact renderers: the CENTRAL jobs poll in Chat
@@ -264,6 +273,13 @@ export function Chat({
// The per-chat model control state (model-picker writes via PUT; the // The per-chat model control state (model-picker writes via PUT; the
// record is the source of truth, lifted here so the stats bar re-renders). // record is the source of truth, lifted here so the stats bar re-renders).
const [settings, setSettings] = useState<ChatSettings | undefined>(initialSettings); const [settings, setSettings] = useState<ChatSettings | undefined>(initialSettings);
// The dock's reserved tail height (DockOverlay reports it; the spacer at
// the end of the transcript renders it). Lives here because the spacer is
// inside the scroller while the dock overlays OUTSIDE it — two parents.
// 208 ≈ gradient(96) + composer block(~112): the SSR/pre-hydration
// guess so the static paint has a sane tail. The first layout effect
// replaces it with the measured truth before paint.
const [dockReserve, setDockReserve] = useState(208);
const syncTurns = useCallback(async () => { const syncTurns = useCallback(async () => {
try { try {
const res = await fetch(`/api/chats/${chatId}`); const res = await fetch(`/api/chats/${chatId}`);
@@ -458,7 +474,8 @@ export function Chat({
the plan-tree panel as full-height SIBLINGS — the panel spans top to the plan-tree panel as full-height SIBLINGS — the panel spans top to
bottom exactly like the left sidebar, and the header (name + phase bottom exactly like the left sidebar, and the header (name + phase
chip) spans from the left sidebar's edge to the panel's edge. The chip) spans from the left sidebar's edge to the panel's edge. The
dock stays sticky inside the scroller, untouched. */} dock overlays the column bottom (outside the scroller); a spacer in
the transcript reserves its height. */}
<div className="flex h-dvh"> <div className="flex h-dvh">
<div className="flex min-w-0 flex-1 flex-col"> <div className="flex min-w-0 flex-1 flex-col">
{/* h-10, not py-2: the toggle (a size-9 icon button) renders only {/* h-10, not py-2: the toggle (a size-9 icon button) renders only
@@ -600,45 +617,56 @@ export function Chat({
)} )}
</div> </div>
<DockOverlay {/* The dock's reserved tail (DockOverlay reports its height):
pending={pending} keeps at-rest transcript content scrolling fully clear of
chatId={chatId} the overlay — the flow-space replacement for the old
settings={settings} sticky dock. Zero only pre-measurement (the first layout
onSettingsSaved={setSettings} effect reports before paint). */}
requiresReveal={requiresReveal} <div aria-hidden data-testid="dock-spacer" style={{ height: dockReserve }} />
phase={phases.at(-1) ?? "probe"} </ConversationContent>
turns={turns} {/* The dock: an overlay sibling of the scroller (the
composer={ use-stick-to-bottom pattern — like the scroll button). Before
<Composer the button in DOM order so the button keeps painting above
// Stop applies only to a LOCAL stream: a server-side turn has no the dock, as it always has. */}
// client request to abort (stop() would be a no-op lie). <DockOverlay
busy={localStream} pending={pending}
value={text} chatId={chatId}
onChange={setText} settings={settings}
onSend={onSend} onSettingsSaved={setSettings}
onStop={() => stop()} requiresReveal={requiresReveal}
canSend={canSend} phase={phases.at(-1) ?? "probe"}
verdict={ turns={turns}
planVerdict onReserve={setDockReserve}
? { composer={
onAccept: () => sendVerdict(true), <Composer
onDecline: () => sendVerdict(false), // Stop applies only to a LOCAL stream: a server-side turn has no
} // client request to abort (stop() would be a no-op lie).
: undefined busy={localStream}
} value={text}
/> onChange={setText}
onSend={onSend}
onStop={() => stop()}
canSend={canSend}
verdict={
planVerdict
? {
onAccept: () => sendVerdict(true),
onDecline: () => sendVerdict(false),
}
: undefined
} }
onSubmitAnswer={(output) => {
if (!pending) return;
void addToolOutput({
tool: pending.tool as "quiz" | "ask_user_question",
toolCallId: pending.toolCallId,
output,
});
}}
/> />
</ConversationContent> }
<ConversationScrollButton /> onSubmitAnswer={(output) => {
if (!pending) return;
void addToolOutput({
tool: pending.tool as "quiz" | "ask_user_question",
toolCallId: pending.toolCallId,
output,
});
}}
/>
<ConversationScrollButton />
</Conversation> </Conversation>
</div> </div>
</div> </div>
@@ -652,14 +680,27 @@ export function Chat({
} }
/** /**
* The floating blob: ONE persistent shell, sticky at the bottom of the * The floating blob: ONE persistent shell, OVERLAID at the bottom of the
* scroller (in flow — the transcript scrolls fully clear of it, and it never * column (absolute — a sibling of the scroller, the use-stick-to-bottom
* covers the scrollbar). Contents swap between the chat composer and the * pattern; it replaced the old in-flow sticky dock, which died of the
* question dock; shell height morphs between variants (WAAPI, quick). * fold-latency problem below). Contents swap between the chat composer and
* the question dock; shell height morphs between variants (WAAPI, quick). A
* spacer at the end of the transcript (rendered by ChatView from onReserve
* reports) holds the dock's flow-space, so at-rest content scrolls fully
* clear of the overlay.
* *
* Collapse: scroll up and the dock folds into a slim bar; click it (or scroll * Why an overlay (owner, scroll round 1): an in-flow dock IS scroll geometry
* back to the bottom) to re-expand. The question form stays MOUNTED while * — folding it shrinks the scroll content, so the fold had to wait until the
* collapsed (hidden, not unmounted) so a half-made selection survives. * learner was a full dock-height past the bottom (any closer and the browser
* clamp pinned them back, re-expanding the box: the oscillation). Decoupled
* from the flow, the fold is VISUAL ONLY: it fires as soon as the learner
* leaves the bottom, and the reserved tail only ever GROWS while a question
* is open (a shrinking tail would reintroduce the clamp).
*
* Collapse: scroll up past COLLAPSE_DIST and the dock folds into a slim bar;
* click it (or scroll back to the bottom) to re-expand. The question form
* stays MOUNTED while collapsed (hidden, not unmounted) so a half-made
* selection survives.
*/ */
function DockOverlay({ function DockOverlay({
pending, pending,
@@ -671,6 +712,7 @@ function DockOverlay({
chatId, chatId,
settings, settings,
onSettingsSaved, onSettingsSaved,
onReserve,
}: { }: {
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). */
@@ -685,6 +727,9 @@ function DockOverlay({
chatId: string; chatId: string;
settings?: ChatSettings; settings?: ChatSettings;
onSettingsSaved: (settings: ChatSettings) => void; onSettingsSaved: (settings: ChatSettings) => void;
/** Reports the dock's settled height (block + gradient) — ChatView's
* dockReserve state feeds the transcript's spacer from it. */
onReserve: (h: number) => void;
}) { }) {
const { isAtBottom, scrollToBottom, scrollRef } = useStickToBottomContext(); const { isAtBottom, scrollToBottom, scrollRef } = useStickToBottomContext();
// Teach-phase mount (session reload with a pending check): the dock starts // Teach-phase mount (session reload with a pending check): the dock starts
@@ -718,13 +763,10 @@ function DockOverlay({
// fire only when a NEW question lands // fire only when a NEW question lands
}, [pendingId]); }, [pendingId]);
// ── Collapse state machine with hysteresis ── // ── Fold machine (position, not velocity — owner, scroll round 1) ──
// Collapsing frees ~Δpx of flow height. If it happened while the learner is // The dock is an overlay: folding is visual-only and scroll geometry never
// within Δ of the bottom, the browser clamps scrollTop to the new max — // moves, so the thresholds can hug the bottom. Leave it → fold; reach it
// landing them AT the bottom, which re-expands the dock, which re-collapses… // again → re-open. The dead band keeps trackpad drift from chattering.
// the oscillation. So: collapse only once scrolled a full dock-height past
// the bottom (clamp-proof), expand only within 16px of the bottom, and leave
// a wide dead band between the two thresholds. Heights measured live.
const expandedRef = useRef(expanded); const expandedRef = useRef(expanded);
expandedRef.current = expanded; expandedRef.current = expanded;
const pendingRef = useRef(!!pending); const pendingRef = useRef(!!pending);
@@ -733,8 +775,62 @@ function DockOverlay({
requiresRevealRef.current = requiresReveal; requiresRevealRef.current = requiresReveal;
const revealedRef = useRef(revealed); const revealedRef = useRef(revealed);
revealedRef.current = revealed; revealedRef.current = revealed;
const expandedHRef = useRef(0);
const collapsedHRef = useRef(42); // The overlay must not paint over the scroller's scrollbar (owner, scroll
// round 2): the scroller reserves symmetric gutters (scrollbar-gutter:
// stable both-edges), so inset the overlay's right edge by the gutter —
// zero on macOS overlay scrollbars, the real width on classic platforms.
const [sbInset, setSbInset] = useState(0);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const measure = () => setSbInset(Math.round((el.offsetWidth - el.clientWidth) / 2));
measure();
window.addEventListener("resize", measure);
return () => window.removeEventListener("resize", measure);
}, [scrollRef]);
// Wheel over the dock must still scroll the transcript (owner, scroll
// round 2): the overlay is a SIBLING of the scroller, so the browser's
// native scroll chaining stops at the overlay (the old in-flow dock
// chained for free). Internal scrollers — the question area, textarea
// overflow, the model menus — keep native scrolling, including their
// own edge chaining; only when nothing under the cursor can take the
// delta do we forward it to the transcript.
useEffect(() => {
const root = blockRef.current;
const el = scrollRef.current;
if (!root || !el) return;
const canScroll = (n: HTMLElement, dir: number) => {
if (n.scrollHeight - n.clientHeight < 2) return false; // not scrollable
return dir < 0 ? n.scrollTop > 0 : n.scrollTop < n.scrollHeight - n.clientHeight - 0.5;
};
const onWheel = (e: WheelEvent) => {
if (e.deltaY === 0) return;
for (let n = e.target as HTMLElement | null; n && n !== root; n = n.parentElement) {
if (n instanceof HTMLElement && canScroll(n, e.deltaY)) return; // native
}
e.preventDefault();
el.scrollTop += e.deltaY * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? el.clientHeight : 1);
};
root.addEventListener("wheel", onWheel, { passive: false });
return () => root.removeEventListener("wheel", onWheel);
}, [scrollRef]);
// ── The reserve: the spacer's height, reported upward ──
// Grows immediately (typing, new variants). NEVER drops while a question
// is open — a shrinking reserved tail is scroll geometry and the browser
// clamp would pin a nearby learner to the bottom (the old oscillation).
// In chat mode drops apply (the composer's typing shrink is today's
// harmless in-flow behavior; nothing auto-expands there).
const reserveRef = useRef(0);
const blockRef = useRef<HTMLDivElement>(null);
const offer = (blockH: number) => {
const next = blockH + GRADIENT_H;
if (next <= reserveRef.current + 0.5 && pendingRef.current) return; // fold immunity
reserveRef.current = next;
onReserve(next);
};
// Explicit expand (bar click): the smooth scroll to the bottom takes // Explicit expand (bar click): the smooth scroll to the bottom takes
// hundreds of ms, during which the position is still legitimately inside // hundreds of ms, during which the position is still legitimately inside
@@ -763,10 +859,9 @@ function DockOverlay({
const onScroll = () => { const onScroll = () => {
if (!pendingRef.current) return; if (!pendingRef.current) return;
const d = el.scrollHeight - el.clientHeight - el.scrollTop; const d = el.scrollHeight - el.clientHeight - el.scrollTop;
const delta = Math.max(expandedHRef.current - collapsedHRef.current, 240);
if (expandedRef.current) { if (expandedRef.current) {
if (d > delta + 64 && !expandIntentRef.current) setExpanded(false); if (d > COLLAPSE_DIST && !expandIntentRef.current) setExpanded(false);
} else if (d < 16) { } else if (d < EXPAND_DIST) {
// Teach checks stay collapsed until the learner opens them (W5): the // Teach checks stay collapsed until the learner opens them (W5): the
// at-bottom auto-expand would pop the form over the teaching. // at-bottom auto-expand would pop the form over the teaching.
if (requiresRevealRef.current && !revealedRef.current) return; if (requiresRevealRef.current && !revealedRef.current) return;
@@ -778,10 +873,12 @@ function DockOverlay({
return () => el.removeEventListener("scroll", onScroll); return () => el.removeEventListener("scroll", onScroll);
}, [scrollRef, scrollToBottom]); }, [scrollRef, scrollToBottom]);
const recordHeight = (h: number) => { // The settled block height (gradient excluded), offered to the spacer.
if (!pendingRef.current) return; // Only ever called with NATURAL heights — never the animated mid-morph
if (expandedRef.current) expandedHRef.current = h; // value (the iso effect's getAnimations guard wraps every call).
else collapsedHRef.current = h; const measureBlock = () => {
const b = blockRef.current;
if (b) offer(b.getBoundingClientRect().height);
}; };
const mode = pending ? `question:${pending.toolCallId}` : "chat"; const mode = pending ? `question:${pending.toolCallId}` : "chat";
@@ -806,13 +903,15 @@ function DockOverlay({
if (el.getAnimations().length === 0) { if (el.getAnimations().length === 0) {
const h = el.getBoundingClientRect().height; const h = el.getBoundingClientRect().height;
p.height = h; p.height = h;
recordHeight(h); measureBlock(); // typing growth → the spacer grows with the box
} }
return; return;
} }
el.getAnimations().forEach((a) => a.cancel()); // flush to natural height before measuring el.getAnimations().forEach((a) => a.cancel()); // flush to natural height before measuring
const h = el.getBoundingClientRect().height; const h = el.getBoundingClientRect().height;
recordHeight(h); // Variant change → re-reserve. Growth applies instantly; a drop (the
// fold, or question → composer) is filtered by the offer policy.
measureBlock();
if (p && Math.abs(p.height - h) > 1) { if (p && Math.abs(p.height - h) > 1) {
el.animate([{ height: `${p.height}px` }, { height: `${h}px` }], { el.animate([{ height: `${p.height}px` }, { height: `${h}px` }], {
duration: 180, duration: 180,
@@ -823,9 +922,9 @@ function DockOverlay({
}); });
return ( return (
<div className="pointer-events-none sticky bottom-0 mt-auto"> <div className="pointer-events-none absolute inset-x-0 bottom-0" style={{ right: sbInset }}>
<div className="h-24 bg-gradient-to-t from-background via-background/85 to-transparent" /> <div className="h-24 bg-gradient-to-t from-background via-background/85 to-transparent" />
<div className="bg-background pb-4"> <div ref={blockRef} className="bg-background pb-4">
<div className={DOCK}> <div className={DOCK}>
<div ref={shellRef} className="pointer-events-auto overflow-hidden rounded-2xl border bg-card shadow-lg"> <div ref={shellRef} className="pointer-events-auto overflow-hidden rounded-2xl border bg-card shadow-lg">
<div key={mode} className={mode === initialModeRef.current ? undefined : "dock-variant"}> <div key={mode} className={mode === initialModeRef.current ? undefined : "dock-variant"}>
+28 -4
View File
@@ -101,6 +101,7 @@ export function ModelControl({
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const searchRef = useRef<HTMLInputElement>(null); const searchRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const defaults = catalog?.defaults; const defaults = catalog?.defaults;
const effectiveId = settings?.model?.id ?? defaults?.model.id ?? fallbackModelId; const effectiveId = settings?.model?.id ?? defaults?.model.id ?? fallbackModelId;
@@ -114,6 +115,28 @@ export function ModelControl({
if (open === "model") searchRef.current?.focus(); if (open === "model") searchRef.current?.focus();
}, [open]); }, [open]);
// The intuitive dropdown contract (owner, stats-control round 1): any
// click outside the open menu — or an Esc keypress — closes it. Only the
// menu's own surface keeps it open (the toggle buttons live inside the
// same container and handle their own toggling in onClick).
useEffect(() => {
if (open === null) return;
const onPointerDown = (e: PointerEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(null);
}
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(null);
};
document.addEventListener("pointerdown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("pointerdown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [open]);
const save = useCallback( const save = useCallback(
async (next: ChatSettings) => { async (next: ChatSettings) => {
setError(null); setError(null);
@@ -153,7 +176,7 @@ export function ModelControl({
.map((r) => r.m); .map((r) => r.m);
return ( return (
<div className="relative flex items-center gap-1.5" data-testid="model-control"> <div ref={containerRef} className="relative flex items-center gap-1.5" data-testid="model-control">
<button <button
type="button" type="button"
data-testid="stats-model" data-testid="stats-model"
@@ -167,9 +190,8 @@ export function ModelControl({
className="flex items-center gap-1.5 rounded-full border border-border/60 bg-card/70 px-2.5 py-1 transition-colors hover:border-border hover:bg-muted" className="flex items-center gap-1.5 rounded-full border border-border/60 bg-card/70 px-2.5 py-1 transition-colors hover:border-border hover:bg-muted"
> >
<span className="text-card-foreground">{effectiveId ? prettyModel(effectiveId) : "—"}</span> <span className="text-card-foreground">{effectiveId ? prettyModel(effectiveId) : "—"}</span>
<span aria-hidden className="text-xxs text-muted-foreground">▾</span> <span aria-hidden className="text-xxs text-muted-foreground">▾</span>
</button> </button>
<span aria-hidden>·</span>
<button <button
type="button" type="button"
data-testid="stats-thinking" data-testid="stats-thinking"
@@ -181,8 +203,10 @@ export function ModelControl({
title="Thinking level — applies next turn" title="Thinking level — applies next turn"
className="flex items-center gap-1.5 rounded-full border border-border/60 bg-card/70 px-2.5 py-1 transition-colors hover:border-border hover:bg-muted" className="flex items-center gap-1.5 rounded-full border border-border/60 bg-card/70 px-2.5 py-1 transition-colors hover:border-border hover:bg-muted"
> >
{/* Just the level — the button reads like the model button reads
the model name (owner, stats-control round 1). */}
<span className="text-card-foreground"> <span className="text-card-foreground">
{effectiveLevel === undefined ? "Thinking —" : `Thinking: ${LEVEL_LABEL[effectiveLevel] ?? effectiveLevel}`} {effectiveLevel === undefined ? "—" : (LEVEL_LABEL[effectiveLevel] ?? effectiveLevel)}
</span> </span>
<span aria-hidden className="text-xxs text-muted-foreground">▾</span> <span aria-hidden className="text-xxs text-muted-foreground">▾</span>
</button> </button>
+412 -83
View File
@@ -3,6 +3,15 @@
import { useEffect, useId, useMemo, useRef, useState } from "react"; import { useEffect, useId, useMemo, useRef, useState } from "react";
import { MaximizeIcon, MinusIcon, PlusIcon, XIcon } from "lucide-react"; import { MaximizeIcon, MinusIcon, PlusIcon, XIcon } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
// The placement engine — literally mermaid's: dagre-d3-es, the fork
// mermaid 11 ships with (owner, plan-graph round 8: same engine as the
// mermaid graphs, our SVG skin stays). The deep subpath is how mermaid
// itself imports it.
import { graphlib } from "dagre-d3-es";
import { layout as dagreLayout } from "dagre-d3-es/src/dagre/index.js";
// Mermaid's default flowchart edge curve: dagre's waypoints drawn through
// d3's curveBasis (the exact recipe dagre-d3's own renderer uses).
import { curveBasis, line } from "d3-shape";
import type { PlanBlockData, PlanNodeStatus, PlanTree } from "@/src/core"; import type { PlanBlockData, PlanNodeStatus, PlanTree } from "@/src/core";
import { derivedNodeStatus, derivePlanStatuses } from "@/src/core"; import { derivedNodeStatus, derivePlanStatuses } from "@/src/core";
@@ -12,17 +21,19 @@ import { derivedNodeStatus, derivePlanStatuses } from "@/src/core";
* the right sidebar is retired. * the right sidebar is retired.
* *
* - kind "full": the whole DAG, BORDERLESS — no wrapping chrome, the SVG * - kind "full": the whole DAG, BORDERLESS — no wrapping chrome, the SVG
* scales to the full column width (viewBox = layout extent, so the * scales to the full column width (viewBox = layout extent + GRAPH_PAD,
* leftmost/rightmost nodes hug the edges), natural height, no cap. * so strokes/glow never clip), natural height, capped at the chat prose
* font size (TEXT_CAP_SCALE — fills the column, never oversizes text).
* - Clicking opens the full plan in a full-screen popup overlay — * - Clicking opens the full plan in a full-screen popup overlay —
* the LIVE plan: the latest tree from the transcript with statuses * the LIVE plan: the latest tree from the transcript with statuses
* derived at click time (contracts/plan.ts derivePlanStatuses). Blocks * derived at click time (contracts/plan.ts derivePlanStatuses). Blocks
* are frozen snapshots rendered exactly as carried (never re-derived); * are frozen snapshots rendered exactly as carried (never re-derived);
* the popup is the live view (user decision, 2026-09). * the popup is the live view (user decision, 2026-09).
* *
* Hand-built SVG everywhere (user decision): a deterministic layered * Hand-built SVG everywhere (user decision): placement runs on dagre
* layout fills exactly and stays SSR-testable — no force simulation, no * (mermaid's engine — deterministic, synchronous, DOM-free, so SSR and
* coupling to the graph stream's sigma internals. Pan/zoom in the popup * render tests stay exact); the skin is ours — no coupling to the graph
* stream's internals. Pan/zoom in the popup
* is harvested from the retired tree panel's graph (fit-floor semantics: * is harvested from the retired tree panel's graph (fit-floor semantics:
* fully zoomed out IS the fit; panning locks at the floor). * fully zoomed out IS the fit; panning locks at the floor).
*/ */
@@ -31,6 +42,8 @@ import { derivedNodeStatus, derivePlanStatuses } from "@/src/core";
export const NODE_W = 160; export const NODE_W = 160;
export const NODE_H = 44; export const NODE_H = 44;
/** Baseline-to-baseline distance of node titles. */
export const LINE_H = 13;
export const GAP_X = 24; export const GAP_X = 24;
export const GAP_Y = 48; export const GAP_Y = 48;
@@ -40,7 +53,9 @@ export interface LayoutNode {
title: string; title: string;
} }
/** Topological order + longest-path depth (Kahn, authoring tie-break). */ /** Topological order + longest-path depth (Kahn, authoring tie-break) —
* order + depth semantics kept for tests; placement itself runs on dagre
* now (owner, round 8). */
export function topoFor(nodes: readonly LayoutNode[], edges: ReadonlyArray<{ from: string; to: string }>) { export function topoFor(nodes: readonly LayoutNode[], edges: ReadonlyArray<{ from: string; to: string }>) {
const index = new Map(nodes.map((n, i) => [n.id, i] as const)); const index = new Map(nodes.map((n, i) => [n.id, i] as const));
const indegree = new Map<string, number>(nodes.map((n) => [n.id, 0] as const)); const indegree = new Map<string, number>(nodes.map((n) => [n.id, 0] as const));
@@ -79,68 +94,256 @@ export function topoFor(nodes: readonly LayoutNode[], edges: ReadonlyArray<{ fro
return { order, depth }; return { order, depth };
} }
/** Layered positions: depth = row (top-down), authoring order within the /** Placement — dagre (mermaid's engine, owner round 8): Sugiyama-style
* row, each row centered on the widest one. */ * layered layout, TB, with our geometry (NODE_W boxes, GAP_X/GAP_Y
* separations, zero margin — the caller adds GRAPH_PAD/POPUP_PAD).
* Node sizes are fed in (boxes grow with their wrapped content, round
* 7); dagre returns node CENTERS, we hand back top-lefts like before,
* so every consumer (diagram, popup fit, plan window) is unchanged.
* Deterministic + synchronous + DOM-free: SSR and render tests stay
* exact. Dangling edges are ignored defensively — a ghost endpoint
* would spawn an unmeasured node and break the measurer. */
export function blockLayout(nodes: readonly LayoutNode[], edges: ReadonlyArray<{ from: string; to: string }>) { export function blockLayout(nodes: readonly LayoutNode[], edges: ReadonlyArray<{ from: string; to: string }>) {
const { order, depth } = topoFor(nodes, edges);
const layers: LayoutNode[][] = [];
for (const n of order) {
const d = depth.get(n.id) ?? 0;
if (!layers[d]) layers[d] = [];
layers[d].push(n);
}
const rowWidth = (l: LayoutNode[]) => l.length * NODE_W + (l.length - 1) * GAP_X;
let maxWidth = 0;
for (const l of layers) maxWidth = Math.max(maxWidth, rowWidth(l));
const x = new Map<string, number>(); const x = new Map<string, number>();
const y = new Map<string, number>(); const y = new Map<string, number>();
layers.forEach((l, d) => { const nodeH = new Map<string, number>();
const start = (maxWidth - rowWidth(l)) / 2; /** Dagre's per-edge waypoint routes — each starts/ends at the node
l.forEach((n, i) => { * border intersection on whatever SIDE the edge leaves/enters
x.set(n.id, start + i * (NODE_W + GAP_X)); * (round 11: the mermaid line drawing, no more fixed bottom-center →
y.set(n.id, d * (NODE_H + GAP_Y)); * top-center). Keyed `${from}->${to}`. */
}); const edgePts = new Map<string, Array<{ x: number; y: number }>>();
}); if (nodes.length === 0) return { x, y, nodeH, edgePts, width: 0, height: 0 };
const height = layers.length * NODE_H + Math.max(0, layers.length - 1) * GAP_Y; const ids = new Set(nodes.map((n) => n.id));
return { x, y, width: maxWidth, height }; const routed: Array<{ from: string; to: string }> = [];
const g = new graphlib.Graph({ compound: false, multigraph: true });
g.setGraph({ rankdir: "TB", nodesep: GAP_X, ranksep: GAP_Y, marginx: 0, marginy: 0 });
for (const n of nodes) {
const h = NODE_H + Math.max(0, wrapTitle(n.title).length - 2) * LINE_H;
nodeH.set(n.id, h);
g.setNode(n.id, { width: NODE_W, height: h });
}
for (const e of edges) {
if (ids.has(e.from) && ids.has(e.to)) {
g.setEdge(e.from, e.to, { label: "", width: 0, height: 0 });
routed.push(e);
}
}
// (the d.ts marks opts required; the runtime ignores it)
dagreLayout(g, undefined);
for (const n of nodes) {
const pos = g.node(n.id);
const h = nodeH.get(n.id) ?? NODE_H;
if (pos) {
x.set(n.id, pos.x - NODE_W / 2);
y.set(n.id, pos.y - h / 2);
}
}
for (const e of routed) {
const pts: Array<{ x: number; y: number }> | undefined = g.edge(e.from, e.to)?.points;
if (pts) edgePts.set(`${e.from}->${e.to}`, pts.map((pt) => ({ x: pt.x, y: pt.y })));
}
return {
x,
y,
nodeH,
edgePts,
width: Math.max(g.graph().width ?? 0, 1),
height: Math.max(g.graph().height ?? 0, 1),
};
} }
/** Greedy word wrap to ≤ maxLines lines of ≤ maxChars; overflow ends in an /** Greedy word wrap to lines of ≤ maxChars — NOTHING is dropped: the
* ellipsis, a single oversized word is hard-clamped. Pure. */ * node's box grows to fit every line (owner: show the full content of
export function wrapTitle(title: string, maxChars = 20, maxLines = 2): string[] { * each node). A single oversized word is hard-chunked across lines at
* maxChars. Pure. */
export function wrapTitle(title: string, maxChars = 20): string[] {
const words = title.trim().split(/\s+/).filter(Boolean); const words = title.trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return [""]; if (words.length === 0) return [""];
const lines: string[] = []; // Oversized words can never fit a line — chunk them so no content is lost.
const atoms: string[] = [];
for (const w of words) { for (const w of words) {
for (let i = 0; i < w.length; i += maxChars) atoms.push(w.slice(i, i + maxChars));
}
const lines: string[] = [];
for (const a of atoms) {
const last = lines[lines.length - 1]; const last = lines[lines.length - 1];
const fits = last !== undefined && last.length + 1 + w.length <= maxChars; if (last !== undefined && last.length + 1 + a.length <= maxChars) {
if (fits) { lines[lines.length - 1] = `${last} ${a}`;
lines[lines.length - 1] = `${last} ${w}`;
} else if (lines.length < maxLines) {
lines.push(w.length > maxChars ? `${w.slice(0, maxChars - 1)}…` : w);
} else { } else {
const lastLine = lines[lines.length - 1]; lines.push(a);
if (lastLine !== undefined && !lastLine.endsWith("…")) {
lines[lines.length - 1] =
lastLine.length >= maxChars ? `${lastLine.slice(0, maxChars - 1)}…` : `${lastLine}…`;
}
} }
} }
return lines; return lines;
} }
// ─── Geometry caps (owner, plan-graph refinement round 1) ─────────────────────
/** SVG-unit padding around the layout extent: strokes are centered on the
* geometry (half bleeds outside the extent) and the glow halo reaches −4 —
* without this pad the outermost node borders clip at the svg edge. */
export const GRAPH_PAD = 8;
/** The current node's yellow glow: a fill blurred so it bleeds over the
* node's edges (owner). A Gaussian's visible spread is ≈3σ, so the glow
* reaches GLOW_MARGIN + 3·GLOW_BLUR past the node — any surface that
* shows the glow must pad its viewBox by GLOW_REACH or the blur clips
* at the frame edge (owner: extend the container padding to fit it). */
export const GLOW_MARGIN = 8;
export const GLOW_BLUR = 5;
export const GLOW_REACH = GLOW_MARGIN + 3 * GLOW_BLUR;
/** Node titles render at text-xs (12.5px) in svg units. The in-chat block
* scales up to fill the column but NEVER past the chat prose size
* (--lh-prose-size, 16.5px) — the width cap that makes that hold. */
export const TEXT_CAP_SCALE = 16.5 / 12.5;
// ─── The owner's arrowhead (plan-graph round 5) ──────────────────────────────
/** Baked from arrowhead_1.svg (the CM/MJX long-arrow head), transform chain
* collapsed: viewBox 0 0 50.2 104, tip at (50.2, 52), notch mouth at
* x 27.2 with a 9.6-tall opening — at full height 104 the tail opening
* is ≈10, per the owner's spec. Solid white fill, no padding. */
export const ARROW_HEAD = "M48,55.2C49.4,54.6 50.2,53.4 50.2,52C50.2,50.6 49.4,49.4 48,48.8C38.2,45.6 29.2,38.8 21,28.4C15.2,21.4 11.4,13.2 9.6,3.8C9,1.2 7.4,0 4.8,0C1.6,0 0,1.6 0,5L0.2,5.6C3.6,23 12.6,36.8 27.2,47.2L27.2,56.8C12.6,67.2 3.6,81 0.2,98.4L0,99C0,102.4 1.6,104 4.8,104C7.4,104 9,102.8 9.6,100.2C11.4,90.8 15.2,82.6 21,75.6C29.2,65.2 38.2,58.4 48,55.2Z";
/** Head height 16: the tail opening (9.6/104 of height) then tucks the
* 1.5-wide line flush — the LaTeX proportion. The owner's rule: keep the
* current line width; size the head from the 104→10 ratio. */
const HEAD_H = 16;
const HEAD_SCALE = HEAD_H / 104;
const HEAD_W = 50.2 * HEAD_SCALE;
/** The drawn line terminates at the notch mouth (refX 27.2) — it never
* reaches the narrow tip, where it would show through (owner). The tip
* overhangs the line's end by this much, so edges shorten by it to land
* the tip exactly on the target node's border. */
const HEAD_TIP_OVERHANG = (50.2 - 27.2) * HEAD_SCALE;
/** The head floats this far off the target face — the tip kisses nothing
* (owner, round 12). ~half a head length: clearly docked-off, still
* visually attached. */
const DOCK_GAP = 4;
/** The shaft stub — the straight run before the head (owner, round 12):
* control points from Q on sit exactly on the approach line, so the
* basis spline renders that stretch perfectly straight and the head
* lands on a straight shaft; the corner where the route squares up onto
* the line is rounded by the curve. ~1.5 head lengths of shaft. */
const STRAIGHT_RUN = 12;
/** The tilt limit at the termination point, measured off the entry
* face's perpendicular: past ±40° one of the head's barbs crosses the
* tip's level and the silhouette breaks (owner measured it in
* Photoshop, round 14). The final run swings to the cone edge instead. */
const ENTRY_ANGLE_LIMIT = (40 * Math.PI) / 180;
const edgeLine = line<{ x: number; y: number }>()
.x((p) => p.x)
.y((p) => p.y)
.curve(curveBasis);
/** The entry face's outward unit normal — whichever face of the target
* box dagre's border point sits on. Pure. */
function faceNormal(
border: { x: number; y: number },
box: { x: number; y: number; w: number; h: number },
): { x: number; y: number } {
const d = { top: border.y - box.y, bottom: box.y + box.h - border.y, left: border.x - box.x, right: box.x + box.w - border.x };
const face = (Object.keys(d) as Array<keyof typeof d>).reduce((a, b) => (d[a] <= d[b] ? a : b));
switch (face) {
case "top":
return { x: 0, y: -1 };
case "bottom":
return { x: 0, y: 1 };
case "left":
return { x: -1, y: 0 };
default:
return { x: 1, y: 0 };
}
}
/** The edge path — mermaid's own line drawing: dagre's border-intersected
* waypoints through d3's curveBasis (mermaid's default flowchart curve —
* the same recipe dagre-d3's renderer uses). The head docks off the
* target: its TIP floats DOCK_GAP past the border along the FACE NORMAL
* (constant gap at any tilt), its mouth sits at the path's end on a
* straight run (the shaft stub), and — when the target box is given —
* the entry direction is clamped to ±ENTRY_ANGLE_LIMIT of the face's
* perpendicular (owner, round 14): a steeper natural approach swings to
* the 40° cone edge instead of breaking the head's silhouette. Without
* a target box the unclamped geometry applies. Straight collinear
* routes render identically. Pure, exported for tests. */
export function edgePathD(
points: Array<{ x: number; y: number }>,
target?: { x: number; y: number; w: number; h: number },
): string {
const pts = points.map((p) => ({ ...p }));
if (pts.length >= 2) {
const last = pts[pts.length - 1]!;
const prev = pts[pts.length - 2]!;
const dx = last.x - prev.x;
const dy = last.y - prev.y;
const len = Math.hypot(dx, dy) || 1;
let ux = dx / len;
let uy = dy / len;
// The tip: DOCK_GAP outside the face along its normal (or along the
// approach when no box is known).
let tipx: number;
let tipy: number;
if (target) {
const n = faceNormal(last, target);
tipx = last.x + n.x * DOCK_GAP;
tipy = last.y + n.y * DOCK_GAP;
// Clamp the approach to the ±LIMIT cone around the normal: if
// the outgoing direction -u tilts past ENTRY_ANGLE_LIMIT, swing
// it to the cone edge (same rotation side as the route).
const v = { x: -ux, y: -uy };
const vn = v.x * n.x + v.y * n.y;
if (vn < Math.cos(ENTRY_ANGLE_LIMIT)) {
const t = { x: v.x - vn * n.x, y: v.y - vn * n.y };
const tl = Math.hypot(t.x, t.y);
const that = tl > 0 ? { x: t.x / tl, y: t.y / tl } : { x: -n.y, y: n.x };
const c = Math.cos(ENTRY_ANGLE_LIMIT);
const s = Math.sin(ENTRY_ANGLE_LIMIT);
v.x = c * n.x + s * that.x;
v.y = c * n.y + s * that.y;
ux = -v.x;
uy = -v.y;
}
} else {
tipx = last.x - ux * DOCK_GAP;
tipy = last.y - uy * DOCK_GAP;
}
// The mouth: overhang back from the tip along the (clamped) approach.
const M = { x: tipx - ux * HEAD_TIP_OVERHANG, y: tipy - uy * HEAD_TIP_OVERHANG };
// From Q the control points lie on the approach line — the basis
// spline renders Q→M perfectly straight (a B-spline span with
// collinear control points IS the line), and curveBasis's internal
// endpoint repetition makes the end tangent exactly the approach
// direction. NOTE: do NOT repeat M manually — a duplicated final
// control point renders a zero-length last segment, and
// orient="auto" on a zero-length segment falls back to 0° (every
// head pointing right — the round-12 regression, fixed round 13).
const Q = { x: M.x - ux * STRAIGHT_RUN, y: M.y - uy * STRAIGHT_RUN };
pts.splice(pts.length - 1, 1, Q, M);
}
return edgeLine(pts) ?? "";
}
// ─── Status styling (the retired panel's color language, unchanged) ────────── // ─── Status styling (the retired panel's color language, unchanged) ──────────
// The simple look (owner, plan-graph round 4): rectangular boxes, white
// outlines/arrows/text; no fill on visited, today's gray look on upcoming,
// and the current node carries a faint yellow glow (a blurred yellow fill
// bleeding over the edges). The IN-CHAT block is monochrome white instead
// — no status language; the popup/window show the progression.
const BOX: Record<PlanNodeStatus, string> = { const BOX: Record<PlanNodeStatus, string> = {
visited: "fill-phase-teach/10 stroke-phase-teach/50", // 50%-opacity app background instead of true transparency: a routed
current: "fill-primary/10 stroke-primary", // edge passing behind the box composites UNDER the fill — dimmed to
// half, not shown straight through (owner, round 9).
visited: "fill-background/50 stroke-white",
current: "fill-background/50 stroke-white",
// The grayed-out not-yet-visited look stays as-is (owner).
upcoming: "fill-card stroke-border", upcoming: "fill-card stroke-border",
}; };
const TEXT: Record<PlanNodeStatus, string> = { const TEXT: Record<PlanNodeStatus, string> = {
visited: "fill-muted-foreground text-xs", visited: "fill-white text-xs",
current: "fill-foreground text-xs font-medium", current: "fill-white text-xs font-medium",
upcoming: "fill-muted-foreground/80 text-xs", upcoming: "fill-muted-foreground/80 text-xs",
}; };
const MONO_BOX = "fill-background/50 stroke-white";
const MONO_TEXT = "fill-white text-xs";
/** The diagram itself: positioned nodes + bezier edges. Pure-ish render — /** The diagram itself: positioned nodes + bezier edges. Pure-ish render —
* every input is data, so SSR render tests pin the whole thing. */ * every input is data, so SSR render tests pin the whole thing. */
@@ -151,6 +354,10 @@ export function PlanDiagram({
glowId, glowId,
markerId, markerId,
viewBoxOverride, viewBoxOverride,
pad: padUnits,
textCapScale,
naturalSize,
monochrome,
}: { }: {
nodes: readonly LayoutNode[]; nodes: readonly LayoutNode[];
edges: ReadonlyArray<{ from: string; to: string }>; edges: ReadonlyArray<{ from: string; to: string }>;
@@ -159,48 +366,112 @@ export function PlanDiagram({
markerId: string; markerId: string;
/** A windowed viewBox (the plan window) instead of the full extent. */ /** A windowed viewBox (the plan window) instead of the full extent. */
viewBoxOverride?: string; viewBoxOverride?: string;
/** Pads the viewBox by this many svg units so centered strokes and the
* glow halo never clip at the extent edges (full-extent renders only). */
pad?: number;
/** Caps the rendered width at extent × scale — the chat-font-size zoom
* ceiling for the in-chat block (fills the column, never past prose). */
textCapScale?: number;
/** Renders at the viewBox width in px (1 svg unit = 1 px at zoom 1) —
* the popup's transform math depends on it; w-full inside the popup's
* shrink-to-fit wrapper resolved to the popup width and broke fit. */
naturalSize?: boolean;
/** The in-chat block's mode: every node white (fill-none, white stroke
* and text, no glow) — statuses never show in the transcript (owner). */
monochrome?: boolean;
}) { }) {
const layout = useMemo(() => blockLayout(nodes, edges), [nodes, edges]); const layout = useMemo(() => blockLayout(nodes, edges), [nodes, edges]);
// Pad only the full-extent render — the plan window's override viewBox is
// a deliberate viewport crop, and the popup keeps its own fit margins.
const pad = viewBoxOverride ? 0 : (padUnits ?? 0);
const w = Math.max(layout.width, 1);
const h = Math.max(layout.height, 1);
const vbW = w + 2 * pad;
const vbH = h + 2 * pad;
return ( return (
<svg <svg
viewBox={viewBoxOverride ?? `0 0 ${Math.max(layout.width, 1)} ${Math.max(layout.height, 1)}`} viewBox={viewBoxOverride ?? `${-pad} ${-pad} ${vbW} ${vbH}`}
className="block h-auto w-full" className={`block h-auto${naturalSize ? "" : " w-full"}${textCapScale ? " mx-auto" : ""}`}
style={
textCapScale
? { maxWidth: `${Math.round(vbW * textCapScale * 100) / 100}px` }
: naturalSize
? { width: `${vbW}px` }
: undefined
}
role="img" role="img"
aria-label="Plan diagram" aria-label="Plan diagram"
> >
<defs> <defs>
<marker <marker
id={markerId} id={markerId}
viewBox="0 0 8 8" viewBox="0 0 50.2 104"
refX="7" refX={27.2}
refY="4" refY={52}
markerWidth="6" markerWidth={HEAD_W}
markerHeight="6" markerHeight={HEAD_H}
orient="auto-start-reverse" markerUnits="userSpaceOnUse"
orient="auto"
> >
<path d="M0,1 L7,4 L0,7 z" className="fill-muted-foreground/50" /> {/* The owner's arrowhead. The mouth (x 27.2) sits at refX so the
line ends AT the notch — the tip beyond it is solid fill. */}
<path d={ARROW_HEAD} className="fill-white" />
</marker> </marker>
{/* Same head, grayed — arrows into not-yet-visited nodes are
grayed out like the nodes themselves (owner, round 6). */}
<marker
id={`${markerId}-dim`}
viewBox="0 0 50.2 104"
refX={27.2}
refY={52}
markerWidth={HEAD_W}
markerHeight={HEAD_H}
markerUnits="userSpaceOnUse"
orient="auto"
>
<path d={ARROW_HEAD} className="fill-border" />
</marker>
{/* The current node's highlight: a faint yellow fill BLURRED so it
bleeds out over the node's edges (owner). Generous filter region
so the blur never clips at its own bounds. */}
<filter id={`${markerId}-glow`} x="-60%" y="-60%" width="220%" height="220%">
<feGaussianBlur stdDeviation={GLOW_BLUR} />
</filter>
</defs> </defs>
{edges.map((e) => { {edges.map((e) => {
const x1 = (layout.x.get(e.from) ?? 0) + NODE_W / 2; // Dagre's route for this edge: border-intersected waypoints —
const y1 = (layout.y.get(e.from) ?? 0) + NODE_H; // the line leaves/enters nodes on whatever side dagre chose
const x2 = (layout.x.get(e.to) ?? 0) + NODE_W / 2; // (mermaid's drawing, owner round 11). Unroutable edges
const y2 = layout.y.get(e.to) ?? 0; // (dangling endpoints) draw nothing.
const mid = (y1 + y2) / 2; const pts = layout.edgePts.get(`${e.from}->${e.to}`);
if (!pts) return null;
// The target's box — drives the docking gap + entry-angle clamp.
const tx = layout.x.get(e.to);
const ty = layout.y.get(e.to);
const target =
tx !== undefined && ty !== undefined
? { x: tx, y: ty, w: NODE_W, h: layout.nodeH.get(e.to) ?? NODE_H }
: undefined;
// An arrow into a not-yet-visited node is grayed out like the
// node (owner, round 6); the in-chat block stays all-white.
const dim = !monochrome && statusOf(e.to) === "upcoming";
return ( return (
<path <path
key={`${e.from}->${e.to}`} key={`${e.from}->${e.to}`}
d={`M ${x1} ${y1} C ${x1} ${mid}, ${x2} ${mid}, ${x2} ${y2}`} d={edgePathD(pts, target)}
className="fill-none stroke-muted-foreground/40" className={dim ? "fill-none stroke-border" : "fill-none stroke-white"}
strokeWidth={1.5} strokeWidth={1.5}
markerEnd={`url(#${markerId})`} markerEnd={`url(#${markerId}${dim ? "-dim" : ""})`}
/> />
); );
})} })}
{nodes.map((n) => { {nodes.map((n) => {
const status = statusOf(n.id); const status = statusOf(n.id);
const glow = n.id === glowId; const glow = n.id === glowId && !monochrome;
const lines = wrapTitle(n.title); const lines = wrapTitle(n.title);
// The box grows with its wrapped content; text centers in it.
const h = layout.nodeH.get(n.id) ?? NODE_H;
const y0 = (h - lines.length * LINE_H) / 2 + 10;
return ( return (
<g <g
key={n.id} key={n.id}
@@ -208,21 +479,35 @@ export function PlanDiagram({
data-testid={`plan-node-${n.id}`} data-testid={`plan-node-${n.id}`}
data-status={status} data-status={status}
> >
{/* Rectangular — no corner rounding (owner). */}
<rect
width={NODE_W}
height={h}
className={monochrome ? MONO_BOX : BOX[status]}
strokeWidth={1.5}
/>
{glow && ( {glow && (
// The glow target: a soft primary halo breathing around the box. // The current node's highlight: faint yellow, blurred so it
// bleeds over the edges — static, no pulse (owner, round 6).
// Paints ABOVE the box fill (owner, round 10): the round-9
// background fill no longer dims the glow inside the box,
// while edges behind the box still dim under that fill.
<rect <rect
x={-4} x={-GLOW_MARGIN}
y={-4} y={-GLOW_MARGIN}
width={NODE_W + 8} width={NODE_W + 2 * GLOW_MARGIN}
height={NODE_H + 8} height={h + 2 * GLOW_MARGIN}
rx={12} className="fill-yellow-400/25"
className="fill-none stroke-primary/70 animate-pulse" filter={`url(#${markerId}-glow)`}
strokeWidth={1.5}
/> />
)} )}
<rect width={NODE_W} height={NODE_H} rx={10} className={BOX[status]} strokeWidth={1.5} />
{lines.map((line, i) => ( {lines.map((line, i) => (
<text key={i} x={12} y={lines.length === 1 ? 27 : 18 + i * 13} className={TEXT[status]}> <text
key={i}
x={12}
y={y0 + i * LINE_H}
className={monochrome ? MONO_TEXT : TEXT[status]}
>
{line} {line}
</text> </text>
))} ))}
@@ -254,7 +539,18 @@ export function PlanBlock({ data, onOpen }: { data: PlanBlockData; onOpen: () =>
title="Open the full plan" title="Open the full plan"
className="block w-full text-left" className="block w-full text-left"
> >
<PlanDiagram nodes={data.nodes} edges={data.edges} statusOf={statusOf} markerId={markerId} /> {/* pad keeps centered strokes + the glow halo inside the frame; the
cap keeps node text at or below chat prose size while still filling
the column when it can (owner, plan-graph refinement round 1). */}
<PlanDiagram
nodes={data.nodes}
edges={data.edges}
statusOf={statusOf}
markerId={markerId}
pad={GRAPH_PAD}
textCapScale={TEXT_CAP_SCALE}
monochrome
/>
</button> </button>
); );
} }
@@ -281,10 +577,16 @@ export function latestTree(messages: readonly PopupMessage[]): PlanTree | null {
return null; return null;
} }
/** Zoom bounds: never below fit (fully zoomed out IS the fit), never above 2×. */ /** Zoom bounds: never below fit (fully zoomed out IS the fit — the whole
const ZOOM_MAX = 2; * graph visible, whichever axis binds — owner, plan-graph round 2), never
const ZOOM_FLOOR = 0.15; * above ZOOM_MAX (raised 2×→4× for reading node titles closely, owner,
* plan-graph round 3). The old 0.15 floor could sit ABOVE a true fit for
* wide graphs; the fit itself is the floor now. */
const ZOOM_MAX = 4;
const FIT_PAD = 16; const FIT_PAD = 16;
/** The popup shows the glow, so its frame pads by the glow's reach —
* the in-chat block (monochrome, no glow) stays tight at GRAPH_PAD. */
const POPUP_PAD = GLOW_REACH;
const FIT_ZOOM_MAX = 1.5; const FIT_ZOOM_MAX = 1.5;
interface GraphView { interface GraphView {
@@ -295,10 +597,9 @@ interface GraphView {
function fitZoomFor(box: { w: number; h: number }, viewport: { w: number; h: number }): number { function fitZoomFor(box: { w: number; h: number }, viewport: { w: number; h: number }): number {
if (!viewport.w || !viewport.h || box.w === 0) return 1; if (!viewport.w || !viewport.h || box.w === 0) return 1;
return Math.max( // No artificial floor: a wide graph must fit even below 0.15× — the
ZOOM_FLOOR, // epsilon only guards degenerate viewports.
Math.min((viewport.w - FIT_PAD) / box.w, (viewport.h - FIT_PAD) / box.h, FIT_ZOOM_MAX), return Math.max(0.02, Math.min((viewport.w - FIT_PAD) / box.w, (viewport.h - FIT_PAD) / box.h, FIT_ZOOM_MAX));
);
} }
function fitGraphView(box: { w: number; h: number }, viewport: { w: number; h: number }, zoom: number): GraphView { function fitGraphView(box: { w: number; h: number }, viewport: { w: number; h: number }, zoom: number): GraphView {
@@ -354,12 +655,22 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
}, []); }, []);
const layout = useMemo(() => (tree ? blockLayout(tree.nodes, tree.edges) : null), [tree]); const layout = useMemo(() => (tree ? blockLayout(tree.nodes, tree.edges) : null), [tree]);
const box = useMemo(() => (layout ? { w: layout.width, h: layout.height } : { w: 0, h: 0 }), [layout]); // The fit box is the PADDED ink box — the diagram renders with POPUP_PAD
// (= the glow's reach) so the blurred glow never clips at the frame, and
// the fit must account for that padding.
const box = useMemo(
() => (layout ? { w: layout.width + 2 * POPUP_PAD, h: layout.height + 2 * POPUP_PAD } : { w: 0, h: 0 }),
[layout],
);
const fitZoom = useMemo(() => fitZoomFor(box, size), [box, size]); const fitZoom = useMemo(() => fitZoomFor(box, size), [box, size]);
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 atFloor = view.zoom <= fitZoom + 1e-9; const atFloor = view.zoom <= fitZoom + 1e-9;
// The transition stays off until the FIRST fit has been applied — the
// popup paints at the fit directly instead of flying in from the initial
// top-left zoom:1 state (owner, plan-graph round 4).
const [settled, setSettled] = useState(false);
useEffect(() => { useEffect(() => {
if (atFloor) userAdjustedRef.current = false; if (atFloor) userAdjustedRef.current = false;
@@ -367,6 +678,12 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
useEffect(() => { useEffect(() => {
if (userAdjustedRef.current || !size.w || !size.h) return; if (userAdjustedRef.current || !size.w || !size.h) return;
setView(fitGraphView(box, size, fitZoom)); setView(fitGraphView(box, size, fitZoom));
// Enable the transition only AFTER the fit has painted: flipping
// settled in the SAME commit lets the browser animate from the
// initial top-left zoom:1 state — the fly-in the owner keeps
// seeing (round 4's gate wasn't enough; round 7 fix).
const raf = requestAnimationFrame(() => setSettled(true));
return () => cancelAnimationFrame(raf);
}, [fitZoom, size, box]); }, [fitZoom, size, box]);
useEffect(() => { useEffect(() => {
@@ -386,12 +703,17 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
return () => el.removeEventListener("wheel", onWheel); return () => el.removeEventListener("wheel", onWheel);
}, [fitZoom, box, size]); }, [fitZoom, box, size]);
// Smooth zoom: the transform eases between states (CSS retargets from the
// current computed value, so chained wheel ticks stay fluid). Panning is
// per-frame — the transition is off while dragging or it rubber-bands.
const [panning, setPanning] = useState(false);
const dragRef = useRef<{ px: number; py: number; x: number; y: number; moved: boolean } | null>(null); const dragRef = useRef<{ px: number; py: number; x: number; y: number; moved: boolean } | null>(null);
const startDrag = (e: React.PointerEvent<HTMLDivElement>) => { const startDrag = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return; if (e.button !== 0) return;
if ((e.target as HTMLElement).closest("button")) return; if ((e.target as HTMLElement).closest("button")) return;
if (atFloor) return; if (atFloor) return;
dragRef.current = { px: e.clientX, py: e.clientY, x: view.x, y: view.y, moved: false }; dragRef.current = { px: e.clientX, py: e.clientY, x: view.x, y: view.y, moved: false };
setPanning(true);
e.currentTarget.classList.add("cursor-grabbing"); e.currentTarget.classList.add("cursor-grabbing");
e.currentTarget.setPointerCapture(e.pointerId); e.currentTarget.setPointerCapture(e.pointerId);
}; };
@@ -409,6 +731,7 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
dragRef.current = null; dragRef.current = null;
if (d.moved) userAdjustedRef.current = true; if (d.moved) userAdjustedRef.current = true;
} }
setPanning(false);
e.currentTarget.classList.remove("cursor-grabbing"); e.currentTarget.classList.remove("cursor-grabbing");
}; };
const stepZoom = (factor: number) => { const stepZoom = (factor: number) => {
@@ -462,7 +785,11 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
<> <>
<div <div
className="absolute top-0 left-0 origin-top-left" className="absolute top-0 left-0 origin-top-left"
style={{ transform: `translate(${view.x}px, ${view.y}px) scale(${view.zoom})` }} style={{
transform: `translate(${view.x}px, ${view.y}px) scale(${view.zoom})`,
transition:
panning || !settled ? "none" : "transform 180ms ease-out",
}}
> >
<PlanDiagram <PlanDiagram
nodes={tree.nodes} nodes={tree.nodes}
@@ -470,6 +797,8 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
statusOf={statusOf} statusOf={statusOf}
glowId={statuses?.current ?? null} glowId={statuses?.current ?? null}
markerId={markerId} markerId={markerId}
pad={POPUP_PAD}
naturalSize
/> />
</div> </div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between p-2"> <div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between p-2">
+1 -1
View File
@@ -52,7 +52,7 @@ export function planWindowViewBox(tree: PlanTree, current: string | null): strin
: Math.max(layout.width, 1) / 2; : Math.max(layout.width, 1) / 2;
const cy = const cy =
focusId !== null focusId !== null
? (layout.y.get(focusId) ?? 0) + NODE_H / 2 ? (layout.y.get(focusId) ?? 0) + (layout.nodeH.get(focusId) ?? NODE_H) / 2
: Math.max(layout.height, 1) / 2; : Math.max(layout.height, 1) / 2;
const clamp = (c: number, extent: number, view: number) => const clamp = (c: number, extent: number, view: number) =>
extent <= view ? extent / 2 : Math.min(Math.max(c, view / 2), extent - view / 2); extent <= view ? extent / 2 : Math.min(Math.max(c, view / 2), extent - view / 2);
+2 -2
View File
@@ -1,7 +1,7 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts"; import "./.next/types/routes.d.ts";
import "./.next/dev/types/root-params.d.ts"; import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+2
View File
@@ -32,7 +32,9 @@
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"d3-force": "^3.0.0", "d3-force": "^3.0.0",
"d3-selection": "^3.0.0", "d3-selection": "^3.0.0",
"d3-shape": "^3.2.0",
"d3-zoom": "^3.0.0", "d3-zoom": "^3.0.0",
"dagre-d3-es": "7.0.14",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"katex": "^0.16.47", "katex": "^0.16.47",
"linkedom": "^0.18.13", "linkedom": "^0.18.13",
+63 -9
View File
@@ -6,6 +6,7 @@ import {
PlanPopup, PlanPopup,
blockLayout, blockLayout,
topoFor, topoFor,
edgePathD,
wrapTitle, wrapTitle,
GAP_X, GAP_X,
NODE_H, NODE_H,
@@ -58,10 +59,55 @@ describe("plan block layout (pure)", () => {
assert.ok(layout.x.get("x") === 0 && layout.x.get("y") === NODE_W + GAP_X); assert.ok(layout.x.get("x") === 0 && layout.x.get("y") === NODE_W + GAP_X);
}); });
it("wrapTitle wraps greedily and ellipsizes overflow", () => { it("edgePathD draws dagre's route through curveBasis — mouth pulled back from the border", () => {
// Straight 3-point route (source border → mid → target border): the
// basis curve starts exactly at the border point; the END pulls
// back by HEAD_TIP_OVERHANG + the docking gap (92 − 3.538 − 4) so the
// head's TIP floats 4 short of the border with its mouth at the
// path's end, on a straight shaft (owner, rounds 11–12).
const d = edgePathD([{ x: 80, y: 44 }, { x: 80, y: 68 }, { x: 80, y: 92 }]);
assert.ok(d.startsWith("M80,44"), "starts at the source border point");
assert.ok(d.endsWith("L80,84.462"), "mouth pulled back; tip floats 4 off the border");
// The whole run stays on-axis (every x is 80): a collinear route
// renders straight — the shaft stub never bends a straight edge.
const xs = [...d.matchAll(/(?:[MLC]|,)(-?[\d.]+),/g)].map((m) => m[1]);
assert.ok(xs.length > 0 && xs.every((v) => v === "80"), "collinear route renders on-axis");
// A perpendicular entry with the target box is the SAME geometry.
const pd = edgePathD([{ x: 80, y: 44 }, { x: 80, y: 68 }, { x: 80, y: 92 }], { x: 0, y: 92, w: 160, h: 44 });
assert.equal(pd, d);
});
it("edgePathD clamps the entry to ±40° of the face perpendicular (owner, round 14)", () => {
const box = { x: 20, y: 100, w: 160, h: 44 };
// A 60° tilt into the top face swings to the 40° cone edge: the
// mouth lands at (97.726, 93.289) and the final segment runs at
// exactly 40° (Δx/Δy = tan 40°). The tip floats DOCK_GAP (4) off the
// face along the normal: border (100,100) → tip (100,96).
const clamped = edgePathD([{ x: 70, y: 70 }, { x: 100, y: 100 }], box);
assert.ok(clamped.endsWith("L97.726,93.289"), "swung to the cone edge");
// final L's from-point is the previous number pair; recompute direction
const nums = [...clamped.matchAll(/(-?[\d.]+)/g)].map((m) => Number(m[1]));
const ax = nums[nums.length - 4]!;
const ay = nums[nums.length - 3]!;
const bx = nums[nums.length - 2]!;
const by = nums[nums.length - 1]!;
assert.ok(Math.abs((bx - ax) / (by - ay) - Math.tan((40 * Math.PI) / 180)) < 0.01, "40° cone edge");
// Tip = mouth + u'·overhang floats 4 above the face.
const tipY = by + 0.766 * 3.538;
assert.ok(Math.abs(tipY - 96) < 0.01, "docking gap along the face normal");
// A shallow (~9.5°) tilt is WITHIN the limit: no swing.
const free = edgePathD([{ x: 90, y: 40 }, { x: 100, y: 100 }], box);
assert.ok(free.endsWith("L99.418,92.51"), "unclamped within the cone");
});
it("wrapTitle wraps greedily and NEVER drops content (boxes grow instead)", () => {
assert.deepEqual(wrapTitle("Chain rule intuition"), ["Chain rule intuition"]); // exactly 20 chars — fits one line assert.deepEqual(wrapTitle("Chain rule intuition"), ["Chain rule intuition"]); // exactly 20 chars — fits one line
assert.deepEqual(wrapTitle("Chain rule intuition and fluency"), ["Chain rule intuition", "and fluency"]); assert.deepEqual(wrapTitle("Chain rule intuition and fluency"), ["Chain rule intuition", "and fluency"]);
assert.deepEqual(wrapTitle("supercalifragilisticexpialidocious word"), ["supercalifragilisti…", "word"]); // oversized word hard-clamped, overflow dropped // Oversized word hard-chunked, nothing lost (owner, round 7).
assert.deepEqual(wrapTitle("supercalifragilisticexpialidocious word"), [
"supercalifragilistic",
"expialidocious word",
]);
assert.deepEqual(wrapTitle("supercalifragilistic"), ["supercalifragilistic"]); // exactly maxChars — fits assert.deepEqual(wrapTitle("supercalifragilistic"), ["supercalifragilistic"]); // exactly maxChars — fits
}); });
}); });
@@ -82,11 +128,14 @@ describe("plan block render", () => {
assert.match(html, /data-testid="plan-block-full"/); assert.match(html, /data-testid="plan-block-full"/);
// Borderless: the wrapper carries no border/rounded chrome. // Borderless: the wrapper carries no border/rounded chrome.
assert.ok(!/data-testid="plan-block-full"[^>]*border/.test(html), "no wrapping chrome"); assert.ok(!/data-testid="plan-block-full"[^>]*border/.test(html), "no wrapping chrome");
// Fills the column: the svg is w-full with the layout extent as viewBox // Fills the column UP TO the chat-font cap: the svg is w-full +
// (leftmost/rightmost nodes hug the edges), height natural. // mx-auto with maxWidth = extent × TEXT_CAP_SCALE, so node text never
assert.match(html, /<svg[^>]*class="block h-auto w-full"/); // renders past the 16.5px prose size (owner, plan-graph round 1).
// Two stacked nodes: 2*NODE_H + GAP_Y = 136 natural height, width = one node column. assert.match(html, /<svg[^>]*class="block h-auto w-full mx-auto"/);
assert.match(html, /viewBox="0 0 160 136"/); assert.match(html, /max-width:232\.32px/); // (160+16) × 1.32
// Two stacked nodes: 2*NODE_H + GAP_Y = 136 extent + GRAPH_PAD on
// every side (strokes/glow never clip at the edges).
assert.match(html, /viewBox="-8 -8 176 152"/);
// Snapshot statuses render as carried. // Snapshot statuses render as carried.
assert.match(html, /data-testid="plan-node-a" data-status="visited"/); assert.match(html, /data-testid="plan-node-a" data-status="visited"/);
assert.match(html, /data-testid="plan-node-b" data-status="upcoming"/); assert.match(html, /data-testid="plan-node-b" data-status="upcoming"/);
@@ -135,8 +184,13 @@ describe("plan popup (live derivation)", () => {
assert.match(html, /data-testid="plan-node-a" data-status="visited"/); assert.match(html, /data-testid="plan-node-a" data-status="visited"/);
assert.match(html, /data-testid="plan-node-b" data-status="current"/); assert.match(html, /data-testid="plan-node-b" data-status="current"/);
assert.match(html, /data-testid="plan-node-c" data-status="upcoming"/); assert.match(html, /data-testid="plan-node-c" data-status="upcoming"/);
// The current node is the glow target. // The current node is the glow target: a faint yellow fill, blurred
assert.match(html, /stroke-primary\/70 animate-pulse/); // so it bleeds over the edges — static, no pulse (owner, round 6).
assert.match(html, /fill-yellow-400\/25/);
assert.match(html, /feGaussianBlur/);
assert.doesNotMatch(html, /animate-pulse/);
// Arrows into not-yet-visited nodes are grayed out (owner, round 6).
assert.match(html, /marker-end="url\(#.*-dim\)"/);
assert.match(html, /live — statuses derive from your check outcomes/); assert.match(html, /live — statuses derive from your check outcomes/);
}); });
+5 -2
View File
@@ -34,9 +34,12 @@ describe("stats bar", () => {
assert.match(html, /data-testid="stats-bar"/); assert.match(html, /data-testid="stats-bar"/);
assert.match(html, /data-testid="stats-context">—</); assert.match(html, /data-testid="stats-context">—</);
assert.match(html, /data-testid="stats-cost">—</); assert.match(html, /data-testid="stats-cost">—</);
// no turns, no settings, catalog not loaded → both control labels "—" // no turns, no settings, catalog not loaded → both control labels
// "—" (the thinking button shows just the level, like the model
// button shows just the model name — no prefix, no dot separator)
assert.match(html, /data-testid="stats-model"[^>]*>\s*<span[^>]*>—<\/span>/); assert.match(html, /data-testid="stats-model"[^>]*>\s*<span[^>]*>—<\/span>/);
assert.match(html, /Thinking —</); assert.match(html, /data-testid="stats-thinking"[^>]*>\s*<span[^>]*>—<\/span>/);
assert.ok(!html.includes("aria-hidden>·"), "no dot between the controls");
// the control reads as a button: pill border + dropdown chevron // the control reads as a button: pill border + dropdown chevron
assert.match(html, /data-testid="stats-model"[^>]*rounded-full[^>]*>/); assert.match(html, /data-testid="stats-model"[^>]*rounded-full[^>]*>/);
assert.match(html, /▾/); assert.match(html, /▾/);