graph: the rendering switch — chains view as the home (release polish 2)
The settings Graph section splits into two switches (owner, 2026-10-06): - EXTRACTION (was the lone "Graph" row): the DATA channel — pipeline, auditor, prober graph tools. Key unchanged (graph.enabled), label now names its job; off = the graph stops growing, nothing is deleted. - GRAPH RENDERING (new, graph.rendering, default on): display-only. Off = the home's main view is the CHAINS VIEW instead of the canvas; data, reasoning-chain model, and the pipeline keep working untouched. The chains home (components/chains-home.tsx): ChainTableBody in flow — same body as the overlay, same powers (query + sort, selection, the delete fence with its confirm dialog), but onClose is absent so the top-bar X does not render (the table IS the page; ChainTableBody now takes onClose optionally). The chrome survivors float top-right below the top bar: Import notes (ALWAYS — the entry-point law) + the cost pill (GraphCostControl, now exported). The composer blob persists over everything; the table scrolls CLEAR of it via a fixed pb-40 band (text under a floating box is noise — unlike the canvas, which runs under). Pick flow mirrors the overlay contract exactly (home lifts the pick set; selectMode swaps the bar's confirm to Done). The chip relabels to "Pick propositions" (no "from the map" — there is no map); while picking, the chip row hides so the floating bar above the composer is the one voice. Settings resolve rendering through the one core resolver + renderingEnabled(); read server-side per navigation, so the switch applies on the next visit home. Smoke-verified live (headless browser): chains home renders without the X, import + search + composer clearance confirmed, pick flow checked 2 propositions -> Done -> chip count -> practice session started with the graph context carried (2 props, 2 chains), settings restored. Known follow-ups (flagged, not built): after Done the bar morphs to the table's Clear/Delete management bar while the chip also shows the count (two voices, same set — the table's own editor law); /chat/new's pick chip still says "from the map". Tests: rendering in factory/sanitize/parse/resolve pins, the GraphSection render pins (Extraction label + rendering row), the home-variant chain-table pin (no close X). 1414 pass; tsc, lint, build green.
This commit is contained in:
@@ -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"
|
||||
/>
|
||||
</div>
|
||||
{onClose && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="chain-table-close"
|
||||
@@ -269,6 +273,7 @@ export function ChainTableBody({
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</TopBar>
|
||||
|
||||
{/* The list: NO per-row lines (owner ruling) — the ONLY horizontal
|
||||
|
||||
@@ -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<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, 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 && (
|
||||
<div className="absolute right-3 top-[4.5rem] z-20 flex items-center gap-2">
|
||||
{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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -325,7 +325,9 @@ export function displayCostTotals(costs: Pick<GraphCostSummary, "stages">): {
|
||||
* 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.
|
||||
|
||||
+39
-12
@@ -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. */}
|
||||
<div className="lh-drag absolute inset-x-0 top-0 h-13" aria-hidden="true" />
|
||||
|
||||
{/* Graph — the main view, full-bleed (owner, graph-view round 1): no
|
||||
padding on any side; the canvas runs from the sidebar to the right
|
||||
edge and from the bottom to the top, under everything else. */}
|
||||
{/* The main view — full-bleed (owner, graph-view round 1): no
|
||||
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. */}
|
||||
<main className="absolute inset-0">
|
||||
<GraphView
|
||||
chainSelectOpen={chainSelectOpen}
|
||||
practiceSelection={pickedIds}
|
||||
onPracticeSelectionChange={setPracticeProps}
|
||||
onChainSelectClose={() => setChainSelectOpen(false)}
|
||||
/>
|
||||
{rendering ? (
|
||||
<GraphView
|
||||
chainSelectOpen={chainSelectOpen}
|
||||
practiceSelection={pickedIds}
|
||||
onPracticeSelectionChange={setPracticeProps}
|
||||
onChainSelectClose={() => setChainSelectOpen(false)}
|
||||
/>
|
||||
) : (
|
||||
<ChainsHome
|
||||
chainSelectOpen={chainSelectOpen}
|
||||
practiceSelection={pickedIds}
|
||||
onPracticeSelectionChange={setPracticeProps}
|
||||
onChainSelectClose={() => setChainSelectOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Header floats over the graph — pointer-events-none except the
|
||||
@@ -185,7 +207,10 @@ export function Home({ initialConfigured }: { initialConfigured: boolean }) {
|
||||
send consumes it). Practice mode with nothing picked
|
||||
shows the pick HINT so the affordance is discoverable;
|
||||
teaching mode with nothing picked renders nothing. */}
|
||||
{(practiceProps.length > 0 || mode === "practice") && (
|
||||
{/* While PICKING in the chains home the chip row hides
|
||||
(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">
|
||||
<button
|
||||
type="button"
|
||||
@@ -201,7 +226,9 @@ export function Home({ initialConfigured }: { initialConfigured: boolean }) {
|
||||
<DumbbellIcon aria-hidden="true" className="size-3.5 text-muted-foreground" />
|
||||
{practiceProps.length > 0
|
||||
? `${practiceProps.length} ${practiceProps.length === 1 ? "proposition" : "propositions"} selected`
|
||||
: "Pick propositions from the map"}
|
||||
: rendering
|
||||
? "Pick propositions from the map"
|
||||
: "Pick propositions"}
|
||||
</button>
|
||||
{practiceProps.length > 0 && (
|
||||
<button
|
||||
|
||||
@@ -364,10 +364,16 @@ export function GraphSection({
|
||||
const resolved = resolveSettings(graph === undefined ? null : { version: 1, graph }).graph;
|
||||
return (
|
||||
<>
|
||||
<SettingsRow label="Graph" testid="graph-row-enabled">
|
||||
{/* EXTRACTION (renamed, owner 2026-10-06): this switch is the DATA
|
||||
channel — the pipeline, the auditor, the prober's graph tools. It
|
||||
was born labeled "Graph" and that label lied once the rendering
|
||||
switch below arrived: off ≠ no graph, off = the graph stops
|
||||
growing. The testid keeps the old name's shape (row-enabled) so
|
||||
this stays one rename, not a test re-pin. */}
|
||||
<SettingsRow label="Extraction" hint="Off = the graph stops growing" testid="graph-row-enabled">
|
||||
<Toggle
|
||||
checked={graph?.enabled ?? true}
|
||||
label="graph enabled"
|
||||
label="extraction enabled"
|
||||
onChange={(on) =>
|
||||
// Compose, never replace-whole (the section PUT is, but the
|
||||
// row must not drop its sibling field — fixed with the budget
|
||||
@@ -376,6 +382,17 @@ export function GraphSection({
|
||||
}
|
||||
/>
|
||||
</SettingsRow>
|
||||
{/* GRAPH RENDERING (owner, 2026-10-06 — the release graph-controls
|
||||
round): display-only. Off = the home shows the chains view
|
||||
instead of the canvas. The ledgers, the pipeline, everything
|
||||
data keeps working — that is the extraction switch above. */}
|
||||
<SettingsRow label="Graph rendering" hint="Off = home shows the chains view" testid="graph-row-rendering">
|
||||
<Toggle
|
||||
checked={graph?.rendering ?? true}
|
||||
label="graph rendering enabled"
|
||||
onChange={(on) => onSectionChange({ ...graph, rendering: on })}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label="Extraction chunk budget"
|
||||
hint={`${resolved.extractionChunkTokens} tok`}
|
||||
|
||||
Reference in New Issue
Block a user