"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"; /** * 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 ( ); } }