From 53be8a2834802e5b0211b06c805513f62d0529d1 Mon Sep 17 00:00:00 2001 From: Amos Date: Sat, 22 Aug 2026 11:37:28 +0300 Subject: [PATCH] W7: persistent right-panel plan tree --- components/chat.tsx | 9 +- components/tree-panel.tsx | 358 +++++++++++++++++++++++++++++++ tests/render-tree-panel.test.tsx | 272 +++++++++++++++++++++++ 3 files changed, 637 insertions(+), 2 deletions(-) create mode 100644 components/tree-panel.tsx create mode 100644 tests/render-tree-panel.test.tsx diff --git a/components/chat.tsx b/components/chat.tsx index 0aa8425..f6af9f1 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -19,6 +19,7 @@ import { artifactRenderers } from "@/components/artifacts"; import { Composer } from "@/components/composer"; import { QuestionDock } from "@/components/question-dock"; import { SidebarToggle } from "@/components/sidebar"; +import { TreePanel } from "@/components/tree-panel"; import type { TeacherUIMessage } from "@/src/agent/ui-message"; import type { ArtifactPartProps } from "@/src/core/contracts"; import type { QuestionAnswerData } from "@/src/core/question-core"; @@ -161,8 +162,11 @@ export function Chat({
{title}
-
- + {/* The session row: transcript scroller + the persistent plan-tree panel + (W7, vision §2). The panel is a flex SIBLING of the scroller — the dock + stays sticky inside the scroller, untouched. */} +
+
{messages.length === 0 && !busy && ( @@ -256,6 +260,7 @@ export function Chat({ +
diff --git a/components/tree-panel.tsx b/components/tree-panel.tsx new file mode 100644 index 0000000..5efdf42 --- /dev/null +++ b/components/tree-panel.tsx @@ -0,0 +1,358 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { CheckIcon, ChevronsRightIcon, ListTreeIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { + planNodeStatus, + type PlanNode, + type PlanNodeStatus, + type PlanTree, +} from "@/src/core/contracts"; + +/** +* 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 divider in the transcript (plan-divider) +* marks the seam; THIS panel is the surface for every other plan_* part +* (they render nothing inline, by design). +* +* Quiet register (matches plan-divider): hairline border, muted text, one +* highlighted row for the current position. Collapses to a thin rail; the +* preference persists in localStorage. Hidden below `xl` (CSS-only). +*/ + +/** localStorage key for the collapse preference (matches lh:sidebar-open). */ +const STORAGE_KEY = "lh:tree-panel-collapsed"; + +/** +* 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; +} + +/** 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( + tree.nodes.map((n) => [n.id, 0] as const), + ); + const dependents = new Map(); + 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(); + 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 ─────────────────────────────────────────────────────────────────── + +export function TreePanel({ + messages, +}: { + messages: readonly TreePanelMessage[]; +}) { + const tree = useMemo(() => latestTree(messages), [messages]); + + // Start expanded so server and client first-render agree (no hydration + // mismatch); the stored preference is applied after mount. Same pattern + // as the sidebar. + const [collapsed, setCollapsed] = useState(false); + + useEffect(() => { + try { + const stored = window.localStorage.getItem(STORAGE_KEY); + if (stored !== null) setCollapsed(stored === "1"); + } catch { + // private mode / storage disabled — the preference is simply not persisted + } + }, []); + + const toggle = () => { + const next = !collapsed; + setCollapsed(next); + try { + window.localStorage.setItem(STORAGE_KEY, next ? "1" : "0"); + } catch { + // see above + } + }; + + // 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; + + return collapsed ? ( + + ) : ( + + ); +} + +/** 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 ( + + ); +} + +/** The expanded panel: header + the node list in topological order. */ +export function TreePanelBody({ + tree, + onCollapse, +}: { + tree: PlanTree; + 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 ( + + ); +} + +/** 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 ( + + ); + case "current": + return ( + + ); + case "upcoming": + return ( + + ); + } +} diff --git a/tests/render-tree-panel.test.tsx b/tests/render-tree-panel.test.tsx new file mode 100644 index 0000000..616a6d6 --- /dev/null +++ b/tests/render-tree-panel.test.tsx @@ -0,0 +1,272 @@ +import React from "react"; +(globalThis as Record).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(), ""); + const probing = asMessages([ + { parts: [{ type: "tool-quiz", state: "output-available", toolCallId: "q1", output: {} }] }, + ]); + assert.equal(renderToStaticMarkup(), ""); + + const failedInit = asMessages([{ parts: [planPart("plan_init", { ...chainTree, nodes: [] }, false)] }]); + assert.equal(renderToStaticMarkup(), "", "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(); + 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(); + 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(); + 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( + , + ); + // 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( + , + ); + 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( + , + ); + 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( {}} />); + 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"); + }); +});