feat: the plan window — the plan's ambient, live, auto-moving presence

The successor of the injected plan blocks (docs/probe-teach-split.md §6):
always on once a tree exists, a zoomed-in window (2:1, ~2¼ node widths)
centered on the CURRENT node, re-centering by pure derivation as tagged
checks move the lesson — statuses derive live from the transcript on
every render (derivePlanStatuses), glow on current, fallback focus on
the lesson's first node, click opens the existing full-DAG popup.
Bottom-left, mirroring the activity dock's bottom-right; hidden on
narrow viewports; the learner's visual polish pass comes later.

PlanDiagram gains a viewBoxOverride (exported); latestTree exported.
Tests: viewBox centering/clamping (pure) + render (hidden pre-plan,
windowed viewBox, glowing current, starting fallback).
This commit is contained in:
amosblomqvist
2026-09-11 21:21:43 +03:00
parent ec56794029
commit 41d6ae531c
5 changed files with 224 additions and 5 deletions
+6 -3
View File
@@ -144,23 +144,26 @@ const TEXT: Record<PlanNodeStatus, string> = {
/** The diagram itself: positioned nodes + bezier edges. Pure-ish render —
* every input is data, so SSR render tests pin the whole thing. */
function PlanDiagram({
export function PlanDiagram({
nodes,
edges,
statusOf,
glowId,
markerId,
viewBoxOverride,
}: {
nodes: readonly LayoutNode[];
edges: ReadonlyArray<{ from: string; to: string }>;
statusOf: (id: string) => PlanNodeStatus;
glowId?: string | null;
markerId: string;
/** A windowed viewBox (the plan window) instead of the full extent. */
viewBoxOverride?: string;
}) {
const layout = useMemo(() => blockLayout(nodes, edges), [nodes, edges]);
return (
<svg
viewBox={`0 0 ${Math.max(layout.width, 1)} ${Math.max(layout.height, 1)}`}
viewBox={viewBoxOverride ?? `0 0 ${Math.max(layout.width, 1)} ${Math.max(layout.height, 1)}`}
className="block h-auto w-full"
role="img"
aria-label="Plan diagram"
@@ -264,7 +267,7 @@ export interface PopupMessage {
}
/** The latest plan tree in the transcript (latest tool-plan_ output). */
function latestTree(messages: readonly PopupMessage[]): PlanTree | null {
export function latestTree(messages: readonly PopupMessage[]): PlanTree | null {
for (let m = messages.length - 1; m >= 0; m--) {
const parts = messages[m]?.parts;
if (!parts) continue;