"use client"; /** * The Agent section (settings-app wave): background-role model overrides, * the tool toggles, and the prompt override switches — three GROUPS * (owner, 2026-09-20: clear separation — Models / Tools / System prompts), * ONE section (PUT /api/settings/agent replaces the whole agent object, * so every change composes models+tools+prompts together from the record). * * The role selects (owner, 2026-09-20) are the EXACT same dropdown as the * chat model selector — the shared ModelMenuList/ThinkingMenuList from * ./model-menu.tsx (fuzzy search, arrow-walk, Enter pick). The collapsed * trigger keeps the thin select look; the opened menu is the model * picker's. Menus open DOWNWARD here (the dialog sits mid-screen); the * chat control opens upward — the shell belongs to each mount, the body * is shared. * * 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). * * Models: each of the six background roles defaults to config.ts's pin * (no override). The Default row clears the model override; the stored id * stays VISIBLE even when the catalog no longer contains it — a trigger * showing "Default" over a live override would lie about the record. * * Prompts: GET /api/settings/prompts segments; the switch requires an * override FILE (route-enforced), so a file-less row dims whole-row * (graph-view graying) until one is written. Switch state lives in this * section's PUT; text writes through the segment route. */ import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import type React from "react"; import { BACKGROUND_MODEL_ROLES, type AgentSettings, type BackgroundModelRole, type ModelCatalogEntry } from "@/src/core"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import { prettyModel } from "@/components/usage-format"; import { SettingsGroup, SettingsRow, Toggle } from "./controls.tsx"; import { ModelMenuList, ThinkingMenuList } from "./model-menu"; /** The role's display label — schema ids are camelCase, the label is human. */ const ROLE_LABEL: Record = { researcher: "Researcher", demoBuilder: "Demo builder", visualMaker: "Visual maker", extractor: "Extractor", auditor: "Auditor", labeler: "Labeler", }; /** 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 } }; } /** Compose: one role's model/thinking override. Empty model = absent * override (Default); empty thinking = thinking falls back. Pure — tests. */ export function composeRoleModel( agent: AgentSettings | undefined, role: BackgroundModelRole, modelId: string, thinking: string, ): AgentSettings { const prev = agent?.models?.[role]; const models = { ...agent?.models }; if (!modelId && !thinking) delete models[role]; else { models[role] = { ...(modelId ? { model: { provider: "openrouter", id: modelId } } : {}), ...(thinking ? { thinkingLevel: thinking as ThinkingLevel } : {}), }; } return { ...agent, models }; } /** 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, }: { value: 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; }) { return ( ); } /** The opened dropdown, PORTALED to document.body with fixed positioning: * the dialog's overflow frame must not clip it — the owner wants the * menus flowing over the whole screen, outside the settings surface * (2026-09-20). Right-aligned to its trigger, repositioned while the * dialog content scrolls under it. */ function AnchoredMenu({ anchorTestid, menuClass, testid, children, }: { /** The trigger to hang from — its data-testid. */ anchorTestid: string; /** The positioned menu's classes (width, padding, frame). */ menuClass: string; testid: string; children: React.ReactNode; }) { const [style, setStyle] = useState<{ right: number; top: number } | null>(null); useEffect(() => { const place = () => { const anchor = document.querySelector(`[data-testid="${anchorTestid}"]`); if (!anchor) return; const r = anchor.getBoundingClientRect(); setStyle({ right: window.innerWidth - r.right, top: r.bottom + 4 }); }; place(); // Capture-phase: catches the dialog's inner content scroll. window.addEventListener("scroll", place, true); window.addEventListener("resize", place); return () => { window.removeEventListener("scroll", place, true); window.removeEventListener("resize", place); }; }, [anchorTestid]); if (style === null) return null; return createPortal(
{children}
, document.body, ); } export function AgentSection({ agent, onSectionChange, }: { agent: AgentSettings | undefined; /** Writes the composed WHOLE agent section (replace-PUT discipline). */ onSectionChange: (next: AgentSettings) => void; }) { const [catalog, setCatalog] = useState(null); /** The config-pinned model per background role — GET /api/models' * rolePins (Platform stream, landing as a standalone commit; master * routing 2026-09-20). Optional here: until the rebase the rows read * "Default: config pin" and light up the moment the field arrives. */ const [rolePins, setRolePins] = useState | null>(null); 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 role, which kind. */ const [openMenu, setOpenMenu] = useState<{ role: BackgroundModelRole; kind: "model" | "thinking" } | null>(null); const menuAnchorRef = useRef(null); // The catalog + the prompt segments — one fetch each per section mount. useEffect(() => { fetch("/api/models") .then((r) => r.json()) .then((body: { models: ModelCatalogEntry[]; rolePins?: Record }) => { setCatalog(body.models); if (body.rolePins) setRolePins(body.rolePins); }) .catch(() => setCatalog(null)); fetch("/api/settings/prompts") .then((r) => r.json()) .then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body)) .catch(() => setPrompts(null)); }, []); // 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; } // Refetch: file presence gates the switch, so the view must track it. fetch("/api/settings/prompts") .then((r) => r.json()) .then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body)); setSegmentStatus(`Saved ${path}`); } 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; } fetch("/api/settings/prompts") .then((r) => r.json()) .then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body)); } /** The pinned Default row — clears this control's override (absent key * = config pin). Same row language as the shared menu's list rows. */ const defaultRow = (active: boolean, onPick: () => void) => ( ); const segmentRow = (info: SegmentInfo) => { const fileExists = info.overrideText !== null; const open = openSegment === info.path; return (
onSectionChange(composePromptSwitch(agent, info.path, on))} /> {open && (
{info.shared && (

Shared base — appears in both agents.

)}

Source