- {effectiveId ? `${prettyModel(effectiveId)} has no thinking levels.` : "Pick a model first."}
-
- )}
- {levels.map((level) => (
-
- ))}
+ void save({ ...(settings ?? {}), thinkingLevel: level as ChatSettings["thinkingLevel"] })}
+ empty={catalog ? (effectiveId ? `${prettyModel(effectiveId)} has no thinking levels.` : "Pick a model first.") : "Loading…"}
+ footer="Levels this model actually supports"
+ />
{error &&
{error}
}
-
- Levels this model actually supports
-
)}
diff --git a/components/settings/agent-section.tsx b/components/settings/agent-section.tsx
index 9ed301f..45ad2a2 100644
--- a/components/settings/agent-section.tsx
+++ b/components/settings/agent-section.tsx
@@ -1,34 +1,42 @@
"use client";
/**
- * The Agent section (thin settings wave): background-role model overrides,
- * the tool toggles, and the prompt override switches — three blocks, ONE
- * section (PUT /api/settings/agent replaces the whole agent object, so
- * every change composes models+tools+prompts together from the record).
+ * 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 gets a Default option (absent
- * override = config.ts's pin) plus the /api/models catalog. The thinking
- * select shows the CHOSEN model's supported levels when a model override
- * exists; without one the factory model is unknown client-side, so the
- * standard levels list (the server sanitizer accepts any valid level).
+ * 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), the text writes through the segment
- * route. Switch state lives in this section's PUT; text does not.
+ * 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, useState } from "react";
-import {
- BACKGROUND_MODEL_ROLES,
- type AgentSettings,
- type BackgroundModelRole,
-} from "@/src/core";
+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 type { ModelCatalogEntry } from "@/src/core";
-import { SettingsRow, ThinSelect, Toggle } from "./controls.tsx";
+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 = {
@@ -91,6 +99,96 @@ export function composePromptSwitch(agent: AgentSettings | undefined, path: stri
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,
@@ -100,16 +198,27 @@ export function AgentSection({
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 page load.
+ // The catalog + the prompt segments — one fetch each per section mount.
useEffect(() => {
fetch("/api/models")
.then((r) => r.json())
- .then((body: { models: ModelCatalogEntry[] }) => setCatalog(body.models))
+ .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())
@@ -117,6 +226,36 @@ export function AgentSection({
.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}`, {
@@ -147,6 +286,22 @@ export function AgentSection({
.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;
@@ -154,8 +309,9 @@ export function AgentSection({
- {/* ── Models ── */}
- {BACKGROUND_MODEL_ROLES.map((role) => {
- const override = agent?.models?.[role];
- const modelId = override?.model?.id ?? "";
- const thinking = override?.thinkingLevel ?? "";
- const chosen = catalog?.find((m) => m.id === modelId);
- const levels = chosen ? chosen.supportedLevels : THINKING_LEVELS;
- return (
-
-
- onSectionChange(composeRoleModel(agent, role, id, thinking))}
+
+ {BACKGROUND_MODEL_ROLES.map((role) => {
+ const override = agent?.models?.[role];
+ const modelId = override?.model?.id ?? "";
+ const thinking = override?.thinkingLevel ?? "";
+ // 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);
+ const levels = chosen ? chosen.supportedLevels : THINKING_LEVELS;
+ // 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";
+ const thisMenuOpen =
+ openMenu?.role === role ? openMenu.kind : null;
+ return (
+
+ {/* The anchor for the outside-click contract is the
+ span holding BOTH triggers of the open menu. */}
+
-
- {/* The stored id stays VISIBLE even when the catalog
- hasn't loaded (SSR) or no longer contains it —
- a select that silently shows "Default" over a
- live override would lie about the record. */}
- {modelId && !catalog?.some((m) => m.id === modelId) && (
-
+ setOpenMenu(thisMenuOpen === "model" ? null : { role, kind: "model" })}
+ widthClass="min-w-20"
+ />
+ setOpenMenu(thisMenuOpen === "thinking" ? null : { role, kind: "thinking" })}
+ widthClass="w-20"
+ />
+ {thisMenuOpen === "model" && (
+
+ {defaultRow(
+ modelId === "",
+ () => {
+ onSectionChange(composeRoleModel(agent, role, "", thinking));
+ setOpenMenu(null);
+ },
+ )}
+ {
+ onSectionChange(composeRoleModel(agent, role, entry.id, thinking));
+ setOpenMenu(null);
+ }}
+ footer="Applied to every run of this role"
+ />
+
)}
- {catalog?.map((m) => (
-
- ))}
-
- onSectionChange(composeRoleModel(agent, role, modelId, level))}
- >
-
- {levels.map((l) => (
-
- ))}
-
-
+ {thisMenuOpen === "thinking" && (
+
+ {defaultRow(
+ thinking === "",
+ () => {
+ onSectionChange(composeRoleModel(agent, role, modelId, ""));
+ setOpenMenu(null);
+ },
+ )}
+ {
+ onSectionChange(composeRoleModel(agent, role, modelId, level));
+ setOpenMenu(null);
+ }}
+ />
+
+ )}
+
+
+ );
+ })}
+
+
+
+ {TOGGLE_TOOLS.map((id) => (
+
+ onSectionChange(composeTool(agent, id, on))}
+ />
- );
- })}
+ ))}
+
- {/* ── Tools ── */}
- {TOGGLE_TOOLS.map((id) => (
-
- onSectionChange(composeTool(agent, id, on))}
- />
-
- ))}
-
- {/* ── Prompts ── */}
- {prompts === null ? (
-
Loading prompt segments…
- ) : (
-
- {(["prober", "teacher"] as const).map((who) => (
-
+ );
+}
+
/** One label + one control. Label left (with optional muted hint), control
* right — the settings row shape. */
export function SettingsRow({
@@ -39,14 +59,18 @@ export function SettingsRow({
hint,
children,
testid,
+ dimmed,
}: {
label: string;
hint?: string;
children: React.ReactNode;
testid?: string;
+ /** Dependency graying, graph-view style: the whole row dims when the
+ * control is inert (parent off, no file yet, …). */
+ dimmed?: boolean;
}) {
return (
-
+
{label}
{hint && {hint}}
@@ -80,14 +104,18 @@ export function Toggle({
onClick={() => onChange(!checked)}
className={cn(
"relative h-4.5 w-8 shrink-0 rounded-full border border-border transition-colors",
- checked ? "bg-foreground" : "bg-muted",
- disabled && "cursor-not-allowed opacity-40",
+ // Owner (2026-09-20): on is a gray track with a WHITE disc (a full-white
+ // track was too loud); off stays the muted track.
+ checked ? "bg-muted-foreground" : "bg-muted",
+ disabled && "cursor-not-allowed",
)}
>
@@ -119,7 +147,7 @@ export function ThinSelect({
onChange={(e) => onChange(e.target.value)}
className={cn(
"h-7 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground outline-none",
- disabled && "cursor-not-allowed opacity-40",
+ disabled && "cursor-not-allowed",
)}
>
{children}
diff --git a/components/settings/keyboard-section.tsx b/components/settings/keyboard-section.tsx
index aae1ef5..94ef13c 100644
--- a/components/settings/keyboard-section.tsx
+++ b/components/settings/keyboard-section.tsx
@@ -155,7 +155,7 @@ export function KeyboardSection() {
);
})}
- Stored in this browser (lh:keymap) — overrides only.
+ Stored on this device (lh:keymap) — overrides only.
@@ -504,13 +507,13 @@ function SessionsSidebar() {
/** The app frame: sidebar inline + content. Rendered once in the root layout. */
export function AppShell({ children }: { children: React.ReactNode }) {
return (
- <>
+ {children}
{/* The single document-level keydown listener (keyboard wave A) —
after the content in the tree, but it's window-level so order only
matters for the events it dispatches, never for DOM priority. */}
- >
+
);
}
diff --git a/tests/render-settings.test.tsx b/tests/render-settings.test.tsx
index e44f3f3..5f5a22b 100644
--- a/tests/render-settings.test.tsx
+++ b/tests/render-settings.test.tsx
@@ -15,6 +15,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { TOGGLE_TOOLS, AgentSection } from "../components/settings/agent-section.tsx";
import { AppearanceSection, GraphSection } from "../components/settings/appearance-section.tsx";
import { KeyboardSection } from "../components/settings/keyboard-section.tsx";
+import { SettingsDialogBody, SettingsDialogProvider } from "../components/settings/settings-dialog.tsx";
import { KEYBOARD_ACTIONS } from "../src/core/index.ts";
/** The full opening tag of the element carrying a testid — attribute
@@ -26,14 +27,50 @@ function tagOf(html: string, testid: string): string {
return html.slice(html.lastIndexOf("<", i), html.indexOf(">", i) + 1);
}
+describe("SettingsDialog (the designed surface, SSR)", () => {
+ it("provider CLOSED: children only — no dialog, no portal content", () => {
+ const html = renderToStaticMarkup(
+
+
the app
+ ,
+ );
+ assert.ok(html.includes("the app"));
+ assert.ok(!html.includes("settings-backdrop"));
+ assert.ok(!html.includes("settings-dialog"));
+ });
+
+ it("the dialog frame: role=dialog + the ✕ + the five-section nav, General active", () => {
+ const html = renderToStaticMarkup( {}} />);
+ assert.ok(html.includes('role="dialog"'));
+ assert.ok(html.includes('aria-modal="true"'));
+ assert.ok(html.includes('aria-label="Close settings"'));
+ for (const id of ["general", "agent", "appearance", "graph", "keyboard"]) {
+ assert.ok(html.includes(`data-testid="settings-nav-${id}"`), id);
+ }
+ // General is the landing section; the others are not current.
+ assert.ok(tagOf(html, "settings-nav-general").includes('aria-current="true"'));
+ assert.ok(tagOf(html, "settings-nav-keyboard").includes('aria-current="false"'));
+ // Fetches don't run under SSR — the loading state IS the SSR state.
+ assert.ok(html.includes("Loading settings…"));
+ // Modal-only doctrine: no link back to /settings anywhere in it.
+ assert.ok(!html.includes('href="/settings"'));
+ });
+});
+
describe("AgentSection (thin SSR)", () => {
- it("renders all six background-role rows, Default-first selects", () => {
+ it("renders all six background-role rows, Default-triggered dropdowns", () => {
const html = renderToStaticMarkup( {}} />);
for (const role of ["researcher", "demoBuilder", "visualMaker", "extractor", "auditor", "labeler"]) {
assert.ok(html.includes(`data-testid="role-row-${role}"`), role);
- assert.ok(html.includes(`data-testid="role-model-${role}"`), `${role} model select`);
+ // The shared model-picker dropdown as the opened menu (owner,
+ // 2026-09-20), the thin trigger as the collapsed look.
+ assert.ok(html.includes(`data-testid="role-model-${role}"`), `${role} model trigger`);
+ assert.ok(html.includes(`data-testid="role-thinking-${role}"`), `${role} thinking trigger`);
}
- assert.equal((html.match(/>Default<\/option>/g) ?? []).length, 12, "six roles × two selects");
+ assert.equal((html.match(/>Default<\/span>/g) ?? []).length, 12, "six roles × two Default triggers");
+ assert.ok(html.includes('data-testid="group-models"'), "Models sub-group");
+ assert.ok(html.includes('data-testid="group-tools"'), "Tools sub-group");
+ assert.ok(html.includes('data-testid="group-prompts"'), "System prompts sub-group");
});
it("renders the owner's FINAL tool list — research, demo, visual, pdf", () => {
@@ -59,9 +96,9 @@ describe("AgentSection (thin SSR)", () => {
onSectionChange={() => {}}
/>,
);
- // SSR has no catalog: the stored id renders as its own option (a select
- // that showed "Default" over a live override would lie about the record).
- assert.ok(html.includes(`value="z-ai/glm-4.7-flash" selected=""`), "the override id is selected, visible");
+ // The trigger shows the stored id even with no catalog loaded (a
+ // trigger reading "Default" over a live override would lie).
+ assert.ok(html.includes("Glm 4.7 Flash"), "the override id is visible (prettyModel form)");
assert.ok(tagOf(html, "toggle-tool-demo").includes('aria-checked="false"'), "demo off");
assert.ok(tagOf(html, "toggle-tool-visual").includes('aria-checked="true"'), "visual still on");
});
@@ -105,7 +142,9 @@ describe("AppearanceSection (thin SSR)", () => {
it("mode is rendered DISABLED — dark-only this wave, no lying control", () => {
const html = renderToStaticMarkup( {}} />);
assert.ok(tagOf(html, "select-mode").includes("disabled"), "mode select disabled");
- assert.ok(html.includes("Light mode ships with the design wave"));
+ assert.ok(html.includes("Light ships with the design wave"));
+ // The whole row dims, graph-view style (the control never adds its own).
+ assert.ok(tagOf(html, "row-mode").includes("opacity-40"), "mode row dims whole");
});
it("hex inputs carry the factory placeholders; prose size resolves against the factory", () => {
@@ -113,7 +152,7 @@ describe("AppearanceSection (thin SSR)", () => {
assert.ok(html.includes(`placeholder="#141414"`));
assert.ok(html.includes(`placeholder="Source Sans 3"`));
assert.ok(html.includes(`placeholder="#c96b4a"`));
- assert.ok(html.includes("16px — prose, inputs, session titles scale together"));
+ assert.ok(html.includes("16px"));
});
it("a set background shows in the input, not the placeholder", () => {