"use client"; /** * The Appearance + Graph sections (thin settings wave). * * Appearance controls resolve against the factory in the provider; writes * PUT the whole appearance section and hand the delta back so the CSS * variables move IMMEDIATELY (the section's whole point — it must DO * something). Hex inputs commit on blur/Enter; the background derives the * raised/accent/edge palette in code (components/settings/appearance.tsx). * Mode is dark-only this wave: control rendered DISABLED with a note — the * light palette is the design wave's work, and a lying control is worse * than an honest disabled one. * * Graph graying (owner, 2026-09-20) follows the graph viewer's render-panel * convention: the whole row dims when a parent is off (stored values * persist but resolve inert — the switch shows stored, the dim effective). */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { MenuShell } from "@/components/menu-shell"; import { useSettingsSaved } from "@/components/settings-signal"; import { EMBEDDING_PRICES, type AppearanceSettings, type GraphSettings } from "@/src/core"; import { useAppearance } from "./appearance.tsx"; import { useSettingsDialog } from "./settings-dialog.tsx"; import { CommitInput, SettingsRow, ThinSelect, Toggle } from "./controls.tsx"; /** One row of GET /api/models/embeddings (the live-federated BYOK * catalog). The server twin is src/server/embeddings.ts's * EmbeddingCatalogModel — components may not import the server layer, * and the shape is exactly these two strings. */ interface EmbeddingCatalogModel { id: string; name: string; } /** Pure: the price line for a catalog row — USD per 1M INPUT tokens * (embeddings emit no output). Unlisted models stay honestly "—" * (never zero). The label convention is the model menu's. */ export function embeddingPriceLabel(id: string): string { const price = EMBEDDING_PRICES[id]; if (!price) return "—"; const fmt = (n: number) => `$${n < 1 ? n.toFixed(2).replace(/0$/, "") : n.toFixed(2)}`; return `${fmt(price.input)}/1M in`; } /** Pure: which ladder rungs need a configured embedding model to do * anything — everything but the master switch. */ export function needsEmbeddingModel(key: "enabled" | "embedding" | "clustering" | "labeling"): boolean { return key !== "enabled"; } export function AppearanceSection({ appearance, onSectionChange, }: { /** The record's appearance delta (absent = factory). */ appearance: AppearanceSettings | undefined; /** PUT the whole appearance section; the page re-applies on success. */ onSectionChange: (next: AppearanceSettings) => void; }) { const { appearance: resolved } = useAppearance(); const md = appearance?.mdRendering; return ( <> {}} testid="select-mode" disabled> onSectionChange({ ...appearance, background: v === "" ? null : v })} /> onSectionChange({ ...appearance, fontFamily: v === "" ? null : v })} /> { // Empty = factory default (owner, fix wave): omit the key so the record // UNSETS and the field stays empty (absence, not null, is the // unset for numbers). Number("") is 0, so the old clamp wrote 12. if (v === "") { onSectionChange({ ...appearance, proseFontSize: undefined }); return; } const n = Number(v); if (!Number.isFinite(n)) return; // bad input: no write, factory stays onSectionChange({ ...appearance, proseFontSize: Math.min(24, Math.max(12, Math.round(n))) }); }} /> onSectionChange({ ...appearance, sessionNameSerif: on })} /> { // Empty = factory default (owner, fix wave): Number("") is 0, so // Absence is the unset representation — the old clamp wrote 100. if (v === "") { onSectionChange({ ...appearance, mdRendering: { ...md, headingWeight: undefined } }); return; } const n = Number(v); if (!Number.isFinite(n)) return; onSectionChange({ ...appearance, mdRendering: { ...md, headingWeight: Math.min(900, Math.max(100, Math.round(n))) }, }); }} /> onSectionChange({ ...appearance, mdRendering: { ...md, boldColor: v === "" ? null : v } })} /> onSectionChange({ ...appearance, mdRendering: { ...md, italicColor: v === "" ? null : v } })} /> ); } const LADDER: readonly { key: "enabled" | "embedding" | "clustering" | "labeling"; label: string; needs: "enabled" | "embedding" | "clustering" | "labeling" | null; }[] = [ { key: "enabled", label: "Graph", needs: null }, { key: "embedding", label: "Embedding", needs: "enabled" }, { key: "clustering", label: "Clustering", needs: "embedding" }, { key: "labeling", label: "Labeling", needs: "clustering" }, ]; export function GraphSection({ graph, onSectionChange, }: { graph: GraphSettings | undefined; onSectionChange: (next: GraphSettings) => void; }) { const { open: openSettingsSection } = useSettingsDialog(); // The BYOK embedding catalog — null until the first fetch lands. const [catalog, setCatalog] = useState(null); const [menuOpen, setMenuOpen] = useState(false); const anchorRef = useRef(null); // The re-embed gate: a pick that differs from the stored model, held // until the user answers the confirm strip (or the graph turns out to // be empty — nothing to re-embed, the write lands directly). const [pending, setPending] = useState(null); // null while the count fetch is in flight; -1 when it failed (the // confirm line then omits the number rather than guessing one). const [pendingCount, setPendingCount] = useState(null); // Stored values (the switches show these) vs EFFECTIVE values (a // stored-on rung under a broken chain is inert — the schema's ladder). const stored = { enabled: graph?.enabled ?? true, embedding: graph?.embedding ?? true, clustering: graph?.clustering ?? true, labeling: graph?.labeling ?? true, }; const effective = { enabled: stored.enabled, embedding: stored.enabled && stored.embedding, clustering: stored.enabled && stored.embedding && stored.clustering, labeling: stored.enabled && stored.embedding && stored.clustering && stored.labeling, }; // No embedding model = the whole ladder under the master switch is // inert (the embedding port refuses without one) — those rows gray // out with a hint pointing at the picker below (owner, wave 6). const modelUnset = !graph?.embeddingModel; const commitPickRef = useRef(onSectionChange); commitPickRef.current = onSectionChange; const commitRef = useRef<(id: string | null) => void>(null); commitRef.current = (id) => commitPickRef.current({ ...graph, embeddingModel: id }); // Catalog: fetch on mount and re-read on any settings save — keying a // provider changes what this list can offer. const refreshCatalog = useCallback(async () => { try { const resp = await fetch("/api/models/embeddings"); if (!resp.ok) return; const list = (await resp.json()) as EmbeddingCatalogModel[]; if (Array.isArray(list)) setCatalog(list); } catch { // leave the last known list; the empty-add path stays clickable } }, []); useEffect(() => { void refreshCatalog(); }, [refreshCatalog]); useSettingsSaved(() => { void refreshCatalog(); }); // Dismissal: the agent-section contract — outside pointerdown closes // unless it lands inside the menu or on the anchor; Escape is CLAIMED // so the dialog behind stays put. useEffect(() => { if (!menuOpen) return; const onPointer = (e: PointerEvent) => { const t = e.target as HTMLElement; if (t.closest("[data-lh-menu]") || anchorRef.current?.contains(t)) return; setMenuOpen(false); }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { e.stopPropagation(); setMenuOpen(false); } }; document.addEventListener("pointerdown", onPointer); document.addEventListener("keydown", onKey, true); return () => { document.removeEventListener("pointerdown", onPointer); document.removeEventListener("keydown", onKey, true); }; }, [menuOpen]); // A held pick: count the graph's propositions. Zero = nothing to // re-embed, so the write lands without a confirm (the strip would be // noise). Otherwise the strip shows the honest number. useEffect(() => { if (!pending) return; let cancelled = false; (async () => { let n = -1; try { const resp = await fetch("/api/graph"); const snap = (await resp.json()) as { nodes?: unknown }; if (Array.isArray(snap.nodes)) n = snap.nodes.length; } catch { // count unknown — the strip reads "all propositions" } if (cancelled) return; if (n === 0) { setPending(null); setPendingCount(null); commitRef.current?.(pending.id); } else { setPendingCount(n); } })(); return () => { cancelled = true; }; }, [pending]); const pick = (m: EmbeddingCatalogModel | null) => { setMenuOpen(false); if (m === null) { // Unset never re-embeds — the port just stops resolving. if (graph?.embeddingModel != null) commitRef.current?.(null); return; } if (m.id === graph?.embeddingModel) return; setPending(m); setPendingCount(null); }; // The trigger's label: the catalog row's name when known, else the raw // slug (a key may have been removed after the model was set). const currentName = useMemo(() => { const id = graph?.embeddingModel ?? null; if (!id) return null; return catalog?.find((m) => m.id === id)?.name ?? id; }, [graph?.embeddingModel, catalog]); return ( <> {LADDER.map(({ key, label, needs }) => { // Touchable only when every ancestor is EFFECTIVELY on, and // (below the master switch) only when a model is configured. const parentOn = needs === null ? true : effective[needs]; const inert = !parentOn || (needsEmbeddingModel(key) && modelUnset); return ( onSectionChange({ ...graph, [key]: on })} /> ); })} {/* The embedding-model picker — the setting every rung above depends on. Empty catalog means no provider key is set: the trigger BECOMES the deep link to the Keys section. */} {catalog === null ? ( Loading… ) : catalog.length === 0 ? ( ) : ( )} {/* The re-embed confirm — switching models rewrites every vector in the graph (the staleness machinery, next background pass). Honesty over comfort: the count is the user's decision input. */} {pending && (

Switch to {pending.name}?

{pendingCount === null ? "Counting the graph’s propositions…" : pendingCount > 0 ? `All ${pendingCount.toLocaleString()} propositions in the graph will be re-embedded with the new model (runs with the next background pass).` : "All propositions in the graph will be re-embedded with the new model (runs with the next background pass)."}

{pendingCount !== null && (
)}
)} {menuOpen && catalog !== null && catalog.length > 0 && ( {catalog.map((m) => ( ))} )} ); }