diff --git a/components/tree-panel.tsx b/components/tree-panel.tsx index 4b217b9..b0455a8 100644 --- a/components/tree-panel.tsx +++ b/components/tree-panel.tsx @@ -1,7 +1,15 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; -import { CheckIcon, ChevronsRightIcon, ListTreeIcon } from "lucide-react"; +import { 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"; @@ -21,14 +29,23 @@ import { planNodeStatus, type PlanNode, type PlanNodeStatus, type PlanTree } fro * (PhaseDivider, message-level) marks the transition; THIS panel is the * surface for every plan_* part (they render nothing inline, by design). * -* Quiet register (matches the phase dividers): 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). +* 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 key for the collapse preference (matches lh:sidebar-open). */ -const STORAGE_KEY = "lh:tree-panel-collapsed"; +/** 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. */ +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. @@ -148,25 +165,30 @@ export function TreePanel({ }) { 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); + // Start in the list so server and client first-render agree (no + // hydration mismatch); the stored preference is applied after mount. + // Same pattern as the sidebar. + const [mode, setMode] = useState("list"); useEffect(() => { try { - const stored = window.localStorage.getItem(STORAGE_KEY); - if (stored !== null) setCollapsed(stored === "1"); + 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 toggle = () => { - const next = !collapsed; - setCollapsed(next); + const go = (next: PanelMode) => { + setMode(next); try { - window.localStorage.setItem(STORAGE_KEY, next ? "1" : "0"); + window.localStorage.setItem(MODE_KEY, next); } catch { // see above } @@ -176,15 +198,19 @@ export function TreePanel({ // the moment plan_init's output lands and updates live from there. if (!tree || tree.nodes.length === 0) return null; - return collapsed ? ( - - ) : ( - - ); + if (mode === "rail") { + return ( + go("list")} + /> + ); + } + if (mode === "graph") { + return go("list")} onCollapse={() => go("rail")} />; + } + return go("graph")} onCollapse={() => go("rail")} />; } /** The collapsed panel: a thin strip with the map icon + quiet progress. */ @@ -221,12 +247,15 @@ export function TreePanelRail({ ); } -/** The expanded panel: header + the node list in topological order. */ +/** 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]); @@ -250,6 +279,15 @@ export function TreePanelBody({ > {doneCount}/{totalCount} + + + + + + + ); +} + +/** 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 ( + + ); +} diff --git a/tests/render-tree-panel.test.tsx b/tests/render-tree-panel.test.tsx index 616a6d6..579af50 100644 --- a/tests/render-tree-panel.test.tsx +++ b/tests/render-tree-panel.test.tsx @@ -270,3 +270,143 @@ describe("TreePanel rendering", () => { 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(); + 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(); + 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(); + 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(); + const texts = html.match(/]*>([^<]*)<\/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(); + assert.ok(html.includes("Foundation — Where it all starts.")); + }); +});