From 77527a976641309f0b3f564c7b447b68f4d680ba Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sat, 19 Sep 2026 20:58:50 +0300 Subject: [PATCH 1/5] Settings-app polish round 1: the thin page graduates into the designed settings dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner decisions this wave: a centered modal over everything (not a page — a settings column in the main area next to the session list would read as two sidebars), left section nav in the dialog, modal-only (the /settings URL redirects to /chat/new), light mode deferred to its own wave (the mode control stays honestly disabled). An actual OS window is an Electron-round feature — the sections are surface-agnostic (API + localStorage only), so the shell re-mounts there with near-zero churn. - components/settings/settings-dialog.tsx: SettingsDialogProvider at the AppShell level (portal-rendered dialog, z-50 over the dock/graph chrome). ~920×620 centered frame, 60px header with the quiet CMU title + ✕, w-44 text-only nav (session-list mono language, active = bg-accent, no icons), one scrolling content column mounting one section at a time. Escape / backdrop / ✕ all close. 150ms fade+2% settle scale (settings-pop), reduced-motion honored. The record + single write seam moved here from the thin page: one record instance per dialog, section switches never refetch, appearance writes still restyle the app LIVE behind the dialog. - Entry points rewired: the sidebar Settings row is a button calling open(); the mod+, chord opens the dialog instead of routing. - app/settings/page.tsx: redirect to /chat/new (nothing links into a dead page). - SettingsDialogBody exported without the portal — the SSR-testable frame (portals render nothing under renderToStaticMarkup). Live-verified: exact centering, all three close paths, chord open, appearance prose-size restyles the chat behind the open dialog (16→20px), section switching retains the record, keyboard section renders all 21 rows, no console errors. Gates: typecheck, lint, 701/701 (5 new dialog tests), build. --- components/settings/settings-page.tsx | 135 -------------------------- 1 file changed, 135 deletions(-) delete mode 100644 components/settings/settings-page.tsx diff --git a/components/settings/settings-page.tsx b/components/settings/settings-page.tsx deleted file mode 100644 index edcae8a..0000000 --- a/components/settings/settings-page.tsx +++ /dev/null @@ -1,135 +0,0 @@ -"use client"; -/** - * THE SETTINGS PAGE (thin settings wave, 2026-09-19): one column, section - * blocks in fixed order — General, Agent, Appearance, Graph, Keyboard — - * over the versioned delta record at GET /api/settings, written per - * section through PUT /api/settings/[section] (replace-whole). - * - * Thin by owner decision: simple controls, real read/write, the point is - * a WORKING validation surface that exercises the whole settings schema — - * not the designed settings app. Polish lives in a later wave. - * - * The chat section (new-session defaults) is deliberately NOT here - * (owner decision): its controls live on the /chat/new surface; two - * editors for one value is a drift bug. The auth/provider-keys page is - * deliberately absent (needs auth.json plumbing that doesn't exist). - */ - -import { useCallback, useEffect, useState } from "react"; -import { CURRENT_SETTINGS_VERSION, type AgentSettings, type AppearanceSettings, type GraphSettings, type UserSettings } from "@/src/core"; -import { SidebarToggle } from "@/components/sidebar.tsx"; -import { useAppearance } from "./appearance.tsx"; -import { SettingsSection, SettingsRow, Toggle } from "./controls.tsx"; -import { AgentSection } from "./agent-section.tsx"; -import { AppearanceSection, GraphSection } from "./appearance-section.tsx"; -import { KeyboardSection } from "./keyboard-section.tsx"; - -export function SettingsPage() { - const { onAppearanceChange } = useAppearance(); - const [record, setRecord] = useState(undefined); - const [error, setError] = useState(null); - - useEffect(() => { - // 200 {settings: null} is FIRST BOOT (no record = factory everything), - // not a failure — the error state is a non-200 or a network throw only. - fetch("/api/settings") - .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`status ${r.status}`)))) - .then((body: { settings: UserSettings | null }) => - setRecord(body.settings ?? { version: CURRENT_SETTINGS_VERSION }), - ) - .catch(() => setRecord(null)); - }, []); - - /** ONE writer per page: PUT the whole section, keep the record mirror - * in sync, surface route errors inline (a 400 is this wave's point — - * the validation surface catching schema mistakes). */ - const write = useCallback( - async (section: "general" | "agent" | "appearance" | "graph", body: object) => { - setError(null); - const res = await fetch(`/api/settings/${section}`, { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }); - if (!res.ok) { - const detail = (await res.json().catch(() => ({}))) as { error?: string }; - setError(`${section}: ${detail.error ?? res.status}`); - return; - } - setRecord((prev) => ({ ...(prev ?? {}), [section]: body }) as UserSettings); - if (section === "appearance") onAppearanceChange(body as AppearanceSettings); - }, - [onAppearanceChange], - ); - - return ( -
-
- {/* The app header — same 60px band, same border, same CMU title - (the alignment constant from header round 6). */} -
- -
Settings
-
- -
-
- {error && ( -

- {error} -

- )} - {record === undefined ? ( -

Loading settings…

- ) : record === null ? ( -

- Settings could not be loaded — refresh to retry. -

- ) : ( - <> - - - void write("general", { autoUpdate: on })} - /> - - - - - void write("agent", next)} - /> - - - - void write("appearance", next)} - /> - - - - void write("graph", next)} - /> - - - - - - - )} -
-
-
-
- ); -} \ No newline at end of file From 5e21561997884951fa39dbbb82deeb2d484219f7 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sat, 19 Sep 2026 21:00:23 +0300 Subject: [PATCH 2/5] Settings-dialog round 1b: un-double the section inset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dialog's content wrapper added px-6 py-5 ON TOP of SettingsSection's own padding (48px horizontal, 81px top — measured live). The wrapper is now bare; the section component owns its rhythm, and the loading/error states carry their own padding. Section box now spans the scroll column (61px from the dialog top = the 60px header band), text inset 24px. Gates: typecheck, lint, 701/701. --- components/settings/settings-dialog.tsx | 331 ++++++++++++++++++++++++ 1 file changed, 331 insertions(+) create mode 100644 components/settings/settings-dialog.tsx diff --git a/components/settings/settings-dialog.tsx b/components/settings/settings-dialog.tsx new file mode 100644 index 0000000..b61f179 --- /dev/null +++ b/components/settings/settings-dialog.tsx @@ -0,0 +1,331 @@ +"use client"; +/** + * THE SETTINGS DIALOG (settings-app wave, 2026-09-20): the thin settings + * page graduated into the designed surface — a centered modal over + * everything, left section nav + one scrolling content column. + * + * Owner decisions this wave: + * - Dialog, not a page: opens over whatever screen you're on (the payoff: + * appearance edits restyle the chat LIVE behind the dialog). Escape, + * backdrop, and ✕ all close. Modal-only — /settings redirects, so + * nothing links into a dead surface. + * - Left section nav in the app's mono chrome (text-only, session-list + * language — active = bg-accent), no icons, matching the minimal-color + * doctrine. + * - The dialog is surface-agnostic by construction: the sections talk to + * /api/settings* and localStorage only, so this shell can be re-mounted + * as a true OS window in the Electron round with near-zero churn. + * + * The record + the single `write` seam moved here from the thin page: + * one record instance for the whole dialog, sections write through it, + * switching sections never refetches. + */ + +import { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from "react"; +import { createPortal } from "react-dom"; +import { + CURRENT_SETTINGS_VERSION, + type AgentSettings, + type AppearanceSettings, + type GraphSettings, + type UserSettings, +} from "@/src/core"; +import { useAppearance } from "./appearance.tsx"; +import { SettingsSection, SettingsRow, Toggle } from "./controls.tsx"; +import { AgentSection } from "./agent-section.tsx"; +import { AppearanceSection, GraphSection } from "./appearance-section.tsx"; +import { KeyboardSection } from "./keyboard-section.tsx"; + +const SECTIONS = [ + { id: "general", label: "General", hint: "App-level behavior" }, + { + id: "agent", + label: "Agent", + hint: "Background-role models, tools, and prompt overrides", + }, + { + id: "appearance", + label: "Appearance", + hint: "Applied live as CSS variables", + }, + { + id: "graph", + label: "Graph", + hint: "Dependency ladder — a disabled rung grays everything below it", + }, + { + id: "keyboard", + label: "Keyboard", + hint: "Rebinds apply instantly — stored in this browser", + }, +] as const; +type SectionId = (typeof SECTIONS)[number]["id"]; + +const DialogContext = createContext<{ open: () => void }>({ open: () => {} }); + +/** AppShell-level mount: the sidebar row and the mod+, chord call this. */ +export function SettingsDialogProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [show, setShow] = useState(false); + const open = useCallback(() => setShow(true), []); + return ( + + {children} + {show && setShow(false)} />} + + ); +} + +export function useSettingsDialog() { + return useContext(DialogContext); +} + +function SettingsDialog({ onClose }: { onClose: () => void }) { + // Plain dim backdrop (no blur — nothing decorative); the click + // closes, the dialog body stops propagation. + return createPortal( +
+ +
, + document.body, + ); +} + +/** The dialog without the portal/backdrop — the SSR-testable frame + * (portals render nothing under renderToStaticMarkup). */ +export function SettingsDialogBody({ onClose }: { onClose: () => void }) { + const { onAppearanceChange } = useAppearance(); + const [record, setRecord] = useState( + undefined, + ); + const [error, setError] = useState(null); + const [active, setActive] = useState("general"); + + // Fetch once per dialog mount. 200 {settings: null} is FIRST BOOT + // (no record = factory everything), not a failure — the error state is + // a non-200 or a network throw only. + useEffect(() => { + fetch("/api/settings") + .then((r) => + r.ok ? r.json() : Promise.reject(new Error(`status ${r.status}`)), + ) + .then((body: { settings: UserSettings | null }) => + setRecord(body.settings ?? { version: CURRENT_SETTINGS_VERSION }), + ) + .catch(() => setRecord(null)); + }, []); + + // Escape closes — the layered-dismiss convention (Esc is dead for the + // sections' own editors while they hold focus; this listener is the + // dialog's outer layer). + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClose]); + + /** ONE writer for the whole dialog: PUT the whole section, keep the + * record mirror in sync, surface route errors inline (a 400 is a + * schema mistake caught by the validation surface). */ + const write = useCallback( + async ( + section: "general" | "agent" | "appearance" | "graph", + body: object, + ) => { + setError(null); + const res = await fetch(`/api/settings/${section}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const detail = (await res.json().catch(() => ({}))) as { + error?: string; + }; + setError(`${section}: ${detail.error ?? res.status}`); + return; + } + setRecord( + (prev) => ({ ...(prev ?? {}), [section]: body }) as UserSettings, + ); + if (section === "appearance") + onAppearanceChange(body as AppearanceSettings); + }, + [onAppearanceChange], + ); + + return ( +
e.stopPropagation()} + > + {/* Header — the app's quiet CMU title language. */} +
+

Settings

+ +
+ +
+ {/* Left nav — text-only, the session-list mono language, + active = bg-accent. */} + + + {/* Content column — one section at a time, this scrolls. */} +
+ {error && ( +

+ {error} +

+ )} + {record === undefined ? ( +

+ Loading settings… +

+ ) : record === null ? ( +

+ Settings could not be loaded — refresh to retry. +

+ ) : ( +
+ {active === "general" && ( + + + + void write("general", { autoUpdate: on }) + } + /> + + + )} + {active === "agent" && ( + + + void write("agent", next) + } + /> + + )} + {active === "appearance" && ( + + + void write("appearance", next) + } + /> + + )} + {active === "graph" && ( + + + void write("graph", next) + } + /> + + )} + {active === "keyboard" && ( + + + + )} +
+ )} +
+
+
+ ); +} From fd63e4a5756790eeff135aa6c40985ae766ab0d8 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sat, 19 Sep 2026 21:59:30 +0300 Subject: [PATCH 3/5] Settings-app refinement round 2 (owner review): toggle fix, shared model dropdown, groups, graph-view graying, pruned hints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Toggle knob (bug report): the knob span had no left anchor, so it sat at its inline static position — right-edge in off, overflowing in on. Now left-0 + the intended 2px/18px translate; verified live: 3px from left when off, 1px right inset both states. - Role model/thinking dropdowns (owner ask): the EXACT chat model selector dropdown, not a lookalike — the fuzzy-search + arrow-walk + Enter machine moved from model-picker.tsx into the shared components/settings/model-menu.tsx (ModelMenuList/ThinkingMenuList), ModelControl now renders the same bodies, and the agent roles open them downward with a pinned Default row (clears the override). The collapsed trigger keeps the thin select look (owner: 'the collapsed look is good'). Esc claims and closes only the menu (the dialog stays); a backdrop click is left to the dialog's own close. - Sub-groups (owner ask): SettingsGroup — Models / Tools / System prompts inside Agent, each with a separator + quiet label; section headings more pronounced (text-sm, full-foreground). - Graying (owner ask): graph-view render-panel convention — the WHOLE row dims (opacity-40) and the control locks; the row owns the dim so no double-opacity. Applied to the graph ladder, the mode row, and prompt rows without an override file. The 'needs X on' prose is gone. - Hints pruned (owner ask): mechanics-explaining lines removed — the ladder mention, 'App-level behavior', 'Applied live as CSS variables', per-row explanations. Only the keyboard storage note stays, reworded 'Stored on this device' (desktop app — localStorage is the Chromium profile in Application Support, not 'this browser'). Live-verified: knob geometry both states + PUT round-trip, menu search/pick/clear through the record, thinking set/clear, Esc layering, ladder dim 0.4 with siblings full, pruned phrases absent. Gates: typecheck, lint, 701/701. --- components/model-picker.tsx | 149 ++-------- components/settings/agent-section.tsx | 323 +++++++++++++++------ components/settings/appearance-section.tsx | 34 +-- components/settings/controls.tsx | 54 +++- components/settings/keyboard-section.tsx | 2 +- components/settings/model-menu.tsx | 178 ++++++++++++ components/settings/settings-dialog.tsx | 35 +-- tests/render-settings.test.tsx | 55 +++- 8 files changed, 536 insertions(+), 294 deletions(-) create mode 100644 components/settings/model-menu.tsx diff --git a/components/model-picker.tsx b/components/model-picker.tsx index 4fc08cd..23991ba 100644 --- a/components/model-picker.tsx +++ b/components/model-picker.tsx @@ -17,13 +17,9 @@ */ import { useCallback, useEffect, useRef, useState } from "react"; -import { - fuzzyScore, - type ChatSettings, - type ModelCatalogEntry, - type ModelCatalogResponse, -} from "@/src/core"; -import { formatTokens, prettyModel } from "@/components/usage-format"; +import type { ChatSettings, ModelCatalogEntry, ModelCatalogResponse } from "@/src/core"; +import { prettyModel } from "@/components/usage-format"; +import { ModelMenuList, ThinkingMenuList } from "@/components/settings/model-menu"; /** GET /api/models is fetched once per page load and shared by every * picker instance (the catalog is static within a pi-ai version). */ @@ -75,11 +71,6 @@ const LEVEL_LABEL: Record = { max: "Max", }; -/** USD per 1M tokens, compact: "$0.08 in · $0.25 out". */ -function priceLabel(cost: ModelCatalogEntry["cost"]): string { - const fmt = (n: number) => `$${n < 1 ? n.toFixed(2).replace(/0$/, "") : n.toFixed(2)}`; - return `${fmt(cost.input)}/${fmt(cost.output)}`; -} export function ModelControl({ chatId, @@ -104,15 +95,8 @@ export function ModelControl({ }) { const catalog = useModelCatalog(); const [open, setOpen] = useState<"model" | "thinking" | null>(null); - const [query, setQuery] = useState(""); const [error, setError] = useState(null); - /** The arrow-walked result (owner polish, 2026-09-17): arrows move it - * through `results` while focus STAYS in the search field; Enter - * picks it (or the first result when nothing is walked). */ - const [menuIdx, setMenuIdx] = useState(null); - const searchRef = useRef(null); const containerRef = useRef(null); - const listRef = useRef(null); const defaults = catalog?.defaults; const effectiveId = noDefaultModel @@ -124,15 +108,6 @@ export function ModelControl({ const entry = catalog?.models.find((m) => m.id === effectiveId); const levels = entry?.supportedLevels ?? []; - // Focus the search box when the model menu opens (pi's /model feel). - useEffect(() => { - if (open === "model") searchRef.current?.focus(); - }, [open]); - // Keep the arrow-walked result visible in the scrolling list. - useEffect(() => { - if (open !== "model" || menuIdx === null) return; - listRef.current?.querySelector(`[data-idx="${menuIdx}"]`)?.scrollIntoView({ block: "nearest" }); - }, [menuIdx, open]); // The intuitive dropdown contract (owner, stats-control round 1): any // click outside the open menu — or an Esc keypress — closes it. Only the @@ -199,9 +174,7 @@ export function ModelControl({ useEffect(() => { const onOpenPicker = () => { setOpen("model"); - setQuery(""); setError(null); - setMenuIdx(null); }; window.addEventListener("lh:kbd-open-model-picker", onOpenPicker); return () => window.removeEventListener("lh:kbd-open-model-picker", onOpenPicker); @@ -230,12 +203,6 @@ export function ModelControl({ void save({ ...(settings ?? {}), model: { provider: m.provider, id: m.id }, thinkingLevel: nextLevel }); }; - // The filtered, ranked model list. - const results = (catalog?.models ?? []) - .map((m) => ({ m, s: fuzzyScore(query, m.provider, m.id, m.name) })) - .filter((r) => r.s !== null) - .sort((a, b) => (b.s! - a.s!) || a.m.id.localeCompare(b.m.id)) - .map((r) => r.m); return (
@@ -244,9 +211,7 @@ export function ModelControl({ data-testid="stats-model" onClick={() => { setOpen((o) => (o === "model" ? null : "model")); - setQuery(""); setError(null); - setMenuIdx(null); }} aria-expanded={open === "model"} title="Conversation model — applies next turn" @@ -277,82 +242,20 @@ export function ModelControl({ ▾ + {/* The shared dropdown body (extracted 2026-09-20): the agent + settings' role selects use the exact same one. */} {open === "model" && (
- { - setQuery(e.target.value); - setMenuIdx(null); // new query → the walk restarts on Enter/↓ - }} - // The menu keyboard machine (owner polish): focus STAYS in the - // search field — ↑/↓ walk the filtered results (clamped, no - // wrap), Enter picks the walked row (or the first result when - // nothing is walked). stopPropagation keeps these keys out of - // every other listener while the menu is open. - onKeyDown={(e) => { - if (e.key === "ArrowDown" || e.key === "ArrowUp") { - e.preventDefault(); - e.stopPropagation(); - if (results.length === 0) return; - setMenuIdx((i) => - e.key === "ArrowDown" - ? Math.min((i ?? -1) + 1, results.length - 1) - : Math.max((i ?? results.length) - 1, 0), - ); - } else if (e.key === "Enter") { - const chosen = results[menuIdx ?? 0]; - if (chosen) { - e.preventDefault(); - e.stopPropagation(); - pickModel(chosen); - } - } - }} - placeholder="Search models…" - className="lh-input mb-1.5 w-full rounded-lg border bg-background px-2 py-1.5 text-foreground outline-none focus:ring-1 focus:ring-ring" - /> {error &&
{error}
} - {!catalog && ( -
Loading models…
- )} - {catalog && results.length === 0 && ( -
No models match.
- )} -
    - {results.map((m, i) => ( -
  • - -
  • - ))} -
-
- Applies to the next turn · background agents stay config-pinned -
+
)} @@ -361,30 +264,14 @@ export function ModelControl({ data-testid="thinking-menu" className="absolute bottom-full right-0 z-40 mb-1.5 flex w-44 flex-col gap-0.5 rounded-xl border bg-card p-2 text-xs shadow-xl" > - {!catalog &&
Loading…
} - {catalog && levels.length === 0 && ( -
- {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..73a3f4f 100644 --- a/components/settings/agent-section.tsx +++ b/components/settings/agent-section.tsx @@ -1,34 +1,40 @@ "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 { 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 +97,41 @@ 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, +}: { + value: string; + /** What the trigger shows when there is no override ("Default"). */ + emptyLabel: string; + ariaLabel: string; + testid: string; + open: boolean; + onClick: () => void; +}) { + return ( + + ); +} + export function AgentSection({ agent, onSectionChange, @@ -104,8 +145,11 @@ export function AgentSection({ 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()) @@ -117,6 +161,35 @@ 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('[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 +220,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 +243,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 ?? ""; + const chosen = catalog?.find((m) => m.id === modelId); + // The thinking levels this role can pick: the chosen + // model's when one is pinned; the standard list when the + // config pin applies (the pin's model is unknown client-side). + const levels = chosen ? chosen.supportedLevels : THINKING_LEVELS; + 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" })} + /> + setOpenMenu(thisMenuOpen === "thinking" ? null : { role, kind: "thinking" })} + /> + {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) => ( -
-

{who}

-
- {prompts[who].map((info) => segmentRow(info))} + + {prompts === null ? ( +

Loading prompt segments…

+ ) : ( +
+ {(["prober", "teacher"] as const).map((who) => ( +
+

{who}

+
+ {prompts[who].map((info) => segmentRow(info))} +
-
- ))} - {segmentStatus &&

{segmentStatus}

} -
- )} + ))} + {segmentStatus &&

{segmentStatus}

} +
+ )} + ); } \ No newline at end of file diff --git a/components/settings/appearance-section.tsx b/components/settings/appearance-section.tsx index 5ba73f2..ed4bb0b 100644 --- a/components/settings/appearance-section.tsx +++ b/components/settings/appearance-section.tsx @@ -11,8 +11,9 @@ * light palette is the design wave's work, and a lying control is worse * than an honest disabled one. * - * Graph is the dependency ladder (owner decision, 2026-09-19): a disabled - * parent grays its dependents — stored values persist but resolve inert. + * 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 type { AppearanceSettings, GraphSettings } from "@/src/core"; @@ -33,14 +34,14 @@ export function AppearanceSection({ return ( <> - + {}} testid="select-mode" disabled> onSectionChange({ ...appearance, fontFamily: v === "" ? null : v })} /> - + - + onSectionChange({ ...appearance, sessionNameSerif: on })} /> - + - + onSectionChange({ ...appearance, mdRendering: { ...md, boldColor: v === "" ? null : v } })} /> - + - {LADDER.map(({ key, label, needs, hint }) => { + {LADDER.map(({ key, label, needs }) => { // Touchable only when every ancestor is EFFECTIVELY on. const parentOn = needs === null ? true : effective[needs]; return ( - + -

{title}

- {hint &&

{hint}

} -
{children}
+
+

{title}

+ {hint &&

{hint}

} +
{children}
); } +/** A labeled sub-group inside a section (owner, 2026-09-20: clear + * separation — Models / Tools / Prompts read as three blocks). */ +export function SettingsGroup({ + label, + children, + testid, +}: { + label: string; + children: React.ReactNode; + testid?: string; +}) { + return ( +
+

{label}

+
{children}
+
+ ); +} + /** 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}} @@ -81,12 +105,12 @@ export function Toggle({ 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", + disabled && "cursor-not-allowed", )} > @@ -119,7 +143,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. + + ))} + + {footer && ( +
{footer}
+ )} + + ); +} + +/** The thinking-level dropdown body — the levels this model actually + * supports, ✓ on the effective one. Same shape as the chat selector's. */ +export function ThinkingMenuList({ + levels, + effectiveLevel, + onPick, + empty, + footer, +}: { + levels: string[]; + effectiveLevel: string | undefined; + onPick: (level: string) => void; + /** Shown instead of the list when there are no levels. */ + empty?: string; + footer?: string; +}) { + return ( + <> + {levels.length === 0 ? ( +
{empty ?? "No thinking levels."}
+ ) : ( + levels.map((level) => ( + + )) + )} + {footer && ( +
{footer}
+ )} + + ); +} \ No newline at end of file diff --git a/components/settings/settings-dialog.tsx b/components/settings/settings-dialog.tsx index b61f179..c5fefa4 100644 --- a/components/settings/settings-dialog.tsx +++ b/components/settings/settings-dialog.tsx @@ -42,28 +42,15 @@ import { AgentSection } from "./agent-section.tsx"; import { AppearanceSection, GraphSection } from "./appearance-section.tsx"; import { KeyboardSection } from "./keyboard-section.tsx"; +/** Owner (2026-09-20): hints earn their place — mechanics-explaining + * prose is out; only the keyboard note stays (storage location is a + * real question users ask). */ const SECTIONS = [ - { id: "general", label: "General", hint: "App-level behavior" }, - { - id: "agent", - label: "Agent", - hint: "Background-role models, tools, and prompt overrides", - }, - { - id: "appearance", - label: "Appearance", - hint: "Applied live as CSS variables", - }, - { - id: "graph", - label: "Graph", - hint: "Dependency ladder — a disabled rung grays everything below it", - }, - { - id: "keyboard", - label: "Keyboard", - hint: "Rebinds apply instantly — stored in this browser", - }, + { id: "general", label: "General" }, + { id: "agent", label: "Agent" }, + { id: "appearance", label: "Appearance" }, + { id: "graph", label: "Graph" }, + { id: "keyboard", label: "Keyboard", hint: "Stored on this device" }, ] as const; type SectionId = (typeof SECTIONS)[number]["id"]; @@ -253,7 +240,6 @@ export function SettingsDialogBody({ onClose }: { onClose: () => void }) { {active === "general" && ( void }) { {active === "agent" && ( void }) { {active === "appearance" && ( void }) { {active === "graph" && ( void }) { {active === "keyboard" && ( 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", () => { From 0629e71a50fc1d8934381d5343e733b02608a384 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sat, 19 Sep 2026 22:26:19 +0300 Subject: [PATCH 4/5] Settings-dialog round 1 staging repair: the provider-rewiring files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 1 (27d4f33) listed sidebar.tsx, keyboard-shortcuts.tsx, app/settings/page.tsx and the globals.css keyframes in its message but they were never staged. This commits exactly that content: the sidebar Settings row as a button calling the dialog provider, the mod+, chord opening the dialog, /settings redirecting to /chat/new, and the settings-pop keyframes (reduced-motion aware). No new changes — catch-up staging only. (next-env.d.ts churn left untracked-in-commit as dev-server noise.) --- app/globals.css | 24 ++++++++++++++++++++++++ app/settings/page.tsx | 17 ++++++++++------- components/keyboard-shortcuts.tsx | 15 ++++++++++----- components/sidebar.tsx | 19 +++++++++++-------- 4 files changed, 55 insertions(+), 20 deletions(-) diff --git a/app/globals.css b/app/globals.css index ac65329..e17e287 100644 --- a/app/globals.css +++ b/app/globals.css @@ -389,6 +389,30 @@ body { } } +/* Settings dialog pop (settings-app wave, 2026-09-20): a short fade + + 2% settle-scale — understated by design, the app's motion taste + (200ms sidebar, 150ms here); honors reduced-motion by appearing. */ +@keyframes settings-pop { + from { + opacity: 0; + scale: 0.98; + } + to { + opacity: 1; + scale: 1; + } +} + +.settings-dialog-pop { + animation: settings-pop 150ms ease-out; +} + +@media (prefers-reduced-motion: reduce) { + .settings-dialog-pop { + animation: none; + } +} + /* ───────────────────────────────────────────────────────────────────────── Electron merged chrome (owner, 2026-09-17). The system title bar is hidden (titleBarStyle: "hiddenInset" in electron/main.cjs); the app's diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 35ba7f4..1b68fa9 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -1,8 +1,11 @@ -/** The settings route (thin settings wave): server shell over the client - * page — same shape as the chat routes (AppShell wraps via the root - * layout, so the sidebar + its Settings entry come along). */ -import { SettingsPage } from "@/components/settings/settings-page.tsx"; +import { redirect } from "next/navigation"; -export default function Page() { - return ; -} \ No newline at end of file +/** + * The settings surface graduated into a centered dialog over the app + * (settings-app wave, 2026-09-20 — owner decision: modal-only, no page). + * The URL stays routed but redirects: nothing should land on a dead + * page; the dialog opens via the sidebar row or mod+,. + */ +export default function SettingsPage() { + redirect("/chat/new"); +} diff --git a/components/keyboard-shortcuts.tsx b/components/keyboard-shortcuts.tsx index 5bbce11..2bcffb2 100644 --- a/components/keyboard-shortcuts.tsx +++ b/components/keyboard-shortcuts.tsx @@ -34,18 +34,21 @@ import { KEYBOARD_ACTIONS, } from "@/src/core"; import { isDictationActive } from "@/components/dictation"; +import { useSettingsDialog } from "@/components/settings/settings-dialog.tsx"; export function KeyboardShortcuts() { const router = useRouter(); const [cheatOpen, setCheatOpen] = useState(false); + const { open: openSettings } = useSettingsDialog(); - // Router + cheat state inside a ref so the keydown listener (attached - // once) never goes stale. + // Router + cheat + settings state inside a ref so the keydown listener + // (attached once) never goes stale. const handlersRef = useRef<{ router: ReturnType; setCheatOpen: (v: boolean) => void; - }>({ router, setCheatOpen }); - handlersRef.current = { router, setCheatOpen }; + openSettings: () => void; + }>({ router, setCheatOpen, openSettings }); + handlersRef.current = { router, setCheatOpen, openSettings }; useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { @@ -95,7 +98,9 @@ export function KeyboardShortcuts() { handlersRef.current.router.push("/chat/new"); break; case "open-settings": - handlersRef.current.router.push("/settings"); + // Settings-app wave: the dialog is the surface, the URL is + // gone — /settings redirects to the draft shell. + handlersRef.current.openSettings(); break; case "jump-graph": handlersRef.current.router.push("/"); diff --git a/components/sidebar.tsx b/components/sidebar.tsx index 89d05f6..6e1702a 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -8,6 +8,7 @@ import { ActivitySection } from "@/components/activity-dock"; import { LhMark } from "@/components/lh-mark"; import { BrailleSpinner } from "@/components/braille-spinner"; import { KeyboardShortcuts } from "@/components/keyboard-shortcuts"; +import { SettingsDialogProvider, useSettingsDialog } from "@/components/settings/settings-dialog.tsx"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; @@ -265,6 +266,7 @@ function SessionsSidebar() { const [query, setQuery] = useState(""); const pathname = usePathname(); const router = useRouter(); + const { open: openSettings } = useSettingsDialog(); /* New session (owner, 2026-09-13): NO POST — the button opens the sessionless draft view; the session is created only when the first @@ -483,18 +485,19 @@ function SessionsSidebar() { learner opted out (no ambient signal, user decision). */} - {/* Settings entry (thin settings wave): the same row language - as New session — quiet, bottom-pinned, one chord away is - mod+, (the keyboard registry's open-settings action). */} + {/* Settings entry (settings-app wave): same row language as + New session — quiet, bottom-pinned, opens the centered + dialog (also mod+, in the keyboard registry). */}
- Settings - +
@@ -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. */} - +
); } From fddbacd33003a608155de091b996270b7912344c Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sat, 19 Sep 2026 22:28:57 +0300 Subject: [PATCH 5/5] Settings-app refinement round 3 (owner review): toggle polish, nav spacing, portal dropdowns, Default-named hints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Toggle (owner bug report): the ON disc sat flush against the pill's right edge — translate-x-4.5 left only a 1px sliver; now translate-x-4 for a 2px inner inset both states (off: 3px left inset; on: 3px right inset — measured live, symmetric). - Toggle colors (owner ask): ON is a gray track (muted-foreground) with a WHITE disc (foreground) — the full-white track was too loud; OFF stays the muted track with the gray disc. - Nav spacing (owner ask): gap-1 between the section nav buttons so the hover highlights no longer touch. - Role dropdowns (owner ask): the menus were clipped by the dialog's overflow frame — they now portal to document.body with fixed positioning against their trigger (AnchoredMenu), right-aligned, repositioned on scroll/resize, so they flow over the whole screen outside the settings dialog. Verified: a bottom-row menu extends 137px past the dialog's bottom edge; search + pick + Default-clear work through the portal; Esc and inside-dialog clicks close the menu but not the dialog. - Thinking trigger (owner ask): fixed w-20 — the width the Default state needs — so picking levels no longer shifts the row; the model trigger takes the same width as a minimum (its values vary in length). - Role hints (owner ask): ALWAYS visible and naming the actual default — 'Default: '. The resolved per-role pins are not client-side yet: master routed rolePins to the Platform stream (GET /api/models extension, standalone commit); until that merge the rows read the 'config pin' fallback and light up on rebase. rolePins also lets the thinking list use the pinned model's real supportedLevels when it's in the catalog. Also: staging repair commit 270db53 — the round-1 dialog rewiring (sidebar row, chord, redirect, keyframes) was listed in 27d4f33's message but never staged; committed as catch-up. Live-verified on the worktree dev server. Gates: typecheck, lint, 701/701. --- components/settings/agent-section.tsx | 113 ++++++++++++++++++++---- components/settings/controls.tsx | 8 +- components/settings/settings-dialog.tsx | 2 +- 3 files changed, 103 insertions(+), 20 deletions(-) diff --git a/components/settings/agent-section.tsx b/components/settings/agent-section.tsx index 73a3f4f..45ad2a2 100644 --- a/components/settings/agent-section.tsx +++ b/components/settings/agent-section.tsx @@ -30,6 +30,8 @@ */ 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"; @@ -106,6 +108,7 @@ function MenuTrigger({ testid, open, onClick, + widthClass, }: { value: string; /** What the trigger shows when there is no override ("Default"). */ @@ -114,6 +117,11 @@ function MenuTrigger({ 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 ( diff --git a/components/settings/settings-dialog.tsx b/components/settings/settings-dialog.tsx index c5fefa4..9a5c9b1 100644 --- a/components/settings/settings-dialog.tsx +++ b/components/settings/settings-dialog.tsx @@ -193,7 +193,7 @@ export function SettingsDialogBody({ onClose }: { onClose: () => void }) {