2026-09-19 18:26:25 +03:00
|
|
|
"use client";
|
|
|
|
|
/**
|
2026-09-19 21:59:30 +03:00
|
|
|
* 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.
|
2026-09-19 18:26:25 +03:00
|
|
|
*
|
|
|
|
|
* 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).
|
|
|
|
|
*
|
2026-09-19 21:59:30 +03:00
|
|
|
* 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.
|
2026-09-19 18:26:25 +03:00
|
|
|
*
|
|
|
|
|
* Prompts: GET /api/settings/prompts segments; the switch requires an
|
2026-09-19 21:59:30 +03:00
|
|
|
* 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.
|
2026-09-19 18:26:25 +03:00
|
|
|
*/
|
|
|
|
|
|
2026-09-19 21:59:30 +03:00
|
|
|
import { useEffect, useRef, useState } from "react";
|
2026-09-19 22:28:57 +03:00
|
|
|
import { createPortal } from "react-dom";
|
|
|
|
|
import type React from "react";
|
2026-09-19 21:59:30 +03:00
|
|
|
import { BACKGROUND_MODEL_ROLES, type AgentSettings, type BackgroundModelRole, type ModelCatalogEntry } from "@/src/core";
|
2026-09-19 18:26:25 +03:00
|
|
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
2026-09-19 21:59:30 +03:00
|
|
|
import { prettyModel } from "@/components/usage-format";
|
|
|
|
|
import { SettingsGroup, SettingsRow, Toggle } from "./controls.tsx";
|
|
|
|
|
import { ModelMenuList, ThinkingMenuList } from "./model-menu";
|
2026-09-19 18:26:25 +03:00
|
|
|
|
|
|
|
|
/** The role's display label — schema ids are camelCase, the label is human. */
|
|
|
|
|
const ROLE_LABEL: Record<BackgroundModelRole, string> = {
|
|
|
|
|
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 };
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 21:59:30 +03:00
|
|
|
/** 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,
|
2026-09-19 22:28:57 +03:00
|
|
|
widthClass,
|
2026-09-19 21:59:30 +03:00
|
|
|
}: {
|
|
|
|
|
value: string;
|
|
|
|
|
/** What the trigger shows when there is no override ("Default"). */
|
|
|
|
|
emptyLabel: string;
|
|
|
|
|
ariaLabel: string;
|
|
|
|
|
testid: string;
|
|
|
|
|
open: boolean;
|
|
|
|
|
onClick: () => void;
|
2026-09-19 22:28:57 +03:00
|
|
|
/** 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;
|
2026-09-19 21:59:30 +03:00
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
aria-label={ariaLabel}
|
|
|
|
|
aria-expanded={open}
|
|
|
|
|
data-testid={testid}
|
|
|
|
|
onClick={onClick}
|
2026-09-19 22:28:57 +03:00
|
|
|
className={`flex h-7 items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 ${widthClass}`}
|
2026-09-19 21:59:30 +03:00
|
|
|
>
|
2026-09-19 22:28:57 +03:00
|
|
|
<span className="min-w-0 flex-1 truncate">{value || emptyLabel}</span>
|
2026-09-19 21:59:30 +03:00
|
|
|
<span aria-hidden className="text-muted-foreground">
|
|
|
|
|
▾
|
|
|
|
|
</span>
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 22:28:57 +03:00
|
|
|
/** 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(
|
|
|
|
|
<div
|
|
|
|
|
data-testid={testid}
|
|
|
|
|
data-lh-menu=""
|
|
|
|
|
style={{ position: "fixed", right: style.right, top: style.top, zIndex: 60 }}
|
|
|
|
|
className={menuClass}
|
|
|
|
|
>
|
|
|
|
|
{children}
|
|
|
|
|
</div>,
|
|
|
|
|
document.body,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 18:26:25 +03:00
|
|
|
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<ModelCatalogEntry[] | null>(null);
|
2026-09-19 22:28:57 +03:00
|
|
|
/** 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<Record<string, { model: { provider: string; id: string }; thinkingLevel?: string }> | null>(null);
|
2026-09-19 18:26:25 +03:00
|
|
|
const [prompts, setPrompts] = useState<{ prober: SegmentInfo[]; teacher: SegmentInfo[] } | null>(null);
|
|
|
|
|
const [openSegment, setOpenSegment] = useState<string | null>(null);
|
|
|
|
|
const [segmentDraft, setSegmentDraft] = useState<Record<string, string>>({});
|
|
|
|
|
const [segmentStatus, setSegmentStatus] = useState<string | null>(null);
|
2026-09-19 21:59:30 +03:00
|
|
|
/** 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<HTMLSpanElement>(null);
|
2026-09-19 18:26:25 +03:00
|
|
|
|
2026-09-19 21:59:30 +03:00
|
|
|
// The catalog + the prompt segments — one fetch each per section mount.
|
2026-09-19 18:26:25 +03:00
|
|
|
useEffect(() => {
|
|
|
|
|
fetch("/api/models")
|
|
|
|
|
.then((r) => r.json())
|
2026-09-19 22:28:57 +03:00
|
|
|
.then((body: { models: ModelCatalogEntry[]; rolePins?: Record<string, { model: { provider: string; id: string }; thinkingLevel?: string }> }) => {
|
|
|
|
|
setCatalog(body.models);
|
|
|
|
|
if (body.rolePins) setRolePins(body.rolePins);
|
|
|
|
|
})
|
2026-09-19 18:26:25 +03:00
|
|
|
.catch(() => setCatalog(null));
|
|
|
|
|
fetch("/api/settings/prompts")
|
|
|
|
|
.then((r) => r.json())
|
|
|
|
|
.then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body))
|
|
|
|
|
.catch(() => setPrompts(null));
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-09-19 21:59:30 +03:00
|
|
|
// 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;
|
2026-09-19 22:28:57 +03:00
|
|
|
if (target instanceof Element && target.closest("[data-lh-menu]")) return;
|
2026-09-19 21:59:30 +03:00
|
|
|
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]);
|
|
|
|
|
|
2026-09-19 18:26:25 +03:00
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 21:59:30 +03:00
|
|
|
/** 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) => (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
data-testid="menu-default"
|
|
|
|
|
onClick={onPick}
|
|
|
|
|
className={`flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-muted ${
|
|
|
|
|
active ? "bg-muted/60 text-card-foreground" : "text-card-foreground"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<span>Default</span>
|
|
|
|
|
{active && <span aria-hidden>✓</span>}
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
|
2026-09-19 18:26:25 +03:00
|
|
|
const segmentRow = (info: SegmentInfo) => {
|
|
|
|
|
const fileExists = info.overrideText !== null;
|
|
|
|
|
const open = openSegment === info.path;
|
|
|
|
|
return (
|
|
|
|
|
<div key={info.path} data-testid={`prompt-segment-${info.path}`}>
|
|
|
|
|
<SettingsRow
|
|
|
|
|
label={info.path}
|
2026-09-19 21:59:30 +03:00
|
|
|
hint={fileExists ? undefined : "no override file yet — write one below"}
|
2026-09-19 18:26:25 +03:00
|
|
|
testid={`prompt-row-${info.path}`}
|
2026-09-19 21:59:30 +03:00
|
|
|
dimmed={!fileExists}
|
2026-09-19 18:26:25 +03:00
|
|
|
>
|
|
|
|
|
<Toggle
|
|
|
|
|
checked={info.enabled && fileExists}
|
|
|
|
|
disabled={!fileExists}
|
|
|
|
|
label={`prompt ${info.path}`}
|
|
|
|
|
onChange={(on) => onSectionChange(composePromptSwitch(agent, info.path, on))}
|
|
|
|
|
/>
|
|
|
|
|
</SettingsRow>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setOpenSegment(open ? null : info.path);
|
|
|
|
|
setSegmentStatus(null);
|
|
|
|
|
}}
|
|
|
|
|
className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
|
|
|
|
|
>
|
|
|
|
|
{open ? "Hide text" : "Show text"}
|
|
|
|
|
</button>
|
|
|
|
|
{open && (
|
|
|
|
|
<div className="mt-1 flex flex-col gap-2">
|
|
|
|
|
{info.shared && (
|
|
|
|
|
<p className="text-xs text-muted-foreground/70">Shared base — appears in both agents.</p>
|
|
|
|
|
)}
|
|
|
|
|
<div>
|
|
|
|
|
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">Source</p>
|
|
|
|
|
<textarea
|
|
|
|
|
readOnly
|
|
|
|
|
value={info.sourceText}
|
|
|
|
|
aria-label={`source ${info.path}`}
|
|
|
|
|
className="h-24 w-full resize-y rounded-sm border border-border bg-muted p-2 font-ui text-xs text-muted-foreground"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">Override</p>
|
|
|
|
|
<textarea
|
|
|
|
|
value={segmentDraft[info.path] ?? info.overrideText ?? ""}
|
|
|
|
|
onChange={(e) => setSegmentDraft({ ...segmentDraft, [info.path]: e.target.value })}
|
|
|
|
|
aria-label={`override ${info.path}`}
|
|
|
|
|
placeholder="Write the override text…"
|
|
|
|
|
className="h-24 w-full resize-y rounded-sm border border-border bg-background p-2 font-ui text-xs text-foreground outline-none"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => void saveSegment(info.path, segmentDraft[info.path] ?? "")}
|
|
|
|
|
className="text-xs text-foreground underline-offset-2 hover:underline"
|
|
|
|
|
>
|
|
|
|
|
Save override
|
|
|
|
|
</button>
|
|
|
|
|
{fileExists && (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => void deleteSegment(info.path)}
|
|
|
|
|
className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-red-500 hover:underline"
|
|
|
|
|
>
|
|
|
|
|
Delete override
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
2026-09-19 21:59:30 +03:00
|
|
|
<SettingsGroup label="Models" testid="group-models">
|
|
|
|
|
{BACKGROUND_MODEL_ROLES.map((role) => {
|
|
|
|
|
const override = agent?.models?.[role];
|
|
|
|
|
const modelId = override?.model?.id ?? "";
|
|
|
|
|
const thinking = override?.thinkingLevel ?? "";
|
2026-09-19 22:28:57 +03:00
|
|
|
// The model whose levels govern the thinking list: the
|
|
|
|
|
// override, else the config pin (rolePins makes the
|
|
|
|
|
// default's levels resolvable when it's in the catalog);
|
|
|
|
|
// the standard ladder as the last fallback.
|
|
|
|
|
const effectiveId = modelId || rolePins?.[role]?.model.id || "";
|
|
|
|
|
const chosen = catalog?.find((m) => m.id === effectiveId);
|
2026-09-19 21:59:30 +03:00
|
|
|
const levels = chosen ? chosen.supportedLevels : THINKING_LEVELS;
|
2026-09-19 22:28:57 +03:00
|
|
|
// The row ALWAYS names the default (owner, 2026-09-20: the
|
|
|
|
|
// old "config pin applies" hint said nothing). rolePins
|
|
|
|
|
// carries the resolved default; the fallback reads until
|
|
|
|
|
// Platform's rolePins lands on main.
|
|
|
|
|
const defaultName = rolePins?.[role]?.model.id
|
|
|
|
|
? prettyModel(rolePins[role].model.id)
|
|
|
|
|
: "config pin";
|
2026-09-19 21:59:30 +03:00
|
|
|
const thisMenuOpen =
|
|
|
|
|
openMenu?.role === role ? openMenu.kind : null;
|
|
|
|
|
return (
|
|
|
|
|
<SettingsRow
|
|
|
|
|
key={role}
|
|
|
|
|
label={ROLE_LABEL[role]}
|
2026-09-19 22:28:57 +03:00
|
|
|
hint={`Default: ${defaultName}`}
|
2026-09-19 21:59:30 +03:00
|
|
|
testid={`role-row-${role}`}
|
|
|
|
|
>
|
|
|
|
|
{/* The anchor for the outside-click contract is the
|
|
|
|
|
span holding BOTH triggers of the open menu. */}
|
|
|
|
|
<span
|
|
|
|
|
ref={thisMenuOpen ? menuAnchorRef : undefined}
|
2026-09-19 22:28:57 +03:00
|
|
|
className="relative flex items-center gap-2"
|
2026-09-19 18:26:25 +03:00
|
|
|
>
|
2026-09-19 21:59:30 +03:00
|
|
|
<MenuTrigger
|
|
|
|
|
value={modelId ? prettyModel(modelId) : ""}
|
|
|
|
|
emptyLabel="Default"
|
|
|
|
|
ariaLabel={`${ROLE_LABEL[role]} model`}
|
|
|
|
|
testid={`role-model-${role}`}
|
|
|
|
|
open={thisMenuOpen === "model"}
|
|
|
|
|
onClick={() => setOpenMenu(thisMenuOpen === "model" ? null : { role, kind: "model" })}
|
2026-09-19 22:28:57 +03:00
|
|
|
widthClass="min-w-20"
|
2026-09-19 21:59:30 +03:00
|
|
|
/>
|
|
|
|
|
<MenuTrigger
|
|
|
|
|
value={thinking}
|
|
|
|
|
emptyLabel="Default"
|
|
|
|
|
ariaLabel={`${ROLE_LABEL[role]} thinking`}
|
|
|
|
|
testid={`role-thinking-${role}`}
|
|
|
|
|
open={thisMenuOpen === "thinking"}
|
|
|
|
|
onClick={() => setOpenMenu(thisMenuOpen === "thinking" ? null : { role, kind: "thinking" })}
|
2026-09-19 22:28:57 +03:00
|
|
|
widthClass="w-20"
|
2026-09-19 21:59:30 +03:00
|
|
|
/>
|
|
|
|
|
{thisMenuOpen === "model" && (
|
2026-09-19 22:28:57 +03:00
|
|
|
<AnchoredMenu
|
|
|
|
|
anchorTestid={`role-model-${role}`}
|
|
|
|
|
testid={`role-model-menu-${role}`}
|
|
|
|
|
menuClass="flex w-[22rem] flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
|
2026-09-19 21:59:30 +03:00
|
|
|
>
|
|
|
|
|
{defaultRow(
|
|
|
|
|
modelId === "",
|
|
|
|
|
() => {
|
|
|
|
|
onSectionChange(composeRoleModel(agent, role, "", thinking));
|
|
|
|
|
setOpenMenu(null);
|
|
|
|
|
},
|
|
|
|
|
)}
|
|
|
|
|
<ModelMenuList
|
|
|
|
|
models={catalog ?? undefined}
|
|
|
|
|
effectiveId={modelId || undefined}
|
|
|
|
|
onPick={(entry) => {
|
|
|
|
|
onSectionChange(composeRoleModel(agent, role, entry.id, thinking));
|
|
|
|
|
setOpenMenu(null);
|
|
|
|
|
}}
|
|
|
|
|
footer="Applied to every run of this role"
|
|
|
|
|
/>
|
2026-09-19 22:28:57 +03:00
|
|
|
</AnchoredMenu>
|
2026-09-19 18:26:25 +03:00
|
|
|
)}
|
2026-09-19 21:59:30 +03:00
|
|
|
{thisMenuOpen === "thinking" && (
|
2026-09-19 22:28:57 +03:00
|
|
|
<AnchoredMenu
|
|
|
|
|
anchorTestid={`role-thinking-${role}`}
|
|
|
|
|
testid={`role-thinking-menu-${role}`}
|
|
|
|
|
menuClass="flex w-44 flex-col gap-0.5 rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
|
2026-09-19 21:59:30 +03:00
|
|
|
>
|
|
|
|
|
{defaultRow(
|
|
|
|
|
thinking === "",
|
|
|
|
|
() => {
|
|
|
|
|
onSectionChange(composeRoleModel(agent, role, modelId, ""));
|
|
|
|
|
setOpenMenu(null);
|
|
|
|
|
},
|
|
|
|
|
)}
|
|
|
|
|
<ThinkingMenuList
|
|
|
|
|
levels={levels.map(String)}
|
|
|
|
|
effectiveLevel={thinking || undefined}
|
|
|
|
|
onPick={(level) => {
|
|
|
|
|
onSectionChange(composeRoleModel(agent, role, modelId, level));
|
|
|
|
|
setOpenMenu(null);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
2026-09-19 22:28:57 +03:00
|
|
|
</AnchoredMenu>
|
2026-09-19 21:59:30 +03:00
|
|
|
)}
|
|
|
|
|
</span>
|
|
|
|
|
</SettingsRow>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</SettingsGroup>
|
|
|
|
|
|
|
|
|
|
<SettingsGroup label="Tools" testid="group-tools">
|
|
|
|
|
{TOGGLE_TOOLS.map((id) => (
|
|
|
|
|
<SettingsRow key={id} label={`Tool · ${id}`} testid={`tool-row-${id}`}>
|
|
|
|
|
<Toggle
|
|
|
|
|
checked={agent?.tools?.[id] ?? true}
|
|
|
|
|
label={`tool ${id}`}
|
|
|
|
|
onChange={(on) => onSectionChange(composeTool(agent, id, on))}
|
|
|
|
|
/>
|
2026-09-19 18:26:25 +03:00
|
|
|
</SettingsRow>
|
2026-09-19 21:59:30 +03:00
|
|
|
))}
|
|
|
|
|
</SettingsGroup>
|
2026-09-19 18:26:25 +03:00
|
|
|
|
2026-09-19 21:59:30 +03:00
|
|
|
<SettingsGroup label="System prompts" testid="group-prompts">
|
|
|
|
|
{prompts === null ? (
|
|
|
|
|
<p className="text-xs text-muted-foreground/70">Loading prompt segments…</p>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="flex flex-col gap-4">
|
|
|
|
|
{(["prober", "teacher"] as const).map((who) => (
|
|
|
|
|
<div key={who}>
|
|
|
|
|
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">{who}</p>
|
|
|
|
|
<div className="flex flex-col gap-2">
|
|
|
|
|
{prompts[who].map((info) => segmentRow(info))}
|
|
|
|
|
</div>
|
2026-09-19 18:26:25 +03:00
|
|
|
</div>
|
2026-09-19 21:59:30 +03:00
|
|
|
))}
|
|
|
|
|
{segmentStatus && <p className="text-xs text-muted-foreground/70">{segmentStatus}</p>}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</SettingsGroup>
|
2026-09-19 18:26:25 +03:00
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|