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:
+164
-65
@@ -40,6 +40,15 @@ import { findPendingToolCall, phaseAt3, phaseTimeline } from "@/src/core";
|
||||
*/
|
||||
const COLUMN = "mx-auto w-full max-w-none px-6";
|
||||
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
|
||||
@@ -264,6 +273,13 @@ export function Chat({
|
||||
// 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).
|
||||
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 () => {
|
||||
try {
|
||||
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
|
||||
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
|
||||
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 min-w-0 flex-1 flex-col">
|
||||
{/* h-10, not py-2: the toggle (a size-9 icon button) renders only
|
||||
@@ -600,45 +617,56 @@ export function Chat({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DockOverlay
|
||||
pending={pending}
|
||||
chatId={chatId}
|
||||
settings={settings}
|
||||
onSettingsSaved={setSettings}
|
||||
requiresReveal={requiresReveal}
|
||||
phase={phases.at(-1) ?? "probe"}
|
||||
turns={turns}
|
||||
composer={
|
||||
<Composer
|
||||
// Stop applies only to a LOCAL stream: a server-side turn has no
|
||||
// client request to abort (stop() would be a no-op lie).
|
||||
busy={localStream}
|
||||
value={text}
|
||||
onChange={setText}
|
||||
onSend={onSend}
|
||||
onStop={() => stop()}
|
||||
canSend={canSend}
|
||||
verdict={
|
||||
planVerdict
|
||||
? {
|
||||
onAccept: () => sendVerdict(true),
|
||||
onDecline: () => sendVerdict(false),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{/* The dock's reserved tail (DockOverlay reports its height):
|
||||
keeps at-rest transcript content scrolling fully clear of
|
||||
the overlay — the flow-space replacement for the old
|
||||
sticky dock. Zero only pre-measurement (the first layout
|
||||
effect reports before paint). */}
|
||||
<div aria-hidden data-testid="dock-spacer" style={{ height: dockReserve }} />
|
||||
</ConversationContent>
|
||||
{/* The dock: an overlay sibling of the scroller (the
|
||||
use-stick-to-bottom pattern — like the scroll button). Before
|
||||
the button in DOM order so the button keeps painting above
|
||||
the dock, as it always has. */}
|
||||
<DockOverlay
|
||||
pending={pending}
|
||||
chatId={chatId}
|
||||
settings={settings}
|
||||
onSettingsSaved={setSettings}
|
||||
requiresReveal={requiresReveal}
|
||||
phase={phases.at(-1) ?? "probe"}
|
||||
turns={turns}
|
||||
onReserve={setDockReserve}
|
||||
composer={
|
||||
<Composer
|
||||
// Stop applies only to a LOCAL stream: a server-side turn has no
|
||||
// client request to abort (stop() would be a no-op lie).
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -652,14 +680,27 @@ export function Chat({
|
||||
}
|
||||
|
||||
/**
|
||||
* The floating blob: ONE persistent shell, sticky at the bottom of the
|
||||
* scroller (in flow — the transcript scrolls fully clear of it, and it never
|
||||
* covers the scrollbar). Contents swap between the chat composer and the
|
||||
* question dock; shell height morphs between variants (WAAPI, quick).
|
||||
* The floating blob: ONE persistent shell, OVERLAID at the bottom of the
|
||||
* column (absolute — a sibling of the scroller, the use-stick-to-bottom
|
||||
* pattern; it replaced the old in-flow sticky dock, which died of the
|
||||
* 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
|
||||
* back to the bottom) to re-expand. The question form stays MOUNTED while
|
||||
* collapsed (hidden, not unmounted) so a half-made selection survives.
|
||||
* Why an overlay (owner, scroll round 1): an in-flow dock IS scroll geometry
|
||||
* — folding it shrinks the scroll content, so the fold had to wait until the
|
||||
* 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({
|
||||
pending,
|
||||
@@ -671,6 +712,7 @@ function DockOverlay({
|
||||
chatId,
|
||||
settings,
|
||||
onSettingsSaved,
|
||||
onReserve,
|
||||
}: {
|
||||
pending: ReturnType<typeof findPendingToolCall>;
|
||||
/** Teach-phase pending question: arrives collapsed behind the reveal button (W5). */
|
||||
@@ -685,6 +727,9 @@ function DockOverlay({
|
||||
chatId: string;
|
||||
settings?: ChatSettings;
|
||||
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();
|
||||
// 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
|
||||
}, [pendingId]);
|
||||
|
||||
// ── Collapse state machine with hysteresis ──
|
||||
// Collapsing frees ~Δpx of flow height. If it happened while the learner is
|
||||
// within Δ of the bottom, the browser clamps scrollTop to the new max —
|
||||
// landing them AT the bottom, which re-expands the dock, which re-collapses…
|
||||
// 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.
|
||||
// ── Fold machine (position, not velocity — owner, scroll round 1) ──
|
||||
// The dock is an overlay: folding is visual-only and scroll geometry never
|
||||
// moves, so the thresholds can hug the bottom. Leave it → fold; reach it
|
||||
// again → re-open. The dead band keeps trackpad drift from chattering.
|
||||
const expandedRef = useRef(expanded);
|
||||
expandedRef.current = expanded;
|
||||
const pendingRef = useRef(!!pending);
|
||||
@@ -733,8 +775,62 @@ function DockOverlay({
|
||||
requiresRevealRef.current = requiresReveal;
|
||||
const revealedRef = useRef(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
|
||||
// hundreds of ms, during which the position is still legitimately inside
|
||||
@@ -763,10 +859,9 @@ function DockOverlay({
|
||||
const onScroll = () => {
|
||||
if (!pendingRef.current) return;
|
||||
const d = el.scrollHeight - el.clientHeight - el.scrollTop;
|
||||
const delta = Math.max(expandedHRef.current - collapsedHRef.current, 240);
|
||||
if (expandedRef.current) {
|
||||
if (d > delta + 64 && !expandIntentRef.current) setExpanded(false);
|
||||
} else if (d < 16) {
|
||||
if (d > COLLAPSE_DIST && !expandIntentRef.current) setExpanded(false);
|
||||
} else if (d < EXPAND_DIST) {
|
||||
// Teach checks stay collapsed until the learner opens them (W5): the
|
||||
// at-bottom auto-expand would pop the form over the teaching.
|
||||
if (requiresRevealRef.current && !revealedRef.current) return;
|
||||
@@ -778,10 +873,12 @@ function DockOverlay({
|
||||
return () => el.removeEventListener("scroll", onScroll);
|
||||
}, [scrollRef, scrollToBottom]);
|
||||
|
||||
const recordHeight = (h: number) => {
|
||||
if (!pendingRef.current) return;
|
||||
if (expandedRef.current) expandedHRef.current = h;
|
||||
else collapsedHRef.current = h;
|
||||
// The settled block height (gradient excluded), offered to the spacer.
|
||||
// Only ever called with NATURAL heights — never the animated mid-morph
|
||||
// value (the iso effect's getAnimations guard wraps every call).
|
||||
const measureBlock = () => {
|
||||
const b = blockRef.current;
|
||||
if (b) offer(b.getBoundingClientRect().height);
|
||||
};
|
||||
|
||||
const mode = pending ? `question:${pending.toolCallId}` : "chat";
|
||||
@@ -806,13 +903,15 @@ function DockOverlay({
|
||||
if (el.getAnimations().length === 0) {
|
||||
const h = el.getBoundingClientRect().height;
|
||||
p.height = h;
|
||||
recordHeight(h);
|
||||
measureBlock(); // typing growth → the spacer grows with the box
|
||||
}
|
||||
return;
|
||||
}
|
||||
el.getAnimations().forEach((a) => a.cancel()); // flush to natural height before measuring
|
||||
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) {
|
||||
el.animate([{ height: `${p.height}px` }, { height: `${h}px` }], {
|
||||
duration: 180,
|
||||
@@ -823,9 +922,9 @@ function DockOverlay({
|
||||
});
|
||||
|
||||
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="bg-background pb-4">
|
||||
<div ref={blockRef} className="bg-background pb-4">
|
||||
<div className={DOCK}>
|
||||
<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"}>
|
||||
|
||||
@@ -101,6 +101,7 @@ export function ModelControl({
|
||||
const [query, setQuery] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const defaults = catalog?.defaults;
|
||||
const effectiveId = settings?.model?.id ?? defaults?.model.id ?? fallbackModelId;
|
||||
@@ -114,6 +115,28 @@ export function ModelControl({
|
||||
if (open === "model") searchRef.current?.focus();
|
||||
}, [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(
|
||||
async (next: ChatSettings) => {
|
||||
setError(null);
|
||||
@@ -153,7 +176,7 @@ export function ModelControl({
|
||||
.map((r) => r.m);
|
||||
|
||||
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
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
<span aria-hidden>·</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="stats-thinking"
|
||||
@@ -181,8 +203,10 @@ export function ModelControl({
|
||||
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"
|
||||
>
|
||||
{/* Just the level — the button reads like the model button reads
|
||||
the model name (owner, stats-control round 1). */}
|
||||
<span className="text-card-foreground">
|
||||
{effectiveLevel === undefined ? "Thinking —" : `Thinking: ${LEVEL_LABEL[effectiveLevel] ?? effectiveLevel}`}
|
||||
{effectiveLevel === undefined ? "—" : (LEVEL_LABEL[effectiveLevel] ?? effectiveLevel)}
|
||||
</span>
|
||||
<span aria-hidden className="text-xxs text-muted-foreground">▾</span>
|
||||
</button>
|
||||
|
||||
+412
-83
@@ -3,6 +3,15 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { MaximizeIcon, MinusIcon, PlusIcon, XIcon } from "lucide-react";
|
||||
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 { derivedNodeStatus, derivePlanStatuses } from "@/src/core";
|
||||
|
||||
@@ -12,17 +21,19 @@ import { derivedNodeStatus, derivePlanStatuses } from "@/src/core";
|
||||
* the right sidebar is retired.
|
||||
*
|
||||
* - kind "full": the whole DAG, BORDERLESS — no wrapping chrome, the SVG
|
||||
* scales to the full column width (viewBox = layout extent, so the
|
||||
* leftmost/rightmost nodes hug the edges), natural height, no cap.
|
||||
* scales to the full column width (viewBox = layout extent + GRAPH_PAD,
|
||||
* 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 —
|
||||
* the LIVE plan: the latest tree from the transcript with statuses
|
||||
* derived at click time (contracts/plan.ts derivePlanStatuses). Blocks
|
||||
* are frozen snapshots rendered exactly as carried (never re-derived);
|
||||
* the popup is the live view (user decision, 2026-09).
|
||||
*
|
||||
* Hand-built SVG everywhere (user decision): a deterministic layered
|
||||
* layout fills exactly and stays SSR-testable — no force simulation, no
|
||||
* coupling to the graph stream's sigma internals. Pan/zoom in the popup
|
||||
* Hand-built SVG everywhere (user decision): placement runs on dagre
|
||||
* (mermaid's engine — deterministic, synchronous, DOM-free, so SSR and
|
||||
* 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:
|
||||
* 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_H = 44;
|
||||
/** Baseline-to-baseline distance of node titles. */
|
||||
export const LINE_H = 13;
|
||||
export const GAP_X = 24;
|
||||
export const GAP_Y = 48;
|
||||
|
||||
@@ -40,7 +53,9 @@ export interface LayoutNode {
|
||||
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 }>) {
|
||||
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));
|
||||
@@ -79,68 +94,256 @@ export function topoFor(nodes: readonly LayoutNode[], edges: ReadonlyArray<{ fro
|
||||
return { order, depth };
|
||||
}
|
||||
|
||||
/** Layered positions: depth = row (top-down), authoring order within the
|
||||
* row, each row centered on the widest one. */
|
||||
/** Placement — dagre (mermaid's engine, owner round 8): Sugiyama-style
|
||||
* 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 }>) {
|
||||
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 y = new Map<string, number>();
|
||||
layers.forEach((l, d) => {
|
||||
const start = (maxWidth - rowWidth(l)) / 2;
|
||||
l.forEach((n, i) => {
|
||||
x.set(n.id, start + i * (NODE_W + GAP_X));
|
||||
y.set(n.id, d * (NODE_H + GAP_Y));
|
||||
});
|
||||
});
|
||||
const height = layers.length * NODE_H + Math.max(0, layers.length - 1) * GAP_Y;
|
||||
return { x, y, width: maxWidth, height };
|
||||
const nodeH = new Map<string, number>();
|
||||
/** Dagre's per-edge waypoint routes — each starts/ends at the node
|
||||
* border intersection on whatever SIDE the edge leaves/enters
|
||||
* (round 11: the mermaid line drawing, no more fixed bottom-center →
|
||||
* 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 ids = new Set(nodes.map((n) => n.id));
|
||||
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
|
||||
* ellipsis, a single oversized word is hard-clamped. Pure. */
|
||||
export function wrapTitle(title: string, maxChars = 20, maxLines = 2): string[] {
|
||||
/** Greedy word wrap to lines of ≤ maxChars — NOTHING is dropped: the
|
||||
* node's box grows to fit every line (owner: show the full content of
|
||||
* 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);
|
||||
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 (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 fits = last !== undefined && last.length + 1 + w.length <= maxChars;
|
||||
if (fits) {
|
||||
lines[lines.length - 1] = `${last} ${w}`;
|
||||
} else if (lines.length < maxLines) {
|
||||
lines.push(w.length > maxChars ? `${w.slice(0, maxChars - 1)}…` : w);
|
||||
if (last !== undefined && last.length + 1 + a.length <= maxChars) {
|
||||
lines[lines.length - 1] = `${last} ${a}`;
|
||||
} else {
|
||||
const lastLine = lines[lines.length - 1];
|
||||
if (lastLine !== undefined && !lastLine.endsWith("…")) {
|
||||
lines[lines.length - 1] =
|
||||
lastLine.length >= maxChars ? `${lastLine.slice(0, maxChars - 1)}…` : `${lastLine}…`;
|
||||
}
|
||||
lines.push(a);
|
||||
}
|
||||
}
|
||||
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) ──────────
|
||||
|
||||
// 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> = {
|
||||
visited: "fill-phase-teach/10 stroke-phase-teach/50",
|
||||
current: "fill-primary/10 stroke-primary",
|
||||
// 50%-opacity app background instead of true transparency: a routed
|
||||
// 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",
|
||||
};
|
||||
const TEXT: Record<PlanNodeStatus, string> = {
|
||||
visited: "fill-muted-foreground text-xs",
|
||||
current: "fill-foreground text-xs font-medium",
|
||||
visited: "fill-white text-xs",
|
||||
current: "fill-white text-xs font-medium",
|
||||
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 —
|
||||
* every input is data, so SSR render tests pin the whole thing. */
|
||||
@@ -151,6 +354,10 @@ export function PlanDiagram({
|
||||
glowId,
|
||||
markerId,
|
||||
viewBoxOverride,
|
||||
pad: padUnits,
|
||||
textCapScale,
|
||||
naturalSize,
|
||||
monochrome,
|
||||
}: {
|
||||
nodes: readonly LayoutNode[];
|
||||
edges: ReadonlyArray<{ from: string; to: string }>;
|
||||
@@ -159,48 +366,112 @@ export function PlanDiagram({
|
||||
markerId: string;
|
||||
/** A windowed viewBox (the plan window) instead of the full extent. */
|
||||
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]);
|
||||
// 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 (
|
||||
<svg
|
||||
viewBox={viewBoxOverride ?? `0 0 ${Math.max(layout.width, 1)} ${Math.max(layout.height, 1)}`}
|
||||
className="block h-auto w-full"
|
||||
viewBox={viewBoxOverride ?? `${-pad} ${-pad} ${vbW} ${vbH}`}
|
||||
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"
|
||||
aria-label="Plan diagram"
|
||||
>
|
||||
<defs>
|
||||
<marker
|
||||
id={markerId}
|
||||
viewBox="0 0 8 8"
|
||||
refX="7"
|
||||
refY="4"
|
||||
markerWidth="6"
|
||||
markerHeight="6"
|
||||
orient="auto-start-reverse"
|
||||
viewBox="0 0 50.2 104"
|
||||
refX={27.2}
|
||||
refY={52}
|
||||
markerWidth={HEAD_W}
|
||||
markerHeight={HEAD_H}
|
||||
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>
|
||||
{/* 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>
|
||||
{edges.map((e) => {
|
||||
const x1 = (layout.x.get(e.from) ?? 0) + NODE_W / 2;
|
||||
const y1 = (layout.y.get(e.from) ?? 0) + NODE_H;
|
||||
const x2 = (layout.x.get(e.to) ?? 0) + NODE_W / 2;
|
||||
const y2 = layout.y.get(e.to) ?? 0;
|
||||
const mid = (y1 + y2) / 2;
|
||||
// Dagre's route for this edge: border-intersected waypoints —
|
||||
// the line leaves/enters nodes on whatever side dagre chose
|
||||
// (mermaid's drawing, owner round 11). Unroutable edges
|
||||
// (dangling endpoints) draw nothing.
|
||||
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 (
|
||||
<path
|
||||
key={`${e.from}->${e.to}`}
|
||||
d={`M ${x1} ${y1} C ${x1} ${mid}, ${x2} ${mid}, ${x2} ${y2}`}
|
||||
className="fill-none stroke-muted-foreground/40"
|
||||
d={edgePathD(pts, target)}
|
||||
className={dim ? "fill-none stroke-border" : "fill-none stroke-white"}
|
||||
strokeWidth={1.5}
|
||||
markerEnd={`url(#${markerId})`}
|
||||
markerEnd={`url(#${markerId}${dim ? "-dim" : ""})`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{nodes.map((n) => {
|
||||
const status = statusOf(n.id);
|
||||
const glow = n.id === glowId;
|
||||
const glow = n.id === glowId && !monochrome;
|
||||
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 (
|
||||
<g
|
||||
key={n.id}
|
||||
@@ -208,21 +479,35 @@ export function PlanDiagram({
|
||||
data-testid={`plan-node-${n.id}`}
|
||||
data-status={status}
|
||||
>
|
||||
{/* Rectangular — no corner rounding (owner). */}
|
||||
<rect
|
||||
width={NODE_W}
|
||||
height={h}
|
||||
className={monochrome ? MONO_BOX : BOX[status]}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
{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
|
||||
x={-4}
|
||||
y={-4}
|
||||
width={NODE_W + 8}
|
||||
height={NODE_H + 8}
|
||||
rx={12}
|
||||
className="fill-none stroke-primary/70 animate-pulse"
|
||||
strokeWidth={1.5}
|
||||
x={-GLOW_MARGIN}
|
||||
y={-GLOW_MARGIN}
|
||||
width={NODE_W + 2 * GLOW_MARGIN}
|
||||
height={h + 2 * GLOW_MARGIN}
|
||||
className="fill-yellow-400/25"
|
||||
filter={`url(#${markerId}-glow)`}
|
||||
/>
|
||||
)}
|
||||
<rect width={NODE_W} height={NODE_H} rx={10} className={BOX[status]} strokeWidth={1.5} />
|
||||
{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}
|
||||
</text>
|
||||
))}
|
||||
@@ -254,7 +539,18 @@ export function PlanBlock({ data, onOpen }: { data: PlanBlockData; onOpen: () =>
|
||||
title="Open the full plan"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -281,10 +577,16 @@ export function latestTree(messages: readonly PopupMessage[]): PlanTree | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Zoom bounds: never below fit (fully zoomed out IS the fit), never above 2×. */
|
||||
const ZOOM_MAX = 2;
|
||||
const ZOOM_FLOOR = 0.15;
|
||||
/** Zoom bounds: never below fit (fully zoomed out IS the fit — the whole
|
||||
* graph visible, whichever axis binds — owner, plan-graph round 2), never
|
||||
* 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;
|
||||
/** 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;
|
||||
|
||||
interface GraphView {
|
||||
@@ -295,10 +597,9 @@ interface GraphView {
|
||||
|
||||
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),
|
||||
);
|
||||
// No artificial floor: a wide graph must fit even below 0.15× — the
|
||||
// epsilon only guards degenerate viewports.
|
||||
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 {
|
||||
@@ -354,12 +655,22 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
|
||||
}, []);
|
||||
|
||||
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 [view, setView] = useState<GraphView>({ x: 0, y: 0, zoom: 1 });
|
||||
const userAdjustedRef = useRef(false);
|
||||
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(() => {
|
||||
if (atFloor) userAdjustedRef.current = false;
|
||||
@@ -367,6 +678,12 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
|
||||
useEffect(() => {
|
||||
if (userAdjustedRef.current || !size.w || !size.h) return;
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -386,12 +703,17 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
|
||||
return () => el.removeEventListener("wheel", onWheel);
|
||||
}, [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 startDrag = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
if ((e.target as HTMLElement).closest("button")) return;
|
||||
if (atFloor) return;
|
||||
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.setPointerCapture(e.pointerId);
|
||||
};
|
||||
@@ -409,6 +731,7 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
|
||||
dragRef.current = null;
|
||||
if (d.moved) userAdjustedRef.current = true;
|
||||
}
|
||||
setPanning(false);
|
||||
e.currentTarget.classList.remove("cursor-grabbing");
|
||||
};
|
||||
const stepZoom = (factor: number) => {
|
||||
@@ -462,7 +785,11 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
|
||||
<>
|
||||
<div
|
||||
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
|
||||
nodes={tree.nodes}
|
||||
@@ -470,6 +797,8 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
|
||||
statusOf={statusOf}
|
||||
glowId={statuses?.current ?? null}
|
||||
markerId={markerId}
|
||||
pad={POPUP_PAD}
|
||||
naturalSize
|
||||
/>
|
||||
</div>
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between p-2">
|
||||
|
||||
@@ -52,7 +52,7 @@ export function planWindowViewBox(tree: PlanTree, current: string | null): strin
|
||||
: Math.max(layout.width, 1) / 2;
|
||||
const cy =
|
||||
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;
|
||||
const clamp = (c: number, extent: number, view: number) =>
|
||||
extent <= view ? extent / 2 : Math.min(Math.max(c, view / 2), extent - view / 2);
|
||||
|
||||
Vendored
+2
-2
@@ -1,7 +1,7 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/dev/types/root-params.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/types/root-params.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -32,7 +32,9 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"d3-force": "^3.0.0",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-shape": "^3.2.0",
|
||||
"d3-zoom": "^3.0.0",
|
||||
"dagre-d3-es": "7.0.14",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"katex": "^0.16.47",
|
||||
"linkedom": "^0.18.13",
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
PlanPopup,
|
||||
blockLayout,
|
||||
topoFor,
|
||||
edgePathD,
|
||||
wrapTitle,
|
||||
GAP_X,
|
||||
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);
|
||||
});
|
||||
|
||||
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 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
|
||||
});
|
||||
});
|
||||
@@ -82,11 +128,14 @@ describe("plan block render", () => {
|
||||
assert.match(html, /data-testid="plan-block-full"/);
|
||||
// Borderless: the wrapper carries no border/rounded 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
|
||||
// (leftmost/rightmost nodes hug the edges), height natural.
|
||||
assert.match(html, /<svg[^>]*class="block h-auto w-full"/);
|
||||
// Two stacked nodes: 2*NODE_H + GAP_Y = 136 natural height, width = one node column.
|
||||
assert.match(html, /viewBox="0 0 160 136"/);
|
||||
// Fills the column UP TO the chat-font cap: the svg is w-full +
|
||||
// mx-auto with maxWidth = extent × TEXT_CAP_SCALE, so node text never
|
||||
// renders past the 16.5px prose size (owner, plan-graph round 1).
|
||||
assert.match(html, /<svg[^>]*class="block h-auto w-full mx-auto"/);
|
||||
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.
|
||||
assert.match(html, /data-testid="plan-node-a" data-status="visited"/);
|
||||
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-b" data-status="current"/);
|
||||
assert.match(html, /data-testid="plan-node-c" data-status="upcoming"/);
|
||||
// The current node is the glow target.
|
||||
assert.match(html, /stroke-primary\/70 animate-pulse/);
|
||||
// The current node is the glow target: a faint yellow fill, blurred
|
||||
// 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/);
|
||||
});
|
||||
|
||||
|
||||
@@ -34,9 +34,12 @@ describe("stats bar", () => {
|
||||
assert.match(html, /data-testid="stats-bar"/);
|
||||
assert.match(html, /data-testid="stats-context">—</);
|
||||
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, /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
|
||||
assert.match(html, /data-testid="stats-model"[^>]*rounded-full[^>]*>/);
|
||||
assert.match(html, /▾/);
|
||||
|
||||
Reference in New Issue
Block a user