From 0cc108615b8cb1a9be3b5108162afd2f8739a54f Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 6 Oct 2026 09:47:26 +0300 Subject: [PATCH] =?UTF-8?q?graph:=20the=20MINIMAL=20home=20=E2=80=94=20the?= =?UTF-8?q?=20no-graph=20home,=20take=20two=20(release=20polish=202.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chains-as-home experiment is retired (owner, 2026-10-06): dragging the table out of its overlay home caused every mess we saw — checkboxes by default, the bar/pill collision, the clearance band. The eventual plan is the DATA VIEW as home; until that is designed and built, the rendering-off home is deliberately minimal: - The SAME composer blob home already floats — teach/practice switcher and pick pill untouched. - The graph chrome's survivors in the graph view's OWN places: the top-right column — actions row (Chains + Import, the same 36px band pills) with the cost pill BELOW it. Import always renders (the entry-point law). No node search, no render gear (canvas things). - A quiet centered summary (KnowledgeSummary, a pure body — pinned by the render suite): propositions · chains, teaching · practice session counts, the imported-sources list as truncated muted rows; the empty knowledge base says so and names the import entry point. Biased up off the composer (pb-40), pointer-events-none. - NO recent sessions (the sidebar owns that, owner ruling). The chain table returns to overlay-only duty: ChainTableBody's onClose-optional and topBarExtra experiment hooks are reverted, and components/chains-home.tsx is deleted. MinimalHome renders the STANDARD ChainTableOverlay behind its Chains button — the pick flow mirrors GraphView's contract verbatim (chainSelectOpen raises the overlay in select mode; Done reports the lifted pick set down). Smoke-verified live, twice: the summary shows real counts (262 props · 160 chains, 195 teaching · 17 practice, 4 source paths); overlay open/close via button + X; pick flow — cluster check → bar "23 nodes → 17 chains · Clear · Done" → uncheck 2 props → Done → chip "21 propositions selected"; practice session starts from the composer; settings restore returns the canvas home. (First run's "select mode missing" was a tester artifact: the bar is selection-gated and prop checkboxes render only when expanded — both confirmed working.) 1417 tests (new KnowledgeSummary pins); tsc, lint, build green. --- components/chain-table.tsx | 24 +-- components/chains-home.tsx | 242 ----------------------------- components/home.tsx | 14 +- components/minimal-home.tsx | 239 ++++++++++++++++++++++++++++ tests/render-chain-table.test.tsx | 19 --- tests/render-minimal-home.test.tsx | 53 +++++++ 6 files changed, 302 insertions(+), 289 deletions(-) delete mode 100644 components/chains-home.tsx create mode 100644 components/minimal-home.tsx create mode 100644 tests/render-minimal-home.test.tsx diff --git a/components/chain-table.tsx b/components/chain-table.tsx index c79c171..678c397 100644 --- a/components/chain-table.tsx +++ b/components/chain-table.tsx @@ -114,7 +114,6 @@ export function ChainTableBody({ onClear, onDelete, onClose, - topBarExtra, selectMode, onSelect, }: { @@ -137,15 +136,7 @@ export function ChainTableBody({ onClear: () => void; /** Opens the full-death confirm dialog (owner, 2026-09-29 night). */ onDelete: () => void; - /** Dismisses the overlay (top-bar X). ABSENT = the HOME variant - * (graph rendering off, owner 2026-10-06): the table is the page, - * there is nothing to close, and the X does not render. */ - onClose?: () => void; - /** HOME VARIANT (owner, 2026-10-06 polish): extra controls injected - * into the shared TopBar, right cluster, LEFT of the search input — - * the chains home's Import-notes + cost pill live here instead of - * floating over the list (covering table content read badly). */ - topBarExtra?: React.ReactNode; + onClose: () => void; /** PRACTICE SELECT (2026-10-04): the floating bar's confirm becomes * "Practice these" — no delete fence; the caller owns what the * selection becomes. */ @@ -251,17 +242,12 @@ export function ChainTableBody({ chat surface. NO TITLE (owner round 11, 2026-10-05): the serif title register is RESERVED — it always names a session; the chain table names nothing, so its bar carries counts + search + - close only — plus, in the home variant, the injected - topBarExtra (import + cost, owner 2026-10-06). Search, close, - and the extra carve out with lh-no-drag (the mask law: later - in the DOM). */} + close only. Search + close carve out with lh-no-drag (the mask + law: later in the DOM). */} {chains.length} {chains.length === 1 ? "chain" : "chains"} · {nodes.length} {nodes.length === 1 ? "node" : "nodes"} - {topBarExtra && ( -
{topBarExtra}
- )}
- {onClose && ( - - )}
{/* The list: NO per-row lines (owner ruling) — the ONLY horizontal diff --git a/components/chains-home.tsx b/components/chains-home.tsx deleted file mode 100644 index 110e7c2..0000000 --- a/components/chains-home.tsx +++ /dev/null @@ -1,242 +0,0 @@ -"use client"; - -/** - * THE CHAINS HOME (owner, 2026-10-06 — the release graph-controls round): - * when GRAPH RENDERING is off (graph.rendering in settings), the home's - * main view is NOT the canvas — it is the chain table, in flow, as the - * page. Same body as the overlay (ChainTableBody), same powers (query + - * sort, selection, the delete fence), but there is nothing to close so - * the top-bar X does not render (onClose absent = home variant). - * - * The composer blob persists over everything exactly as it floats over - * the canvas (home owns it); this view keeps clear of it with a fixed - * bottom band (pb-40 ≈ the composer column's height — chip row + form + - * stats + dock padding), so the table scrolls CLEAR of the composer - * instead of under it: a text table under a floating box is noise - * (owner-approved wording: "bottom clearance so content scrolls clear"). - * - * The graph chrome's survivors: Import notes (ALWAYS — the entry point, - * the empty-graph law) and the cost pill, riding THE TOP BAR's right - * cluster (owner refinement, 2026-10-06 — floating over the list covered - * table content). Node search is canvas-scoped and the render-settings - * gear is canvas rendering — neither applies; the table's own search row - * covers query. - * - * The PRACTICE PICK flow mirrors the overlay's contract exactly (the - * caller — home.tsx — lifts the pick set; selectMode swaps the floating - * bar's confirm to Done). In this home the chip's empty label drops the - * "from the map" (there is no map); while picking, home hides the chip - * row so the bar (which carries count + Clear + Done) is the one voice. - */ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { UploadIcon } from "lucide-react"; -import { ChainTableBody, type ChainSort } from "@/components/chain-table.tsx"; -import { DeleteNodeDialog } from "@/components/delete-node-dialog.tsx"; -import { GraphCostControl } from "@/components/graph-view.tsx"; -import { Hint } from "@/components/ui/hint"; -import type { GraphCostSummary, GraphNode, GraphSnapshot } from "@/src/core"; - -/** The fixed bottom clearance ≈ the composer column at its tallest - * (chip row + form + stats + the dock's own pb-5). The canvas home lets - * content run under the blob; a text table scrolls clear instead. */ -const COMPOSER_CLEARANCE = "pb-40"; - -export function ChainsHome({ - chainSelectOpen = false, - practiceSelection, - onPracticeSelectionChange, - onChainSelectClose, -}: { - /** The lifted pick set drives selectMode: Done (not the delete fence) - * confirms, and the selection is caller-owned (the chip reflects it). */ - chainSelectOpen?: boolean; - practiceSelection?: ReadonlySet; - onPracticeSelectionChange?: (nodes: { id: string; name: string; statement: string }[]) => void; - onChainSelectClose?: () => void; -}) { - const [snapshot, setSnapshot] = useState(null); - const [costs, setCosts] = useState(null); - const [importing, setImporting] = useState(false); - const fileInputRef = useRef(null); - - // Table state — the overlay's own shape (query/sort/expand/selection - // + the delete dialog), minus Esc-to-dismiss: this is the page. - const [query, setQuery] = useState(""); - const [sort, setSort] = useState({ col: "name", dir: 1 }); - const [internalSelection, setInternalSelection] = useState>(() => new Set()); - const [expandedClusters, setExpandedClusters] = useState>(() => new Set()); - const [expandedChains, setExpandedChains] = useState>(() => new Set()); - const [deleteOpen, setDeleteOpen] = useState(false); - - const readGraph = useCallback(() => { - fetch("/api/graph") - .then((res) => res.json() as Promise) - .then(setSnapshot) - .catch(() => {}); - fetch("/api/graph/costs") - .then((res) => res.json() as Promise) - .then(setCosts) - .catch(() => {}); - }, []); - useEffect(() => { - readGraph(); - }, [readGraph]); - - // Notes import — the same front door as the graph home: POST awaits - // the whole pipeline server-side, then one refetch lands the merged - // graph. The button carries the busy state for the duration. - const onImportFiles = (files: FileList | null) => { - if (!files || files.length === 0) return; - setImporting(true); - const form = new FormData(); - for (const file of Array.from(files)) form.append("files", file); - fetch("/api/graph/import", { method: "POST", body: form }) - .then(async (res) => { - const body = (await res.json()) as { results: { filePath: string; ok: boolean }[] }; - const failed = body.results.filter((r) => !r.ok); - if (failed.length > 0) console.error("notes import failed for:", failed); - }) - .catch((err) => console.error("import failed:", err)) - .finally(() => { - setImporting(false); - readGraph(); - }); - }; - - // Controlled when the caller lifts the selection (the practice pick - // set — home always lifts, same law as the overlay on the graph home); - // internal otherwise. The lifted value is RESOLVED nodes (ids → - // {id, name, statement}) against the live snapshot — the caller holds - // the pick set (count + seed), never raw ids. - const selection = practiceSelection ?? internalSelection; - const nodes = snapshot?.propositions ?? []; - const chains = snapshot?.chains ?? []; - const byId = useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]); - const applySelection = (next: ReadonlySet) => { - if (onPracticeSelectionChange) { - const picked: { id: string; name: string; statement: string }[] = []; - for (const id of next) { - const node = byId.get(id); - if (node) picked.push({ id: node.id, name: node.name, statement: node.statement }); - } - onPracticeSelectionChange(picked); - } else setInternalSelection(next); - }; - const selectedNodes = [...selection].map((id) => byId.get(id)).filter((n): n is GraphNode => n !== undefined); - const isEmpty = snapshot !== null && nodes.length === 0; - - const toggleNodes = (ids: string[], next: boolean) => { - const s = new Set(selection); - for (const id of ids) { - if (next) s.add(id); - else s.delete(id); - } - applySelection(s); - }; - const toggleIn = (set: ReadonlySet, id: string) => { - const s = new Set(set); - if (s.has(id)) s.delete(id); - else s.add(id); - return s; - }; - - return ( -
- { - onImportFiles(e.target.files); - e.target.value = ""; // allow re-picking the same file - }} - /> - {isEmpty ? ( -
-

No chains yet.

- - - -
- ) : ( - - setSort((prev) => - prev.col === col ? { col, dir: prev.dir === 1 ? -1 : 1 } : { col, dir: col === "name" ? 1 : -1 }, - ) - } - selection={selection} - expandedClusters={expandedClusters} - expandedChains={expandedChains} - onToggleCluster={(key) => setExpandedClusters((prev) => toggleIn(prev, key))} - onToggleChain={(id) => setExpandedChains((prev) => toggleIn(prev, id))} - onToggleNodes={toggleNodes} - onClear={() => applySelection(new Set())} - onDelete={() => setDeleteOpen(true)} - // onClose ABSENT — the home variant: no top-bar X. - selectMode={chainSelectOpen} - onSelect={onChainSelectClose} - // The chrome survivors ride THE TOP BAR (owner refinement, - // 2026-10-06): floating over the list covered table content — - // the bar's right cluster (left of search) is their home now, - // consistent with the graph view where they sit in the top - // band. Import ALWAYS (the entry-point law); the cost pill - // when any run exists. Node search + the render gear stay - // canvas things. - topBarExtra={ - <> - {costs && costs.runs.length > 0 && } -
- - - -
- - } - /> - )} - - {deleteOpen && ( - ({ id: n.id, name: n.name }))} - onClose={() => setDeleteOpen(false)} - onDeleted={(deadIds) => { - // Prune the dead ids (the rest of a big selection - // survives), then refetch — fresh rows flow back in. - setDeleteOpen(false); - const dead = new Set(deadIds); - applySelection(new Set([...selection].filter((id) => !dead.has(id)))); - readGraph(); - }} - /> - )} -
- ); -} diff --git a/components/home.tsx b/components/home.tsx index 737516f..2a574a1 100644 --- a/components/home.tsx +++ b/components/home.tsx @@ -4,7 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { CornerDownLeftIcon, DumbbellIcon } from "lucide-react"; import { GraphView } from "@/components/graph-view"; -import { ChainsHome } from "@/components/chains-home"; +import { MinimalHome } from "@/components/minimal-home"; import { SetupDialog } from "@/components/setup-dialog"; import { useStrongKeyless, useZeroKeys, ZeroKeysPill } from "@/components/setup-cta"; import { DictationBar, joinDictation, useDictation } from "@/components/dictation"; @@ -163,8 +163,9 @@ export function Home({ padding on any side; it runs from the sidebar to the right edge and from the bottom to the top, under everything else. With rendering OFF (graph.rendering, owner 2026-10-06) it is the - CHAINS VIEW — the same table as the overlay, in flow; data, - extraction, and the pick flow are untouched. */} + MINIMAL HOME — a quiet knowledge-base summary + the graph + chrome's survivors; data, extraction, and the pick flow are + untouched, and the chain table stays behind its button. */}
{rendering ? ( setChainSelectOpen(false)} /> ) : ( - 0 || mode === "practice") && !(rendering === false && chainSelectOpen) && ( + {(practiceProps.length > 0 || mode === "practice") && (
+ ); +} + +export function MinimalHome({ + chainSelectOpen = false, + practiceSelection, + onPracticeSelectionChange, + onChainSelectClose, +}: { + /** The lifted pick set drives the overlay's select mode (Done instead + * of the delete fence); the chip in home's composer reflects it. */ + chainSelectOpen?: boolean; + practiceSelection?: ReadonlySet; + onPracticeSelectionChange?: (nodes: { id: string; name: string; statement: string }[]) => void; + onChainSelectClose?: () => void; +}) { + const [snapshot, setSnapshot] = useState(null); + const [costs, setCosts] = useState(null); + const [chats, setChats] = useState([]); + const [chainsOpen, setChainsOpen] = useState(false); + const [importing, setImporting] = useState(false); + const fileInputRef = useRef(null); + + const readState = useCallback(() => { + fetch("/api/graph") + .then((res) => res.json() as Promise) + .then(setSnapshot) + .catch(() => {}); + fetch("/api/graph/costs") + .then((res) => res.json() as Promise) + .then(setCosts) + .catch(() => {}); + fetch("/api/chats") + .then((res) => res.json() as Promise<{ chats: ChatSummary[] }>) + .then((body) => setChats(body.chats)) + .catch(() => {}); + }, []); + useEffect(() => { + readState(); + }, [readState]); + + // The practice chip opens the table in select mode (GraphView's law): + // the pick intent raises the overlay; Done/close reports back down. + useEffect(() => { + if (chainSelectOpen) setChainsOpen(true); + }, [chainSelectOpen]); + + // Notes import — the same front door as the graph home: POST awaits + // the whole pipeline server-side, then one refetch lands the numbers. + const onImportFiles = (files: FileList | null) => { + if (!files || files.length === 0) return; + setImporting(true); + const form = new FormData(); + for (const file of Array.from(files)) form.append("files", file); + fetch("/api/graph/import", { method: "POST", body: form }) + .then(async (res) => { + const body = (await res.json()) as { results: { filePath: string; ok: boolean }[] }; + const failed = body.results.filter((r) => !r.ok); + if (failed.length > 0) console.error("notes import failed for:", failed); + }) + .catch((err) => console.error("import failed:", err)) + .finally(() => { + setImporting(false); + readState(); + }); + }; + + const teachSessions = chats.filter((c) => c.kind === "teach").length; + const practiceSessions = chats.filter((c) => c.kind === "practice").length; + + return ( +
+ { + onImportFiles(e.target.files); + e.target.value = ""; // allow re-picking the same file + }} + /> + + {/* The quiet summary — centered, biased up off the composer. */} +
+ +
+ + {/* The chrome survivors in the graph view's own places (owner, + 2026-10-06): the top-right column — actions row (Chains + + Import, the same 36px band pills), the cost pill BELOW it. + Node search and the render-settings gear are canvas things + and stay gone; Import ALWAYS renders (the entry-point law). */} +
+
+
+ + + +
+
+ + + +
+
+ {costs && costs.runs.length > 0 && ( +
+ +
+ )} +
+ + {/* The chain table — the STANDARD overlay, unchanged, behind its + button. LAST CHILD OF THE ROOT, DELIBERATELY (the graph view's + DOM-order drag law): app-region subtraction resolves by DOM + order, so a full-bleed surface covering the drag band must + paint after everything it covers. */} + 0} + nodes={snapshot?.propositions ?? []} + chains={snapshot?.chains ?? []} + sessions={snapshot?.sessions ?? {}} + fileSources={snapshot?.fileSources ?? {}} + onClose={() => { + setChainsOpen(false); + onChainSelectClose?.(); + }} + selectMode={chainSelectOpen} + controlledSelection={practiceSelection} + onSelectionChange={(ids) => { + // Resolve ids → nodes against the live snapshot; the + // caller holds the pick set (count + seed). + const props = snapshot?.propositions ?? []; + const picked: { id: string; name: string; statement: string }[] = []; + for (const id of ids) { + const n = props.find((node) => node.id === id); + if (n) picked.push({ id: n.id, name: n.name, statement: n.statement }); + } + onPracticeSelectionChange?.(picked); + }} + /> +
+ ); +} diff --git a/tests/render-chain-table.test.tsx b/tests/render-chain-table.test.tsx index a34014c..60ad1aa 100644 --- a/tests/render-chain-table.test.tsx +++ b/tests/render-chain-table.test.tsx @@ -82,31 +82,12 @@ const BASE = { onGenerate: () => {}, onDelete: () => {}, onClose: () => {}, - topBarExtra: undefined as React.ReactNode, }; function render(props: Partial = {}) { return renderToStaticMarkup(React.createElement(ChainTableBody, { ...BASE, ...props })); } -describe("ChainTableBody — the HOME variant (graph rendering off, 2026-10-06)", () => { - it("onClose ABSENT = the page, not the overlay: no close X, everything else intact", () => { - const html = render({ onClose: undefined }); - assert.ok(!html.includes('data-testid="chain-table-close"'), "nothing to close — the table IS the page"); - assert.ok(html.includes('data-testid="chain-table-search"'), "the search row stays"); - assert.ok(html.includes("chain"), "the counts stay"); - assert.ok(html.includes("lh-drag"), "the window-drag band stays (same bar as every surface)"); - }); - - it("topBarExtra injects into the bar's right cluster — import + cost ride the TOP BAR (owner, 2026-10-06)", () => { - const html = render({ onClose: undefined, topBarExtra: React.createElement("b", { "data-testid": "tbx" }, "X") }); - assert.ok(html.includes('data-testid="tbx"'), "the extra renders inside the bar"); - assert.ok(html.includes("lh-no-drag"), "the cluster carves out of the drag band"); - const overlay = render(); - assert.ok(!overlay.includes('data-testid="tbx"'), "absent by default — the overlay variant injects nothing"); - }); -}); - describe("ChainTableBody — clusters (the outermost layer)", () => { it("collapsed by default: cluster rows render, chain rows do not", () => { const html = render(); diff --git a/tests/render-minimal-home.test.tsx b/tests/render-minimal-home.test.tsx new file mode 100644 index 0000000..810581a --- /dev/null +++ b/tests/render-minimal-home.test.tsx @@ -0,0 +1,53 @@ +/** The minimal home's KnowledgeSummary — the quiet centered summary of + * the knowledge base (owner, 2026-10-06: "very quiet, very minimal"). + * Pure body: props in, markup out. */ +import assert from "node:assert/strict"; +import { renderToStaticMarkup } from "react-dom/server"; +import React from "react"; +import { describe, it } from "node:test"; +import { KnowledgeSummary } from "../components/minimal-home.tsx"; + +function render(props: Partial[0]> = {}) { + return renderToStaticMarkup( + React.createElement(KnowledgeSummary, { + propositions: 12, + chains: 5, + teachSessions: 3, + practiceSessions: 2, + sources: [], + ...props, + }), + ); +} + +describe("KnowledgeSummary — the quiet home summary (2026-10-06)", () => { + it("renders the knowledge base in numbers: propositions · chains, teaching · practice sessions", () => { + const html = render(); + assert.ok(html.includes("12 propositions"), "propositions count"); + assert.ok(html.includes("5 chains"), "chains count"); + assert.ok(html.includes("3 teaching sessions"), "teaching count (plural)"); + assert.ok(html.includes("2 practice sessions"), "practice count (plural)"); + assert.ok(!html.includes("import notes"), "no empty-state hint when there is content"); + }); + + it("singularizes: one of each reads as single, not '1 sessions'", () => { + const html = render({ propositions: 1, chains: 1, teachSessions: 1, practiceSessions: 1 }); + assert.ok(html.includes("1 proposition ·")); + assert.ok(html.includes("1 chain")); + assert.ok(html.includes("1 teaching session")); + assert.ok(html.includes("1 practice session")); + }); + + it("lists the imported sources — quiet truncated rows (owner: 'a list of sources that have been imported')", () => { + const html = render({ sources: ["/notes/databases.md", "/notes/laravel.md"] }); + assert.ok(html.includes("/notes/databases.md")); + assert.ok(html.includes("/notes/laravel.md")); + assert.ok(html.includes('class="w-full truncate'), "each row truncates — long paths never blow the column"); + }); + + it("the empty knowledge base says so, quietly — import is the entry point", () => { + const html = render({ propositions: 0, chains: 0, teachSessions: 0, practiceSessions: 0 }); + assert.ok(html.includes("Nothing here yet")); + assert.ok(html.includes("import notes"), "the hint names the entry point"); + }); +});