From 3e263a18cca8ccad28a942724d8331ef057223f7 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sat, 19 Sep 2026 20:55:35 +0300 Subject: [PATCH 1/6] Graph pipeline cost metrics: capture naming spend, fold all channels, cost pill + panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cluster namer bills the money channel now: per-step usage + model captured via onStep, persisted as NamingRunUsage records to data/graph/naming-runs.jsonl (idempotent per startedAt; failed/aborted runs persist too — money spent either way). callUsage promoted to extraction.ts module scope for reuse. - core/cost-summary.ts: the pure fold behind the pill — chat runs, notes-source runs, and naming runs into stage totals (extraction / audit / naming) + newest-first recent runs. Dollars INFERRED read-time via prices.ts, never stored; unpriced models excluded-but-counted (render '—', never a silent zero); tokens always sum. - GET /api/graph/costs (src/server graphCosts composition, thin route). - Graph view: bottom-left cost pill (cumulative dollars headline) + expandable breakdown panel (stage rows with tokens secondary, recent runs with time/title/merged/cost); fetched on mount, refetched when a pipeline run finishes; merged into one bottom-left stack with the running-pipeline pills. - Tests: pure-fold suite, naming capture wiring (full importNotes -> naming path), store idempotency + strict reads. --- app/api/graph/costs/route.ts | 19 +++ components/graph-view.tsx | 144 ++++++++++++++++++++- config.ts | 3 + src/agent/subagents/cluster-namer.ts | 59 ++++++--- src/agent/subagents/extraction.ts | 29 +++-- src/core/contracts/graph.ts | 37 +++++- src/core/cost-summary.ts | 185 +++++++++++++++++++++++++++ src/core/index.ts | 1 + src/core/ports.ts | 6 + src/server/graph.ts | 24 ++++ src/store/graph.ts | 26 ++++ tests/cost-summary.test.ts | 126 ++++++++++++++++++ tests/extraction.test.ts | 19 +++ tests/graph.test.ts | 25 +++- 14 files changed, 666 insertions(+), 37 deletions(-) create mode 100644 app/api/graph/costs/route.ts create mode 100644 src/core/cost-summary.ts create mode 100644 tests/cost-summary.test.ts diff --git a/app/api/graph/costs/route.ts b/app/api/graph/costs/route.ts new file mode 100644 index 0000000..eaa8459 --- /dev/null +++ b/app/api/graph/costs/route.ts @@ -0,0 +1,19 @@ +/** + * GET /api/graph/costs — the graph view's cost pill (2026-09 graph cost + * metrics wave): what the graph PIPELINE cost, folded read-time from the + * three usage channels (chat extraction runs, notes-source runs, + * cluster-naming runs). Thin transport over src/server's graphCosts(); + * the fold itself is pure core (core/cost-summary.ts). Dollars are + * inferred from PRICES at read time — never stored, so a price sync + * re-prices the whole history for free. + * + * Scope (user-decided): the graph pipeline ONLY — teaching turns and + * sub-agent jobs have their own cost channels elsewhere. + */ +import { graphCosts } from "@/src/server"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + return Response.json(graphCosts()); +} diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 777472e..5c78023 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -61,10 +61,10 @@ * camera never moves. */ -import { useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; -import { FileTextIcon, Loader2Icon, SearchIcon, SettingsIcon, UploadIcon, WaypointsIcon } from "lucide-react"; -import { incrementalLayout, type GraphNode, type GraphSnapshot } from "@/src/core"; +import { CoinsIcon, FileTextIcon, Loader2Icon, SearchIcon, SettingsIcon, UploadIcon, WaypointsIcon } from "lucide-react"; +import { incrementalLayout, type GraphCostSummary, type GraphNode, type GraphSnapshot } from "@/src/core"; import { GraphCanvasRenderer, type GraphRenderSettings } from "./graph-canvas.ts"; import { GraphCoverageControl } from "./graph-coverage.tsx"; import { usePopoverDismiss } from "@/components/popover-dismiss"; @@ -209,6 +209,97 @@ function SettingSlider({ ); } +/** Compact token display: 45231 → "45.2k"; absent → "—". */ +function fmtTokens(n: number | undefined): string { + if (n === undefined) return "—"; + if (n < 1000) return String(Math.round(n)); + if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`; + return `${(n / 1_000_000).toFixed(2)}M`; +} + +/** Relative time for run rows: "now", "12m", "3h", "2d". */ +function timeAgo(startedAt: number): string { + const mins = Math.round((Date.now() - startedAt) / 60_000); + if (mins < 1) return "now"; + if (mins < 60) return `${mins}m`; + const hours = Math.round(mins / 60); + if (hours < 24) return `${hours}h`; + return `${Math.round(hours / 24)}d`; +} + +/** Run/stage cost at 3 decimals (pipeline pennies are real money at this + * scale); headline totals use 2. Null (unpriced model) renders "—". */ +function fmtRunCost(dollars: number | null): string { + return dollars === null ? "—" : `$${dollars.toFixed(3)}`; +} + +/** + * The graph-pipeline cost pill's expanded panel (2026-09 graph cost + * metrics wave): stage totals (extraction / audit / cluster-naming — + * dollars headline, tokens secondary) + the recent-runs list. Presenta- + * tional: the fold happens server-side (core/cost-summary.ts); dollars + * are inferred read-time, never stored. + */ +function GraphCostPanel({ costs }: { costs: GraphCostSummary }) { + const stageRows: [string, GraphCostSummary["stages"]["extraction"]][] = [ + ["Extraction", costs.stages.extraction], + ["Audit", costs.stages.audit], + ["Cluster naming", costs.stages.naming], + ]; + return ( +
+
+ + Graph pipeline costs + + + ${costs.totalDollars.toFixed(2)} + {costs.unpricedRuns > 0 && ( + · {costs.unpricedRuns} unpriced + )} + +
+
+ {stageRows.map(([label, stage]) => ( +
+ {label} + + {fmtRunCost(stage.runs === 0 || (stage.unpricedRuns > 0 && stage.dollars === 0) ? null : stage.dollars)} + {stage.unpricedRuns > 0 && stage.dollars > 0 && ( + +{stage.unpricedRuns} unpriced + )} + + {" "} + · {fmtTokens(stage.tokens.inputTokens)} in / {fmtTokens(stage.tokens.outputTokens)} out + + +
+ ))} +
+
+ {costs.runs.map((run) => ( +
+ + {timeAgo(run.startedAt)}{" "} + {run.title} + {run.merged && ( + + {" "} + +{run.merged.nodes}n + + )} + {run.kind === "naming" && ( + {run.labels ?? 0} labels + )} + + {fmtRunCost(run.dollars)} +
+ ))} +
+
+ ); +} + /** * The rendering-settings gear + its collapsible panel (owner, graph-view * round 2): its own container at the top right, separate from the search @@ -751,6 +842,29 @@ export function GraphView() { }; }, []); + // Graph pipeline costs (2026-09 cost metrics wave): the cumulative fold + // of every usage channel (chat + notes extraction runs, cluster naming) + // behind the bottom-left cost pill. Fetched once on mount and refetched + // when a pipeline run finishes (running -> none) so fresh spend lands + // without a manual reload. Best-effort: a fetch failure never blocks + // the view — the pill just stays on its last numbers. + const [costs, setCosts] = useState(null); + const [costsOpen, setCostsOpen] = useState(false); + const refreshCosts = useCallback(() => { + fetch("/api/graph/costs") + .then((res) => res.json() as Promise) + .then(setCosts) + .catch(() => {}); + }, []); + useEffect(() => { + refreshCosts(); + }, [refreshCosts]); + const costsHadRunning = useRef(false); + useEffect(() => { + if (costsHadRunning.current && pipelineRuns.length === 0) refreshCosts(); + costsHadRunning.current = pipelineRuns.length > 0; + }, [pipelineRuns, refreshCosts]); + // Layout (Option A): the positions ledger (written by the server's // incremental settle after every merge) IS the layout — the renderer // receives it as frozen data. The local one-shot forceAtlas2 pass runs @@ -1062,9 +1176,25 @@ export function GraphView() { )} - {/* Background pipeline status: what is currently running. */} - {pipelineRuns.length > 0 && ( -
+ {/* Bottom-left stack (2026-09-17 merge): the graph-pipeline cost + pill + its expandable panel, and the running-pipeline pills — + one container so they stack instead of overlapping. The pill + shows cumulative pipeline spend (dollars headline, inferred + read-time — never stored); click toggles the breakdown. */} + {(costs?.runs.length ?? 0) > 0 || pipelineRuns.length > 0 ? ( +
+ {costsOpen && costs && } + {costs && costs.runs.length > 0 && ( + + )} {pipelineRuns.map((run) => (
))}
- )} + ) : null} {selected && !isEmpty && (
void, ): Promise<{ key: string; label: string }[]> { let submitted: { key: string; label: string }[] | undefined; await runSubagent( @@ -98,6 +100,7 @@ async function nameClusters( ), maxTurns: 4, abortSignal: signal, + onAssistantMessage: (message) => onStep?.(callUsage(message.usage), message.model), shouldStop: (_message, toolResults) => toolResults.some((r) => r.toolName === "submit_labels" && !r.isError), }, @@ -156,23 +159,49 @@ async function doRefresh(deps: { if (unnamed.length === 0) return { detected: communities.length, claimed, named: 0, appended: 0 }; - const labels = await nameClusters(engine, signal, unnamed); + // Money channel (2026-09 graph cost metrics): the naming call is the + // pipeline's third billable stage — capture per-step usage + model as + // they bill, then persist ONE record for the run, success or failure + // (money spent either way, same discipline as extraction runs). + const startedAt = Date.now(); + const t0 = performance.now(); + const steps: NamingRunUsage["steps"] = []; + let model = "unknown"; let appended = 0; - const seen = new Set(); - for (const { key, members } of unnamed) { - if (seen.has(key)) continue; - const label = labels.find((l) => l.key === key)?.label?.trim(); - if (!label) continue; - seen.add(key); - graphStore.appendClusterLabel({ - id: clusterContentId(members), - label, - core: members, - createdAt: new Date().toISOString(), + try { + const labels = await nameClusters(engine, signal, unnamed, (usage, modelId) => { + steps.push(usage); + model = modelId; + }); + const seen = new Set(); + for (const { key, members } of unnamed) { + if (seen.has(key)) continue; + const label = labels.find((l) => l.key === key)?.label?.trim(); + if (!label) continue; + seen.add(key); + graphStore.appendClusterLabel({ + id: clusterContentId(members), + label, + core: members, + createdAt: new Date().toISOString(), + }); + appended++; + } + return { detected: communities.length, claimed, named: unnamed.length, appended }; + } finally { + // The finally (not just success) keeps failed/aborted runs billed + // too — zero appended, steps as far as they got. Runs whose model + // never billed a step (aborted before the first message) still + // record: the idempotency key is this run's startedAt. + graphStore.appendNamingUsage({ + startedAt, + at: new Date().toISOString(), + durationMs: Math.round(performance.now() - t0), + model, + steps, + stats: { detected: communities.length, claimed, named: unnamed.length, appended }, }); - appended++; } - return { detected: communities.length, claimed, named: unnamed.length, appended }; } let namingTail: Promise = Promise.resolve(); diff --git a/src/agent/subagents/extraction.ts b/src/agent/subagents/extraction.ts index 5427c18..5588c46 100644 --- a/src/agent/subagents/extraction.ts +++ b/src/agent/subagents/extraction.ts @@ -588,6 +588,22 @@ export interface ExtractionPipeline { importNotes(input: NotesImportInput): Promise; } +/** pi's per-step usage, flattened to the persisted CallUsage fields + * (the money channel's capture shape, contracts/chat.ts). Exported for + * the cluster namer — the same capture shape (2026-09 graph cost + * metrics wave). Module scope: no closure deps, pure projection. */ +export function callUsage(usage: Usage): CallUsage { + return { + inputTokens: usage.input, + outputTokens: usage.output, + cacheReadTokens: usage.cacheRead, + cacheWriteTokens: usage.cacheWrite, + // Optional in pi's Usage (providers that never emit reasoning + // tokens leave it unset) — persisted as 0, never dropped. + reasoningTokens: usage.reasoning ?? 0, + }; +} + export function createExtractionPipeline(deps: ExtractionPipelineDeps): ExtractionPipeline { const { chatStore, graphStore, embedder: embed } = deps; // THE SHARED REGISTRIES (runs() above — globalThis): every pipeline @@ -619,19 +635,6 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti run.usage[stage]!.steps.push(usage); } - /** pi's per-step usage, flattened to the persisted CallUsage fields - * (the money channel's capture shape, contracts/chat.ts). */ - function callUsage(usage: Usage): CallUsage { - return { - inputTokens: usage.input, - outputTokens: usage.output, - cacheReadTokens: usage.cacheRead, - cacheWriteTokens: usage.cacheWrite, - // Optional in pi's Usage (providers that never emit reasoning - // tokens leave it unset) — persisted as 0, never dropped. - reasoningTokens: usage.reasoning ?? 0, - }; - } async function extract(chatId: string, view: string): Promise { const signal = runningExtractions.get(chatId)?.controller.signal; diff --git a/src/core/contracts/graph.ts b/src/core/contracts/graph.ts index 1ca8a75..f57642e 100644 --- a/src/core/contracts/graph.ts +++ b/src/core/contracts/graph.ts @@ -20,7 +20,7 @@ /** Two relations only (DECIDED 2026-09-02): follows = b is derivable from a; * supports = a grounds b without entailing it. No scalars, no encoded * strength — strength is derived at read time from topology. */ -import type { ExtractionRunUsage } from "./chat.ts"; +import type { CallUsage, ExtractionRunUsage } from "./chat.ts"; export type EdgeType = "follows" | "supports"; @@ -65,6 +65,41 @@ export interface NotesSource { extractions?: ExtractionRunUsage[]; } +/** What one cluster-naming refresh did — the namer's ClusterRefreshStats, + * contract-shaped so the agent layer needs no contracts import. + * Structural twins stay interchangeable. */ +export interface NamingRunStats { + /** Communities the current graph divides into (all sizes). */ + detected: number; + /** Communities that kept a label via core-survival. */ + claimed: number; + /** Communities sent to the namer (unnamed, size >= MIN_CLUSTER_SIZE). */ + named: number; + /** Labeling records actually appended (idempotent dedupe may shrink). */ + appended: number; +} + +/** One cluster-naming run's usage record — the money channel's THIRD stage + * (graph cost metrics, 2026-09). The namer is graph-global (tied to no + * chat or source), so runs live in their own append-only ledger + * (config.LEDGER_FILES.namingRuns). Appended when a run actually called + * the model; skipped refreshes (nothing to name) never bill and never + * append. Idempotent per startedAt — same discipline as every usage + * channel. Cost is INFERRED at read time from steps + model + * (core/prices.ts) — dollars are never stored. */ +export interface NamingRunUsage { + /** ms epoch — the idempotency key. */ + startedAt: number; + /** ISO — wall-clock time of the run. */ + at: string; + durationMs: number; + /** The labeler role's model id. */ + model: string; + /** Every billed call (usually ONE — the namer is single-submit). */ + steps: CallUsage[]; + stats: NamingRunStats; +} + export interface GraphNode { /** Opaque stable id, store-assigned: "n_" + nanoid-like. */ id: string; diff --git a/src/core/cost-summary.ts b/src/core/cost-summary.ts new file mode 100644 index 0000000..2c29ff5 --- /dev/null +++ b/src/core/cost-summary.ts @@ -0,0 +1,185 @@ +/** + * Graph-pipeline cost summary (2026-09 graph cost metrics wave) — the pure + * fold behind the graph view's cost pill. Three usage channels in: chat + * extraction runs (ChatRecord.extractions), notes-source runs + * (NotesSource.extractions), and cluster-naming runs (naming-runs.jsonl). + * Out: per-stage totals (extraction / audit / cluster-naming) + a capped + * recent-runs list. + * + * MONEY CHANNEL LAW: dollars are INFERRED here at read time via + * core/prices.ts — never stored anywhere. An unpriced model contributes + * ZERO dollars but is counted in `unpricedRuns` so the UI can render + * "— unpriced" honestly, never a silent zero. Tokens always sum. + * + * Client-safe: pure types + math, no node imports; exposed via the core + * barrel so the pill/panel components carry no cost math. + */ + +import type { CallUsage, EmissionStats, ExtractionRunUsage, NamingRunUsage } from "./contracts.ts"; +import { callCost, sumSteps } from "./prices.ts"; + +/** One pipeline stage's cumulative cost across every run. */ +export interface StageCost { + /** Inferred USD summed over PRICED runs (partial when unpricedRuns > 0). */ + dollars: number; + /** Stage-runs whose model is missing from PRICES (render "—", never zero). */ + unpricedRuns: number; + /** Runs that billed this stage. */ + runs: number; + /** Token totals across every run. */ + tokens: CallUsage; +} + +/** One run in the recent-runs list (extraction or naming). */ +export interface RecentRun { + startedAt: number; + /** ISO wall-clock. */ + at: string; + durationMs: number; + /** "extraction" covers both front doors (chat + notes). */ + kind: "extraction" | "naming"; + /** Chat title / source file path / fixed label for naming runs. */ + title: string; + /** Whole-run inferred USD; null only when EVERY stage is unpriced. */ + dollars: number | null; + tokens: CallUsage; + /** What the run merged into the graph; absent on failed runs. */ + merged?: EmissionStats; + /** Naming runs: labeling records appended (extraction runs omit it). */ + labels?: number; +} + +/** The cost pill's whole payload: stage totals + recent runs. */ +export interface GraphCostSummary { + /** Inferred USD across all priced stages of all runs. */ + totalDollars: number; + /** Runs where EVERY stage was unpriced (totalDollars excludes them). */ + unpricedRuns: number; + /** The three billable pipeline stages (merger/embedder are free). */ + stages: { + extraction: StageCost; + audit: StageCost; + naming: StageCost; + }; + /** Newest first, capped at `recentCap` (default 10). */ + runs: RecentRun[]; +} + +/** What the fold reads: already-collected run records, front-door-tagged. */ +export interface CostSummaryInput { + /** Chat-front-door runs: chat title + the record's usage log. */ + chats: { title: string; extractions: ExtractionRunUsage[] }[]; + /** Notes-front-door runs: imported file path + the source's usage log. */ + sources: { filePath: string; extractions: ExtractionRunUsage[] }[]; + /** Cluster-naming runs (graph-global ledger). */ + naming: NamingRunUsage[]; + recentCap?: number; +} + +const ZERO_TOKENS: CallUsage = { + inputTokens: undefined, + outputTokens: undefined, + cacheReadTokens: undefined, + cacheWriteTokens: undefined, + reasoningTokens: undefined, +}; + +const emptyStage = (): StageCost => ({ dollars: 0, unpricedRuns: 0, runs: 0, tokens: { ...ZERO_TOKENS } }); + +/** One stage's dollars over its steps; null when the model is unpriced + * (all-or-nothing: a stage runs one model). */ +function stageDollars(model: string, steps: readonly CallUsage[]): number | null { + let total = 0; + for (const step of steps) { + const c = callCost(step, model); + if (c === null) return null; + total += c; + } + return total; +} + +/** Merge one step-set's tokens into an accumulating CallUsage. */ +function mergeTokens(into: CallUsage, steps: readonly CallUsage[]): void { + const summed = sumSteps(steps); + into.inputTokens = (into.inputTokens ?? 0) + (summed.inputTokens ?? 0); + into.outputTokens = (into.outputTokens ?? 0) + (summed.outputTokens ?? 0); + into.cacheReadTokens = (into.cacheReadTokens ?? 0) + (summed.cacheReadTokens ?? 0); + into.cacheWriteTokens = (into.cacheWriteTokens ?? 0) + (summed.cacheWriteTokens ?? 0); + into.reasoningTokens = (into.reasoningTokens ?? 0) + (summed.reasoningTokens ?? 0); +} + +/** Fold every usage channel into the pill's payload. Pure: same input, + * same output — no clock, no fs, no store reads. */ +export function graphCostSummary(input: CostSummaryInput): GraphCostSummary { + const extraction = emptyStage(); + const audit = emptyStage(); + const naming = emptyStage(); + const runs: RecentRun[] = []; + + const foldExtractionRun = (run: ExtractionRunUsage, title: string) => { + const stages = run.stages ?? { extractor: { model: "unknown", steps: [] }, auditor: { model: "unknown", steps: [] } }; + // Per-stage: tokens always count; dollars only when priced. + extraction.runs++; + mergeTokens(extraction.tokens, stages.extractor.steps); + const extDollars = stageDollars(stages.extractor.model, stages.extractor.steps); + if (extDollars === null) extraction.unpricedRuns++; + else extraction.dollars += extDollars; + + audit.runs++; + mergeTokens(audit.tokens, stages.auditor.steps); + const audDollars = stageDollars(stages.auditor.model, stages.auditor.steps); + if (audDollars === null) audit.unpricedRuns++; + else audit.dollars += audDollars; + + // Run-level: null only when BOTH stages are unpriced. + const priced = extDollars ?? 0; + const runDollars = extDollars === null && audDollars === null ? null : priced + (audDollars ?? 0); + const allSteps = [...stages.extractor.steps, ...stages.auditor.steps]; + runs.push({ + startedAt: run.startedAt, + at: run.at, + durationMs: run.durationMs, + kind: "extraction", + title, + dollars: runDollars, + tokens: sumSteps(allSteps), + ...(run.stats ? { merged: run.stats } : {}), + }); + }; + + for (const chat of input.chats) { + for (const run of chat.extractions) foldExtractionRun(run, chat.title); + } + for (const source of input.sources) { + for (const run of source.extractions) foldExtractionRun(run, source.filePath); + } + for (const run of input.naming) { + naming.runs++; + mergeTokens(naming.tokens, run.steps); + const dollars = stageDollars(run.model, run.steps); + if (dollars === null) naming.unpricedRuns++; + else naming.dollars += dollars; + runs.push({ + startedAt: run.startedAt, + at: run.at, + durationMs: run.durationMs, + kind: "naming", + title: "Cluster naming", + dollars, + tokens: sumSteps(run.steps), + labels: run.stats.appended, + }); + } + + runs.sort((a, b) => b.startedAt - a.startedAt); + const capped = runs.slice(0, input.recentCap ?? 10); + + // A run is "unpriced" when it billed nothing countable at all. + const unpricedRuns = runs.filter((r) => r.dollars === null).length; + return { + totalDollars: extraction.dollars + audit.dollars + naming.dollars, + unpricedRuns, + stages: { extraction, audit, naming }, + runs: capped, + }; +} diff --git a/src/core/index.ts b/src/core/index.ts index 6796a9e..a288244 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -34,6 +34,7 @@ export * from "./math-delims.ts"; export * from "./ui-message.ts"; export * from "./tool-parts.ts"; export * from "./prices.ts"; +export * from "./cost-summary.ts"; export * from "./fuzzy.ts"; // settings.ts + settings-resolution.ts (settings-storage wave, 2026-09-19): // the user-settings record's shape/sanitizer (pure) and its fully-resolved diff --git a/src/core/ports.ts b/src/core/ports.ts index 079d643..1784803 100644 --- a/src/core/ports.ts +++ b/src/core/ports.ts @@ -28,6 +28,7 @@ import type { TurnUsage, NotesSource, ClusterLabeling, + NamingRunUsage, EmbeddingsLedger, } from "./contracts.ts"; import type { UserSettings } from "./settings.ts"; @@ -101,6 +102,11 @@ export interface GraphStore { appendClusterLabel(labeling: ClusterLabeling): void; readClusterLabels(): ClusterLabeling[]; + /** Append one cluster-naming run's usage (graph-global money channel) — + * idempotent per startedAt. */ + appendNamingUsage(entry: NamingRunUsage): void; + readNamingRuns(): NamingRunUsage[]; + /** Upsert one imported-notes source record (config.SOURCES_FILE — a * JSON map, not a ledger; an upsert keeps the existing usage log). */ upsertSource(source: NotesSource): void; diff --git a/src/server/graph.ts b/src/server/graph.ts index d175488..79e3d29 100644 --- a/src/server/graph.ts +++ b/src/server/graph.ts @@ -1,4 +1,6 @@ import type { GraphSnapshot } from "../core/contracts.ts"; +import type { GraphCostSummary } from "../core/cost-summary.ts"; +import { graphCostSummary } from "../core/cost-summary.ts"; import { graphSnapshot } from "../core/graph.ts"; import { stores } from "./stores.ts"; @@ -11,3 +13,25 @@ import { stores } from "./stores.ts"; export function graphView(): GraphSnapshot { return graphSnapshot(stores); } + +/** The graph view's cost surface (GET /api/graph/costs — the 2026-09 cost + * pill): fold every usage channel into stage totals + recent runs. A pure + * read over the chat records (chat-front-door runs), the notes sources + * (notes-front-door runs), and the naming ledger (graph-global runs); + * dollars are inferred by the pure core fold (core/cost-summary.ts + + * prices.ts) — nothing here stores or caches money. */ +export function graphCosts(): GraphCostSummary { + const chats = stores.chatStore + .listChats() + .flatMap((summary) => { + const record = stores.chatStore.getChat(summary.id); + return record?.extractions?.length + ? [{ title: record.title ?? summary.id, extractions: record.extractions }] + : []; + }); + const sources = stores.graphStore + .readSources() + .filter((s) => s.extractions?.length) + .map((s) => ({ filePath: s.filePath, extractions: s.extractions! })); + return graphCostSummary({ chats, sources, naming: stores.graphStore.readNamingRuns() }); +} diff --git a/src/store/graph.ts b/src/store/graph.ts index b18e6de..c5da267 100644 --- a/src/store/graph.ts +++ b/src/store/graph.ts @@ -44,6 +44,7 @@ import type { ProvenancePoint, CoverageEntry, SessionCheckpoint, + NamingRunUsage, } from "../core/contracts.ts"; import type { GraphStore } from "../core/ports.ts"; @@ -90,6 +91,19 @@ function readValid(file: string, isValid: (v: unknown) => v is T): T[] { const isProvenancePoint = (v: unknown): v is ProvenancePoint => isRecord(v) && isString(v.chatId) && isString(v.messageId); +const isNamingStats = (v: unknown): v is NamingRunUsage["stats"] => + isRecord(v) && + ["detected", "claimed", "named", "appended"].every((k) => typeof v[k] === "number"); + +const isNamingRun = (v: unknown): v is NamingRunUsage => + isRecord(v) && + typeof v.startedAt === "number" && + isString(v.at) && + typeof v.durationMs === "number" && + isString(v.model) && + Array.isArray(v.steps) && + isNamingStats(v.stats); + const isClusterLabeling = (v: unknown): v is ClusterLabeling => isRecord(v) && isString(v.id) && @@ -198,6 +212,18 @@ export const graphStore: GraphStore = { return readValid(LEDGER_FILES.clusterLabels, isClusterLabeling); }, + // ── cluster-naming run usage (config.LEDGER_FILES.namingRuns) ── + + /** Idempotent per startedAt — the same discipline as every usage + * channel (chats.appendExtractionUsage, appendSourceUsage). */ + appendNamingUsage(entry: NamingRunUsage): void { + if (this.readNamingRuns().some((r) => r.startedAt === entry.startedAt)) return; + appendLine(LEDGER_FILES.namingRuns, entry); + }, + readNamingRuns(): NamingRunUsage[] { + return readValid(LEDGER_FILES.namingRuns, isNamingRun); + }, + // ── imported-notes bookkeeping (config.SOURCES_FILE — a JSON map) ── /** Upsert: keeps the existing record's usage log when re-importing. */ diff --git a/tests/cost-summary.test.ts b/tests/cost-summary.test.ts new file mode 100644 index 0000000..411a276 --- /dev/null +++ b/tests/cost-summary.test.ts @@ -0,0 +1,126 @@ +/** + * Tests for src/core/cost-summary.ts — the graph cost pill's pure fold. + * Same laws as the money channel everywhere: dollars INFERRED at read + * time (core/prices.ts), unpriced models excluded-but-counted (render + * "—", never a silent zero), tokens always sum. + */ + +import assert from "node:assert/strict"; +import { describe, it as test } from "node:test"; +import { + graphCostSummary, + type CostSummaryInput, + type GraphCostSummary, +} from "../src/core/cost-summary.ts"; +import type { CallUsage, ExtractionRunUsage, NamingRunUsage } from "../src/core/contracts.ts"; +import { PRICES, callCost } from "../src/core/prices.ts"; + +/** A model with full published pricing (from the generated registry). */ +const PRICED = "z-ai/glm-5.3-flash"; +assert.ok(PRICES[PRICED], "fixture model must exist in PRICES"); + +const step = (input: number, output: number): CallUsage => ({ + inputTokens: input, + outputTokens: output, + cacheReadTokens: 0, + cacheWriteTokens: undefined, + reasoningTokens: 0, +}); + +const extractionRun = ( + startedAt: number, + extractorModel: string, + auditorModel: string, + stats?: ExtractionRunUsage["stats"], +): ExtractionRunUsage => ({ + startedAt, + at: new Date(startedAt).toISOString(), + durationMs: 10_000, + stages: { + extractor: { model: extractorModel, steps: [step(1000, 200)] }, + auditor: { model: auditorModel, steps: [step(2000, 100)] }, + }, + ...(stats ? { stats } : {}), +}); + +const namingRun = (startedAt: number, model: string, appended: number): NamingRunUsage => ({ + startedAt, + at: new Date(startedAt).toISOString(), + durationMs: 4_000, + model, + steps: [step(500, 50)], + stats: { detected: 8, claimed: 6, named: 2, appended }, +}); + +describe("graphCostSummary (the cost pill's fold)", () => { + test("folds all three channels: stage totals, tokens, newest-first runs", () => { + const input: CostSummaryInput = { + chats: [{ title: "Testing session", extractions: [extractionRun(100, PRICED, PRICED)] }], + sources: [{ filePath: "/vault/modal.md", extractions: [extractionRun(200, PRICED, PRICED)] }], + naming: [namingRun(300, PRICED, 2)], + }; + const s = graphCostSummary(input); + const extStep = callCost(step(1000, 200), PRICED)!; + const audStep = callCost(step(2000, 100), PRICED)!; + assert.equal(s.stages.extraction.dollars, extStep * 2); + assert.equal(s.stages.audit.dollars, audStep * 2); + assert.equal(s.stages.naming.dollars, callCost(step(500, 50), PRICED)); + assert.equal(s.stages.extraction.runs, 2); + assert.equal(s.stages.naming.runs, 1); + assert.equal(s.stages.extraction.tokens.inputTokens, 2000); + assert.equal(s.stages.audit.tokens.outputTokens, 200); + assert.equal(s.totalDollars, extStep * 2 + audStep * 2 + callCost(step(500, 50), PRICED)!); + // All three runs, newest first, titles joined per front door. + assert.deepEqual( + s.runs.map((r) => r.title), + ["Cluster naming", "/vault/modal.md", "Testing session"], + ); + assert.equal(s.runs[0]!.labels, 2); + assert.equal(s.runs[0]!.kind, "naming"); + assert.equal(s.unpricedRuns, 0); + }); + + test("unpriced models: excluded dollars, counted honestly, run null only when BOTH stages unpriced", () => { + const s: GraphCostSummary = graphCostSummary({ + chats: [{ title: "partial", extractions: [extractionRun(100, PRICED, "unknown-model")] }], + sources: [{ filePath: "/vault/x.md", extractions: [extractionRun(200, "unknown-model", "unknown-model")] }], + naming: [], + }); + assert.equal(s.stages.audit.unpricedRuns, 2); + assert.equal(s.stages.audit.dollars, 0); // never a silent zero: unpricedRuns says it + assert.equal(s.stages.extraction.unpricedRuns, 1); + // The partial run still prices its extractor side; the fully + // unpriced run renders "—". Newest first: 200 (both unpriced), 100. + assert.equal(s.runs[0]!.dollars, null); + assert.notEqual(s.runs[1]!.dollars, null); + assert.equal(s.unpricedRuns, 1); // run-level: only the both-stages run + }); + + test("recent runs cap: newest survive", () => { + const extractions = [1, 2, 3, 4].map((n) => extractionRun(n * 100, PRICED, PRICED)); + const s = graphCostSummary({ + chats: [{ title: "chat", extractions }], + sources: [], + naming: [], + recentCap: 2, + }); + assert.equal(s.runs.length, 2); + assert.deepEqual(s.runs.map((r) => r.startedAt), [400, 300]); + // stage totals still count EVERY run, cap is presentational. + assert.equal(s.stages.extraction.runs, 4); + }); + + test("failed runs carry no merged stats; empty input folds to zero", () => { + const s = graphCostSummary({ + chats: [{ title: "failed", extractions: [extractionRun(100, PRICED, PRICED)] }], + sources: [], + naming: [], + }); + assert.equal(s.runs[0]!.merged, undefined); + const empty = graphCostSummary({ chats: [], sources: [], naming: [] }); + assert.equal(empty.totalDollars, 0); + assert.equal(empty.runs.length, 0); + assert.equal(empty.stages.naming.runs, 0); + assert.equal(empty.stages.extraction.tokens.inputTokens, undefined); + }); +}); diff --git a/tests/extraction.test.ts b/tests/extraction.test.ts index b032778..df2919a 100644 --- a/tests/extraction.test.ts +++ b/tests/extraction.test.ts @@ -33,6 +33,7 @@ import type { GraphEdge, GraphNode, LedgerEvent, + NamingRunUsage, NodeMapping, NotesSource, SessionCheckpoint, @@ -70,6 +71,7 @@ function memoryLedgers() { const coverage: CoverageEntry[] = []; const sources = new Map(); const clusterLabels: ClusterLabeling[] = []; + const namingRuns: NamingRunUsage[] = []; const positions = new Map(); const embeddings = { model: "", @@ -111,6 +113,11 @@ function memoryLedgers() { if (!clusterLabels.some((x) => x.id === l.id)) clusterLabels.push(l); }, readClusterLabels: () => clusterLabels.map((l) => ({ ...l, core: [...l.core] })), + // Cluster-naming run usage — mirrors the real store: dedupe per startedAt. + appendNamingUsage: (entry: NamingRunUsage) => { + if (!namingRuns.some((r) => r.startedAt === entry.startedAt)) namingRuns.push(entry); + }, + readNamingRuns: () => namingRuns.map((r) => ({ ...r, steps: [...r.steps], stats: { ...r.stats } })), // The layout ledger — settleGraphLayout writes it after each merge. readPositions: () => Object.fromEntries([...positions].map(([id, p]) => [id, { ...p }])), writePositions: (next) => { @@ -799,6 +806,18 @@ describe("cluster naming (the post-extraction garnish)", () => { assert.equal(again.appended, 0); assert.ok(again.claimed >= 1); assert.equal(ledgers.readClusterLabels().length, 1, "no duplicate records"); + // Money channel (2026-09 cost metrics): the naming call billed — + // ONE run record with the faux model, deterministic > 0 usage, and + // the refresh stats. The second refresh named nothing (no model + // call — early return), so no second record. + const namingRuns = ledgers.readNamingRuns(); + assert.equal(namingRuns.length, 1, "one naming run billed"); + assert.equal(namingRuns[0]!.model, labeler.model.id); + assert.ok(namingRuns[0]!.steps.length >= 1, "at least one billed step"); + assert.ok((namingRuns[0]!.steps[0]!.inputTokens ?? 0) > 0, "usage estimated > 0"); + assert.equal(namingRuns[0]!.stats.appended, 1); + assert.equal(namingRuns[0]!.stats.named, 1); + assert.ok(namingRuns[0]!.durationMs >= 0); }); it("naming failure never fails the run", async () => { diff --git a/tests/graph.test.ts b/tests/graph.test.ts index 7f7f908..c75073a 100644 --- a/tests/graph.test.ts +++ b/tests/graph.test.ts @@ -18,7 +18,7 @@ import assert from "node:assert/strict"; import * as fs from "node:fs"; import { after, describe, it } from "node:test"; import type { UIMessage } from "ai"; -import type { AuditResult } from "../src/core/contracts.ts"; +import type { AuditResult, NamingRunUsage } from "../src/core/contracts.ts"; import type { QuestionAnswerData } from "../src/core/question-core.ts"; import { clusterContentId, getGraph, graphSnapshot, mergeFindings, type GraphDeps } from "../src/core/graph.ts"; import { graphStore } from "../src/store/graph.ts"; @@ -592,6 +592,29 @@ describe("graph ledger store", () => { assert.deepEqual(nodes[0]!.provenance, [{ chatId: "chat_c", messageId: "m1" }]); }); + it("appendNamingUsage is idempotent per startedAt and strict-reads foreign shapes", () => { + const run: NamingRunUsage = { + startedAt: 1789800000000, + at: "2026-09-17T00:00:00.000Z", + durationMs: 1234, + model: "z-ai/glm-5.3-flash", + steps: [{ inputTokens: 10, outputTokens: 2, cacheReadTokens: 0, cacheWriteTokens: undefined, reasoningTokens: 0 }], + stats: { detected: 4, claimed: 3, named: 1, appended: 1 }, + }; + graphStore.appendNamingUsage(run); + graphStore.appendNamingUsage(run); // re-append: same startedAt — no-op + // A torn append + a foreign shape (missing stats) — skipped by the guard. + fs.appendFileSync( + LEDGER_FILES.namingRuns, + `{not json}\n${JSON.stringify({ startedAt: 1789800000001, at: "x", durationMs: 1, model: "m", steps: [] })}\n`, + "utf-8", + ); + const runs = graphStore.readNamingRuns().filter((r) => r.startedAt >= 1789800000000); + assert.equal(runs.length, 1, "one record — idempotent append, corrupt/foreign lines skipped"); + assert.deepEqual(runs[0]!.stats, { detected: 4, claimed: 3, named: 1, appended: 1 }); + assert.equal(runs[0]!.steps.length, 1); + }); + it("returns RAW node lines — repeated ids (link-appends) are each their own line; getGraph merges", () => { const line = (msgId: string) => JSON.stringify({ From 138d3d8ce688a22f7382df9504e6bfce6a636dc7 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sat, 19 Sep 2026 21:55:02 +0300 Subject: [PATCH 2/6] Cost control: bare top-right number with dropdown; pipeline pills move under it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner-directed placement wave (2026-09-20): the bottom-left stack overflowed the composer in narrow windows, and the pill chrome was too prominent for a passive number. - The cost surface is now JUST a number (cumulative dollars) top right, below the actions row — no pill container, hover-highlight kept, dropdown breakdown kept, viewport-capped + scrollable. - Same dismiss pattern as the settings gear: outside click or Escape closes (shared usePopoverDismiss; two dropdowns never open at once). - Running-pipeline pills moved under the cost number in the same top-right column — one right-aligned column grows downward from the top, so no window width can push chrome over the composer. --- components/graph-view.tsx | 99 ++++++++++++++++++++++++--------------- 1 file changed, 60 insertions(+), 39 deletions(-) diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 5c78023..2aa6609 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -63,7 +63,7 @@ import { useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; -import { CoinsIcon, FileTextIcon, Loader2Icon, SearchIcon, SettingsIcon, UploadIcon, WaypointsIcon } from "lucide-react"; +import { FileTextIcon, Loader2Icon, SearchIcon, SettingsIcon, UploadIcon, WaypointsIcon } from "lucide-react"; import { incrementalLayout, type GraphCostSummary, type GraphNode, type GraphSnapshot } from "@/src/core"; import { GraphCanvasRenderer, type GraphRenderSettings } from "./graph-canvas.ts"; import { GraphCoverageControl } from "./graph-coverage.tsx"; @@ -234,11 +234,45 @@ function fmtRunCost(dollars: number | null): string { } /** - * The graph-pipeline cost pill's expanded panel (2026-09 graph cost - * metrics wave): stage totals (extraction / audit / cluster-naming — - * dollars headline, tokens secondary) + the recent-runs list. Presenta- - * tional: the fold happens server-side (core/cost-summary.ts); dollars - * are inferred read-time, never stored. + * The graph-pipeline cost control (2026-09-20 placement wave): a bare + * cumulative-dollars number — no pill chrome (owner: "just a number"), + * hover-highlighted, click toggles the breakdown panel. Same dismiss + * 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 }) { + const [open, setOpen] = useState(false); + // Click anywhere outside (or Escape) closes the panel — the root + // wraps button + panel, so the toggle itself keeps working. + const rootRef = useRef(null); + usePopoverDismiss(rootRef, open, () => setOpen(false)); + return ( +
+ + {open && ( +
+ +
+ )} +
+ ); +} + +/** + * The cost control's expanded panel: stage totals (extraction / audit / + * cluster-naming — dollars headline, tokens secondary) + the recent-runs + * list. Presentational: the fold happens server-side + * (core/cost-summary.ts); dollars are inferred read-time, never stored. */ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) { const stageRows: [string, GraphCostSummary["stages"]["extraction"]][] = [ @@ -849,7 +883,6 @@ export function GraphView() { // without a manual reload. Best-effort: a fetch failure never blocks // the view — the pill just stays on its last numbers. const [costs, setCosts] = useState(null); - const [costsOpen, setCostsOpen] = useState(false); const refreshCosts = useCallback(() => { fetch("/api/graph/costs") .then((res) => res.json() as Promise) @@ -1113,7 +1146,8 @@ export function GraphView() { threshold the row wraps to a second line instead of overflowing left over the header toggle cluster (92px gutter + 36px button); 9rem keeps one clear column of air alongside it. */} -
+
+
{!isEmpty && ( <> {/* Search — its own container (icon + input + no-match note). */} @@ -1176,38 +1210,25 @@ export function GraphView() { )}
- {/* Bottom-left stack (2026-09-17 merge): the graph-pipeline cost - pill + its expandable panel, and the running-pipeline pills — - one container so they stack instead of overlapping. The pill - shows cumulative pipeline spend (dollars headline, inferred - read-time — never stored); click toggles the breakdown. */} - {(costs?.runs.length ?? 0) > 0 || pipelineRuns.length > 0 ? ( -
- {costsOpen && costs && } - {costs && costs.runs.length > 0 && ( - - )} - {pipelineRuns.map((run) => ( -
- - {run.phase} - {run.title} - {Math.max(0, Math.round((Date.now() - run.startedAt) / 1000))}s -
- ))} + {/* Top-right tail (owner, 2026-09-20): the pipeline cost number — + just a number, hover-highlighted, dropdown breakdown — then + the running-pipeline pills under it. Lives BELOW the actions + row so narrow windows can never push it over the composer + the way the old bottom-left stack did; the whole top-right + cluster is one column, right-aligned. */} + {costs && costs.runs.length > 0 && } + {pipelineRuns.map((run) => ( +
+ + {run.phase} + {run.title} + {Math.max(0, Math.round((Date.now() - run.startedAt) / 1000))}s
- ) : null} + ))} +
{selected && !isEmpty && (
Date: Sat, 19 Sep 2026 22:02:19 +0300 Subject: [PATCH 3/6] Header band: one row of controls, 36px, aligned with the sidebar toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner report: the controls folded onto two rows (search+coverage / import+gear) — the 2026-09-17 flex-wrap kicking in, plus a real bug: my previous wave moved max-w-[calc(100%-9rem)] onto the shrink-wrapped column wrapper, where the percentage reference broke and the wrap fired far too early. Owner wants the controls to read as header content. - The width cap moves to the absolute column itself (100% resolves against the full-bleed parent, as before): max-w-[calc(100%-5.5rem)]. - One row, NEVER wraps (owner rejected the two-row fold): search is the flexible element (w-56 + min-w-0, shrinks under the cap); the other controls are shrink-0. - All controls slimmed into the header band: top-2 (8px) matching the header's py-2, 36px tall matching the toggle's h-9 — size-9 buttons, h-7 import button, px-1 py-[3px] containers (border included = 36). - Cost number + pipeline pills stay below the band, unchanged. --- components/graph-coverage.tsx | 2 +- components/graph-view.tsx | 49 +++++++++++++++++++++-------------- 2 files changed, 31 insertions(+), 20 deletions(-) diff --git a/components/graph-coverage.tsx b/components/graph-coverage.tsx index a21b5a9..e49e5b9 100644 --- a/components/graph-coverage.tsx +++ b/components/graph-coverage.tsx @@ -102,7 +102,7 @@ export function GraphCoverageControl({ refreshKey = 0 }: { refreshKey?: number } title="Extraction coverage — what the knowledge graph has covered" aria-expanded={open} onClick={() => setOpen((o) => !o)} - className={`flex size-11 items-center justify-center rounded-xl border bg-card/85 shadow-lg backdrop-blur transition-colors hover:bg-accent ${ + className={`flex size-9 items-center justify-center rounded-xl border bg-card/85 shadow-lg backdrop-blur transition-colors hover:bg-accent ${ open ? "text-foreground" : "text-muted-foreground hover:text-foreground" }`} > diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 2aa6609..e75faa1 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -361,7 +361,7 @@ function GraphSettingsControl({ title="Rendering settings" aria-expanded={open} onClick={() => setOpen((o) => !o)} - className={`flex size-11 items-center justify-center rounded-xl border bg-card/85 shadow-lg backdrop-blur transition-colors hover:bg-accent ${ + className={`flex size-9 items-center justify-center rounded-xl border bg-card/85 shadow-lg backdrop-blur transition-colors hover:bg-accent ${ open ? "text-foreground" : "text-muted-foreground hover:text-foreground" }`} > @@ -1134,24 +1134,35 @@ export function GraphView() { coverage, import, gear — coverage sits between search and import; the gear keeps the corner. items-start (not center) so the search container's no-match note can grow downward - without shifting its siblings — every button is 44px tall, - top alignment pins the row. Coverage renders even when the - graph is EMPTY — an empty graph with unextracted sessions is - exactly when it matters (extract → the graph gains nodes). + without shifting its siblings — every control is 36px tall + (header band, 2026-09-20), top alignment pins the row. + Coverage renders even when the graph is EMPTY — an empty + graph with unextracted sessions is exactly when it matters + (extract → the graph gains nodes). lh-no-drag (2026-09-17): home.tsx's floating drag strip underlays this row — Electron's drag mask ignores z-index, so without an explicit carve-out the controls were - unclickable in the desktop app (owner report). max-w + flex-wrap - (narrow-window wave, 2026-09-17): below the sidebar auto-close - threshold the row wraps to a second line instead of overflowing - left over the header toggle cluster (92px gutter + 36px button); - 9rem keeps one clear column of air alongside it. */} -
-
+ unclickable in the desktop app (owner report). The old + flex-wrap (narrow-window wave, 2026-09-17) folded the row + onto two lines below the sidebar threshold — owner rejected + it (2026-09-20): one row always, the search shrinks (see + the header-band comment on the column below). */} + {/* Header band (owner, 2026-09-20): the controls sit IN the + header's visual row — top-2 aligns with the sidebar toggle's + band (header py-2, toggle h-9), every control slimmed to 36px. + The width cap lives HERE on the absolute element (100% resolves + against the full-bleed parent) — the row NEVER wraps (owner + rejected the two-row fold); in narrow windows the search is + the flexible element and shrinks instead. */} +
+
{!isEmpty && ( <> - {/* Search — its own container (icon + input + no-match note). */} -
+ {/* Search — its own container (icon + input + no-match note). + The header band's flexible element: w-56 normally, shrinks + (min-w-0) when the column's width cap binds in narrow + windows — the row's other controls are shrink-0. */} +
setQuery(e.target.value)} placeholder="Search nodes…" spellCheck={false} - className="h-8 w-full rounded-lg border bg-background pl-8 text-xs outline-none placeholder:text-muted-foreground focus:border-ring" + className="h-7 w-full rounded-lg border bg-background pl-8 text-xs outline-none placeholder:text-muted-foreground focus:border-ring" />
{snapshot !== null && visibleIds.size === 0 && query.trim() !== "" && ( @@ -1176,11 +1187,11 @@ export function GraphView() { with unextracted sessions is exactly when it matters (extract → the graph gains nodes). The panel re-reads when background runs settle (refreshTick). */} - +
{!isEmpty && ( <> - {/* Import notes — its own container. */} -
+ {/* Import notes — its own container (36px, header band). */} +
fileInputRef.current?.click()} - className="flex h-8 items-center gap-1.5 rounded-lg px-3 text-xs text-muted-foreground transition-colors hover:bg-accent disabled:opacity-50" + 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" > {importing ? "Importing…" : "Import notes…"} From 3de911aa5894c5c28ac8ee441652a1ffe74f4e18 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sat, 19 Sep 2026 22:06:19 +0300 Subject: [PATCH 4/6] Top-row chrome: dropdowns layer above the cost number; slight (4px) corners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Stacking: the actions row is now relative + z-20 — an open settings or coverage dropdown paints over the cost number below it (owner report: it layered ON TOP of the panel, still hoverable/clickable). The row is a stacking context inside the column, so its dropdowns cover the number and clicks land on the panel; the number resurfaces untouched when the panel closes (verified with elementFromPoint). - Rectangular look (owner, 2026-09-20): the four top-row controls drop rounded-xl (16px theme) to rounded-sm (4px) — gear, coverage, search container + input, import container + button; the cost number's hover highlight joins the family. Dropdown panels keep their softer cards. --- components/graph-coverage.tsx | 2 +- components/graph-view.tsx | 19 ++++++++++++------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/components/graph-coverage.tsx b/components/graph-coverage.tsx index e49e5b9..a7eb96b 100644 --- a/components/graph-coverage.tsx +++ b/components/graph-coverage.tsx @@ -102,7 +102,7 @@ export function GraphCoverageControl({ refreshKey = 0 }: { refreshKey?: number } title="Extraction coverage — what the knowledge graph has covered" aria-expanded={open} onClick={() => setOpen((o) => !o)} - className={`flex size-9 items-center justify-center rounded-xl border bg-card/85 shadow-lg backdrop-blur transition-colors hover:bg-accent ${ + className={`flex size-9 items-center justify-center rounded-sm border bg-card/85 shadow-lg backdrop-blur transition-colors hover:bg-accent ${ open ? "text-foreground" : "text-muted-foreground hover:text-foreground" }`} > diff --git a/components/graph-view.tsx b/components/graph-view.tsx index e75faa1..9a2dd6b 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -253,7 +253,7 @@ function GraphCostControl({ costs }: { costs: GraphCostSummary }) { title="Graph pipeline costs — click for the breakdown" aria-expanded={open} onClick={() => setOpen((o) => !o)} - className={`rounded-lg px-2 py-1 text-xs tabular-nums transition-colors hover:bg-accent ${ + className={`rounded-sm px-2 py-1 text-xs tabular-nums transition-colors hover:bg-accent ${ open ? "text-foreground" : "text-muted-foreground hover:text-foreground" }`} > @@ -361,7 +361,7 @@ function GraphSettingsControl({ title="Rendering settings" aria-expanded={open} onClick={() => setOpen((o) => !o)} - className={`flex size-9 items-center justify-center rounded-xl border bg-card/85 shadow-lg backdrop-blur transition-colors hover:bg-accent ${ + className={`flex size-9 items-center justify-center rounded-sm border bg-card/85 shadow-lg backdrop-blur transition-colors hover:bg-accent ${ open ? "text-foreground" : "text-muted-foreground hover:text-foreground" }`} > @@ -1155,14 +1155,19 @@ export function GraphView() { rejected the two-row fold); in narrow windows the search is the flexible element and shrinks instead. */}
-
+ {/* z-20 (2026-09-20): the row paints above the cost number below + it — without this, the later-in-DOM cost control layered over + the settings/coverage dropdowns (owner report), still hoverable + and clickable on top of them. The row is a stacking context, so + its open dropdowns cover the number; clicks land on the panel. */} +
{!isEmpty && ( <> {/* Search — its own container (icon + input + no-match note). The header band's flexible element: w-56 normally, shrinks (min-w-0) when the column's width cap binds in narrow windows — the row's other controls are shrink-0. */} -
+
setQuery(e.target.value)} placeholder="Search nodes…" spellCheck={false} - className="h-7 w-full rounded-lg border bg-background pl-8 text-xs outline-none placeholder:text-muted-foreground focus:border-ring" + className="h-7 w-full rounded-sm border bg-background pl-8 text-xs outline-none placeholder:text-muted-foreground focus:border-ring" />
{snapshot !== null && visibleIds.size === 0 && query.trim() !== "" && ( @@ -1191,7 +1196,7 @@ export function GraphView() { {!isEmpty && ( <> {/* Import notes — its own container (36px, header band). */} -
+
fileInputRef.current?.click()} - 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" + className="flex h-7 items-center gap-1.5 rounded-sm px-2.5 text-xs text-muted-foreground transition-colors hover:bg-accent disabled:opacity-50" > {importing ? "Importing…" : "Import notes…"} From 7555da03b92dcadc8eebbe23d5f47a5fe71ce561 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sat, 19 Sep 2026 22:09:37 +0300 Subject: [PATCH 5/6] Top-row chrome: composer-matching 16px corners, equal 8px gutters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Corner rounding back (owner, 2026-09-20): the four controls return to rounded-xl — 16px in this theme, identical to the composer shell's rounded-2xl (verified computed-style equal). Inner elements (search input, import button, cost hover) stay concentric: 16 - 4px padding = rounded-lg (12px), the composer's own concentric math. - Equal spacing: the cluster pins 8px from BOTH edges (top-2 + right-2), still flush with the sidebar toggle's header band (top 8px). --- components/graph-coverage.tsx | 2 +- components/graph-view.tsx | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/components/graph-coverage.tsx b/components/graph-coverage.tsx index a7eb96b..e49e5b9 100644 --- a/components/graph-coverage.tsx +++ b/components/graph-coverage.tsx @@ -102,7 +102,7 @@ export function GraphCoverageControl({ refreshKey = 0 }: { refreshKey?: number } title="Extraction coverage — what the knowledge graph has covered" aria-expanded={open} onClick={() => setOpen((o) => !o)} - className={`flex size-9 items-center justify-center rounded-sm border bg-card/85 shadow-lg backdrop-blur transition-colors hover:bg-accent ${ + className={`flex size-9 items-center justify-center rounded-xl border bg-card/85 shadow-lg backdrop-blur transition-colors hover:bg-accent ${ open ? "text-foreground" : "text-muted-foreground hover:text-foreground" }`} > diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 9a2dd6b..905e6c9 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -253,7 +253,7 @@ function GraphCostControl({ costs }: { costs: GraphCostSummary }) { title="Graph pipeline costs — click for the breakdown" aria-expanded={open} onClick={() => setOpen((o) => !o)} - className={`rounded-sm px-2 py-1 text-xs tabular-nums transition-colors hover:bg-accent ${ + className={`rounded-lg px-2 py-1 text-xs tabular-nums transition-colors hover:bg-accent ${ open ? "text-foreground" : "text-muted-foreground hover:text-foreground" }`} > @@ -361,7 +361,7 @@ function GraphSettingsControl({ title="Rendering settings" aria-expanded={open} onClick={() => setOpen((o) => !o)} - className={`flex size-9 items-center justify-center rounded-sm border bg-card/85 shadow-lg backdrop-blur transition-colors hover:bg-accent ${ + className={`flex size-9 items-center justify-center rounded-xl border bg-card/85 shadow-lg backdrop-blur transition-colors hover:bg-accent ${ open ? "text-foreground" : "text-muted-foreground hover:text-foreground" }`} > @@ -1154,7 +1154,7 @@ export function GraphView() { against the full-bleed parent) — the row NEVER wraps (owner rejected the two-row fold); in narrow windows the search is the flexible element and shrinks instead. */} -
+
{/* z-20 (2026-09-20): the row paints above the cost number below it — without this, the later-in-DOM cost control layered over the settings/coverage dropdowns (owner report), still hoverable @@ -1167,7 +1167,7 @@ export function GraphView() { The header band's flexible element: w-56 normally, shrinks (min-w-0) when the column's width cap binds in narrow windows — the row's other controls are shrink-0. */} -
+
setQuery(e.target.value)} placeholder="Search nodes…" spellCheck={false} - className="h-7 w-full rounded-sm border bg-background pl-8 text-xs outline-none placeholder:text-muted-foreground focus:border-ring" + className="h-7 w-full rounded-lg border bg-background pl-8 text-xs outline-none placeholder:text-muted-foreground focus:border-ring" />
{snapshot !== null && visibleIds.size === 0 && query.trim() !== "" && ( @@ -1196,7 +1196,7 @@ export function GraphView() { {!isEmpty && ( <> {/* Import notes — its own container (36px, header band). */} -
+
fileInputRef.current?.click()} - className="flex h-7 items-center gap-1.5 rounded-sm px-2.5 text-xs text-muted-foreground transition-colors hover:bg-accent disabled:opacity-50" + 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" > {importing ? "Importing…" : "Import notes…"} From d90b1caa231d40aeccaed43851f7fdaee2595c6d Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sat, 19 Sep 2026 22:14:11 +0300 Subject: [PATCH 6/6] Settings dropdown: widen for a clean scrollbar gutter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner report: the scrollbar pressed against the slider content. Panel w-64 -> w-72 and the inner scroll column gains pr-2.5, so the spacing is concentric: content -> 10px -> scrollbar -> 10px -> panel edge. The scrollbar itself is NOT moved — it keeps its distance from the panel's right border; the panel simply widens to give it air. --- components/graph-view.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 905e6c9..ccf18e2 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -373,8 +373,14 @@ function GraphSettingsControl({ // floor of free space above the window bottom at any height, // and the overflow-y-auto column (which also zeroes the flex // item's min-height) scrolls instead of stretching the page. -
-
+ // w-72 + inner pr (owner, 2026-09-20): the scrollbar used to + // press against the content — the panel widens so the scroll + // gutter gets real air. Spacing concentric: content -> 10px + // (pr-2.5) -> scrollbar -> 10px (p-2.5) -> panel edge. The + // scrollbar itself does NOT move: it stays pinned at the same + // distance from the panel's right border. +
+
{/* Size/zoom/region sliders: LADDER-mapped 0-100, step 10 (11 stops, every DEFAULT dead-center at 50, every value a nice number — src/core/render-settings.ts). Physics keeps the