Files
learnharness/components/plan-block.tsx
T
amosblomqvist bb369a077b 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.
2026-09-11 03:26:56 +03:00

534 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}