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 (
-
- ) : (
-
- 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");
+ });
+});