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 (
+
+
+
+
+
+ {doneCount}/{totalCount}
+
+
+ );
+}
+
+/** 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 (
+
+
+
+
+ Plan
+
+
+ {doneCount}/{totalCount}
+
+
+
+
+
+
+ {/* Keyed by tree.version: every mutation (contracts.ts) bumps it, so a
+ set_current/mark_done visibly re-animates the list mid-session. */}
+
+
+ {order.map((node) => {
+ const status: PlanNodeStatus = planNodeStatus(tree, node.id);
+ return (
+
+
+
+
+
+ {node.title}
+
+ {node.id === nextId && (
+
+ next
+
+ )}
+
+ {/* The current position also surfaces its summary — the "where
+ we are, what this covers" answer without hovering. */}
+ {status === "current" && node.summary && (
+
+ {node.summary}
+
+ )}
+
+
+ );
+ })}
+
+
+
+ );
+}
+
+/** 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");
+ });
+});