From f342dcd76685b2f3d87d6fc8c1288879abde7de2 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 8 Jul 2026 00:12:48 +0300 Subject: [PATCH 1/2] Tree lens: collapse path-shaped query results into a forest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New catalog lens (10th): domain → concept → related in one collapsible view. LEVELS mode: one query row = one root→leaf path (exactly what a multi-hop .gq match returns); `levels` names {key, label} column pairs root→leaf; shared prefixes group client-side. - core: buildForest — the pure grouping helper shared by both renderers (sparse paths truncate at the first empty level, children dedupe per parent by key value, first-seen order, path-joined ids as stable collapse identity). Author/runtime Zod schemas follow the Table pattern; the exhaustive buildRuntimeProps switch forced the wiring at compile time. - web: disclosure triangles, child-count badges, indent guides; clicking ANY node writes its key value to select_state (Table's setState dispatch), so a domain, concept, or leaf click drives dependent cells. expand_depth controls the initially-open levels. - tui: box-drawing flatten of the expanded forest (├─/└─/│), ↑/↓ cursor, ←/→ fold/unfold, Enter selects; 14-line sliding viewport. Tests 122 core (buildForest matrix, schema round-trip, <2 levels rejected) + 76 web (real-registry render, collapse, expand_depth: 0, select highlight, empty state). Verified live on the concept graph: 248 domain→concept→related paths, leaf click drove /selected across tabs; TUI render confirmed against the live server. Known data caveat (documented, not a lens bug): .gq traversals are inner joins, so nodes with no leaf-level edge drop out of the path query entirely — the fix is server-side optional match; buildForest already handles the sparse rows it would produce. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011TrhtF1SiJghJASMWECzBh --- packages/core/src/catalog/index.test.ts | 80 +++++++++++- packages/core/src/catalog/index.ts | 14 ++ packages/core/src/catalog/lenses/tree.ts | Bin 0 -> 3815 bytes packages/core/src/spec/index.ts | 2 + packages/tui/src/components/Tree.tsx | 148 ++++++++++++++++++++++ packages/tui/src/registry.ts | 4 +- packages/web/src/components/Tree.test.tsx | 127 +++++++++++++++++++ packages/web/src/components/Tree.tsx | 130 +++++++++++++++++++ packages/web/src/registry.ts | 4 +- 9 files changed, 506 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/catalog/lenses/tree.ts create mode 100644 packages/tui/src/components/Tree.tsx create mode 100644 packages/web/src/components/Tree.test.tsx create mode 100644 packages/web/src/components/Tree.tsx diff --git a/packages/core/src/catalog/index.test.ts b/packages/core/src/catalog/index.test.ts index 2165a7e..22e913f 100644 --- a/packages/core/src/catalog/index.test.ts +++ b/packages/core/src/catalog/index.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { assembleLensSpec, + buildForest, lensComponents, type QueryResult, } from "./index.js"; @@ -17,7 +18,7 @@ const fakeResult: QueryResult = { }; describe("lensComponents", () => { - it("registers all fourteen components (9 lenses + 5 controls)", () => { + it("registers all fifteen components (10 lenses + 5 controls)", () => { expect(Object.keys(lensComponents).sort()).toEqual([ "ActionList", "Button", @@ -33,6 +34,7 @@ describe("lensComponents", () => { "TextInput", "Timeline", "Toggle", + "Tree", ]); }); @@ -294,3 +296,79 @@ describe("Form picker field schema", () => { ).toThrow(/require kind: picker/); }); }); + +describe("Tree lens", () => { + it("builds a Tree spec with rows merged in", () => { + const spec = assembleLensSpec( + "t1", + "Tree", + { + levels: [ + { key: "d.slug", label: "d.name" }, + { key: "c.slug", label: "c.name" }, + ], + select_state: "/selected", + }, + fakeResult, + ); + expect(spec.elements["t1"]?.type).toBe("Tree"); + expect((spec.elements["t1"]?.props as { rows: unknown[] }).rows).toEqual( + fakeResult.rows, + ); + }); + + it("rejects fewer than two levels", () => { + expect(() => + assembleLensSpec("t1", "Tree", { levels: [{ key: "d.slug" }] }, fakeResult), + ).toThrow(); + }); +}); + +describe("buildForest", () => { + const LEVELS = [ + { key: "d", label: "dn" }, + { key: "c", label: "cn" }, + { key: "r", label: "rn" }, + ]; + + it("groups shared prefixes, dedupes, keeps first-seen order and labels", () => { + const rows = [ + { d: "sys", dn: "Systems", c: "loop", cn: "Feedback loops", r: "attr", rn: "Attractors" }, + { d: "sys", dn: "Systems", c: "loop", cn: "Feedback loops", r: "emer", rn: "Emergence" }, + { d: "sys", dn: "Systems", c: "chunk", cn: "Chunking", r: "attr", rn: "Attractors" }, + { d: "cog", dn: "Cognitive", c: "bias", cn: "Bias", r: "loop", rn: "Feedback loops" }, + // duplicate full path — must not duplicate nodes + { d: "sys", dn: "Systems", c: "loop", cn: "Feedback loops", r: "attr", rn: "Attractors" }, + ]; + const forest = buildForest(rows, LEVELS); + expect(forest.map((n) => n.label)).toEqual(["Systems", "Cognitive"]); + const sys = forest[0]!; + expect(sys.children.map((n) => n.label)).toEqual(["Feedback loops", "Chunking"]); + expect(sys.children[0]!.children.map((n) => n.label)).toEqual([ + "Attractors", + "Emergence", + ]); + // same key under different parents = distinct nodes with distinct paths + const cogLoop = forest[1]!.children[0]!.children[0]!; + expect(cogLoop.key).toBe("loop"); + expect(cogLoop.path).not.toBe(sys.children[0]!.path); + expect(cogLoop.depth).toBe(2); + }); + + it("truncates sparse paths at the first empty level and falls back to key as label", () => { + const rows = [ + { d: "sys", dn: "Systems", c: "solo", cn: null, r: null, rn: null }, + { d: "sys", dn: "Systems", c: "", cn: "ignored" }, + { d: "lonely" }, // level-1-only row, no label column value + ]; + const forest = buildForest(rows, LEVELS); + expect(forest.map((n) => n.label)).toEqual(["Systems", "lonely"]); + const solo = forest[0]!.children[0]!; + expect(solo.label).toBe("solo"); // null label → key fallback + expect(solo.children).toEqual([]); + }); + + it("returns an empty forest for no rows", () => { + expect(buildForest([], LEVELS)).toEqual([]); + }); +}); diff --git a/packages/core/src/catalog/index.ts b/packages/core/src/catalog/index.ts index c786c24..76636ed 100644 --- a/packages/core/src/catalog/index.ts +++ b/packages/core/src/catalog/index.ts @@ -8,6 +8,13 @@ import { type TableAuthorProps, type TableRuntimeProps, } from "./lenses/table.js"; +import { + TreeAuthorPropsSchema, + TreeRuntimePropsSchema, + TreeDescription, + type TreeAuthorProps, + type TreeRuntimeProps, +} from "./lenses/tree.js"; import { PathAuthorPropsSchema, PathRuntimePropsSchema, @@ -88,6 +95,7 @@ import { } from "./lenses/number_input.js"; export * from "./lenses/table.js"; +export * from "./lenses/tree.js"; export * from "./lenses/path.js"; export * from "./lenses/subgraph.js"; export * from "./lenses/action_list.js"; @@ -129,6 +137,7 @@ export interface QueryResult { */ export const lensComponents = { Table: { props: TableRuntimePropsSchema, description: TableDescription }, + Tree: { props: TreeRuntimePropsSchema, description: TreeDescription }, Path: { props: PathRuntimePropsSchema, description: PathDescription }, Subgraph: { props: SubgraphRuntimePropsSchema, description: SubgraphDescription }, ActionList: { props: ActionListRuntimePropsSchema, description: ActionListDescription }, @@ -263,6 +272,11 @@ function buildRuntimeProps( const runtime: TableRuntimeProps = { ...author, rows: result.rows }; return runtime as unknown as Record; } + case "Tree": { + const author: TreeAuthorProps = TreeAuthorPropsSchema.parse(authorProps); + const runtime: TreeRuntimeProps = { ...author, rows: result.rows }; + return runtime as unknown as Record; + } case "Path": { const author: PathAuthorProps = PathAuthorPropsSchema.parse(authorProps); const runtime: PathRuntimeProps = { ...author, rows: result.rows }; diff --git a/packages/core/src/catalog/lenses/tree.ts b/packages/core/src/catalog/lenses/tree.ts new file mode 100644 index 0000000000000000000000000000000000000000..a35ccb3b2e8936e681ca981dc8cdcbe84f335459 GIT binary patch literal 3815 zcmai1TW;G%6z#WHaU&QgmB@73Psw&+G-=bqO@i3%k6}b3awIaQILyp2vg`=RR~Jxp z4P97P(sSn_QniyBFd)s$eVu#mL*=qj+R`1}(uU?*EopG8^1)CJ$g(RT1xu- z>2JzZRf)z(YJ@EgOd%SX)B9HHHEC(u${H$bt4PpX;lQSJ_WY0MXXmt3xg3+KC94Yp zYDLFNfu9ZJkjpNDPcVTkvwu^y1WA}V~Xk=9{iP+ zZbsy9qw3F}{@I>I<$QJpt%rN^#_=OlwXu#1XPlk$tdL6qh;LIhdnYrS-0cy8F^Jx! zYE>vO<%6i&n3#DPlIfBqtjE!1GG#+V&ga|ISY6hOWSB0?I{9um3W}fkWCkuwz=64N zqyy!q5S<1sA=rC*Qq|Z})uKwEaxZBlk+@l1bUG2$xU}wX93_-ozkXiLgSUwOA)yq`ky=?7olK z0YMPt*Yj5|sZpp=RM1M7*2)?s=!++-%Ium}r7h^<#p#7R{MK+vLK3+Hh zWQF95StThEl&hsEYx258oud_iH?A1`*-Sw{o0e1{)*J zxDwb-kU;}OPGAx6B~Ot=F4dLz;Kb7=CzI1j_*EV)eLY}`({sx+9K+>*oK+AwqD zxHEnxvjc=i$76Ex-|6LtT1qc@3Wh@F8uPQ0X}D9#+%Nm(Hci8{r&pNAt~;ac@z@o{ z$R(W4w_bMx!L^aO$b9c~rsD{>fM{p+AVlM5L9!~)`upaARwm02(x6PcG2n;51bGYn zygT42bMAXi9Qo|uw%)lRa1+_~E<^9o^}5c=ttZ&xRb~tUo;YkQbOYZD_`p@Tmi5@p z0S@!>YO=T4Zbv`!KH=HpXtT2cQ4;oy8E*}eR&s_On$yMazaVOKD=js`=IQ-h@TP&z zvJ%y`>2{zV^CoDgbz4=3t~qq!ac8MBgv|LCmbF0C$ILzpEJD^ZLL zG3PBtAbi{+#-wLG5XDY4f`~Tuz(^h#Jf-|XfHh*$UVgigAxG}wvB3E^_KIMUS^u{%hBcGWOg|=~W!rOqGKKCTHBM@(f{d5uY z((f;fCItDZPbE4G=QG}y$jJ@PYV?QSM6)#?1x^wGcX;TL7Bog2{4hY-|x zw;Z#N13f)-PCfi!kH<9Kzw2<_e6!E(bu*1VF|dxJ$LP?=c)E}_VVyf(`xnr=4YR{I z;)6)f!jBCPp|62QeJMEALG13Y;NezjbSkR!8hB#Gzs?BM_Hqy`@7VI$>23yto+9%2 z?40zI=ESxbP{intK@G!c literal 0 HcmV?d00001 diff --git a/packages/core/src/spec/index.ts b/packages/core/src/spec/index.ts index 83e061b..7b78a47 100644 --- a/packages/core/src/spec/index.ts +++ b/packages/core/src/spec/index.ts @@ -4,6 +4,7 @@ import { parse as parseYaml } from "yaml"; /** Data-bearing lenses (cells with a query; Form's query is optional — prefill). */ export const LensKind = z.enum([ "Table", + "Tree", "Subgraph", "Path", "ActionList", @@ -28,6 +29,7 @@ export type ControlKind = z.infer; /** Any cell type. */ export const ComponentKind = z.enum([ "Table", + "Tree", "Subgraph", "Path", "ActionList", diff --git a/packages/tui/src/components/Tree.tsx b/packages/tui/src/components/Tree.tsx new file mode 100644 index 0000000..072956c --- /dev/null +++ b/packages/tui/src/components/Tree.tsx @@ -0,0 +1,148 @@ +import React, { useMemo, useState } from "react"; +import { Box, Text, useFocus, useInput } from "ink"; +import { useActions, useStateValue } from "@json-render/ink"; +import { + buildForest, + type TreeNode, + type TreeRuntimeProps, +} from "@modernrelay/notebook-core"; + +interface ComponentCtx

{ + props: P; +} + +const VISIBLE_LINES = 14; + +interface VisibleLine { + node: TreeNode; + prefix: string; +} + +/** Flatten the expanded forest into display lines with box-drawing prefixes. */ +function visibleLines( + nodes: TreeNode[], + expanded: ReadonlySet, + ancestors = "", +): VisibleLine[] { + const out: VisibleLine[] = []; + nodes.forEach((node, index) => { + const last = index === nodes.length - 1; + const branch = node.depth === 0 ? "" : last ? "└─ " : "├─ "; + out.push({ node, prefix: ancestors + branch }); + if (node.children.length > 0 && expanded.has(node.path)) { + const carry = node.depth === 0 ? "" : last ? " " : "│ "; + out.push(...visibleLines(node.children, expanded, ancestors + carry)); + } + }); + return out; +} + +/** + * Tree lens (ink) — the expanded forest as box-drawing lines. ↑/↓ move the + * cursor, ←/→ collapse/expand the focused node, Enter/space writes the + * node's key value to `select_state` (same dispatch as Table). + */ +export function Tree({ + props: p, +}: ComponentCtx): React.ReactElement { + const actions = useActions(); + const selectable = Boolean(p.select_state); + const { isFocused } = useFocus({ autoFocus: selectable }); + const selected = useStateValue(p.select_state ?? "/__never__"); + const forest = useMemo(() => buildForest(p.rows, p.levels), [p.rows, p.levels]); + const [expanded, setExpanded] = useState>(() => { + const open = new Set(); + const walk = (nodes: TreeNode[]): void => { + for (const node of nodes) { + if ( + node.children.length > 0 && + (p.expand_depth === undefined || node.depth < p.expand_depth) + ) { + open.add(node.path); + } + walk(node.children); + } + }; + walk(forest); + return open; + }); + const [cursor, setCursor] = useState(0); + + const lines = visibleLines(forest, expanded); + const total = lines.length; + + useInput( + (input, key) => { + if (total === 0) return; + const line = lines[Math.min(cursor, total - 1)]; + if (key.upArrow) setCursor((c) => (c - 1 + total) % total); + else if (key.downArrow) setCursor((c) => (c + 1) % total); + else if (key.leftArrow && line) { + setExpanded((prev) => { + const next = new Set(prev); + next.delete(line.node.path); + return next; + }); + } else if (key.rightArrow && line && line.node.children.length > 0) { + setExpanded((prev) => new Set(prev).add(line.node.path)); + } else if ((key.return || input === " ") && line && selectable) { + actions.execute({ + action: "setState", + params: { statePath: p.select_state, value: line.node.key }, + }); + } + }, + { isActive: isFocused }, + ); + + if (total === 0) { + return {p.empty_text ?? "(no rows)"}; + } + + const cur = Math.min(cursor, total - 1); + const start = Math.max( + 0, + Math.min(cur - Math.floor(VISIBLE_LINES / 2), total - VISIBLE_LINES), + ); + const window = lines.slice(start, start + VISIBLE_LINES); + const showCounts = p.counts !== false; + + return ( + + {p.title && {p.title}} + {window.map((line, i) => { + const index = start + i; + const isCursor = isFocused && index === cur; + const isSelected = selectable && line.node.key === selected; + const hasChildren = line.node.children.length > 0; + const disclosure = hasChildren + ? expanded.has(line.node.path) + ? "▾ " + : "▸ " + : ""; + return ( + + + {isCursor ? "▶ " : isSelected ? "● " : " "} + {line.prefix} + {disclosure} + {line.node.label} + {showCounts && hasChildren && ( + ({line.node.children.length}) + )} + + + ); + })} + {total > VISIBLE_LINES && ( + + {" "} + {cur + 1}/{total} + + )} + {isFocused && ( + ↑/↓ move · ←/→ fold{selectable ? " · Enter select" : ""} + )} + + ); +} diff --git a/packages/tui/src/registry.ts b/packages/tui/src/registry.ts index a0ed818..d039e37 100644 --- a/packages/tui/src/registry.ts +++ b/packages/tui/src/registry.ts @@ -2,6 +2,7 @@ import { defineCatalog } from "@json-render/core"; import { schema, defineRegistry } from "@json-render/ink"; import { lensActions, lensComponents } from "@modernrelay/notebook-core"; import { Table } from "./components/Table.js"; +import { Tree } from "./components/Tree.js"; import { Path } from "./components/Path.js"; import { Subgraph } from "./components/Subgraph.js"; import { ActionList } from "./components/ActionList.js"; @@ -22,7 +23,8 @@ const catalog = defineCatalog(schema, { }); const { registry: inkRegistry } = defineRegistry(catalog, { - components: { Table, Path, Subgraph, ActionList, Form, Timeline, Card, Quote, Text, Button, Toggle, Select, TextInput, NumberInput }, + components: { Table, + Tree, Path, Subgraph, ActionList, Form, Timeline, Card, Quote, Text, Button, Toggle, Select, TextInput, NumberInput }, actions: { setState: async (params, setState) => { const { statePath, value } = params as { statePath: string; value: unknown }; diff --git a/packages/web/src/components/Tree.test.tsx b/packages/web/src/components/Tree.test.tsx new file mode 100644 index 0000000..aea9549 --- /dev/null +++ b/packages/web/src/components/Tree.test.tsx @@ -0,0 +1,127 @@ +// @vitest-environment happy-dom +// +// Tree lens through the REAL registry: forest grouping renders nested, +// disclosure collapses, node click writes select_state. +import { describe, it, expect, afterEach } from "vitest"; +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { JSONUIProvider, Renderer } from "@json-render/react"; +import { assembleLensSpec, type QueryResult } from "@modernrelay/notebook-core"; +import { webRegistry } from "../registry.js"; + +const ROWS = [ + { d: "sys", dn: "Systems", c: "loop", cn: "Feedback loops", r: "attr", rn: "Attractors" }, + { d: "sys", dn: "Systems", c: "loop", cn: "Feedback loops", r: "emer", rn: "Emergence" }, + { d: "sys", dn: "Systems", c: "chunk", cn: "Chunking", r: "attr", rn: "Attractors" }, + { d: "cog", dn: "Cognitive", c: "bias", cn: "Bias", r: "loop", rn: "Feedback loops" }, +]; + +const result = (rows: Record[]): QueryResult => ({ + query_name: "q", + target: "main", + row_count: rows.length, + columns: Object.keys(rows[0] ?? {}), + rows, +}); + +function treeSpec(extra: Record = {}) { + return assembleLensSpec( + "tree", + "Tree", + { + levels: [ + { key: "d", label: "dn" }, + { key: "c", label: "cn" }, + { key: "r", label: "rn" }, + ], + select_state: "/selected", + ...extra, + }, + result(ROWS), + ); +} + +function mount(spec: ReturnType): { host: HTMLDivElement; root: Root } { + const host = document.createElement("div"); + document.body.appendChild(host); + const root = createRoot(host); + act(() => { + root.render( + + + , + ); + }); + return { host, root }; +} + +afterEach(() => { + document.body.innerHTML = ""; +}); + +describe("Tree lens (web)", () => { + it("renders the grouped forest with counts", () => { + const { host, root } = mount(treeSpec()); + const text = host.textContent ?? ""; + expect(text).toContain("Systems"); + expect(text).toContain("Cognitive"); + expect(text).toContain("Feedback loops"); + expect(text).toContain("Attractors"); + // Systems has 2 concepts; Feedback loops has 2 related + const badges = Array.from(host.querySelectorAll("li > div > [class*='badge'], li > div > span[class*='tabular']")).map((b) => b.textContent); + expect(badges).toContain("2"); + act(() => root.unmount()); + }); + + it("disclosure collapses a branch", () => { + const { host, root } = mount(treeSpec()); + const collapseSystems = Array.from(host.querySelectorAll("button")).find( + (b) => b.getAttribute("aria-label") === "Collapse Systems", + )!; + expect(host.textContent).toContain("Feedback loops"); + act(() => { + collapseSystems.click(); + }); + // Systems' subtree hidden; Cognitive's still visible + expect(host.textContent).not.toContain("Chunking"); + expect(host.textContent).toContain("Bias"); + act(() => root.unmount()); + }); + + it("expand_depth: 0 starts fully collapsed", () => { + const { host, root } = mount(treeSpec({ expand_depth: 0 })); + expect(host.textContent).toContain("Systems"); + expect(host.textContent).not.toContain("Feedback loops"); + act(() => root.unmount()); + }); + + it("node click writes its key value to select_state (highlight follows)", () => { + const { host, root } = mount(treeSpec()); + const chunking = Array.from(host.querySelectorAll("span")).find( + (s) => s.textContent === "Chunking", + )!; + act(() => { + chunking.click(); + }); + // selection is the KEY value; the highlight class lands on the node + expect(chunking.className).toContain("bg-accent"); + // a different node with the same label elsewhere isn't highlighted + const systems = Array.from(host.querySelectorAll("span")).find( + (s) => s.textContent === "Systems", + )!; + expect(systems.className).not.toContain("bg-accent"); + act(() => root.unmount()); + }); + + it("shows empty_text for zero rows", () => { + const spec = assembleLensSpec( + "tree", + "Tree", + { levels: [{ key: "d" }, { key: "c" }], empty_text: "No paths." }, + result([]), + ); + const { host, root } = mount(spec); + expect(host.textContent).toContain("No paths."); + act(() => root.unmount()); + }); +}); diff --git a/packages/web/src/components/Tree.tsx b/packages/web/src/components/Tree.tsx new file mode 100644 index 0000000..b25c30f --- /dev/null +++ b/packages/web/src/components/Tree.tsx @@ -0,0 +1,130 @@ +import React, { useMemo, useState } from "react"; +import { useActions, useStateValue } from "@json-render/react"; +import { + buildForest, + type TreeNode, + type TreeRuntimeProps, +} from "@modernrelay/notebook-core"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; + +interface ComponentCtx

{ + props: P; +} + +/** + * Tree lens — a collapsible forest grouped from path-shaped rows (one row per + * root→leaf chain; `levels` names the column pairs). Clicking a node label + * writes THAT node's key value to `select_state` (Table's mechanism), so any + * level drives dependent cells. Disclosure state is per-node local UI state. + */ +export function Tree({ + props: p, +}: ComponentCtx): React.ReactElement { + const actions = useActions(); + const selected = useStateValue(p.select_state ?? "/__never__"); + const forest = useMemo( + () => buildForest(p.rows, p.levels), + [p.rows, p.levels], + ); + // Expanded node paths. Default: everything with children starts open; + // expand_depth N opens only nodes at depth < N (0 = fully collapsed). + const [expanded, setExpanded] = useState>(() => { + const open = new Set(); + const walk = (nodes: TreeNode[]): void => { + for (const node of nodes) { + if ( + node.children.length > 0 && + (p.expand_depth === undefined || node.depth < p.expand_depth) + ) { + open.add(node.path); + } + walk(node.children); + } + }; + walk(forest); + return open; + }); + const toggle = (path: string): void => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + }; + + if (forest.length === 0) { + return ( +

+ {p.empty_text ?? "(no rows)"} +

+ ); + } + + const selectable = Boolean(p.select_state); + const showCounts = p.counts !== false; + + const renderNode = (node: TreeNode): React.ReactElement => { + const hasChildren = node.children.length > 0; + const isOpen = expanded.has(node.path); + const isSelected = selectable && node.key === selected; + return ( +
  • +
    + {hasChildren ? ( + + ) : ( + + )} + + actions.execute({ + action: "setState", + params: { statePath: p.select_state, value: node.key }, + }), + } + : {})} + > + {node.label} + + {showCounts && hasChildren && ( + + {node.children.length} + + )} +
    + {hasChildren && isOpen && ( +
      + {node.children.map(renderNode)} +
    + )} +
  • + ); + }; + + return ( +
    + {p.title && ( +

    {p.title}

    + )} +
      {forest.map(renderNode)}
    +
    + ); +} diff --git a/packages/web/src/registry.ts b/packages/web/src/registry.ts index 6886340..c69cbd1 100644 --- a/packages/web/src/registry.ts +++ b/packages/web/src/registry.ts @@ -3,6 +3,7 @@ import { schema, defineRegistry } from "@json-render/react"; import { lensActions, lensComponents } from "@modernrelay/notebook-core"; import { setAtPointer } from "@modernrelay/notebook-core"; import { Table } from "./components/Table.js"; +import { Tree } from "./components/Tree.js"; import { Path } from "./components/Path.js"; import { Subgraph } from "./components/Subgraph.js"; import { ActionList } from "./components/ActionList.js"; @@ -25,7 +26,8 @@ const catalog = defineCatalog(schema, { // React's SetState is `(updater: (prev) => next) => void`. We use the // executor's setAtPointer helper to write at JSON-pointer paths immutably. const { registry: webRegistry } = defineRegistry(catalog, { - components: { Table, Path, Subgraph, ActionList, Form, Timeline, Card, Quote, Text, Button, Toggle, Select, TextInput, NumberInput }, + components: { Table, + Tree, Path, Subgraph, ActionList, Form, Timeline, Card, Quote, Text, Button, Toggle, Select, TextInput, NumberInput }, actions: { setState: async (params, setState) => { const { statePath, value } = params as { From cb5d0a9aeed166d74e4c32043e79078c07b6c6aa Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 8 Jul 2026 02:17:42 +0300 Subject: [PATCH 2/2] Tree disclosure = default policy + explicit user toggles (re-read safe) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile: both renderers materialized the expanded set ONCE in a useState initializer, so a state-driven query re-read that grew the forest left new branch paths missing from the set — default-open nodes rendered collapsed. Disclosure is now derived per node (expand_depth policy) overlaid with an explicit user-toggle map keyed by path: new paths get the default, the user's opens/closes survive re-reads. Regression test: re-read adds a domain (renders open) while a user's explicit collapse survives. Selection-by-key findings: intended — selection is ENTITY-level (the key value is what select_state consumers read; duplicate keys in the forest ARE the same graph entity), now stated in the lens description. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011TrhtF1SiJghJASMWECzBh --- packages/core/src/catalog/lenses/tree.ts | Bin 3815 -> 3931 bytes packages/tui/src/components/Tree.tsx | 44 ++++++---------- packages/web/src/components/Tree.test.tsx | 60 ++++++++++++++++++---- packages/web/src/components/Tree.tsx | 51 ++++++++---------- 4 files changed, 88 insertions(+), 67 deletions(-) diff --git a/packages/core/src/catalog/lenses/tree.ts b/packages/core/src/catalog/lenses/tree.ts index a35ccb3b2e8936e681ca981dc8cdcbe84f335459..a897c3f47b77c076bb28ba0abf03c3f9a6b736d6 100644 GIT binary patch delta 140 zcmaDZds}V;Ge^C`qlPI8#i==|$t9Wjc?y}u3a)-3o*|LCIjLo-ISLxtsg(+4i8-aI znmP)_`3gY6qDqDQ, + isOpen: (node: TreeNode) => boolean, ancestors = "", ): VisibleLine[] { const out: VisibleLine[] = []; @@ -29,9 +29,9 @@ function visibleLines( const last = index === nodes.length - 1; const branch = node.depth === 0 ? "" : last ? "└─ " : "├─ "; out.push({ node, prefix: ancestors + branch }); - if (node.children.length > 0 && expanded.has(node.path)) { + if (node.children.length > 0 && isOpen(node)) { const carry = node.depth === 0 ? "" : last ? " " : "│ "; - out.push(...visibleLines(node.children, expanded, ancestors + carry)); + out.push(...visibleLines(node.children, isOpen, ancestors + carry)); } }); return out; @@ -50,25 +50,19 @@ export function Tree({ const { isFocused } = useFocus({ autoFocus: selectable }); const selected = useStateValue(p.select_state ?? "/__never__"); const forest = useMemo(() => buildForest(p.rows, p.levels), [p.rows, p.levels]); - const [expanded, setExpanded] = useState>(() => { - const open = new Set(); - const walk = (nodes: TreeNode[]): void => { - for (const node of nodes) { - if ( - node.children.length > 0 && - (p.expand_depth === undefined || node.depth < p.expand_depth) - ) { - open.add(node.path); - } - walk(node.children); - } - }; - walk(forest); - return open; - }); + // Default policy + explicit user toggles (survives query re-reads; new + // paths from refreshed rows get the expand_depth default). + const defaultOpen = (node: TreeNode): boolean => + node.children.length > 0 && + (p.expand_depth === undefined || node.depth < p.expand_depth); + const [userToggled, setUserToggled] = useState>( + () => new Map(), + ); + const isOpen = (node: TreeNode): boolean => + userToggled.get(node.path) ?? defaultOpen(node); const [cursor, setCursor] = useState(0); - const lines = visibleLines(forest, expanded); + const lines = visibleLines(forest, isOpen); const total = lines.length; useInput( @@ -78,13 +72,9 @@ export function Tree({ if (key.upArrow) setCursor((c) => (c - 1 + total) % total); else if (key.downArrow) setCursor((c) => (c + 1) % total); else if (key.leftArrow && line) { - setExpanded((prev) => { - const next = new Set(prev); - next.delete(line.node.path); - return next; - }); + setUserToggled((prev) => new Map(prev).set(line.node.path, false)); } else if (key.rightArrow && line && line.node.children.length > 0) { - setExpanded((prev) => new Set(prev).add(line.node.path)); + setUserToggled((prev) => new Map(prev).set(line.node.path, true)); } else if ((key.return || input === " ") && line && selectable) { actions.execute({ action: "setState", @@ -116,7 +106,7 @@ export function Tree({ const isSelected = selectable && line.node.key === selected; const hasChildren = line.node.children.length > 0; const disclosure = hasChildren - ? expanded.has(line.node.path) + ? isOpen(line.node) ? "▾ " : "▸ " : ""; diff --git a/packages/web/src/components/Tree.test.tsx b/packages/web/src/components/Tree.test.tsx index aea9549..3f4c406 100644 --- a/packages/web/src/components/Tree.test.tsx +++ b/packages/web/src/components/Tree.test.tsx @@ -41,18 +41,25 @@ function treeSpec(extra: Record = {}) { ); } -function mount(spec: ReturnType): { host: HTMLDivElement; root: Root } { +function mount(spec: ReturnType): { + host: HTMLDivElement; + root: Root; + rerender: (s: ReturnType) => void; +} { const host = document.createElement("div"); document.body.appendChild(host); const root = createRoot(host); - act(() => { - root.render( - - - , - ); - }); - return { host, root }; + const render = (s: ReturnType): void => { + act(() => { + root.render( + + + , + ); + }); + }; + render(spec); + return { host, root, rerender: render }; } afterEach(() => { @@ -113,6 +120,41 @@ describe("Tree lens (web)", () => { act(() => root.unmount()); }); + it("re-read rows keep the default-open policy for NEW branches; user toggles survive", () => { + const { host, root, rerender } = mount(treeSpec()); + // user collapses Systems + act(() => { + Array.from(host.querySelectorAll("button")) + .find((b) => b.getAttribute("aria-label") === "Collapse Systems")! + .click(); + }); + expect(host.textContent).not.toContain("Chunking"); + // a background re-read adds a brand-new domain + const grown = assembleLensSpec( + "tree", + "Tree", + { + levels: [ + { key: "d", label: "dn" }, + { key: "c", label: "cn" }, + { key: "r", label: "rn" }, + ], + select_state: "/selected", + }, + result([ + ...ROWS, + { d: "phys", dn: "Physics", c: "entropy", cn: "Entropy", r: "attr", rn: "Attractors" }, + ]), + ); + rerender(grown); + // the NEW domain follows the default policy (open — no expand_depth set) + expect(host.textContent).toContain("Physics"); + expect(host.textContent).toContain("Entropy"); + // the user's explicit collapse of Systems SURVIVES the re-read + expect(host.textContent).not.toContain("Chunking"); + act(() => root.unmount()); + }); + it("shows empty_text for zero rows", () => { const spec = assembleLensSpec( "tree", diff --git a/packages/web/src/components/Tree.tsx b/packages/web/src/components/Tree.tsx index b25c30f..f6480e3 100644 --- a/packages/web/src/components/Tree.tsx +++ b/packages/web/src/components/Tree.tsx @@ -27,31 +27,20 @@ export function Tree({ () => buildForest(p.rows, p.levels), [p.rows, p.levels], ); - // Expanded node paths. Default: everything with children starts open; - // expand_depth N opens only nodes at depth < N (0 = fully collapsed). - const [expanded, setExpanded] = useState>(() => { - const open = new Set(); - const walk = (nodes: TreeNode[]): void => { - for (const node of nodes) { - if ( - node.children.length > 0 && - (p.expand_depth === undefined || node.depth < p.expand_depth) - ) { - open.add(node.path); - } - walk(node.children); - } - }; - walk(forest); - return open; - }); - const toggle = (path: string): void => { - setExpanded((prev) => { - const next = new Set(prev); - if (next.has(path)) next.delete(path); - else next.add(path); - return next; - }); + // Disclosure = default policy + explicit user toggles. Deriving the default + // per node (instead of materializing a set once) keeps re-read forests + // correct: new paths get the expand_depth default; the user's explicit + // opens/closes survive by path identity. + const defaultOpen = (node: TreeNode): boolean => + node.children.length > 0 && + (p.expand_depth === undefined || node.depth < p.expand_depth); + const [userToggled, setUserToggled] = useState>( + () => new Map(), + ); + const isOpen = (node: TreeNode): boolean => + userToggled.get(node.path) ?? defaultOpen(node); + const toggle = (node: TreeNode): void => { + setUserToggled((prev) => new Map(prev).set(node.path, !isOpen(node))); }; if (forest.length === 0) { @@ -67,7 +56,7 @@ export function Tree({ const renderNode = (node: TreeNode): React.ReactElement => { const hasChildren = node.children.length > 0; - const isOpen = expanded.has(node.path); + const open = isOpen(node); const isSelected = selectable && node.key === selected; return (
  • @@ -75,12 +64,12 @@ export function Tree({ {hasChildren ? ( ) : ( @@ -110,7 +99,7 @@ export function Tree({ )} - {hasChildren && isOpen && ( + {hasChildren && open && (
      {node.children.map(renderNode)}