"use client"; import { useEffect, useId, useMemo, useRef, useState } from "react"; import { MaximizeIcon, MinusIcon, PlusIcon, XIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import type { PlanBlockData, PlanNodeStatus, PlanTree } from "@/src/core"; import { derivedNodeStatus, derivePlanStatuses } from "@/src/core"; /** * PLAN BLOCKS — the plan lives in the chat (wave 4): persisted * `data-plan-block` parts render as DAG blocks in the transcript flow, and * 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. * - kind "neighborhood": the fragment in its own small container, labeled * as a FRAGMENT (clearly not the whole plan), the current node glowing. * - Clicking either 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 * is harvested from the retired tree panel's graph (fit-floor semantics: * fully zoomed out IS the fit; panning locks at the floor). */ // ─── Layout constants + pure helpers (exported for tests) ──────────────────── export const NODE_W = 160; export const NODE_H = 44; export const GAP_X = 24; export const GAP_Y = 48; /** A node for layout purposes — the payload's shape and the tree's shape agree. */ export interface LayoutNode { id: string; title: string; } /** Topological order + longest-path depth (Kahn, authoring tie-break). */ 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(nodes.map((n) => [n.id, 0] as const)); const dependents = new Map(); for (const e of edges) { if (!index.has(e.from) || !index.has(e.to)) continue; // defensive: dangling ids indegree.set(e.to, (indegree.get(e.to) ?? 0) + 1); const list = dependents.get(e.from); if (list) list.push(e.to); else dependents.set(e.from, [e.to]); } const ready = nodes.filter((n) => (indegree.get(n.id) ?? 0) === 0).map((n) => n.id); const order: LayoutNode[] = []; const depth = new Map(); const byId = new Map(nodes.map((n) => [n.id, n] as const)); while (ready.length > 0) { ready.sort((a, b) => (index.get(a) ?? 0) - (index.get(b) ?? 0)); const id = ready.shift()!; const node = byId.get(id); if (node) order.push(node); const d = depth.get(id) ?? 0; depth.set(id, d); for (const t of dependents.get(id) ?? []) { if ((depth.get(t) ?? -1) < d + 1) depth.set(t, d + 1); const left = (indegree.get(t) ?? 0) - 1; indegree.set(t, left); if (left === 0) ready.push(t); } } for (const n of nodes) { if (!depth.has(n.id)) { order.push(n); depth.set(n.id, 0); } } return { order, depth }; } /** Layered positions: depth = row (top-down), authoring order within the * row, each row centered on the widest one. */ 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(); const y = new Map(); 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 }; } /** 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[] { const words = title.trim().split(/\s+/).filter(Boolean); if (words.length === 0) return [""]; const lines: string[] = []; for (const w of words) { 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); } 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}…`; } } } return lines; } // ─── Status styling (the retired panel's color language, unchanged) ────────── const BOX: Record = { done: "fill-phase-teach/10 stroke-phase-teach/50", current: "fill-primary/10 stroke-primary", upcoming: "fill-card stroke-border", }; const TEXT: Record = { done: "fill-muted-foreground text-xs", current: "fill-foreground text-xs font-medium", upcoming: "fill-muted-foreground/80 text-xs", }; /** The diagram itself: positioned nodes + bezier edges. Pure-ish render — * every input is data, so SSR render tests pin the whole thing. */ function PlanDiagram({ nodes, edges, statusOf, glowId, markerId, }: { nodes: readonly LayoutNode[]; edges: ReadonlyArray<{ from: string; to: string }>; statusOf: (id: string) => PlanNodeStatus; glowId?: string | null; markerId: string; }) { const layout = useMemo(() => blockLayout(nodes, edges), [nodes, edges]); return ( {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; return ( ${e.to}`} d={`M ${x1} ${y1} C ${x1} ${mid}, ${x2} ${mid}, ${x2} ${y2}`} className="fill-none stroke-muted-foreground/40" strokeWidth={1.5} markerEnd={`url(#${markerId})`} /> ); })} {nodes.map((n) => { const status = statusOf(n.id); const glow = n.id === glowId; const lines = wrapTitle(n.title); return ( {glow && ( // The glow target: a soft primary halo breathing around the box. )} {lines.map((line, i) => ( {line} ))} ); })} ); } // ─── The transcript block ───────────────────────────────────────────────────── /** * A persisted plan-block part, rendered as carried (snapshot statuses, * never re-derived). Full = borderless and column-filling; neighborhood = * a labeled fragment container with the current node glowing. Either * opens the live plan popup. */ export function PlanBlock({ data, onOpen }: { data: PlanBlockData; onOpen: () => void }) { const markerId = useId().replace(/[^a-zA-Z0-9-]/g, ""); const statusMap = useMemo(() => new Map(data.nodes.map((n) => [n.id, n.status] as const)), [data]); const statusOf = (id: string): PlanNodeStatus => statusMap.get(id) ?? "upcoming"; if (data.kind === "full") { return ( ); } return (
Plan fragment
); } // ─── The live plan popup ────────────────────────────────────────────────────── /** Minimal structural message slice (same trick as phases.ts). */ export interface PopupMessage { parts: ReadonlyArray<{ type: string; state?: string; input?: unknown; output?: unknown }>; } /** The latest plan tree in the transcript (latest tool-plan_ output). */ function latestTree(messages: readonly PopupMessage[]): PlanTree | null { for (let m = messages.length - 1; m >= 0; m--) { const parts = messages[m]?.parts; if (!parts) continue; for (let p = parts.length - 1; p >= 0; p--) { const part = parts[p]!; if (!part.type.startsWith("tool-plan_") || part.state !== "output-available") continue; const output = part.output as { tree?: PlanTree } | undefined; if (output?.tree && Array.isArray(output.tree.nodes)) return output.tree; } } 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; const FIT_PAD = 16; const FIT_ZOOM_MAX = 1.5; interface GraphView { zoom: number; x: number; y: number; } 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), ); } function fitGraphView(box: { w: number; h: number }, viewport: { w: number; h: number }, zoom: number): GraphView { return { zoom, x: (viewport.w - box.w * zoom) / 2, y: (viewport.h - box.h * zoom) / 2 }; } function zoomedGraphView( view: GraphView, factor: number, floor: number, focus: { x: number; y: number }, box: { w: number; h: number }, viewport: { w: number; h: number }, ): GraphView { const zoom = Math.min(Math.max(view.zoom * factor, floor), ZOOM_MAX); if (zoom === view.zoom) return view; if (zoom <= floor) return fitGraphView(box, viewport, floor); const k = zoom / view.zoom; return { zoom, x: focus.x - (focus.x - view.x) * k, y: focus.y - (focus.y - view.y) * k }; } /** * The full plan, live: latest tree + statuses derived at click time. Esc, * backdrop click, or the X close it; drag pans, wheel zooms to the cursor, * the zoom floor is the fit (panning locks at it — nothing to move). */ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessage[]; onClose: () => void }) { const tree = useMemo(() => latestTree(messages), [messages]); const statuses = useMemo( () => (tree ? derivePlanStatuses(tree, messages as Parameters[1]) : null), [tree, messages], ); const statusOf = (id: string): PlanNodeStatus => (statuses ? derivedNodeStatus(statuses, id) : "upcoming"); // Esc closes. useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]); // ── pan/zoom viewport (harvested semantics: fit floor = the default) ── const viewportRef = useRef(null); const [size, setSize] = useState({ w: 0, h: 0 }); useEffect(() => { const el = viewportRef.current; if (!el || typeof ResizeObserver === "undefined") return; const ro = new ResizeObserver(() => setSize({ w: el.clientWidth, h: el.clientHeight })); ro.observe(el); return () => ro.disconnect(); }, []); 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]); const fitZoom = useMemo(() => fitZoomFor(box, size), [box, size]); const [view, setView] = useState({ x: 0, y: 0, zoom: 1 }); const userAdjustedRef = useRef(false); const atFloor = view.zoom <= fitZoom + 1e-9; useEffect(() => { if (atFloor) userAdjustedRef.current = false; }, [atFloor]); useEffect(() => { if (userAdjustedRef.current || !size.w || !size.h) return; setView(fitGraphView(box, size, fitZoom)); }, [fitZoom, size, box]); useEffect(() => { const el = viewportRef.current; if (!el) return; const onWheel = (e: WheelEvent) => { e.preventDefault(); const dy = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY; const factor = Math.exp(-dy * 0.0015); const rect = el.getBoundingClientRect(); setView((v) => zoomedGraphView(v, factor, fitZoom, { x: e.clientX - rect.left, y: e.clientY - rect.top }, box, size), ); userAdjustedRef.current = true; }; el.addEventListener("wheel", onWheel, { passive: false }); return () => el.removeEventListener("wheel", onWheel); }, [fitZoom, box, size]); const dragRef = useRef<{ px: number; py: number; x: number; y: number; moved: boolean } | null>(null); const startDrag = (e: React.PointerEvent) => { 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 }; e.currentTarget.classList.add("cursor-grabbing"); e.currentTarget.setPointerCapture(e.pointerId); }; const moveDrag = (e: React.PointerEvent) => { const d = dragRef.current; if (!d) return; d.moved = true; setView((v) => v.zoom <= fitZoom + 1e-9 ? v : { ...v, x: d.x + (e.clientX - d.px), y: d.y + (e.clientY - d.py) }, ); }; const endDrag = (e: React.PointerEvent) => { const d = dragRef.current; if (d) { dragRef.current = null; if (d.moved) userAdjustedRef.current = true; } e.currentTarget.classList.remove("cursor-grabbing"); }; const stepZoom = (factor: number) => { userAdjustedRef.current = true; setView((v) => zoomedGraphView(v, factor, fitZoom, { x: size.w / 2, y: size.h / 2 }, box, size)); }; const fitView = () => { userAdjustedRef.current = false; if (!size.w || !size.h) return; setView(fitGraphView(box, size, fitZoom)); }; const markerId = useId().replace(/[^a-zA-Z0-9-]/g, ""); return (
{ if (e.target === e.currentTarget) onClose(); }} >
The plan live — statuses derive from your check outcomes
{tree ? ( <>
Drag to pan · scroll to zoom
) : (
No plan in this session yet.
)}
); }