graph: the MINIMAL home — the no-graph home, take two (release polish 2.2)

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.
This commit is contained in:
amosblomqvist
2026-10-06 09:47:26 +03:00
parent 83103062f5
commit 0cc108615b
6 changed files with 302 additions and 289 deletions
+4 -20
View File
@@ -114,7 +114,6 @@ export function ChainTableBody({
onClear, onClear,
onDelete, onDelete,
onClose, onClose,
topBarExtra,
selectMode, selectMode,
onSelect, onSelect,
}: { }: {
@@ -137,15 +136,7 @@ export function ChainTableBody({
onClear: () => void; onClear: () => void;
/** Opens the full-death confirm dialog (owner, 2026-09-29 night). */ /** Opens the full-death confirm dialog (owner, 2026-09-29 night). */
onDelete: () => void; onDelete: () => void;
/** Dismisses the overlay (top-bar X). ABSENT = the HOME variant onClose: () => void;
* (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;
/** PRACTICE SELECT (2026-10-04): the floating bar's confirm becomes /** PRACTICE SELECT (2026-10-04): the floating bar's confirm becomes
* "Practice these" — no delete fence; the caller owns what the * "Practice these" — no delete fence; the caller owns what the
* selection becomes. */ * selection becomes. */
@@ -251,17 +242,12 @@ export function ChainTableBody({
chat surface. NO TITLE (owner round 11, 2026-10-05): the serif chat surface. NO TITLE (owner round 11, 2026-10-05): the serif
title register is RESERVED — it always names a session; the title register is RESERVED — it always names a session; the
chain table names nothing, so its bar carries counts + search + chain table names nothing, so its bar carries counts + search +
close only — plus, in the home variant, the injected close only. Search + close carve out with lh-no-drag (the mask
topBarExtra (import + cost, owner 2026-10-06). Search, close, law: later in the DOM). */}
and the extra carve out with lh-no-drag (the mask law: later
in the DOM). */}
<TopBar> <TopBar>
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground"> <span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{chains.length} {chains.length === 1 ? "chain" : "chains"} · {nodes.length} {nodes.length === 1 ? "node" : "nodes"} {chains.length} {chains.length === 1 ? "chain" : "chains"} · {nodes.length} {nodes.length === 1 ? "node" : "nodes"}
</span> </span>
{topBarExtra && (
<div className="lh-no-drag flex shrink-0 items-center gap-2">{topBarExtra}</div>
)}
<div className="relative shrink-0"> <div className="relative shrink-0">
<SearchIcon className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" /> <SearchIcon className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<input <input
@@ -274,8 +260,7 @@ export function ChainTableBody({
className="lh-no-drag h-8 w-56 rounded-lg border bg-background pl-8 pr-3 text-xs outline-none placeholder:text-muted-foreground focus:border-ring" className="lh-no-drag h-8 w-56 rounded-lg border bg-background pl-8 pr-3 text-xs outline-none placeholder:text-muted-foreground focus:border-ring"
/> />
</div> </div>
{onClose && ( <button
<button
type="button" type="button"
data-testid="chain-table-close" data-testid="chain-table-close"
onClick={onClose} onClick={onClose}
@@ -284,7 +269,6 @@ export function ChainTableBody({
> >
<XIcon className="size-4" /> <XIcon className="size-4" />
</button> </button>
)}
</TopBar> </TopBar>
{/* The list: NO per-row lines (owner ruling) — the ONLY horizontal {/* The list: NO per-row lines (owner ruling) — the ONLY horizontal
-242
View File
@@ -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<string>;
onPracticeSelectionChange?: (nodes: { id: string; name: string; statement: string }[]) => void;
onChainSelectClose?: () => void;
}) {
const [snapshot, setSnapshot] = useState<GraphSnapshot | null>(null);
const [costs, setCosts] = useState<GraphCostSummary | null>(null);
const [importing, setImporting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(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<ChainSort>({ col: "name", dir: 1 });
const [internalSelection, setInternalSelection] = useState<ReadonlySet<string>>(() => new Set());
const [expandedClusters, setExpandedClusters] = useState<ReadonlySet<string>>(() => new Set());
const [expandedChains, setExpandedChains] = useState<ReadonlySet<string>>(() => new Set());
const [deleteOpen, setDeleteOpen] = useState(false);
const readGraph = useCallback(() => {
fetch("/api/graph")
.then((res) => res.json() as Promise<GraphSnapshot>)
.then(setSnapshot)
.catch(() => {});
fetch("/api/graph/costs")
.then((res) => res.json() as Promise<GraphCostSummary>)
.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<string>) => {
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<string>, id: string) => {
const s = new Set(set);
if (s.has(id)) s.delete(id);
else s.add(id);
return s;
};
return (
<div className={`relative flex h-full min-h-0 flex-col bg-background ${COMPOSER_CLEARANCE}`}>
<input
ref={fileInputRef}
type="file"
multiple
accept=".md,.markdown,.txt"
className="hidden"
onChange={(e) => {
onImportFiles(e.target.files);
e.target.value = ""; // allow re-picking the same file
}}
/>
{isEmpty ? (
<div className="flex flex-1 flex-col items-center justify-center gap-4">
<p className="text-sm text-muted-foreground">No chains yet.</p>
<Hint label="Import notes into the graph" side="bottom">
<button
type="button"
disabled={importing}
onClick={() => fileInputRef.current?.click()}
data-testid="import-notes"
className="flex h-8 items-center gap-2 rounded-lg border bg-card px-3 text-xs text-foreground shadow-sm transition-colors hover:bg-accent disabled:opacity-50"
>
<UploadIcon className="size-3.5" />
{importing ? "Importing…" : "Import notes…"}
</button>
</Hint>
</div>
) : (
<ChainTableBody
nodes={nodes}
chains={chains}
sessions={snapshot?.sessions ?? {}}
fileSources={snapshot?.fileSources ?? {}}
query={query}
onQuery={setQuery}
sort={sort}
onSort={(col) =>
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 && <GraphCostControl costs={costs} />}
<div className="shrink-0 rounded-xl border bg-card/85 px-1 py-[3px] shadow-lg backdrop-blur">
<Hint label="Import notes into the graph" side="bottom">
<button
type="button"
disabled={importing}
onClick={() => fileInputRef.current?.click()}
data-testid="import-notes"
className="flex h-7 items-center gap-1.5 rounded-lg px-2.5 text-xs text-muted-foreground transition-colors hover:bg-accent disabled:opacity-50"
>
<UploadIcon className="size-3" />
{importing ? "Importing…" : "Import notes…"}
</button>
</Hint>
</div>
</>
}
/>
)}
{deleteOpen && (
<DeleteNodeDialog
nodes={selectedNodes.map((n) => ({ 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();
}}
/>
)}
</div>
);
}
+6 -8
View File
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { CornerDownLeftIcon, DumbbellIcon } from "lucide-react"; import { CornerDownLeftIcon, DumbbellIcon } from "lucide-react";
import { GraphView } from "@/components/graph-view"; 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 { SetupDialog } from "@/components/setup-dialog";
import { useStrongKeyless, useZeroKeys, ZeroKeysPill } from "@/components/setup-cta"; import { useStrongKeyless, useZeroKeys, ZeroKeysPill } from "@/components/setup-cta";
import { DictationBar, joinDictation, useDictation } from "@/components/dictation"; 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 padding on any side; it runs from the sidebar to the right edge
and from the bottom to the top, under everything else. With and from the bottom to the top, under everything else. With
rendering OFF (graph.rendering, owner 2026-10-06) it is the rendering OFF (graph.rendering, owner 2026-10-06) it is the
CHAINS VIEW — the same table as the overlay, in flow; data, MINIMAL HOME — a quiet knowledge-base summary + the graph
extraction, and the pick flow are untouched. */} chrome's survivors; data, extraction, and the pick flow are
untouched, and the chain table stays behind its button. */}
<main className="absolute inset-0"> <main className="absolute inset-0">
{rendering ? ( {rendering ? (
<GraphView <GraphView
@@ -174,7 +175,7 @@ export function Home({
onChainSelectClose={() => setChainSelectOpen(false)} onChainSelectClose={() => setChainSelectOpen(false)}
/> />
) : ( ) : (
<ChainsHome <MinimalHome
chainSelectOpen={chainSelectOpen} chainSelectOpen={chainSelectOpen}
practiceSelection={pickedIds} practiceSelection={pickedIds}
onPracticeSelectionChange={setPracticeProps} onPracticeSelectionChange={setPracticeProps}
@@ -207,10 +208,7 @@ export function Home({
send consumes it). Practice mode with nothing picked send consumes it). Practice mode with nothing picked
shows the pick HINT so the affordance is discoverable; shows the pick HINT so the affordance is discoverable;
teaching mode with nothing picked renders nothing. */} teaching mode with nothing picked renders nothing. */}
{/* While PICKING in the chains home the chip row hides {(practiceProps.length > 0 || mode === "practice") && (
(owner, 2026-10-06): the floating bar above the composer
already carries count + Clear + Done — one voice, not two. */}
{(practiceProps.length > 0 || mode === "practice") && !(rendering === false && chainSelectOpen) && (
<div className="mb-2 flex items-center justify-start gap-1.5"> <div className="mb-2 flex items-center justify-start gap-1.5">
<button <button
type="button" type="button"
+239
View File
@@ -0,0 +1,239 @@
"use client";
/**
* THE MINIMAL HOME (owner, 2026-10-06 — the no-graph home, take two):
* when GRAPH RENDERING is off (graph.rendering in settings), the home's
* main view is this — deliberately quiet, deliberately minimal. The
* eventual plan is the DATA VIEW as home; until that is designed and
* built, this is the placeholder that asks for nothing new: the SAME
* composer blob home already floats (teach/practice switcher + pick
* pill — untouched), the graph chrome's survivors in the SAME places
* the graph view keeps them (top-right column: actions row, cost pill
* BELOW it), and a centered, quiet summary of the knowledge base in
* numbers. No recent sessions (the sidebar owns that), no canvas, no
* list scrolling under the composer.
*
* The CHAIN TABLE stays behind its button exactly as on the graph view:
* the standard overlay, unchanged — this component owns only the button.
* The practice pick flow mirrors GraphView's contract verbatim (home
* lifts the pick set; the chip opens the overlay in select mode).
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { ListTreeIcon, UploadIcon } from "lucide-react";
import { ChainTableOverlay } from "@/components/chain-table.tsx";
import { GraphCostControl } from "@/components/graph-view.tsx";
import { Hint } from "@/components/ui/hint";
import type { ChatSummary, GraphCostSummary, GraphSnapshot } from "@/src/core";
/** The quiet centered summary — a pure body (props in, markup out) so
* the render suite can pin it statically. Very quiet by owner ruling:
* muted small text, no cards, no chrome. */
export function KnowledgeSummary({
propositions,
chains,
teachSessions,
practiceSessions,
sources,
}: {
propositions: number;
chains: number;
teachSessions: number;
practiceSessions: number;
sources: string[];
}) {
const empty = propositions === 0 && chains === 0 && teachSessions === 0 && practiceSessions === 0 && sources.length === 0;
return (
<div data-testid="knowledge-summary" className="flex max-w-[36rem] flex-col items-center gap-3 text-center">
<p className="font-ui text-sm text-muted-foreground">
{propositions} {propositions === 1 ? "proposition" : "propositions"} · {chains}{" "}
{chains === 1 ? "chain" : "chains"}
</p>
<p className="font-ui text-sm text-muted-foreground">
{teachSessions} {teachSessions === 1 ? "teaching session" : "teaching sessions"} · {practiceSessions}{" "}
{practiceSessions === 1 ? "practice session" : "practice sessions"}
</p>
{empty ? (
<p className="pt-2 font-ui text-xs text-muted-foreground/70">
Nothing here yet — import notes to begin building the graph.
</p>
) : (
sources.length > 0 && (
<div className="flex w-full flex-col items-center gap-0.5 pt-3">
{sources.map((path) => (
<p key={path} className="w-full truncate font-ui text-xs text-muted-foreground/70" title={path}>
{path}
</p>
))}
</div>
)
)}
</div>
);
}
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<string>;
onPracticeSelectionChange?: (nodes: { id: string; name: string; statement: string }[]) => void;
onChainSelectClose?: () => void;
}) {
const [snapshot, setSnapshot] = useState<GraphSnapshot | null>(null);
const [costs, setCosts] = useState<GraphCostSummary | null>(null);
const [chats, setChats] = useState<ChatSummary[]>([]);
const [chainsOpen, setChainsOpen] = useState(false);
const [importing, setImporting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const readState = useCallback(() => {
fetch("/api/graph")
.then((res) => res.json() as Promise<GraphSnapshot>)
.then(setSnapshot)
.catch(() => {});
fetch("/api/graph/costs")
.then((res) => res.json() as Promise<GraphCostSummary>)
.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 (
<div className="relative h-full">
<input
ref={fileInputRef}
type="file"
multiple
accept=".md,.markdown,.txt"
className="hidden"
onChange={(e) => {
onImportFiles(e.target.files);
e.target.value = ""; // allow re-picking the same file
}}
/>
{/* The quiet summary — centered, biased up off the composer. */}
<div className="pointer-events-none flex h-full items-center justify-center pb-40">
<KnowledgeSummary
propositions={snapshot?.propositions.length ?? 0}
chains={snapshot?.chains.length ?? 0}
teachSessions={teachSessions}
practiceSessions={practiceSessions}
sources={Object.values(snapshot?.fileSources ?? {})}
/>
</div>
{/* 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). */}
<div className="pointer-events-none absolute right-2 top-2 z-10 flex max-w-[calc(100%-5.5rem)] flex-col items-end gap-2">
<div className="pointer-events-auto flex min-w-0 items-start gap-2">
<div className="graph-chrome-fade-in shrink-0 rounded-xl border bg-card/85 px-1 py-[3px] shadow-lg backdrop-blur">
<Hint label="Every reasoning chain" side="bottom">
<button
type="button"
onClick={() => setChainsOpen(true)}
data-testid="open-chain-table"
className="flex h-7 items-center gap-1.5 rounded-lg px-2.5 text-xs text-muted-foreground transition-colors hover:bg-accent"
>
<ListTreeIcon className="size-3" />
Chains
</button>
</Hint>
</div>
<div className="shrink-0 rounded-xl border bg-card/85 px-1 py-[3px] shadow-lg backdrop-blur">
<Hint label="Import notes into the graph" side="bottom">
<button
type="button"
disabled={importing}
onClick={() => fileInputRef.current?.click()}
data-testid="import-notes"
className="flex h-7 items-center gap-1.5 rounded-lg px-2.5 text-xs text-muted-foreground transition-colors hover:bg-accent disabled:opacity-50"
>
<UploadIcon className="size-3" />
{importing ? "Importing…" : "Import notes…"}
</button>
</Hint>
</div>
</div>
{costs && costs.runs.length > 0 && (
<div className="pointer-events-auto">
<GraphCostControl costs={costs} />
</div>
)}
</div>
{/* 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. */}
<ChainTableOverlay
open={chainsOpen && (snapshot?.propositions.length ?? 0) > 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);
}}
/>
</div>
);
}
-19
View File
@@ -82,31 +82,12 @@ const BASE = {
onGenerate: () => {}, onGenerate: () => {},
onDelete: () => {}, onDelete: () => {},
onClose: () => {}, onClose: () => {},
topBarExtra: undefined as React.ReactNode,
}; };
function render(props: Partial<typeof BASE> = {}) { function render(props: Partial<typeof BASE> = {}) {
return renderToStaticMarkup(React.createElement(ChainTableBody, { ...BASE, ...props })); 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)", () => { describe("ChainTableBody — clusters (the outermost layer)", () => {
it("collapsed by default: cluster rows render, chain rows do not", () => { it("collapsed by default: cluster rows render, chain rows do not", () => {
const html = render(); const html = render();
+53
View File
@@ -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<Parameters<typeof KnowledgeSummary>[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");
});
});