W7: persistent right-panel plan tree

This commit is contained in:
Amos
2026-08-22 11:37:28 +03:00
parent 8d300b71e0
commit 53be8a2834
3 changed files with 637 additions and 2 deletions
+7 -2
View File
@@ -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({
<div className="min-w-0 flex-1 truncate font-medium">{title}</div>
</header>
<div className="relative min-h-0 flex-1">
<Conversation className="h-full" initial="instant">
{/* 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. */}
<div className="relative flex min-h-0 flex-1">
<Conversation className="h-full min-w-0 flex-1" initial="instant">
<ConversationContent className="min-h-full gap-0 p-0">
<div className={`${COLUMN} flex flex-1 flex-col gap-4 pt-4`}>
{messages.length === 0 && !busy && (
@@ -256,6 +260,7 @@ export function Chat({
</ConversationContent>
<ConversationScrollButton />
</Conversation>
<TreePanel messages={messages} />
</div>
</div>
</ChatActionsContext.Provider>
+358
View File
@@ -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<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 ───────────────────────────────────────────────────────────────────
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 ? (
<TreePanelRail
doneCount={tree.doneNodeIds.length}
totalCount={tree.nodes.length}
onExpand={toggle}
/>
) : (
<TreePanelBody tree={tree} onCollapse={toggle} />
);
}
/** 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 (
<aside
data-testid="tree-panel-rail"
className="hidden w-10 shrink-0 flex-col items-center gap-3 border-l border-border/60 py-3 xl:flex"
>
<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>
</aside>
);
}
/** 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 (
<aside
data-testid="tree-panel"
className="hidden w-64 shrink-0 flex-col border-l border-border/60 bg-card/30 xl:flex"
>
<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={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>
</aside>
);
}
/** 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
/>
);
}
}
+272
View File
@@ -0,0 +1,272 @@
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");
});
});