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 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"}>
|
||||||
|
|||||||
@@ -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
@@ -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">
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Vendored
+2
-2
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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, /▾/);
|
||||||
|
|||||||
Reference in New Issue
Block a user