Files
learnharness/components/plan-block.tsx
T
amosblomqvist 5653c77a29 feat: probe/teach agent split — explicit phase markers, position-only plan map
Diagnosis (2026-09-11 user testing, minimal-effort GLM): sessions stuck on
'plan' forever. Two causes: (1) the three-phase view read the stored tree's
currentNodeId, which nothing ever writes post author-once (2026-09-03) — a
structural dead seam; (2) the teacher stochastically left quiz.nodeId
untagged, and done-by-quiz-result semantics would have falsely completed
nodes anyway.

The redesign (docs/probe-teach-split.md) abolishes derived occupancy in
favor of explicit events:

- Two agents over one transcript: createProberAgent (quiz without a
  nodeId field, ask, probe_done) and createTeacherAgent (full toolset,
  plan_init onward). The chat-handler construction branch keys on a
  probe_done part in the transcript. MODELS.prober added.
- probe_done: server-side seam tool — carries the learner assessment
  (strands + free-form edge read), refuses while a question is open or
  the probe is already concluded, and queues a handoff steer through the
  generalized wake pipeline (steers now dedupe by id, not jobId; job
  finalization only for job steers). The teacher auto-wakes and authors
  the plan; the learner sees the assessment via the probe-done card.
- phases: probe ends at a concluded probe_done, plan ends at a
  successful plan_init — strictly sequential, no skipping, no legacy
  compatibility (data wiped at the pivot per doctrine).
- Plan map = position, not completion: derivePlanStatuses reads only
  tagged quiz CALLS (state/outcome feed nothing); current = latest
  tagged node with consecutive duplicates collapsed, visited = earlier
  distinct stays (a revisit stays in both — current wins at render).
  PlanTree is structure-only; currentNodeId/doneNodeIds and the dead
  mutation helpers are gone. No fallback current.
- Prompts split per agent (prompts/prober/, prompts/teacher/ over the
  trimmed shared base); graph-context injection removed entirely
  (shelved, docs/probe-teach-split.md §5); knowledge-graph context no
  longer reaches either agent.
- Plan blocks are full-only snapshots at plan_init (neighborhood
  machinery retired; the persistent DAG window is the future UI wave);
  usage capture unchanged.

Gates: tsc clean, 294/294 tests (phases/plan-tree/plan-blocks/question-
tools rewritten to the new semantics; probe-done + handoff-wake tests
added), lint, build. Live GLM smoke PASS: prober concluded with an honest
assessment, teacher auto-woke, authored a 10-node plan with the block
attached, then tagged checks on slope-instinct → secant derived current/
visited exactly as designed; grading honest both ways (driver off-by-one
proved a wrong answer grades false).
2026-09-11 06:44:20 +03:00

514 lines
18 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.
* - Clicking 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> = {
visited: "fill-phase-teach/10 stroke-phase-teach/50",
current: "fill-primary/10 stroke-primary",
upcoming: "fill-card stroke-border",
};
const TEXT: Record<PlanNodeStatus, string> = {
visited: "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): borderless and column-filling, it opens the live plan
* popup. Full-only by design since the 2026-09-11 pivot (the neighborhood
* fragments are retired — position derives live in the popup and the
* future DAG window).
*/
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";
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>
);
}
// ─── 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>
);
}