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:
amosblomqvist
2026-10-06 08:26:55 +03:00
parent 8a6eab0b6a
commit 962d3086c0
14 changed files with 392 additions and 27 deletions
+6 -2
View File
@@ -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 (
<Home initialConfigured={slotsConfigured(record?.models)} />
<Home initialConfigured={slotsConfigured(record?.models)} rendering={rendering} />
);
}
+6 -1
View File
@@ -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
+239
View File
@@ -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>
);
}
+3 -1
View File
@@ -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
View File
@@ -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
+19 -2
View File
@@ -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`}
+12
View File
@@ -205,6 +205,9 @@ export function resolveSettings(record: UserSettings | null): EffectiveSettings
// the embedding/clustering/labeling rungs and the BYOK embeddingModel
// are gone (stale record keys are simply never read here).
enabled: graph.enabled ?? FACTORY_SETTINGS.graph.enabled,
// The RENDERING switch (owner, 2026-10-06): display-only — gates
// the home's main view (canvas vs chains), never the data.
rendering: graph.rendering ?? FACTORY_SETTINGS.graph.rendering,
// The chunk budget (extraction-UX redesign, 2026-09-24): absent
// (pre-redesign record, fresh install) resolves to the factory
// 4096; the sanitizer has already clamped/floored any stored value.
@@ -227,6 +230,15 @@ export function extractionEnabled(graph: { enabled: boolean }): boolean {
return graph.enabled;
}
/**
* The RENDERING gate (owner, 2026-10-06): graph off = the home shows the
* chains view, not the canvas. Display-only by construction — every data
* path (ledgers, extraction, tools) never consults this.
*/
export function renderingEnabled(graph: { rendering: boolean }): boolean {
return graph.rendering;
}
/**
* The PROBER GRAPH TOOLS gate (wave 7, owner ruling 2026-09-23): the
* toggle is an agent-capability switch (it gates tool registration), but
+10 -1
View File
@@ -306,6 +306,13 @@ export interface GraphSettings {
* names, and the prober graph tools read the ledgers directly — there
* is nothing left to configure. Data stays stored either way. */
enabled?: boolean;
/** GRAPH RENDERING (owner, 2026-10-06 — the release graph-controls
* round): gates ONLY the home's main view. Off = the home shows the
* chains view instead of the canvas; the ledgers, the extraction
* pipeline, and every write path are untouched — this is a display
* switch, not a data switch (the extraction master above is the
* data switch). Default on. */
rendering?: boolean;
/** The chunk budget (extraction-UX redesign, owner 2026-09-24): the
* transcript-token share of the extractor's context — a run fires
* when a chat's UNCOVERED tokens reach it, and the run batches the
@@ -395,7 +402,7 @@ export const FACTORY_SETTINGS: FactorySettings = {
sessionNameSerif: true,
mdRendering: { headingWeight: 900, boldColor: null, italicColor: null },
},
graph: { enabled: true, extractionChunkTokens: 8192 },
graph: { enabled: true, rendering: true, extractionChunkTokens: 8192 },
compaction: { thresholdTokens: 150_000 },
};
@@ -566,6 +573,8 @@ function sanitizeGraph(raw: Record<string, unknown>): GraphSettings {
const out: GraphSettings = {};
const enabled = boolOrDrop(raw.enabled);
if (enabled !== undefined) out.enabled = enabled;
const rendering = boolOrDrop(raw.rendering);
if (rendering !== undefined) out.rendering = rendering;
const rawTokens = raw.extractionChunkTokens;
if (typeof rawTokens === "number" && Number.isFinite(rawTokens) && rawTokens > 0) {
out.extractionChunkTokens = Math.max(EXTRACTION_CHUNK_TOKENS_FLOOR, Math.floor(rawTokens));
+4
View File
@@ -296,6 +296,10 @@ export function parseGraphSettingsBody(body: unknown): SettingsBodyResult<GraphS
if (typeof raw.enabled !== "boolean") return { ok: false, error: "enabled must be a boolean" };
settings.enabled = raw.enabled;
}
if (raw.rendering !== undefined) {
if (typeof raw.rendering !== "boolean") return { ok: false, error: "rendering must be a boolean" };
settings.rendering = raw.rendering;
}
if (raw.extractionChunkTokens !== undefined) {
if (typeof raw.extractionChunkTokens !== "number" || !Number.isFinite(raw.extractionChunkTokens)) {
return { ok: false, error: "extractionChunkTokens must be a number" };
+10
View File
@@ -88,6 +88,16 @@ function render(props: Partial<typeof BASE> = {}) {
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)");
});
});
describe("ChainTableBody — clusters (the outermost layer)", () => {
it("collapsed by default: cluster rows render, chain rows do not", () => {
const html = render();
+15 -4
View File
@@ -174,19 +174,30 @@ describe("GraphSection — master switch + chunk budget (wave 7 + 2026-10-02)",
// only other control — it denominates the coverage ring and gates the
// auto-extraction trigger, resolved through the same core resolver the
// server uses (the 4096-fallback fix).
it("renders the master-switch row", () => {
it("renders the master-switch row — EXTRACTION since 2026-10-06 (the data channel, renamed when rendering split off)", () => {
const html = renderToStaticMarkup(<GraphSection graph={undefined} onSectionChange={() => {}} />);
assert.ok(html.includes('data-testid="graph-row-enabled"'));
assert.ok(html.includes(">Extraction<"), "the row names the DATA channel, not 'Graph'");
assert.ok(!html.includes("graph-row-embedding"), "the dead rungs render nothing");
assert.ok(!html.includes("Embedding model"), "the picker is gone");
assert.ok(!html.includes("graph-row-clustering"));
assert.ok(!html.includes("graph-row-labeling"));
assert.ok(tagOf(html, "toggle-graph-enabled").includes('aria-checked="true"'), "absent record = factory ON");
assert.ok(tagOf(html, "toggle-extraction-enabled").includes('aria-checked="true"'), "absent record = factory ON");
});
it("a stored off record shows the switch off", () => {
it("a stored off record shows the extraction switch off", () => {
const html = renderToStaticMarkup(<GraphSection graph={{ enabled: false }} onSectionChange={() => {}} />);
assert.ok(tagOf(html, "toggle-graph-enabled").includes('aria-checked="false"'));
assert.ok(tagOf(html, "toggle-extraction-enabled").includes('aria-checked="false"'));
});
it("the GRAPH RENDERING row (2026-10-06): display-only — absent record = factory ON, stored off shows off", () => {
const html = renderToStaticMarkup(<GraphSection graph={undefined} onSectionChange={() => {}} />);
assert.ok(html.includes('data-testid="graph-row-rendering"'));
assert.ok(html.includes(">Graph rendering<"));
assert.ok(tagOf(html, "toggle-graph-rendering-enabled").includes('aria-checked="true"'), "the home defaults to the canvas");
const off = renderToStaticMarkup(<GraphSection graph={{ rendering: false }} onSectionChange={() => {}} />);
assert.ok(tagOf(html, "toggle-extraction-enabled").includes('aria-checked="true"'), "rendering off does NOT touch extraction");
assert.ok(tagOf(off, "toggle-graph-rendering-enabled").includes('aria-checked="false"'));
});
it("the chunk-budget row: unset shows EMPTY with the factory placeholder + hint — one truth with the trigger", () => {
+11
View File
@@ -163,6 +163,17 @@ describe("parseGraphSettingsBody", () => {
assert.equal(parseGraphSettingsBody("nope").ok, false);
});
it("rendering (2026-10-06): boolean display switch — replace-whole like every sibling", () => {
const off = parseGraphSettingsBody({ enabled: true, rendering: false });
assert.ok(off.ok);
assert.deepEqual(off.settings, { enabled: true, rendering: false });
// absent = unset (factory ON at resolution)
const absent = parseGraphSettingsBody({ enabled: true });
assert.ok(absent.ok);
assert.deepEqual(absent.settings, { enabled: true });
assert.equal(parseGraphSettingsBody({ rendering: "off" }).ok, false, "rendering must be a boolean");
});
it("extractionChunkTokens: whole tokens ≥ the 500 floor (extraction-UX redesign, 2026-09-24)", () => {
const ok = parseGraphSettingsBody({ enabled: true, extractionChunkTokens: 2048 });
assert.ok(ok.ok);
+17 -3
View File
@@ -12,6 +12,7 @@ import { describe, it } from "node:test";
import {
extractionEnabled,
graphToolsEnabled,
renderingEnabled,
resolveConversationSpec,
resolveModelSpec,
resolveRoleSpec,
@@ -43,7 +44,7 @@ describe("resolveSettings", () => {
assert.equal(eff.appearance.mdRendering.headingWeight, 900);
// wave 7 collapse: the master switch ONLY + the redesign's chunk
// budget — absent record resolves the factory 8192 (the 2026-09-30 bump)
assert.deepEqual(eff.graph, { enabled: true, extractionChunkTokens: 8192 });
assert.deepEqual(eff.graph, { enabled: true, rendering: true, extractionChunkTokens: 8192 });
});
it("explicit record values win over every factory default", () => {
@@ -76,10 +77,23 @@ describe("resolveSettings", () => {
assert.equal(eff.appearance.mdRendering.headingWeight, 600);
// wave 7 collapse: the section IS the master switch (absent budget
// field = factory 8192 — a pre-redesign record resolves cleanly).
assert.deepEqual(eff.graph, { enabled: false, extractionChunkTokens: 8192 });
assert.deepEqual(eff.graph, { enabled: false, rendering: true, extractionChunkTokens: 8192 });
// The DISPLAY split (owner, 2026-10-06): extraction off leaves the
// rendering switch ON — the data channel and the view are separate.
assert.equal(renderingEnabled(eff.graph), true);
// The chunk budget rides the same resolution: stored value wins.
const budgeted = resolveSettings({ version: 1, graph: { extractionChunkTokens: 2000 } });
assert.deepEqual(budgeted.graph, { enabled: true, extractionChunkTokens: 2000 });
assert.deepEqual(budgeted.graph, { enabled: true, rendering: true, extractionChunkTokens: 2000 });
});
it("graph rendering (2026-10-06): display-only — off resolves off, extraction untouched", () => {
const eff = resolveSettings({ version: 1, graph: { rendering: false } });
assert.equal(eff.graph.rendering, false, "stored off wins");
assert.equal(eff.graph.enabled, true, "the data channel is untouched");
assert.equal(renderingEnabled(eff.graph), false);
assert.equal(extractionEnabled(eff.graph), true);
// absent = factory ON (the home defaults to the canvas)
assert.equal(resolveSettings({ version: 1, graph: {} }).graph.rendering, true);
});
it("partial role override: thinkingLevel alone borrows the SLOT's model (or nothing, without one)", () => {
+1 -1
View File
@@ -24,7 +24,7 @@ describe("FACTORY_SETTINGS (the compiled factory layer)", () => {
// wave 7 collapse: the master switch ONLY + the extraction-UX
// redesign's chunk budget (2024-09-24); default doubled 8192 with
// the digest-aware redesign (owner, 2026-09-30)
assert.deepEqual(FACTORY_SETTINGS.graph, { enabled: true, extractionChunkTokens: 8192 });
assert.deepEqual(FACTORY_SETTINGS.graph, { enabled: true, rendering: true, extractionChunkTokens: 8192 });
const a = FACTORY_SETTINGS.appearance;
assert.equal(a.mode, "dark");
assert.equal(a.background, null); // the mode's own default, not a factory hex