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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user