From bb369a077b84b9940b2980f86ad435d7cfd3c179 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Fri, 11 Sep 2026 03:26:56 +0300 Subject: [PATCH] Plan blocks in chat: full DAG borderless in the flow, fragment + glow, live popup (wave-4 item 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - components/plan-block.tsx: hand-built SVG (user decision) — deterministic layered layout (Kahn topo + longest-path depth, rows centered, authoring tie-break), bezier edges, the retired panel's status color language. - PlanBlock renders data-plan-block parts AS CARRIED (frozen snapshots, never re-derived): kind full = borderless, viewBox = layout extent so nodes hug the column edges, natural height; kind neighborhood = own small labeled 'Plan fragment' container with the current node glowing (pulsing primary halo). - PlanPopup: full-screen overlay with the LIVE plan — latest tree from the transcript, statuses derived at click time via derivePlanStatuses (contracts/plan.ts). Esc / backdrop / X close; drag pan, wheel zoom-to-cursor, zoom buttons; fit-floor semantics harvested from the retired tree panel (zoom floor = the fit, pan locks at it). - chat.tsx: data-plan-block case in the part switch + popup mount. - src/core/ui-message.ts: TeacherUIDataParts gains "plan-block": PlanBlockData (additive, type-only — lane touch flagged to master). - tests/render-plan-block.test.tsx: pure layout (layering, centering, dangling-edge tolerance), wrap, frozen-snapshot render, live derivation. --- components/chat.tsx | 16 + components/plan-block.tsx | 534 +++++++++++++++++++++++++++++++ src/core/ui-message.ts | 4 +- tests/render-plan-block.test.tsx | 171 ++++++++++ 4 files changed, 724 insertions(+), 1 deletion(-) create mode 100644 components/plan-block.tsx create mode 100644 tests/render-plan-block.test.tsx diff --git a/components/chat.tsx b/components/chat.tsx index bba68f7..3af89ae 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -21,6 +21,8 @@ import { SubagentSteerCard } from "@/components/artifacts/steer-card"; import { Composer } from "@/components/composer"; import { JobsContext } from "@/components/jobs-context"; import { StatsBar } from "@/components/stats-bar"; +import { PlanBlock, PlanPopup } from "@/components/plan-block"; +import type { PlanBlockData } from "@/src/core"; import { QuestionDock } from "@/components/question-dock"; import { SidebarToggle } from "@/components/sidebar"; import { SubAgentsPanel } from "@/components/sub-agents-panel"; @@ -177,6 +179,9 @@ export function Chat({ } }, [chatId, setMessages, sendMessage, status]); + // The live plan popup (wave 4): opened by any transcript plan block. + const [planOpen, setPlanOpen] = useState(false); + // ── Session usage (wave-4 stats bar) ── // The money channel: the record's turns log, folded client-side by // core/prices.ts. Seeded once on mount (the page doesn't carry turns), @@ -389,6 +394,16 @@ export function Chat({ ); case "step-start": return pi > 0 ? : null; + case "data-plan-block": + // The plan in the chat (wave 4): a frozen snapshot + // block — rendered as carried, never re-derived. + return ( + setPlanOpen(true)} + /> + ); case "tool-quiz": case "tool-ask_user_question": { // Pending/streaming questions live in the dock, not the @@ -464,6 +479,7 @@ export function Chat({ + {planOpen && setPlanOpen(false)} />} ); diff --git a/components/plan-block.tsx b/components/plan-block.tsx new file mode 100644 index 0000000..33c6541 --- /dev/null +++ b/components/plan-block.tsx @@ -0,0 +1,534 @@ +"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. +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/src/core/ui-message.ts b/src/core/ui-message.ts index 1de809e..80d7ce0 100644 --- a/src/core/ui-message.ts +++ b/src/core/ui-message.ts @@ -1,5 +1,5 @@ import type { UIMessage } from "ai"; -import type { DemoToolInput, ResearchToolInput, SubagentArtifactData, VisualToolInput } from "./contracts.ts"; +import type { DemoToolInput, PlanBlockData, ResearchToolInput, SubagentArtifactData, VisualToolInput } from "./contracts.ts"; import type { AskToolInput, QuestionAnswerData, QuizToolInput } from "./question-core.ts"; /** @@ -24,6 +24,8 @@ export type TeacherUITools = { export type TeacherUIDataParts = { "subagent-artifact": SubagentArtifactData; + /** Plan blocks (wave 4): frozen plan snapshots rendered in the chat flow. */ + "plan-block": PlanBlockData; }; export type TeacherUIMessage = UIMessage; diff --git a/tests/render-plan-block.test.tsx b/tests/render-plan-block.test.tsx new file mode 100644 index 0000000..4dfe45f --- /dev/null +++ b/tests/render-plan-block.test.tsx @@ -0,0 +1,171 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + PlanBlock, + PlanPopup, + blockLayout, + topoFor, + wrapTitle, + GAP_X, + NODE_H, + NODE_W, + type PopupMessage, +} from "../components/plan-block.tsx"; +import type { PlanBlockData, PlanTree } from "../src/core/contracts.ts"; + +// Plan blocks: the pure layered layout, the frozen-snapshot rendering +// (full = borderless + column-filling, neighborhood = labeled fragment +// with the glowing current), and the popup's LIVE derivation. + +const CHAIN: PlanTree = { + nodes: [ + { id: "a", title: "Alpha node" }, + { id: "b", title: "Beta node" }, + { id: "c", title: "Gamma node" }, + ], + edges: [ + { from: "a", to: "b" }, + { from: "b", to: "c" }, + ], + currentNodeId: null, + doneNodeIds: [], + version: 1, +}; + +describe("plan block layout (pure)", () => { + it("layers a chain top-down, one node per depth row", () => { + const { order, depth } = topoFor(CHAIN.nodes, CHAIN.edges); + assert.deepEqual(order.map((n) => n.id), ["a", "b", "c"]); + assert.equal(depth.get("c"), 2); + const layout = blockLayout(CHAIN.nodes, CHAIN.edges); + assert.equal(layout.width, NODE_W); // widest row is the single-node rows + assert.equal(layout.height, 3 * NODE_H + 2 * 48); + }); + + it("centers same-depth nodes over the widest row; tolerates dangling edges", () => { + const nodes = [ + { id: "r", title: "Root" }, + { id: "x", title: "Left" }, + { id: "y", title: "Right" }, + ]; + const layout = blockLayout(nodes, [ + { from: "r", to: "x" }, + { from: "r", to: "y" }, + { from: "ghost", to: "r" }, // dangling: ignored defensively + ]); + assert.equal(layout.width, 2 * NODE_W + GAP_X); + // Root centered over its two children: (2*W+GAP - W)/2 + assert.equal(layout.x.get("r"), (2 * NODE_W + GAP_X - NODE_W) / 2); + assert.ok(layout.x.get("x") === 0 && layout.x.get("y") === NODE_W + GAP_X); + }); + + it("wrapTitle wraps greedily and ellipsizes overflow", () => { + 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 + assert.deepEqual(wrapTitle("supercalifragilistic"), ["supercalifragilistic"]); // exactly maxChars — fits + }); +}); + +describe("plan block render", () => { + it("full block: borderless, column-filling, statuses AS CARRIED (never re-derived)", () => { + const data: PlanBlockData = { + kind: "full", + at: "2026-09-09T12:00:00Z", + current: null, + nodes: [ + { id: "a", title: "Alpha node", status: "done" }, + { id: "b", title: "Beta node", status: "upcoming" }, + ], + edges: [{ from: "a", to: "b" }], + }; + const html = renderToStaticMarkup( {}} />); + 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, /]*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"/); + // Snapshot statuses render as carried. + assert.match(html, /data-testid="plan-node-a" data-status="done"/); + assert.match(html, /data-testid="plan-node-b" data-status="upcoming"/); + assert.match(html, /Open the full plan/); + }); + + it("neighborhood block: own small container, labeled fragment, glowing current", () => { + const data: PlanBlockData = { + kind: "neighborhood", + at: "2026-09-09T12:00:00Z", + current: "mid", + nodes: [ + { id: "up", title: "Upstream", status: "done" }, + { id: "mid", title: "Current node", status: "current" }, + { id: "down", title: "Downstream", status: "upcoming" }, + ], + edges: [ + { from: "up", to: "mid" }, + ], + }; + const html = renderToStaticMarkup( {}} />); + assert.match(html, /data-testid="plan-block-neighborhood"/); + assert.match(html, /class="[^"]*border/, "the fragment has its own container"); + assert.match(html, /Plan fragment/); + // The glow: a pulsing primary halo around the current node. + assert.match(html, /stroke-primary\/70 animate-pulse/); + assert.match(html, /data-testid="plan-node-mid" data-status="current"/); + }); +}); + +describe("plan popup (live derivation)", () => { + function messages(): PopupMessage[] { + return [ + { + parts: [ + { + type: "tool-plan_init", + state: "output-available", + output: { ok: true, tree: CHAIN }, + }, + ], + }, + { + parts: [ + { + type: "tool-quiz", + state: "output-available", + input: { nodeId: "a" }, + output: { correct: true }, + }, + ], + }, + { + parts: [ + { + type: "tool-quiz", + state: "input-available", + input: { nodeId: "b" }, + }, + ], + }, + ]; + } + + it("derives statuses at click time: passed → done, live check → current (glowing)", () => { + const html = renderToStaticMarkup( {}} />); + assert.match(html, /data-testid="plan-popup"/); + assert.match(html, /data-testid="plan-node-a" data-status="done"/); + 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/); + assert.match(html, /live — statuses derive from your check outcomes/); + }); + + it("renders the empty state when the session has no plan", () => { + const html = renderToStaticMarkup( {}} />); + assert.match(html, /No plan in this session yet\./); + }); +}); \ No newline at end of file