diff --git a/app/page.tsx b/app/page.tsx index 4e89922..80b0496 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,6 +1,6 @@ import { Home } from "@/components/home"; import { readSettingsRecord } from "@/src/server"; -import { slotsConfigured } from "@/src/core"; +import { resolveSettings, slotsConfigured } from "@/src/core"; // Dynamic: the configured state is server-rendered into the page (the // setup CTA's truth on the first frame), and every navigation re-reads @@ -12,7 +12,11 @@ export default function HomePage() { // has picked all three slots — the dock shows the CTA while the raw // record's models section lacks any slot pin. const record = readSettingsRecord(); + // GRAPH RENDERING (owner, 2026-10-06): off = the home's main view is + // the chains view. Resolved through the one core resolver (missing + // section / absent key → factory default on); re-read per navigation. + const rendering = resolveSettings(record ?? null).graph.rendering; return ( - + ); } diff --git a/components/chain-table.tsx b/components/chain-table.tsx index d1d7554..7ef8268 100644 --- a/components/chain-table.tsx +++ b/components/chain-table.tsx @@ -136,7 +136,10 @@ export function ChainTableBody({ onClear: () => void; /** Opens the full-death confirm dialog (owner, 2026-09-29 night). */ onDelete: () => void; - onClose: () => 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; /** PRACTICE SELECT (2026-10-04): the floating bar's confirm becomes * "Practice these" — no delete fence; the caller owns what the * selection becomes. */ @@ -260,6 +263,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" /> + {onClose && ( + )} {/* The list: NO per-row lines (owner ruling) — the ONLY horizontal diff --git a/components/chains-home.tsx b/components/chains-home.tsx new file mode 100644 index 0000000..9622646 --- /dev/null +++ b/components/chains-home.tsx @@ -0,0 +1,239 @@ +"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, floating top-right below the + * table's top bar. 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, floating top-right BELOW the table's top + bar (h-15 = 60px + breathing room — owner, 2026-10-06): Import + ALWAYS — the entry point; the cost pill when any run exists. Node + search + the render gear are canvas things. */} + {!isEmpty && ( +
+ {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/graph-view.tsx b/components/graph-view.tsx index 88d0f26..b414b86 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -325,7 +325,9 @@ export function displayCostTotals(costs: Pick): { * pattern as the settings gear: outside click or Escape closes. The * panel is viewport-capped + scrollable like the coverage dropdown. */ -function GraphCostControl({ costs }: { costs: GraphCostSummary }) { +/** Exported for the chains home (graph rendering off, owner + * 2026-10-06): the same bare cumulative-dollars pill + panel. */ +export function GraphCostControl({ costs }: { costs: GraphCostSummary }) { const [open, setOpen] = useState(false); // Click anywhere outside (or Escape) closes the panel — the root // wraps button + panel, so the toggle itself keeps working. diff --git a/components/home.tsx b/components/home.tsx index de566d1..737516f 100644 --- a/components/home.tsx +++ b/components/home.tsx @@ -4,6 +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 { SetupDialog } from "@/components/setup-dialog"; import { useStrongKeyless, useZeroKeys, ZeroKeysPill } from "@/components/setup-cta"; import { DictationBar, joinDictation, useDictation } from "@/components/dictation"; @@ -17,7 +18,16 @@ import type { TurnUsage } from "@/src/core"; * under the header and the new-session dock, both of which float over it * with no gradient scrims. Sessions live in the toggleable sidebar. */ -export function Home({ initialConfigured }: { initialConfigured: boolean }) { +export function Home({ + initialConfigured, + rendering = true, +}: { + initialConfigured: boolean; + /** GRAPH RENDERING off (owner, 2026-10-06): the home's main view is + * the chains view, not the canvas. Read server-side per navigation — + * changing the setting applies on the next visit home. */ + rendering?: boolean; +}) { const router = useRouter(); const [topic, setTopic] = useState(""); /* The topic MIRROR ("nothing gets lost" round, 2026-10-01 — same @@ -149,16 +159,28 @@ export function Home({ initialConfigured }: { initialConfigured: boolean }) { never needs to receive events — the mask routes the pixels. */}