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", () => {