Retire the chat right sidebar: chat = sessions sidebar + chat column (wave-4 item 4)

The plan now lives IN the chat (plan blocks, wave-4 item 3) and background
processes live in the activity dock (item 1), so the right sidebar's
tree panel + sub-agent fleet column is retired from the chat view:

- delete components/tree-panel.tsx (three-mode rail/list/graph panel) and
  components/sub-agents-panel.tsx; their graph interaction machinery lives
  on in plan-block.tsx and fleet observability in activity-dock.tsx.
- app/layout.tsx drops TreePanelModeProvider (the mode no longer exists).
- chat.tsx drops the xl:flex right column; the middle column is the view.
- tests: render-tree-panel.test.tsx deleted; the sub-agents panel suite
  removed from demos.test.tsx (dispatch cards' jobs-context stays — the
  central poll drives transcript dispatch cards).
This commit is contained in:
amosblomqvist
2026-09-11 03:33:13 +03:00
parent bb369a077b
commit 99ba029a3c
6 changed files with 2 additions and 1517 deletions
+2 -5
View File
@@ -2,7 +2,6 @@ import type { Metadata } from "next";
import "katex/dist/katex.min.css";
import "./globals.css";
import { AppShell, SidebarProvider } from "@/components/sidebar";
import { TreePanelModeProvider } from "@/components/tree-panel";
import { ActivityDock, ActivityProvider } from "@/components/activity-dock";
export const metadata: Metadata = {
@@ -15,15 +14,13 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<html lang="en" className="dark">
<body className="min-h-dvh antialiased">
<SidebarProvider>
<TreePanelModeProvider>
<ActivityProvider>
<ActivityProvider>
<AppShell>{children}</AppShell>
{/* The reserved bottom-right corner of every view — renders
nothing while no background process runs. */}
<ActivityDock />
</ActivityProvider>
</TreePanelModeProvider>
</SidebarProvider>
</SidebarProvider>
</body>
</html>
);
-9
View File
@@ -25,8 +25,6 @@ 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";
import { TreePanel } from "@/components/tree-panel";
import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TeacherUIMessage, TurnUsage } from "@/src/core";
import { PhaseChip, PhaseDivider, planNodeCount } from "@/components/phase-divider";
import { findPendingToolCall, phaseAt3, phaseTimeline } from "@/src/core";
@@ -471,13 +469,6 @@ export function Chat({
</Conversation>
</div>
</div>
{/* Right sidebar, a full-height sibling of the middle column: the
live sub-agent fleet (when running) above the plan tree — the
fleet panel renders nothing while idle, so the column is all plan. */}
<div className="hidden shrink-0 flex-col xl:flex">
<SubAgentsPanel jobs={jobs} />
<TreePanel messages={messages} />
</div>
</div>
{planOpen && <PlanPopup messages={messages} onClose={() => setPlanOpen(false)} />}
</JobsContext.Provider>
-108
View File
@@ -1,108 +0,0 @@
"use client";
import { BotIcon, PenToolIcon, PlayIcon, SearchIcon } from "lucide-react";
import type { JobKind, JobRecord } from "@/src/core";
import { useTreePanelMode } from "@/components/tree-panel";
/**
* The sub-agents sidebar section (vision §2.5 live observability): what the
* teacher's background fleet is doing RIGHT NOW in this session — one card
* per running sub-agent with its live activity line (current tool + args)
* and elapsed time, fed by the central jobs poll.
*
* Renders nothing when the fleet is idle (zero clutter); finished jobs
* vanish from the list — their artifacts live in the transcript. Sits at
* the top of the right sidebar, above the plan-tree panel.
*/
const KIND_META: Record<JobKind, { label: string; Icon: typeof SearchIcon }> = {
research: { label: "Researcher", Icon: SearchIcon },
demo: { label: "Demo builder", Icon: PlayIcon },
visual: { label: "Figure maker", Icon: PenToolIcon },
};
/** The collapsed fleet: a quiet strip on the sidebar rail — bot icon +
* running count, exactly like the plan's node-count rail below it. Click
* expands the sidebar back to the full fleet list. */
function SubAgentsRail({ count, onExpand }: { count: number; onExpand: () => void }) {
return (
<div
data-testid="sub-agents-rail"
className="flex w-10 shrink-0 flex-col items-center gap-3 border-b border-l border-border/60 bg-card/30 py-3"
>
<button
type="button"
onClick={onExpand}
title="Show sub-agents"
aria-label="Show sub-agents"
className="flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<BotIcon className="size-4" />
</button>
<span
className="text-[10px] tracking-widest text-muted-foreground/70 [writing-mode:vertical-rl]"
title={`${count} sub-agent${count === 1 ? "" : "s"} running`}
>
{count} running
</span>
</div>
);
}
export function SubAgentsPanel({ jobs }: { jobs: JobRecord[] }) {
const { mode, go } = useTreePanelMode();
const running = jobs.filter((j) => j.status === "running");
if (running.length === 0) return null;
if (mode === "rail")
return <SubAgentsRail count={running.length} onExpand={() => go("list")} />;
return (
<aside
data-testid="sub-agents-panel"
className="flex w-full shrink-0 flex-col border-b border-l border-border/60 bg-card/30 px-3 py-2.5"
>
<div className="flex items-center gap-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
<BotIcon className="size-3.5" />
Sub-agents
</div>
<ul className="mt-2 flex flex-col gap-2">
{running.map((job) => (
<li
key={job.id}
className="rounded-lg border bg-card px-2.5 py-2 text-card-foreground"
data-testid="sub-agent-card"
>
<div className="flex items-center gap-2 text-xs font-medium">
{(() => {
const { label, Icon } = KIND_META[job.kind] ?? KIND_META.research;
return <Icon className="size-3.5 shrink-0 text-muted-foreground" />;
})()}
<span className="min-w-0 flex-1 truncate">{KIND_META[job.kind]?.label ?? job.kind}</span>
<span
className="inline-block size-1.5 animate-pulse rounded-full bg-primary"
aria-hidden="true"
/>
<span className="shrink-0 tabular-nums text-muted-foreground" data-testid="sub-agent-elapsed">
{elapsedOf(job)}
</span>
</div>
{job.activity && (
<div
className="mt-1 truncate text-xs text-muted-foreground"
data-testid="sub-agent-activity"
title={job.activity}
>
{job.activity}
</div>
)}
</li>
))}
</ul>
</aside>
);
}
function elapsedOf(job: JobRecord): string {
if (!job.createdAt) return "";
const seconds = Math.max(0, Math.round((Date.now() - Date.parse(job.createdAt)) / 1000));
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
}
-877
View File
@@ -1,877 +0,0 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import {
CheckIcon,
ChevronsRightIcon,
ListTreeIcon,
MaximizeIcon,
MinusIcon,
NetworkIcon,
PlusIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { planNodeStatus, type PlanNode, type PlanNodeStatus, type PlanTree } from "@/src/core";
/**
* The persistent plan-tree panel (vision §2 "Persistent tree view", W7).
*
* A collapsible column on the right side of the session: the whole tree, at
* all times, with per-node status (done / current / next / upcoming) derived
* from the SAME object the teacher edits — no separate state channel. The
* data source is the transcript itself: every plan_* tool returns the full
* updated tree in its output, so the panel scans backward for the LATEST
* output-available plan part and renders its tree. Live during streaming:
* set_current / mark_done outputs flip the panel mid-session.
*
* The panel and the agent's tools share one object with two faces (agent
* edits it, UI renders it) — the phase seam in the transcript
* (PhaseDivider, message-level) marks the transition; THIS panel is the
* surface for every plan_* part (they render nothing inline, by design).
*
* Three modes, one persisted preference: the LIST (nested — the default,
* and the always-visible "collapsed" view), the GRAPH (expanded: a
* hand-built mermaid-style diagram — full styling control, no mermaid
* renderer — with drag-pan + wheel-zoom whose FLOOR is fit-to-panel), and
* the RAIL (thin strip). Quiet register throughout (matches the phase
* dividers): hairline borders, muted text, one highlighted node for the
* current position. Hidden below `xl` (CSS-only).
*/
/** localStorage: the mode persists as "lh:tree-panel-mode"
* ("rail" | "list" | "graph"); the pre-graph key
* ("lh:tree-panel-collapsed": "1" rail / "0" list) is the fallback. */
/** The panel width per mode — the aside AND its fixed inner layer (see
* TreePanel: the inner layer must not squish while the aside animates). */
const PANEL_WIDTH: Record<PanelMode, string> = {
rail: "w-10",
list: "w-64",
graph: "w-96",
};
const MODE_KEY = "lh:tree-panel-mode";
const LEGACY_KEY = "lh:tree-panel-collapsed";
/** The three panel modes: rail (fully collapsed) | list (default) | graph. */
type PanelMode = "rail" | "list" | "graph";
/**
* Minimal structural slice of a UI message — anything with typed parts.
* (Same shape-of-shapes trick as src/core/phases.ts.)
*/
export interface TreePanelMessage {
parts: ReadonlyArray<{ type: string; state?: string; output?: unknown }>;
}
/**
* The latest plan tree in the transcript: scans messages (and parts) backward
* for the LAST `tool-plan_*` part with an output available and returns its
* `output.tree`. A failed op ({ok:false}) still carries the current tree, so
* it counts; a malformed/missing output falls through to the next candidate.
* Null when no plan output exists yet (probe phase / pre-plan sessions).
*/
export function latestTree(
messages: readonly TreePanelMessage[],
): 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;
}
/** Topological layout of a tree: display order + dependency depth per node. */
export interface TopoLayout {
/** Nodes in topological order (prerequisites first), deterministically tie-broken by authoring order. */
order: PlanNode[];
/** Longest-path depth from a root — drives indentation (a DAG, not a tree: a node indents under its DEEPEST prerequisite). */
depth: Map<string, number>;
}
/** Kahn's algorithm over the tree's `requires` edges. Server ops already guarantee a DAG. */
export function topoLayout(tree: PlanTree): TopoLayout {
const index = new Map(tree.nodes.map((n, i) => [n.id, i] as const));
const indegree = new Map<string, number>(
tree.nodes.map((n) => [n.id, 0] as const),
);
const dependents = new Map<string, string[]>();
for (const e of tree.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]);
}
// Deterministic: among the ready nodes always emit the earliest-authored first.
const ready = tree.nodes
.filter((n) => (indegree.get(n.id) ?? 0) === 0)
.map((n) => n.id);
const order: PlanNode[] = [];
const depth = new Map<string, number>();
const byId = new Map(tree.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);
let d = 0;
for (const e of tree.edges) {
if (e.to === id) d = Math.max(d, (depth.get(e.from) ?? 0) + 1);
}
depth.set(id, d);
for (const t of dependents.get(id) ?? []) {
const left = (indegree.get(t) ?? 0) - 1;
indegree.set(t, left);
if (left === 0) ready.push(t);
}
}
// Unreachable only if the DAG invariant broke; keep every node visible regardless.
for (const n of tree.nodes) {
if (!depth.has(n.id)) {
order.push(n);
depth.set(n.id, 0);
}
}
return { order, depth };
}
/**
* The "next" node (vision §2 lists it as a distinct state): the first
* upcoming node in topological order whose prerequisites are ALL done — the
* head of the unlocked frontier. Null when nothing is ready (or all done).
*/
export function nextNodeId(
tree: PlanTree,
order: readonly PlanNode[],
): string | null {
const done = new Set(tree.doneNodeIds);
for (const n of order) {
if (planNodeStatus(tree, n.id) !== "upcoming") continue;
const blocked = tree.edges.some((e) => e.to === n.id && !done.has(e.from));
if (!blocked) return n.id;
}
return null;
}
// ─── panel ───────────────────────────────────────────────────────────────────
/**
* The panel mode lives in the ROOT layout, not in TreePanel. TreePanel is
* per-page (chat sessions remount it on every navigation), so a local copy
* would paint its default first and then "correct" to the stored mode —
* with the animated frame, opening a session visibly opened-then-closed
* the panel. In the root layout the mode survives navigations untouched,
* exactly like the left sidebar's open state (SidebarProvider).
*/
const TreePanelModeContext = createContext<{ mode: PanelMode; go: (next: PanelMode) => void }>({
mode: "list",
go: () => {},
});
/** The right sidebar's shared mode — every section of the sidebar (plan,
* sub-agents) collapses and expands in lockstep through this hook. */
export function useTreePanelMode() {
return useContext(TreePanelModeContext);
}
/** Root-layout persistence of the panel mode (rail | list | graph). */
export function TreePanelModeProvider({ children }: { children: React.ReactNode }) {
// SSR default: "list" — server and client first-render agree (no
// hydration mismatch). The stored preference applies on mount, and
// the provider itself never remounts, so after that first correction
// the mode is simply THERE on every session you open.
const [mode, setMode] = useState<PanelMode>("list");
useEffect(() => {
try {
const stored = window.localStorage.getItem(MODE_KEY);
if (stored === "rail" || stored === "list" || stored === "graph") {
setMode(stored);
} else {
// legacy key (pre-graph): "1" = the collapsed rail
const legacy = window.localStorage.getItem(LEGACY_KEY);
if (legacy === "1") setMode("rail");
}
} catch {
// private mode / storage disabled — the preference is simply not persisted
}
}, []);
const go = useCallback((next: PanelMode) => {
setMode(next);
try {
window.localStorage.setItem(MODE_KEY, next);
} catch {
// see above
}
}, []);
return <TreePanelModeContext.Provider value={{ mode, go }}>{children}</TreePanelModeContext.Provider>;
}
export function TreePanel({
messages,
}: {
messages: readonly TreePanelMessage[];
}) {
const tree = useMemo(() => latestTree(messages), [messages]);
const { mode, go } = useTreePanelMode();
// Empty state: no tree yet → the panel does not render at all. It appears
// the moment plan_init's output lands and updates live from there.
if (!tree || tree.nodes.length === 0) return null;
// ONE animated container, exactly like the left sidebar: the aside
// animates its width (sidebar-motion transition cascades from the
// provider and is mount-gated there) while the inner layer keeps a
// FIXED width and is clipped by overflow-hidden — content never
// reflows mid-transition, so no scrollbar flash or overshoot. The
// mode's content just swaps instantly inside the steady frame.
return (
<aside
data-testid="tree-panel"
className={cn(
"sidebar-motion hidden min-h-0 flex-1 shrink-0 overflow-hidden border-l border-border/60 bg-card/30 duration-200 xl:flex",
PANEL_WIDTH[mode],
)}
>
<div className={cn("flex h-full flex-col", PANEL_WIDTH[mode])}>
{mode === "rail" ? (
<TreePanelRail
doneCount={tree.doneNodeIds.length}
totalCount={tree.nodes.length}
onExpand={() => go("list")}
/>
) : mode === "graph" ? (
<TreeGraphPanel tree={tree} onShowList={() => go("list")} onCollapse={() => go("rail")} />
) : (
<TreePanelBody tree={tree} onShowGraph={() => go("graph")} onCollapse={() => go("rail")} />
)}
</div>
</aside>
);
}
/** The collapsed panel: a thin strip with the map icon + quiet progress. */
export function TreePanelRail({
doneCount,
totalCount,
onExpand,
}: {
doneCount: number;
totalCount: number;
onExpand: () => void;
}) {
return (
<div
data-testid="tree-panel-rail"
className="flex h-full flex-col items-center gap-3 py-3"
>
<button
type="button"
onClick={onExpand}
title="Show plan"
aria-label="Show plan"
className="flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<ListTreeIcon className="size-4" />
</button>
<span
className="text-[10px] tracking-widest text-muted-foreground/70 [writing-mode:vertical-rl]"
title={`${doneCount} of ${totalCount} nodes done`}
>
{doneCount}/{totalCount}
</span>
</div>
);
}
/** The expanded panel (list mode): header + the node list in topological order. */
export function TreePanelBody({
tree,
onShowGraph,
onCollapse,
}: {
tree: PlanTree;
/** Expand into the mermaid-style graph view. */
onShowGraph: () => void;
onCollapse: () => void;
}) {
const { order, depth } = useMemo(() => topoLayout(tree), [tree]);
const nextId = useMemo(() => nextNodeId(tree, order), [tree, order]);
const doneCount = tree.doneNodeIds.length;
const totalCount = tree.nodes.length;
return (
<div className="flex h-full flex-col">
<header className="flex items-center gap-2 px-3 py-2.5">
<ListTreeIcon className="size-3.5 text-muted-foreground" />
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
Plan
</span>
<span
className="ml-auto text-[11px] tabular-nums text-muted-foreground"
title={`${doneCount} of ${totalCount} nodes done`}
>
{doneCount}/{totalCount}
</span>
<button
type="button"
onClick={onShowGraph}
title="Show plan graph"
aria-label="Show plan graph"
className="flex size-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<NetworkIcon className="size-3.5" />
</button>
<button
type="button"
onClick={onCollapse}
title="Hide plan"
aria-label="Hide plan"
className="flex size-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<ChevronsRightIcon className="size-3.5" />
</button>
</header>
{/* Keyed by tree.version: every mutation (contracts.ts) bumps it, so a
set_current/mark_done visibly re-animates the list mid-session. */}
<nav
key={tree.version}
className="dock-variant min-h-0 flex-1 overflow-y-auto px-2 pb-4"
aria-label="Plan tree"
>
<ol className="flex flex-col">
{order.map((node) => {
const status: PlanNodeStatus = planNodeStatus(tree, node.id);
return (
<li
key={node.id}
data-testid={`tree-node-${node.id}`}
data-status={status}
data-next={node.id === nextId ? "true" : undefined}
title={node.summary || undefined}
style={{ marginLeft: (depth.get(node.id) ?? 0) * 12 }}
className={cn(
"flex items-start gap-2 rounded-lg px-2 py-1.5 text-sm",
status === "current" && "bg-primary/10 text-foreground",
status === "done" && "text-muted-foreground",
status === "upcoming" && "text-muted-foreground/70",
)}
>
<StatusMark status={status} next={node.id === nextId} />
<span className="min-w-0 flex-1">
<span className="flex items-baseline gap-1.5">
<span
className={cn(
"truncate",
status === "current" && "font-medium",
)}
>
{node.title}
</span>
{node.id === nextId && (
<span className="shrink-0 text-[10px] font-medium uppercase tracking-wide text-primary/80">
next
</span>
)}
</span>
{/* The current position also surfaces its summary — the "where
we are, what this covers" answer without hovering. */}
{status === "current" && node.summary && (
<span className="mt-0.5 block text-xs leading-snug text-muted-foreground">
{node.summary}
</span>
)}
</span>
</li>
);
})}
</ol>
</nav>
</div>
);
}
/** The per-state glyph: a settled check, the position dot, an unlocked ring, a hollow ring. */
function StatusMark({
status,
next = false,
}: {
status: PlanNodeStatus;
next?: boolean;
}) {
switch (status) {
case "done":
return (
<CheckIcon className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
);
case "current":
return (
<span
className="mt-1.5 size-2 shrink-0 rounded-full bg-primary"
aria-hidden
/>
);
case "upcoming":
return (
<span
className={cn(
"mt-1.5 size-2 shrink-0 rounded-full border",
next ? "border-primary/60" : "border-muted-foreground/40",
)}
aria-hidden
/>
);
}
}
// ─── the graph (expanded view) ────────────────────────────────────────────────
//
// A hand-built mermaid-style diagram — NOT mermaid's renderer: the styling
// stays in this app's design language (phase colors, quiet strokes) and the
// status coloring maps 1:1 onto the list's marks. Boxes top-down (vertical
// space is the cheap axis here), layered by topoLayout depth, authoring
// order within a layer, edges as vertical bezier curves.
/** Graph geometry (px): node boxes and the gaps that carry the arrows. */
export const NODE_W = 176;
export const NODE_H = 48;
export const GAP_X = 28;
export const GAP_Y = 64;
/** Top-left position per node + the diagram's extent. */
export interface GraphLayout {
x: Map<string, number>;
y: Map<string, number>;
width: number;
height: number;
}
/**
* Layered positions: depth = row (top-down), authoring order within the
* row, each row centered on the widest one. No crossing-reduction pass —
* the teacher authors nodes in dependency order as a rule, and v1 keeps
* the layout deterministic and readable over prettier.
*/
export function graphLayout(order: readonly PlanNode[], depth: ReadonlyMap<string, number>): GraphLayout {
const layers: PlanNode[][] = [];
for (const n of order) {
const d = depth.get(n.id) ?? 0;
if (!layers[d]) layers[d] = [];
layers[d].push(n);
}
const rowWidth = (l: PlanNode[]) => 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 + (layers.length - 1) * GAP_Y;
return { x, y, width: maxWidth, height: Math.max(height, 0) };
}
/**
* Wrap a node title to at most `maxLines` lines of at most `maxChars`
* (greedy word wrap); overflow ends in an ellipsis, a single oversized
* word is hard-clamped. Pure — unit-tested.
*/
export function wrapTitle(title: string, maxChars = 22, 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 {
// out of lines: mark the last one as truncated (once)
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;
}
/** Box + label styling per status — the graph's face of the list's marks:
* done = colored in (the teach-phase green), current = the position,
* next = the unlocked ring, upcoming = quiet. */
const GRAPH_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 GRAPH_TEXT: Record<PlanNodeStatus, string> = {
done: "fill-muted-foreground text-xs",
current: "fill-foreground text-xs font-medium",
upcoming: "fill-muted-foreground/80 text-xs",
};
const GRAPH_HEADER_BTN =
"flex size-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground";
const GRAPH_ZOOM_BTN =
"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";
/** Zoom bounds: never below fit-to-panel (the graph can't disappear into
* nothing — fully zoomed out IS the fit), never above 2×. */
const ZOOM_MAX = 2;
const ZOOM_FLOOR = 0.15;
/** The fit view's breathing room. The fit is the default view — slim
* margins fit more graph. */
const FIT_PAD = 16;
/** The fit may scale a SMALL graph up to fill the panel (wave-2 fix: the
* old ≤1× cap left small diagrams tiny with big empty margins), but
* never past this — a lone node shouldn't become a billboard. */
const FIT_ZOOM_MAX = 1.5;
/** The graph's view transform: zoom + translation (viewport px). */
export interface GraphView {
zoom: number;
x: number;
y: number;
}
/** The zoom that fits a bw×bh graph into a w×h viewport: scales DOWN
* large graphs (floor-clamped) and UP small ones (FIT_ZOOM_MAX-capped)
* so the panel is used, not padded with empty margins. */
export 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,
),
);
}
/** The fit-centered view: the whole bw×bh graph centered in the w×h
* viewport — fully zoomed out is EXACTLY this, nothing to pan. */
export 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 };
}
/**
* One zoom step toward `focus` (viewport px), clamped to [floor, ZOOM_MAX].
* At the floor the view SNAPS to the fit-centered position: fully zoomed
* out is THE fit view — centered, pan locked (the component enforces it).
*/
export 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 interactive diagram: drag to pan, wheel to zoom (to the cursor), the
* fit button resets. The zoom floor is fit-to-panel — at it, the view is
* EXACTLY the centered fit and panning is locked (there is nothing to
* move). Auto-fits on first measure and on layout changes while the view
* sits at the floor; once the learner zooms in or pans, the view is theirs
* (until they return to the floor or press fit). SSR-safe: all measurement
* lives in effects. The zoom controls sit INSIDE the pan surface but
* never start a drag (pointer capture would swallow their clicks).
*/
export function TreeGraph({ tree }: { tree: PlanTree }) {
const { order, depth } = useMemo(() => topoLayout(tree), [tree]);
const nextId = useMemo(() => nextNodeId(tree, order), [tree, order]);
const layout = useMemo(() => graphLayout(order, depth), [order, depth]);
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();
}, []);
// The zoom floor: fully zoomed out, the whole diagram fills the panel
// (small graphs scale UP to use the space, capped at FIT_ZOOM_MAX).
const box = useMemo(() => ({ w: layout.width, h: layout.height }), [layout]);
const fitZoom = useMemo(() => fitZoomFor(box, size), [box, size]);
const [view, setView] = useState<GraphView>({ x: 0, y: 0, zoom: 1 });
const userAdjustedRef = useRef(false);
// Epsilon: zoom is float; "at the floor" needs a tolerance.
const atFloor = view.zoom <= fitZoom + 1e-9;
// Being AT the floor re-arms auto-fit: fully zoomed out is the default
// view, so later layout changes (tree edits) keep it fitting.
useEffect(() => {
if (atFloor) userAdjustedRef.current = false;
}, [atFloor]);
// Auto-fit (first measure, layout changes) — suppressed once the learner
// takes manual control, until they return to the floor (or press fit).
useEffect(() => {
if (userAdjustedRef.current || !size.w || !size.h) return;
setView(fitGraphView(box, size, fitZoom));
}, [fitZoom, size, box]);
// Wheel zoom to the cursor. Non-passive so the page never scrolls instead.
useEffect(() => {
const el = viewportRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const dy = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY; // Firefox line mode
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]);
// Drag pan. Guards: the zoom controls sit inside the pan surface — their
// pointerdown must NOT capture (capture redirects the click away from
// the button — the bug that made them feel dead). And at the fit floor
// there is nothing to pan: don't even begin (the cursor stays honest).
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");
};
// Button zoom steps toward the viewport center (same math as wheel).
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));
};
return (
<div
ref={viewportRef}
data-testid="plan-graph"
className={cn(
"relative min-h-0 flex-1 touch-none overflow-hidden select-none",
atFloor ? "cursor-default" : "cursor-grab",
)}
onPointerDown={startDrag}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
<div
className="absolute top-0 left-0 origin-top-left"
style={{ transform: `translate(${view.x}px, ${view.y}px) scale(${view.zoom})` }}
>
<svg width={layout.width} height={layout.height} role="img" aria-label="Plan tree graph">
<defs>
<marker
id="lh-plan-arrow"
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>
{tree.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(#lh-plan-arrow)"
/>
);
})}
{order.map((n) => {
const status: PlanNodeStatus = planNodeStatus(tree, n.id);
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={`graph-node-${n.id}`}
data-status={status}
data-next={n.id === nextId ? "true" : undefined}
>
<title>{n.summary ? `${n.title} — ${n.summary}` : n.title}</title>
<rect width={NODE_W} height={NODE_H} rx={10} className={GRAPH_BOX[status]} strokeWidth={1.5} />
{status === "current" && (
<circle cx={NODE_W - 12} cy={12} r={3} className="fill-primary" aria-hidden />
)}
{lines.map((line, i) => (
<text
key={i}
x={12}
y={lines.length === 1 ? 29 : 19 + i * 14}
className={GRAPH_TEXT[status]}
>
{line}
</text>
))}
</g>
);
})}
</svg>
</div>
{/* Quiet affordances: the hint and the zoom controls. */}
<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={GRAPH_ZOOM_BTN}>
<MinusIcon className="size-3.5" />
</button>
<button type="button" title="Fit plan to panel" aria-label="Fit plan to panel" onClick={fitView} data-testid="graph-fit" className={GRAPH_ZOOM_BTN}>
<MaximizeIcon className="size-3.5" />
</button>
<button type="button" title="Zoom in" aria-label="Zoom in" onClick={() => stepZoom(1.25)} className={GRAPH_ZOOM_BTN}>
<PlusIcon className="size-3.5" />
</button>
</div>
</div>
</div>
);
}
/** The expanded panel (graph mode): wider, the diagram, and the mode toggles. */
export function TreeGraphPanel({
tree,
onShowList,
onCollapse,
}: {
tree: PlanTree;
onShowList: () => void;
onCollapse: () => void;
}) {
const doneCount = tree.doneNodeIds.length;
const totalCount = tree.nodes.length;
return (
<div data-testid="tree-graph-panel" className="flex h-full flex-col">
<header className="flex items-center gap-2 px-3 py-2.5">
<NetworkIcon className="size-3.5 text-muted-foreground" />
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">Plan</span>
<span
className="ml-auto text-[11px] tabular-nums text-muted-foreground"
title={`${doneCount} of ${totalCount} nodes done`}
>
{doneCount}/{totalCount}
</span>
<button
type="button"
onClick={onShowList}
title="Show as list"
aria-label="Show as list"
className={GRAPH_HEADER_BTN}
>
<ListTreeIcon className="size-3.5" />
</button>
<button
type="button"
onClick={onCollapse}
title="Hide plan"
aria-label="Hide plan"
className={GRAPH_HEADER_BTN}
>
<ChevronsRightIcon className="size-3.5" />
</button>
</header>
<TreeGraph tree={tree} />
</div>
);
}
-20
View File
@@ -43,7 +43,6 @@ import { SubagentSteerCard } from "../components/artifacts/steer-card.tsx";
import { DispatchCard } from "../components/artifacts/dispatch-card.tsx";
import { JobCard } from "../components/artifacts/job-card.tsx";
import { JobsContext } from "../components/jobs-context.ts";
import { SubAgentsPanel } from "../components/sub-agents-panel.tsx";
import { demoInputSchema, jobTools, visualInputSchema } from "../src/agent/tools/jobs.ts";
import { runDemoBuilder } from "../src/agent/subagents/demo-builder.ts";
import { headlessOverride, type DemoRunResult, type HeadlessApi } from "../src/agent/subagents/headless.ts";
@@ -381,25 +380,6 @@ describe("steer card rendering (the delivered artifact)", () => {
});
});
describe("sub-agents panel (sidebar observability)", () => {
const runningJob = (kind: "research" | "visual"): JobRecord =>
({ id: `j-${kind}`, kind, chatId: "c", toolCallId: "t", status: "running", activity: 'web_search: "sigma"', createdAt: new Date(Date.now() - 40000).toISOString(), updatedAt: new Date().toISOString() }) as JobRecord;
it("lists running sub-agents with kind, activity, and elapsed", () => {
const html = renderToStaticMarkup(<SubAgentsPanel jobs={[runningJob("research"), runningJob("visual")]} />);
assert.match(html, /Sub-agents/);
assert.match(html, /Researcher/);
assert.match(html, /Figure maker/);
assert.match(html, /web_search: (&quot;|")sigma/);
assert.match(html, /40s|\d+m/);
});
it("renders nothing when the fleet is idle (done jobs vanish)", () => {
const done = { ...runningJob("research"), status: "done" };
assert.equal(renderToStaticMarkup(<SubAgentsPanel jobs={[done as JobRecord]} />), "");
assert.equal(renderToStaticMarkup(<SubAgentsPanel jobs={[]} />), "");
});
});
describe("sanitizeSvg", () => {
it("strips script blocks, event handlers, and javascript: urls, keeps geometry", () => {
-498
View File
@@ -1,498 +0,0 @@
import React from "react";
(globalThis as Record<string, unknown>).React = React;
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import {
latestTree,
nextNodeId,
topoLayout,
TreePanel,
TreePanelRail,
type TreePanelMessage,
} from "../components/tree-panel.tsx";
import type { PlanTree } from "../src/core/contracts.ts";
/**
* Render + derivation tests for the persistent plan-tree panel (W7, vision
* §2 "Persistent tree view"). Pattern follows render-cards.test.tsx: static
* HTML via react-dom/server, assertions on the markup — plus unit coverage
* for the pure helpers (latestTree / topoLayout / nextNodeId) that drive the
* panel from the transcript's plan tool outputs.
*/
/** A small DAG authored in NON-topological node order (b, a, c): a → b → c. */
const chainTree: PlanTree = {
nodes: [
{ id: "b", title: "Chain middle", summary: "The middle step." },
{ id: "a", title: "Foundation", summary: "Where it all starts." },
{ id: "c", title: "Summit", summary: "The payoff." },
],
edges: [
{ from: "a", to: "b" },
{ from: "b", to: "c" },
],
currentNodeId: "b",
doneNodeIds: ["a"],
version: 3,
};
function planPart(tool: string, tree: PlanTree, ok = true) {
return {
type: `tool-${tool}`,
state: "output-available",
toolCallId: `${tool}-1`,
output: { ok, tree },
};
}
/**
* Test literals carry real UI-part fields (toolCallId, text, …) beyond the
* narrow structural slice TreePanelMessage needs — typed once at the boundary
* so the fixtures can read like real transcript parts.
*/
function asMessages(raw: unknown[]): TreePanelMessage[] {
return raw as TreePanelMessage[];
}
describe("latestTree (the transcript is the data source)", () => {
it("returns null when no plan tool output exists (probe phase / pre-plan sessions)", () => {
const messages = asMessages([
{ parts: [{ type: "text", text: "hello" }] },
{ parts: [{ type: "tool-quiz", state: "output-available", toolCallId: "q1", output: {} }] },
]);
assert.equal(latestTree(messages), null);
});
it("returns the LAST output-available plan tree (set_current overrides init)", () => {
const initTree: PlanTree = { ...chainTree, currentNodeId: null, doneNodeIds: [], version: 1 };
const messages = asMessages([
{ parts: [planPart("plan_init", initTree)] },
{ parts: [planPart("plan_set_current", chainTree)] },
]);
const tree = latestTree(messages);
assert.equal(tree?.currentNodeId, "b");
assert.equal(tree?.version, 3);
});
it("skips plan parts still streaming or pending their output", () => {
const initTree: PlanTree = { ...chainTree, version: 1 };
const messages = asMessages([
{
parts: [
planPart("plan_init", initTree),
{ type: "tool-plan_mark_done", state: "input-streaming", toolCallId: "md1" },
],
},
{ parts: [{ type: "tool-plan_set_current", state: "input-available", toolCallId: "sc1" }] },
]);
// The pending mark_done must not shadow the init output behind it.
assert.equal(latestTree(messages)?.version, 1);
});
it("counts a failed op's output (it carries the current tree) but skips malformed output", () => {
const failedOp = asMessages([{ parts: [planPart("plan_add_edge", chainTree, false)] }]);
assert.equal(latestTree(failedOp)?.version, 3, "ok:false still carries the live tree");
const malformed = asMessages([
{ parts: [{ type: "tool-plan_init", state: "output-available", toolCallId: "p1", output: { ok: true } }] },
{ parts: [planPart("plan_mark_done", chainTree)] },
]);
assert.equal(latestTree(malformed)?.version, 3, "falls through a treeless output to the earlier one");
});
});
describe("topoLayout (order + depth)", () => {
it("orders nodes topologically even when authored out of order", () => {
const { order } = topoLayout(chainTree);
assert.deepEqual(
order.map((n) => n.id),
["a", "b", "c"],
);
});
it("computes longest-path depth for indentation", () => {
const { depth } = topoLayout(chainTree);
assert.equal(depth.get("a"), 0);
assert.equal(depth.get("b"), 1);
assert.equal(depth.get("c"), 2);
});
it("keeps every node visible even if an edge references an unknown id", () => {
const broken: PlanTree = {
...chainTree,
edges: [{ from: "ghost", to: "a" }],
};
assert.equal(topoLayout(broken).order.length, 3);
});
});
describe("nextNodeId (the unlocked frontier)", () => {
it("picks the first upcoming node whose prerequisites are all done", () => {
const { order } = topoLayout(chainTree);
// a done, b current (not done), c upcoming but blocked by b → no next.
assert.equal(nextNodeId(chainTree, order), null);
const unblocked: PlanTree = { ...chainTree, doneNodeIds: ["a", "b"], currentNodeId: null };
assert.equal(nextNodeId(unblocked, order), "c");
});
it("an independent root is next as soon as it is upcoming", () => {
const tree: PlanTree = {
nodes: [
{ id: "x", title: "X" },
{ id: "y", title: "Y" },
],
edges: [],
currentNodeId: "x",
doneNodeIds: [],
version: 1,
};
assert.equal(nextNodeId(tree, topoLayout(tree).order), "y");
});
});
describe("TreePanel rendering", () => {
it("renders nothing before any tree exists (probe phase) — and for a failed first init", () => {
assert.equal(renderToStaticMarkup(<TreePanel messages={[]} />), "");
const probing = asMessages([
{ parts: [{ type: "tool-quiz", state: "output-available", toolCallId: "q1", output: {} }] },
]);
assert.equal(renderToStaticMarkup(<TreePanel messages={probing} />), "");
const failedInit = asMessages([{ parts: [planPart("plan_init", { ...chainTree, nodes: [] }, false)] }]);
assert.equal(renderToStaticMarkup(<TreePanel messages={failedInit} />), "", "empty tree → no panel");
});
it("renders the panel with nodes in topological order, statuses, and progress", () => {
const messages = asMessages([{ parts: [planPart("plan_init", chainTree)] }]);
const html = renderToStaticMarkup(<TreePanel messages={messages} />);
assert.ok(html.includes('data-testid="tree-panel"'), "the panel renders once a tree lands");
// Topological order in the markup (authored order was b, a, c).
const ia = html.indexOf("Foundation");
const ib = html.indexOf("Chain middle");
const ic = html.indexOf("Summit");
assert.ok(ia > -1 && ib > -1 && ic > -1);
assert.ok(ia < ib && ib < ic, "prerequisites render before dependents");
// Per-node status from contracts.planNodeStatus.
assert.ok(html.includes('data-status="done"'), "a: done");
assert.ok(html.includes('data-status="current"'), "b: current");
assert.ok(html.includes('data-status="upcoming"'), "c: upcoming");
// Current-position highlight + its summary surfaced (hover has it too).
assert.ok(/data-status="current"[^>]*bg-primary\/10/.test(html), "current row is the highlighted one");
assert.ok(html.includes("The middle step."), "current node's summary is visible");
// Progress counter in the header.
assert.match(html, /1\/3/);
});
it("dependency depth indents dependents (marginLeft grows with depth)", () => {
const messages = asMessages([{ parts: [planPart("plan_init", chainTree)] }]);
const html = renderToStaticMarkup(<TreePanel messages={messages} />);
assert.ok(html.includes("margin-left:0"), "root at depth 0");
assert.ok(html.includes("margin-left:12px"), "depth 1");
assert.ok(html.includes("margin-left:24px"), "depth 2");
});
it("node summaries are available on hover (title attribute)", () => {
const messages = asMessages([{ parts: [planPart("plan_init", chainTree)] }]);
const html = renderToStaticMarkup(<TreePanel messages={messages} />);
assert.ok(html.includes('title="Where it all starts."'), "done node's summary on hover");
});
it("marks the next unlocked node (data-next + the quiet label)", () => {
const tree: PlanTree = {
nodes: [
{ id: "a", title: "Foundation" },
{ id: "b", title: "Middle" },
{ id: "c", title: "Summit" },
],
edges: [
{ from: "a", to: "b" },
{ from: "b", to: "c" },
],
currentNodeId: null,
doneNodeIds: ["a"],
version: 2,
};
const html = renderToStaticMarkup(
<TreePanel messages={asMessages([{ parts: [planPart("plan_mark_done", tree)] }])} />,
);
// b's prerequisite (a) is done → b is next; c stays blocked.
assert.ok(/data-testid="tree-node-b"[^>]*data-next="true"/.test(html), "b is the next node");
assert.ok(!/data-testid="tree-node-c"[^>]*data-next/.test(html), "c is not next");
assert.match(html, /next/, "the quiet next label");
});
it("live updates: a later plan output replaces the earlier tree", () => {
const init: PlanTree = {
nodes: [{ id: "a", title: "Only node" }],
edges: [],
currentNodeId: null,
doneNodeIds: [],
version: 1,
};
const before = renderToStaticMarkup(
<TreePanel messages={asMessages([{ parts: [planPart("plan_init", init)] }])} />,
);
assert.ok(before.includes("Only node"));
const progressed: PlanTree = {
...init,
nodes: [
{ id: "a", title: "Only node" },
{ id: "z", title: "Detour" },
],
currentNodeId: "z",
doneNodeIds: ["a"],
version: 2,
};
const after = renderToStaticMarkup(
<TreePanel
messages={asMessages([
{ parts: [planPart("plan_init", init), planPart("plan_add_node", progressed)] },
])}
/>,
);
assert.ok(after.includes("Detour"), "the new node appears");
assert.ok(after.includes('data-status="current"'), "position moved");
});
it("the collapsed rail: thin strip with the expand affordance and quiet progress", () => {
const html = renderToStaticMarkup(<TreePanelRail doneCount={1} totalCount={3} onExpand={() => {}} />);
assert.ok(html.includes('data-testid="tree-panel-rail"'));
assert.ok(html.includes('aria-label="Show plan"'), "an accessible expand action");
assert.match(html, /1\/3/, "progress survives the collapse");
assert.ok(!html.includes("Foundation"), "no node content in the strip");
});
});
// ─── the graph (expanded view) ────────────────────────────────────────────────
import {
TreeGraph,
graphLayout,
wrapTitle,
NODE_H,
NODE_W,
} from "../components/tree-panel.tsx";
describe("wrapTitle (box labels)", () => {
it("wraps to lines within the char budget", () => {
assert.deepEqual(wrapTitle("Chain rule intuition for composites"), [
"Chain rule intuition",
"for composites",
]);
});
it("a title that fits stays one line", () => {
assert.deepEqual(wrapTitle("Foundation"), ["Foundation"]);
});
it("overflowing titles end in an ellipsis", () => {
const lines = wrapTitle("Substitution and the differential of implicit functions");
assert.equal(lines.length, 2);
assert.ok(lines.at(-1)?.endsWith("…"));
});
it("a single oversized word is hard-clamped", () => {
const lines = wrapTitle("antidifferentiation-techniques-overview");
assert.ok(lines.every((l) => l.length <= 22));
assert.ok(lines.at(-1)?.endsWith("…"));
});
it("empty titles render one empty line (never an empty array)", () => {
assert.deepEqual(wrapTitle(""), [""]);
});
});
describe("graphLayout (layered, top-down)", () => {
it("one node per depth row, authoring order within the row, rows centered", () => {
const { order, depth } = topoLayout(chainTree);
const layout = graphLayout(order, depth);
assert.equal(layout.height, NODE_H * 3 + 64 * 2, "three rows plus arrow gaps");
// b, a: depth 0; c: depth 1 → wait: a is authored second but is the root.
assert.equal(depth.get("a"), 0);
assert.equal(depth.get("b"), 1);
assert.equal(depth.get("c"), 2);
// each row of one node is centered on the (single-column) width.
assert.equal(layout.x.get("a"), 0);
assert.equal(layout.x.get("b"), 0);
assert.equal(layout.x.get("c"), 0);
assert.equal(layout.y.get("a"), 0);
assert.equal(layout.y.get("b"), NODE_H + 64);
});
it("two same-depth nodes sit side by side, centered over the widest row", () => {
const tree: PlanTree = {
nodes: [
{ id: "r", title: "Root" },
{ id: "x", title: "X" },
{ id: "y", title: "Y" },
],
edges: [
{ from: "r", to: "x" },
{ from: "r", to: "y" },
],
currentNodeId: null,
doneNodeIds: [],
version: 1,
};
const { order, depth } = topoLayout(tree);
const layout = graphLayout(order, depth);
const xX = layout.x.get("x")!;
const xY = layout.x.get("y")!;
assert.equal(xY - xX, NODE_W + 28, "one box + one gap apart");
// row 1 is the widest → row 0 centers over it: the root sits exactly
// between the two dependents' centers (88 and 292 → 190 - 88 = 102).
assert.equal(layout.x.get("r"), (2 * NODE_W + 28 - NODE_W) / 2, "the single root centers over the two-wide row");
assert.equal(layout.width, 2 * NODE_W + 28);
});
});
describe("TreeGraph rendering", () => {
it("renders one box per node with its status, one arrow per edge", () => {
const html = renderToStaticMarkup(<TreeGraph tree={chainTree} />);
for (const [id, status] of [
["a", "done"],
["b", "current"],
["c", "upcoming"],
] as const) {
assert.ok(
new RegExp(`data-testid="graph-node-${id}"[^>]*data-status="${status}"`).test(html),
`${id} is ${status}`,
);
}
assert.equal(html.match(/marker-end="url\(#lh-plan-arrow\)"/g)?.length, 2, "two edges, two arrows");
});
it("colors progress: done boxes carry the teach-phase tint", () => {
const html = renderToStaticMarkup(<TreeGraph tree={chainTree} />);
assert.match(html, /data-status="done"[\s\S]*?fill-phase-teach\/10/);
assert.match(html, /data-status="current"[\s\S]*?stroke-primary/);
});
it("marks the next unlocked node", () => {
const tree: PlanTree = {
nodes: [
{ id: "a", title: "Foundation" },
{ id: "b", title: "Middle" },
],
edges: [{ from: "a", to: "b" }],
currentNodeId: null,
doneNodeIds: ["a"],
version: 2,
};
const html = renderToStaticMarkup(<TreeGraph tree={tree} />);
assert.match(html, /data-testid="graph-node-b"[^>]*data-next="true"/);
});
it("node titles wrap inside the box (two lines max, ellipsis on overflow)", () => {
const tree: PlanTree = {
nodes: [{ id: "long", title: "Substitution and the differential of implicit functions" }],
edges: [],
currentNodeId: null,
doneNodeIds: [],
version: 1,
};
const html = renderToStaticMarkup(<TreeGraph tree={tree} />);
const texts = html.match(/<text[^>]*>([^<]*)<\/text>/g) ?? [];
assert.equal(texts.length, 2, "two wrapped lines");
assert.ok(texts.at(-1)?.includes("…"));
});
it("hover shows title and summary", () => {
const html = renderToStaticMarkup(<TreeGraph tree={chainTree} />);
assert.ok(html.includes("Foundation — Where it all starts."));
});
});
// ─── graph view math (pan lock at fit, zoom-to-cursor) ────────────────────────
import {
fitGraphView,
zoomedGraphView,
type GraphView,
} from "../components/tree-panel.tsx";
describe("fitGraphView / zoomedGraphView", () => {
const box = { w: 1000, h: 800 };
const viewport = { w: 300, h: 400 };
it("fit centers the whole graph in the viewport at the given zoom", () => {
const v = fitGraphView(box, viewport, 0.3);
assert.equal(v.zoom, 0.3);
assert.equal(v.x, (300 - 1000 * 0.3) / 2);
assert.equal(v.y, (400 - 800 * 0.3) / 2);
});
it("zooming in moves the point under the cursor to stay put (zoom-to-cursor)", () => {
const v: GraphView = { zoom: 0.5, x: -100, y: -50 };
// world point at cursor (50, 60) pre-zoom: (50 - (-100))/0.5 = 300
const next = zoomedGraphView(v, 2, 0.1, { x: 50, y: 60 }, box, viewport);
assert.equal(next.zoom, 1);
// the same world point maps back to the cursor: (50 - next.x)/1 = 300
assert.equal(50 - next.x, 300);
assert.equal(60 - next.y, 220);
});
it("zooming out past the floor SNAPS to the centered fit (fully zoomed out = the fit)", () => {
const v: GraphView = { zoom: 1.8, x: -900, y: -400 };
const next = zoomedGraphView(v, 1 / 8, 0.3, { x: 100, y: 100 }, box, viewport);
assert.equal(next.zoom, 0.3, "clamped at the floor");
assert.deepEqual(next, fitGraphView(box, viewport, 0.3), "and re-centered: nothing to pan");
});
it("a no-op zoom returns the same view (no drift)", () => {
const v: GraphView = { zoom: 2, x: 10, y: 10 };
// factor 1 → same zoom; at MAX already → clamped to same → same view
assert.equal(zoomedGraphView(v, 1, 0.3, { x: 0, y: 0 }, box, viewport), v);
});
it("zoom-in clamps at the ceiling (2×)", () => {
const v: GraphView = { zoom: 1.9, x: 0, y: 0 };
const next = zoomedGraphView(v, 3, 0.3, { x: 10, y: 10 }, box, viewport);
assert.equal(next.zoom, 2);
});
});
// ─── fit sizing (wave-2: small graphs must USE the panel, not float tiny) ─────
import { fitZoomFor } from "../components/tree-panel.tsx";
describe("fitZoomFor", () => {
it("a graph smaller than the panel scales UP to use it — no tiny diagram with empty margins", () => {
// 5-node chain: 176 wide × 448 tall in a 384×600 panel: height-bound.
// Old bug: the ≤1× cap rendered such graphs at natural size, centered,
// tiny, with big empty areas above and below.
const z = fitZoomFor({ w: 176, h: 448 }, { w: 384, h: 600 });
assert.equal(z, (600 - 16) / 448, "height-bound: fills the tall panel");
assert.ok(z > 1, "scales UP past 1×");
});
it("the scale-up is capped: a lone node doesn't become a billboard", () => {
const z = fitZoomFor({ w: 176, h: 48 }, { w: 384, h: 600 });
assert.equal(z, 1.5, "capped at FIT_ZOOM_MAX");
});
it("a wide graph is width-bound; a tall graph is height-bound", () => {
assert.equal(fitZoomFor({ w: 1000, h: 100 }, { w: 384, h: 600 }), (384 - 16) / 1000);
assert.equal(fitZoomFor({ w: 100, h: 1000 }, { w: 384, h: 600 }), (600 - 16) / 1000);
});
it("a graph larger than the panel scales DOWN, floor-clamped", () => {
assert.equal(fitZoomFor({ w: 1500, h: 1500 }, { w: 384, h: 600 }), (384 - 16) / 1500);
// absurdly large → clamped at the floor, never disappears
assert.equal(fitZoomFor({ w: 5000, h: 5000 }, { w: 384, h: 600 }), 0.15);
assert.equal(fitZoomFor({ w: 1e6, h: 1e6 }, { w: 384, h: 600 }), 0.15);
});
it("an unmeasured viewport (SSR / first paint) stays at 1×", () => {
assert.equal(fitZoomFor({ w: 1000, h: 800 }, { w: 0, h: 0 }), 1);
assert.equal(fitZoomFor({ w: 0, h: 800 }, { w: 384, h: 600 }), 1);
});
});