"use client"; /** * The Agent section (settings-app wave): the THREE MODEL SLOTS, the tool * toggles + the search-engine preference, and the prompt override * switches — three GROUPS (owner, 2026-09-20: clear separation — Models / * Tools / System prompts). * * Model slots (model-slots wave, 2026-09-20, owner ruling: SLOTS ONLY — * the six per-role rows disappeared; per-role overrides remain in the * schema for a future advanced surface): Strong = Prober + Teacher, Cheap * = the five workhorse roles, Vision = Visual maker + PDF reader. Each * slot row carries the shared dropdown (fuzzy search, arrow-walk, Enter * pick — ModelMenuList/ThinkingMenuList from ./model-menu.tsx) over the * FEDERATED catalog (/api/models serves only keyed providers, fresh per * call — saving a key in Keys immediately widens this picker). The * vision slot filters to image-capable models (Model.input). The slots * live in the `models` settings section (PUT /api/settings/models — slot * pins are engine-level, not per-chat); the rest of the section writes * the `agent` section as before. Resolution per tier: slot override → * factory pin (GET /api/models' `slots` — resolved, cannot drift from * what runs). The Default row clears the slot override; a stored id stays * VISIBLE even when its provider's key lapsed out of the catalog — a * trigger showing "Default" over a live override would lie about the * record. * * Search engine (search wave, 2026-09-20, owner: no default — an engine * is live iff its key is present; with a key the engine AUTO-activates, * the explicit choice is only a preference between live engines): a row * in the Tools group, Tavily / Brave via the same shared-menu look, with * the live reason line from /api/search. No engine key at all → the row * grays whole-row (a choice without a key would only BLOCK the other * engine's auto-activation). * * Tools — the owner's final list (2026-09-19): research, demo, visual, pdf. * Absent key = enabled in the schema; the UI writes explicit booleans for * all four (simpler to reason about, sanitize-equivalent). * * Prompts: GET /api/settings/prompts segments — THREE SECTIONS in the * owner's ruled order (2026-09-26 overhaul): Teacher first, Prober, then * Shared LAST. The server returns per-agent lists that each INCLUDE the * shared segments; sectionedSegments deduplicates them (shared chunks * once, in prompt-assembly order). The row rulings: only the TOGGLE * grays when there's no override file (the file name is always * full-contrast); NO description line when there's no override (the empty * state is clean — toggle + name); the indicator ("Your version is * saved") appears ONLY when an override exists. The switch requires an * override FILE (route-enforced), so a file-less toggle is disabled. * The segments view re-reads on the 'lh:settings-saved' signal for the * agent section — the view's enabled flags derive from the record's * switches, and the 2026-09-26 state-echo bug was exactly this reader * missing (the toggle painted its old state until a remount re-fetched). */ import { useCallback, useEffect, useRef, useState } from "react"; import { MenuShell } from "@/components/menu-shell"; import { useSettingsSaved } from "@/components/settings-signal"; import { defaultOrderLevel, populateLevel } from "@/components/thinking-default"; import { useModelCatalog } from "@/components/model-catalog"; import { Hint } from "@/components/ui/hint"; import type React from "react"; import { COMPACTION_THRESHOLD_FLOOR, MODEL_SLOTS, resolveSettings, type AgentSettings, type CompactionSettings, type GraphSettings, type ModelSlotName, type ModelsSettings, } from "@/src/core"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import { prettyModel } from "@/components/usage-format"; import { cn } from "@/lib/utils"; import { SettingsGroup, SettingsRow, SettingsMenuTrigger, Toggle, CommitInput } from "./controls.tsx"; import { ModelMenuList, ThinkingMenuList } from "./model-menu"; import { ChatGPTBackgroundAccess } from "./chatgpt-connection"; import { NativeSubscriptionBackgroundAccess } from "./subscription-connections"; /** One slot's display identity — the tier's roster as the row hint rides * with the resolved default (owner: the hint ALWAYS names the default). */ const SLOT_META: Record = { strong: { label: "Strong", roster: "The main model for your sessions" }, cheap: { label: "Cheap", roster: "The assistants that research, build demos, and grade your work" }, vision: { label: "Vision", roster: "The assistants that draw and read PDFs" }, }; /** The search engines this wave ships (src/core/search.ts) + their labels. */ const SEARCH_ENGINES = ["tavily", "brave"] as const; /** The owner's final toggleable-tool list (confirm with the user before * changing — the Teacher stream settled this 2026-09-19). */ export const TOGGLE_TOOLS = ["research", "demo", "visual", "pdf"] as const; const THINKING_LEVELS: readonly ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"]; /** SegmentInfo as GET /api/settings/prompts returns it. */ interface SegmentInfo { path: string; sourceText: string; overrideText: string | null; enabled: boolean; shared: boolean; } /** Compose: one tool toggled, the rest of the section carried verbatim. * Pure — exported for tests. */ export function composeTool(agent: AgentSettings | undefined, id: string, on: boolean): AgentSettings { return { ...agent, tools: { ...agent?.tools, [id]: on } }; } /** The quizzing mechanics (owner 2026-09-27): a segmented control, not a * toggle — exactly one mechanic is ever active. `explain: true` arms * free-recall (explain in, quiz out); anything else is multiple choice. * `quiz` itself is never written (mechanics-managed — see quizMechanics * in agent/tools.ts). Pure — exported for tests. */ export function composeQuizMechanics(agent: AgentSettings | undefined, mechanics: "choice" | "free"): AgentSettings { return { ...agent, tools: { ...agent?.tools, explain: mechanics === "free" } }; } /** Compose the prober graph-tools toggle (wave 7): its own key in the * agent section — never in the tool-toggles map (owner ruling). Absent = * ON (the toggle discipline); the write is explicit either way, so an * explicit-off record stays visible as stored. Pure — exported for tests. */ export function composeGraphTools(agent: AgentSettings | undefined, on: boolean): AgentSettings { return { ...agent, graphTools: { enabled: on } }; } /** Compose: one slot's model/thinking override. Empty model = absent * override (Default — falls to the factory pin); empty thinking = the * tier keeps its resolved thinking budget. Per-field independence, same * as the schema's ModelSlot. Pure — exported for tests. */ export function composeSlotModel( models: ModelsSettings | undefined, slot: ModelSlotName, provider: string, id: string, thinking: string, ): ModelsSettings { const prev = models?.[slot]; const next = { ...models }; if (!provider && !id && !thinking) { delete next[slot]; return next; } next[slot] = { ...(provider && id ? { model: { provider, id } } : prev?.model ? { model: prev.model } : {}), ...(thinking ? { thinkingLevel: thinking as ThinkingLevel } : {}), }; return next; } /** Compose: the search-engine preference. null = the explicit pin is * REMOVED (auto-activation takes over — the schema's resting state). * Pure — exported for tests. */ export function composeSearchEngine( agent: AgentSettings | undefined, engine: (typeof SEARCH_ENGINES)[number] | null, ): AgentSettings { if (engine === null) { if (!agent?.search) return { ...agent }; const { search: _drop, ...rest } = agent; return { ...rest } as AgentSettings; } return { ...agent, search: { engine } }; } /** The indicator under a row whose override IS the version in use (owner * ruling, refined 2026-09-26: the small text states WHICH VERSION IS * IN USE — "Your version is in use" when the switch is on, and NOTHING * when the default composes, even if a saved override sits on disk * unused. The switch's meaning becomes legible: it visibly controls * this line). Exported for the pin. */ export const PROMPT_IN_USE_HINT = "Your version is in use"; /** One section of the prompt view — the owner's ruled order: Teacher, * Prober, then Shared last. */ export interface PromptSection { id: "teacher" | "prober" | "shared"; label: string; /** The one header that earns a hint: "Shared" alone is not * self-evident. Teacher/Prober are. */ hint?: string; segments: SegmentInfo[]; } /** Split the server's per-agent segment lists into the owner's three * sections. The server contract returns the shared segments in BOTH * lists (same paths, same content) — the shared chunks are listed ONCE * here (taken from the teacher list; identical by construction), in * assembly order: the base file, then the shared fragments sorted. The * agents' own chunks keep their lists' order — which IS the prompt * assembly order (numeric-prefix filename sort). Pure — exported for * tests. */ export function sectionedSegments(view: { prober: SegmentInfo[]; teacher: SegmentInfo[] }): PromptSection[] { const own = (who: "prober" | "teacher") => view[who].filter((s) => !s.shared); return [ { id: "teacher", label: "Teacher", segments: own("teacher") }, { id: "prober", label: "Prober", segments: own("prober") }, { id: "shared", label: "Shared", hint: "Used by both agents", segments: view.teacher.filter((s) => s.shared), }, ]; } /** Compose: one prompt switch. on = explicit true; off = key REMOVED * (absent = off is the schema's resting state; explicit false is the * same thing but noisier). Pure — tests. */ export function composePromptSwitch(agent: AgentSettings | undefined, path: string, on: boolean): AgentSettings { const prompts = { ...agent?.prompts }; if (on) prompts[path] = true; else delete prompts[path]; return { ...agent, prompts }; } /** The collapsed select look (owner: "the look of the collapsed menu is * good") — now a button that opens the shared model-picker dropdown. */ function MenuTrigger({ value, emptyLabel, ariaLabel, testid, open, onClick, widthClass, disabled, hint, }: { value: string; hint?: string; /** What the trigger shows when there is no override ("Default"). */ emptyLabel: string; ariaLabel: string; testid: string; open: boolean; onClick: () => void; /** Owner (2026-09-20): the thinking trigger is FIXED at the width the * Default state needs (values shifting it mid-edit shifted the row); * the model trigger takes the same width as a MINIMUM — its values * genuinely vary in length. */ widthClass: string; /** The no-key search state grays the whole row; the trigger follows. */ disabled?: boolean; }) { const button = ( { if (!disabled) onClick(); }} unavailable={disabled} className={widthClass} /> ); return hint ? {button} : button; } /* The section's dropdowns all mount the shared MenuShell (fix wave chunk * B): portaled to document.body, fixed-anchored to the open trigger by * testid (the section's one-ref pattern), opening DOWN with the shell's * viewport cap and auto-flip. data-lh-menu rides the shell — the * outside-click contract's exemption mark. */ export function AgentSection({ agent, models, graph, compaction, onSectionChange, onModelsChange, onCompactionChange, }: { agent: AgentSettings | undefined; /** The `models` section (slot pins — engine-level, PUT /api/settings/models). */ models: ModelsSettings | undefined; /** The `graph` section — READ ONLY here: the graphTools row composes its * master switch (graph.enabled off dims + disables the row; the * effective gate is server-side, per-turn fresh). */ graph: GraphSettings | undefined; /** The `compaction` section — its own PUT (the threshold is engine * behavior, not an agent-section key; owner round 14, Agent tab). */ compaction: CompactionSettings | undefined; /** Writes the composed WHOLE agent section (replace-PUT discipline). */ onSectionChange: (next: AgentSettings) => void; /** Writes the composed models section (slots — replace-PUT discipline). */ onModelsChange: (next: ModelsSettings) => void; /** Writes the whole compaction section (replace-PUT discipline). */ onCompactionChange: (next: CompactionSettings) => void; }) { const { catalog: body, failed: catalogFailed, refresh: refreshCatalog } = useModelCatalog(); const catalog = body?.models; const slots = body?.slots; /** The live search state (/api/search) — the row's reason line. */ const [search, setSearch] = useState<{ available: boolean; engine: string | null; reason: string } | null>(null); /** Which search engines have a key (/api/keys) — the no-keys-anywhere * state grays the row; per-engine marks ride the menu rows. */ const [engineKeys, setEngineKeys] = useState>>({}); const [prompts, setPrompts] = useState<{ prober: SegmentInfo[]; teacher: SegmentInfo[] } | null>(null); const [openSegment, setOpenSegment] = useState(null); const [segmentDraft, setSegmentDraft] = useState>({}); const [segmentStatus, setSegmentStatus] = useState(null); /** One open dropdown for the whole section: which slot (or the search * row), which kind. */ const [openMenu, setOpenMenu] = useState<{ target: ModelSlotName | "search"; kind: "model" | "thinking" | "engine" } | null>(null); const menuAnchorRef = useRef(null); const refreshSearch = () => fetch("/api/search") .then((r) => r.json()) .then((body: { available: boolean; engine: string | null; reason: string }) => setSearch(body)) .catch(() => setSearch(null)); /** Re-read the prompt-segment view — the signal seam for the * 2026-09-26 state-echo fix: the view's `enabled` flags DERIVE from * the settings record's agent switches (the toggle's truth lives * here, not in the `agent` prop), so an agent-section write must * re-read it or the toggle keeps painting its pre-write state until * a remount. Keeps the last-good view on a failed re-read (the * initial mount fetch below owns the loading/null state). */ const readPrompts = useCallback(() => { fetch("/api/settings/prompts") .then((r) => r.json()) .then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body)) .catch(() => {}); }, []); // The shared catalog seeds the first frame and refreshes per mount and // keys/models signal. Settings props still own saved selections/levels. useSettingsSaved((section) => { // THE state-echo root cause: the prompt switches write through the // agent section, but this view (the toggles' truth) never re-read — // the settings-saved law (every reader re-reads) was missing this // one reader. if (section === "agent") readPrompts(); }); useEffect(() => { fetch("/api/settings/prompts") .then((r) => r.json()) .then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body)) .catch(() => setPrompts(null)); fetch("/api/keys") .then((r) => r.json()) .then((body: { keys: { id: string; present: boolean }[] }) => { const present: Partial> = {}; for (const id of SEARCH_ENGINES) { const envVar = id === "tavily" ? "TAVILY_API_KEY" : "BRAVE_API_KEY"; present[id] = body.keys.some((k) => k.id === envVar && k.present); } setEngineKeys(present); }) .catch(() => {}); }, []); // The reason line re-reads /api/search AFTER an engine write lands — // the write updates the `agent` prop (the dialog's record mirror), so // keying on the pin avoids the race where the fetch beat the PUT and // the hint contradicted the trigger it sits under. useEffect(() => { refreshSearch(); }, [agent?.search?.engine]); // The dropdown contract (the chat model selector's, mirrored): any // click outside the open menu — or an Esc — closes it. Esc is CLAIMED // with stopPropagation so the settings dialog behind does NOT also // close. A pointerdown on the backdrop is left for the dialog (its own // click closes everything); a click inside the dialog just closes us. useEffect(() => { if (openMenu === null) return; const onPointerDown = (e: PointerEvent) => { const target = e.target as Node; if (menuAnchorRef.current?.contains(target)) return; if (target instanceof Element && target.closest("[data-lh-menu]")) return; if (target instanceof Element && target.closest('[role="dialog"]')) { setOpenMenu(null); } // else: outside the dialog — the dialog's own close handles it. }; const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { e.stopPropagation(); setOpenMenu(null); } }; document.addEventListener("pointerdown", onPointerDown); document.addEventListener("keydown", onKeyDown); return () => { document.removeEventListener("pointerdown", onPointerDown); document.removeEventListener("keydown", onKeyDown); }; }, [openMenu]); async function saveSegment(path: string, text: string) { setSegmentStatus(null); const res = await fetch(`/api/settings/prompts/${path}`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ text }), }); if (!res.ok) { setSegmentStatus(`Save failed: ${(await res.json().catch(() => ({}))).error ?? res.status}`); return; } readPrompts(); setSegmentStatus("Saved"); // Auto-on (owner ruling, refined 2026-09-26): writing an override and // putting it to work are ONE action — a save with the switch off was // the "did something, changed nothing" trap (the composed prompt // stayed the default). The file exists now, so the ON switch the // agent-section validation requires is legal; the order (file, then // switch) is the safe one. Saving an edit while already on rides // through — the switch write is idempotent at the record level. if (agent?.prompts?.[path] !== true) { onSectionChange(composePromptSwitch(agent, path, true)); } } async function deleteSegment(path: string) { setSegmentStatus(null); const res = await fetch(`/api/settings/prompts/${path}`, { method: "DELETE" }); if (!res.ok) { setSegmentStatus(`Delete failed: ${res.status}`); return; } // Auto-off — the mirror of save's auto-on: deleting the file while // the switch is on would orphan an ON record switch (the agent-section // validation 400s every later write while the UI's toggle can no // longer reach the OFF escape hatch — the trap found in live verify // 2026-09-26). Composing the OFF here keeps the record clean. if (agent?.prompts?.[path] === true) { onSectionChange(composePromptSwitch(agent, path, false)); } readPrompts(); setSegmentStatus("Deleted"); } /** The search-engine menu row — label + the quiet live-key mark. */ const engineOption = ( id: (typeof SEARCH_ENGINES)[number] | "auto", label: string, active: boolean, live: boolean | null, onPick: () => void, ) => ( ); const segmentRow = (info: SegmentInfo) => { const fileExists = info.overrideText !== null; const open = openSegment === info.path; // The Save affordance (owner ruling, refined 2026-09-26): clickable // only when there's something to save — the draft differs from // what's on disk. An unchanged editor shows a grayed, inert Save // (the always-active button was the "doesn't feel like it does // anything" half of the report). const draft = segmentDraft[info.path] ?? info.overrideText ?? ""; const dirty = draft !== (info.overrideText ?? ""); return (
{/* Graying ruling: ONLY the toggle dims without an override file — the file name above stays full-contrast (the whole-row dim is dead). */} onSectionChange(composePromptSwitch(agent, info.path, on))} /> {open && (

Source