Plan blocks in chat: full DAG borderless in the flow, fragment + glow, live popup (wave-4 item 3)

- 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.
This commit is contained in:
amosblomqvist
2026-09-11 03:26:56 +03:00
parent df58bb87f2
commit bb369a077b
4 changed files with 724 additions and 1 deletions
+16
View File
@@ -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 ? <Separator key={pi} className="my-3" /> : null;
case "data-plan-block":
// The plan in the chat (wave 4): a frozen snapshot
// block — rendered as carried, never re-derived.
return (
<PlanBlock
key={pi}
data={part.data as PlanBlockData}
onOpen={() => 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({
<TreePanel messages={messages} />
</div>
</div>
{planOpen && <PlanPopup messages={messages} onClose={() => setPlanOpen(false)} />}
</JobsContext.Provider>
</ChatActionsContext.Provider>
);
+534
View File
@@ -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<string, number>(nodes.map((n) => [n.id, 0] as const));
const dependents = new Map<string, string[]>();
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<string, number>();
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<string, number>();
const y = new Map<string, number>();
layers.forEach((l, d) => {
const start = (maxWidth - rowWidth(l)) / 2;
l.forEach((n, i) => {
x.set(n.id, start + i * (NODE_W + GAP_X));
y.set(n.id, d * (NODE_H + GAP_Y));
});
});
const height = layers.length * NODE_H + Math.max(0, layers.length - 1) * GAP_Y;
return { x, y, width: maxWidth, height };
}
/** 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<PlanNodeStatus, string> = {
done: "fill-phase-teach/10 stroke-phase-teach/50",
current: "fill-primary/10 stroke-primary",
upcoming: "fill-card stroke-border",
};
const TEXT: Record<PlanNodeStatus, string> = {
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 (
<svg
viewBox={`0 0 ${Math.max(layout.width, 1)} ${Math.max(layout.height, 1)}`}
className="block h-auto w-full"
role="img"
aria-label="Plan diagram"
>
<defs>
<marker
id={markerId}
viewBox="0 0 8 8"
refX="7"
refY="4"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<path d="M0,1 L7,4 L0,7 z" className="fill-muted-foreground/50" />
</marker>
</defs>
{edges.map((e) => {
const x1 = (layout.x.get(e.from) ?? 0) + NODE_W / 2;
const y1 = (layout.y.get(e.from) ?? 0) + NODE_H;
const x2 = (layout.x.get(e.to) ?? 0) + NODE_W / 2;
const y2 = layout.y.get(e.to) ?? 0;
const mid = (y1 + y2) / 2;
return (
<path
key={`${e.from}->${e.to}`}
d={`M ${x1} ${y1} C ${x1} ${mid}, ${x2} ${mid}, ${x2} ${y2}`}
className="fill-none stroke-muted-foreground/40"
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 (
<g
key={n.id}
transform={`translate(${layout.x.get(n.id) ?? 0}, ${layout.y.get(n.id) ?? 0})`}
data-testid={`plan-node-${n.id}`}
data-status={status}
>
{glow && (
// The glow target: a soft primary halo breathing around the box.
<rect
x={-4}
y={-4}
width={NODE_W + 8}
height={NODE_H + 8}
rx={12}
className="fill-none stroke-primary/70 animate-pulse"
strokeWidth={1.5}
/>
)}
<rect width={NODE_W} height={NODE_H} rx={10} className={BOX[status]} strokeWidth={1.5} />
{lines.map((line, i) => (
<text key={i} x={12} y={lines.length === 1 ? 27 : 18 + i * 13} className={TEXT[status]}>
{line}
</text>
))}
</g>
);
})}
</svg>
);
}
// ─── 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 (
<button
type="button"
data-testid="plan-block-full"
onClick={onOpen}
title="Open the full plan"
className="block w-full text-left"
>
<PlanDiagram nodes={data.nodes} edges={data.edges} statusOf={statusOf} markerId={markerId} />
</button>
);
}
return (
<div data-testid="plan-block-neighborhood" className="rounded-xl border bg-card/60 p-2.5">
<div className="mb-1.5 px-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Plan fragment
</div>
<button type="button" onClick={onOpen} title="Open the full plan" className="block w-full text-left">
<PlanDiagram
nodes={data.nodes}
edges={data.edges}
statusOf={statusOf}
glowId={data.current}
markerId={markerId}
/>
</button>
</div>
);
}
// ─── 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<typeof derivePlanStatuses>[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<HTMLDivElement>(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<GraphView>({ 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<HTMLDivElement>) => {
if (e.button !== 0) return;
if ((e.target as HTMLElement).closest("button")) return;
if (atFloor) return;
dragRef.current = { px: e.clientX, py: e.clientY, x: view.x, y: view.y, moved: false };
e.currentTarget.classList.add("cursor-grabbing");
e.currentTarget.setPointerCapture(e.pointerId);
};
const moveDrag = (e: React.PointerEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<div
data-testid="plan-popup"
className="fixed inset-0 z-50 flex flex-col bg-background/95 p-4 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-label="The full plan"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="mb-2 flex items-center gap-2">
<span className="text-sm font-medium">The plan</span>
<span className="text-xs text-muted-foreground">live — statuses derive from your check outcomes</span>
<button
type="button"
onClick={onClose}
aria-label="Close"
data-testid="plan-popup-close"
className="ml-auto rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<XIcon className="size-4" />
</button>
</div>
<div
ref={viewportRef}
className={cn(
"relative min-h-0 flex-1 touch-none overflow-hidden rounded-xl border select-none",
atFloor ? "cursor-default" : "cursor-grab",
)}
onPointerDown={startDrag}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
{tree ? (
<>
<div
className="absolute top-0 left-0 origin-top-left"
style={{ transform: `translate(${view.x}px, ${view.y}px) scale(${view.zoom})` }}
>
<PlanDiagram
nodes={tree.nodes}
edges={tree.edges}
statusOf={statusOf}
glowId={statuses?.current ?? null}
markerId={markerId}
/>
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between p-2">
<span className="text-[10px] text-muted-foreground/60">Drag to pan · scroll to zoom</span>
<div className="flex gap-1">
<button
type="button"
title="Zoom out"
aria-label="Zoom out"
onClick={() => stepZoom(1 / 1.25)}
className="pointer-events-auto flex size-7 items-center justify-center rounded-md border border-border/70 bg-card/80 text-muted-foreground backdrop-blur transition-colors hover:bg-accent hover:text-foreground"
>
<MinusIcon className="size-3.5" />
</button>
<button
type="button"
title="Fit plan to screen"
aria-label="Fit plan to screen"
onClick={fitView}
data-testid="plan-popup-fit"
className="pointer-events-auto flex size-7 items-center justify-center rounded-md border border-border/70 bg-card/80 text-muted-foreground backdrop-blur transition-colors hover:bg-accent hover:text-foreground"
>
<MaximizeIcon className="size-3.5" />
</button>
<button
type="button"
title="Zoom in"
aria-label="Zoom in"
onClick={() => stepZoom(1.25)}
className="pointer-events-auto flex size-7 items-center justify-center rounded-md border border-border/70 bg-card/80 text-muted-foreground backdrop-blur transition-colors hover:bg-accent hover:text-foreground"
>
<PlusIcon className="size-3.5" />
</button>
</div>
</div>
</>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No plan in this session yet.
</div>
)}
</div>
</div>
);
}
+3 -1
View File
@@ -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<unknown, TeacherUIDataParts, TeacherUITools>;
+171
View File
@@ -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(<PlanBlock data={data} onOpen={() => {}} />);
assert.match(html, /data-testid="plan-block-full"/);
// Borderless: the wrapper carries no border/rounded chrome.
assert.ok(!/data-testid="plan-block-full"[^>]*border/.test(html), "no wrapping chrome");
// Fills the column: the svg is w-full with the layout extent as viewBox
// (leftmost/rightmost nodes hug the edges), height natural.
assert.match(html, /<svg[^>]*class="block h-auto w-full"/);
// Two stacked nodes: 2*NODE_H + GAP_Y = 136 natural height, width = one node column.
assert.match(html, /viewBox="0 0 160 136"/);
// 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(<PlanBlock data={data} onOpen={() => {}} />);
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(<PlanPopup messages={messages()} onClose={() => {}} />);
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(<PlanPopup messages={[{ parts: [] }]} onClose={() => {}} />);
assert.match(html, /No plan in this session yet\./);
});
});