Settings-app refinement round 2 (owner review): toggle fix, shared model dropdown, groups, graph-view graying, pruned hints

- 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.
This commit is contained in:
amosblomqvist
2026-09-19 22:29:17 +03:00
parent 5e21561997
commit fd63e4a575
8 changed files with 536 additions and 294 deletions
+18 -131
View File
@@ -17,13 +17,9 @@
*/ */
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import type { ChatSettings, ModelCatalogEntry, ModelCatalogResponse } from "@/src/core";
fuzzyScore, import { prettyModel } from "@/components/usage-format";
type ChatSettings, import { ModelMenuList, ThinkingMenuList } from "@/components/settings/model-menu";
type ModelCatalogEntry,
type ModelCatalogResponse,
} from "@/src/core";
import { formatTokens, prettyModel } from "@/components/usage-format";
/** GET /api/models is fetched once per page load and shared by every /** GET /api/models is fetched once per page load and shared by every
* picker instance (the catalog is static within a pi-ai version). */ * picker instance (the catalog is static within a pi-ai version). */
@@ -75,11 +71,6 @@ const LEVEL_LABEL: Record<string, string> = {
max: "Max", 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({ export function ModelControl({
chatId, chatId,
@@ -104,15 +95,8 @@ export function ModelControl({
}) { }) {
const catalog = useModelCatalog(); const catalog = useModelCatalog();
const [open, setOpen] = useState<"model" | "thinking" | null>(null); const [open, setOpen] = useState<"model" | "thinking" | null>(null);
const [query, setQuery] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(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<number | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const defaults = catalog?.defaults; const defaults = catalog?.defaults;
const effectiveId = noDefaultModel const effectiveId = noDefaultModel
@@ -124,15 +108,6 @@ export function ModelControl({
const entry = catalog?.models.find((m) => m.id === effectiveId); const entry = catalog?.models.find((m) => m.id === effectiveId);
const levels = entry?.supportedLevels ?? []; 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 // The intuitive dropdown contract (owner, stats-control round 1): any
// click outside the open menu — or an Esc keypress — closes it. Only the // click outside the open menu — or an Esc keypress — closes it. Only the
@@ -199,9 +174,7 @@ export function ModelControl({
useEffect(() => { useEffect(() => {
const onOpenPicker = () => { const onOpenPicker = () => {
setOpen("model"); setOpen("model");
setQuery("");
setError(null); setError(null);
setMenuIdx(null);
}; };
window.addEventListener("lh:kbd-open-model-picker", onOpenPicker); window.addEventListener("lh:kbd-open-model-picker", onOpenPicker);
return () => window.removeEventListener("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 }); 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 ( return (
<div ref={containerRef} className="relative flex items-center gap-1.5" data-testid="model-control"> <div ref={containerRef} className="relative flex items-center gap-1.5" data-testid="model-control">
@@ -244,9 +211,7 @@ export function ModelControl({
data-testid="stats-model" data-testid="stats-model"
onClick={() => { onClick={() => {
setOpen((o) => (o === "model" ? null : "model")); setOpen((o) => (o === "model" ? null : "model"));
setQuery("");
setError(null); setError(null);
setMenuIdx(null);
}} }}
aria-expanded={open === "model"} aria-expanded={open === "model"}
title="Conversation model — applies next turn" title="Conversation model — applies next turn"
@@ -277,82 +242,20 @@ export function ModelControl({
<span aria-hidden className="text-xs text-muted-foreground">▾</span> <span aria-hidden className="text-xs text-muted-foreground">▾</span>
</button> </button>
{/* The shared dropdown body (extracted 2026-09-20): the agent
settings' role selects use the exact same one. */}
{open === "model" && ( {open === "model" && (
<div <div
data-testid="model-menu" data-testid="model-menu"
className="absolute bottom-full right-0 z-40 mb-1.5 flex w-[22rem] flex-col rounded-xl border bg-card p-2 text-xs shadow-xl" className="absolute bottom-full right-0 z-40 mb-1.5 flex w-[22rem] flex-col rounded-xl border bg-card p-2 text-xs shadow-xl"
> >
<input
ref={searchRef}
data-testid="model-search"
value={query}
onChange={(e) => {
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 && <div className="mb-1.5 text-destructive">{error}</div>} {error && <div className="mb-1.5 text-destructive">{error}</div>}
{!catalog && ( <ModelMenuList
<div className="px-2 py-3 text-center text-muted-foreground">Loading models…</div> models={catalog?.models}
)} effectiveId={effectiveId}
{catalog && results.length === 0 && ( onPick={pickModel}
<div className="px-2 py-3 text-center text-muted-foreground">No models match.</div> footer="Applies to the next turn · background agents stay config-pinned"
)} />
<ul ref={listRef} data-testid="model-list" className="flex max-h-72 flex-col overflow-y-auto px-1">
{results.map((m, i) => (
<li key={`${m.provider}/${m.id}`}>
<button
type="button"
data-testid={`model-option-${m.id.replace(/[^a-z0-9-]/gi, "_")}`}
data-idx={i}
onClick={() => pickModel(m)}
className={`flex w-full flex-col gap-0.5 rounded-lg px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted ${
i === menuIdx ? "bg-accent/40 ring-1 ring-inset ring-border" : ""
}${m.id === effectiveId && i !== menuIdx ? " bg-muted/60" : ""}`}
>
<span className="flex items-center justify-between gap-2">
<span className="truncate text-card-foreground">{m.name}</span>
<span className="shrink-0 tabular-nums text-muted-foreground" title="USD per 1M tokens in/out">
{priceLabel(m.cost)}
</span>
</span>
<span className="flex items-center justify-between gap-2 text-muted-foreground">
<span className="truncate">{m.id}</span>
<span className="shrink-0 tabular-nums">{formatTokens(m.contextWindow)}</span>
</span>
</button>
</li>
))}
</ul>
<div className="mt-1 border-t px-2 pt-1.5 text-xs text-muted-foreground">
Applies to the next turn · background agents stay config-pinned
</div>
</div> </div>
)} )}
@@ -361,30 +264,14 @@ export function ModelControl({
data-testid="thinking-menu" 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" 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 && <div className="px-2 py-3 text-center text-muted-foreground">Loading…</div>} <ThinkingMenuList
{catalog && levels.length === 0 && ( levels={catalog ? levels : []}
<div className="px-2 py-3 text-center text-muted-foreground"> effectiveLevel={effectiveLevel}
{effectiveId ? `${prettyModel(effectiveId)} has no thinking levels.` : "Pick a model first."} onPick={(level) => void save({ ...(settings ?? {}), thinkingLevel: level as ChatSettings["thinkingLevel"] })}
</div> empty={catalog ? (effectiveId ? `${prettyModel(effectiveId)} has no thinking levels.` : "Pick a model first.") : "Loading…"}
)} footer="Levels this model actually supports"
{levels.map((level) => ( />
<button
key={level}
type="button"
data-testid={`thinking-option-${level}`}
onClick={() => void save({ ...(settings ?? {}), thinkingLevel: level })}
className={`flex items-center justify-between rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-muted ${
level === effectiveLevel ? "bg-muted/60 text-card-foreground" : "text-card-foreground"
}`}
>
<span>{LEVEL_LABEL[level] ?? level}</span>
{level === effectiveLevel && <span aria-hidden>✓</span>}
</button>
))}
{error && <div className="mt-1 text-destructive">{error}</div>} {error && <div className="mt-1 text-destructive">{error}</div>}
<div className="mt-1 border-t px-2 pt-1.5 text-xs text-muted-foreground">
Levels this model actually supports
</div>
</div> </div>
)} )}
</div> </div>
+227 -96
View File
@@ -1,34 +1,40 @@
"use client"; "use client";
/** /**
* The Agent section (thin settings wave): background-role model overrides, * The Agent section (settings-app wave): background-role model overrides,
* the tool toggles, and the prompt override switches — three blocks, ONE * the tool toggles, and the prompt override switches — three GROUPS
* section (PUT /api/settings/agent replaces the whole agent object, so * (owner, 2026-09-20: clear separation — Models / Tools / System prompts),
* every change composes models+tools+prompts together from the record). * 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. * 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 * Absent key = enabled in the schema; the UI writes explicit booleans for
* all four (simpler to reason about, sanitize-equivalent). * all four (simpler to reason about, sanitize-equivalent).
* *
* Models: each of the six background roles gets a Default option (absent * Models: each of the six background roles defaults to config.ts's pin
* override = config.ts's pin) plus the /api/models catalog. The thinking * (no override). The Default row clears the model override; the stored id
* select shows the CHOSEN model's supported levels when a model override * stays VISIBLE even when the catalog no longer contains it — a trigger
* exists; without one the factory model is unknown client-side, so the * showing "Default" over a live override would lie about the record.
* standard levels list (the server sanitizer accepts any valid level).
* *
* Prompts: GET /api/settings/prompts segments; the switch requires an * Prompts: GET /api/settings/prompts segments; the switch requires an
* override FILE (route-enforced), the text writes through the segment * override FILE (route-enforced), so a file-less row dims whole-row
* route. Switch state lives in this section's PUT; text does not. * (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 { useEffect, useRef, useState } from "react";
import { import { BACKGROUND_MODEL_ROLES, type AgentSettings, type BackgroundModelRole, type ModelCatalogEntry } from "@/src/core";
BACKGROUND_MODEL_ROLES,
type AgentSettings,
type BackgroundModelRole,
} from "@/src/core";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import type { ModelCatalogEntry } from "@/src/core"; import { prettyModel } from "@/components/usage-format";
import { SettingsRow, ThinSelect, Toggle } from "./controls.tsx"; 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. */ /** The role's display label — schema ids are camelCase, the label is human. */
const ROLE_LABEL: Record<BackgroundModelRole, string> = { const ROLE_LABEL: Record<BackgroundModelRole, string> = {
@@ -91,6 +97,41 @@ export function composePromptSwitch(agent: AgentSettings | undefined, path: stri
return { ...agent, prompts }; 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 (
<button
type="button"
aria-label={ariaLabel}
aria-expanded={open}
data-testid={testid}
onClick={onClick}
className="flex h-7 items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50"
>
<span className="max-w-52 truncate">{value || emptyLabel}</span>
<span aria-hidden className="text-muted-foreground">
▾
</span>
</button>
);
}
export function AgentSection({ export function AgentSection({
agent, agent,
onSectionChange, onSectionChange,
@@ -104,8 +145,11 @@ export function AgentSection({
const [openSegment, setOpenSegment] = useState<string | null>(null); const [openSegment, setOpenSegment] = useState<string | null>(null);
const [segmentDraft, setSegmentDraft] = useState<Record<string, string>>({}); const [segmentDraft, setSegmentDraft] = useState<Record<string, string>>({});
const [segmentStatus, setSegmentStatus] = useState<string | null>(null); const [segmentStatus, setSegmentStatus] = useState<string | null>(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<HTMLSpanElement>(null);
// The catalog + the prompt segments — one fetch each per page load. // The catalog + the prompt segments — one fetch each per section mount.
useEffect(() => { useEffect(() => {
fetch("/api/models") fetch("/api/models")
.then((r) => r.json()) .then((r) => r.json())
@@ -117,6 +161,35 @@ export function AgentSection({
.catch(() => setPrompts(null)); .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) { async function saveSegment(path: string, text: string) {
setSegmentStatus(null); setSegmentStatus(null);
const res = await fetch(`/api/settings/prompts/${path}`, { const res = await fetch(`/api/settings/prompts/${path}`, {
@@ -147,6 +220,22 @@ export function AgentSection({
.then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body)); .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) => (
<button
type="button"
data-testid="menu-default"
onClick={onPick}
className={`flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-muted ${
active ? "bg-muted/60 text-card-foreground" : "text-card-foreground"
}`}
>
<span>Default</span>
{active && <span aria-hidden>✓</span>}
</button>
);
const segmentRow = (info: SegmentInfo) => { const segmentRow = (info: SegmentInfo) => {
const fileExists = info.overrideText !== null; const fileExists = info.overrideText !== null;
const open = openSegment === info.path; const open = openSegment === info.path;
@@ -154,8 +243,9 @@ export function AgentSection({
<div key={info.path} data-testid={`prompt-segment-${info.path}`}> <div key={info.path} data-testid={`prompt-segment-${info.path}`}>
<SettingsRow <SettingsRow
label={info.path} label={info.path}
hint={fileExists ? undefined : "no override file — write one below to enable the switch"} hint={fileExists ? undefined : "no override file yet — write one below"}
testid={`prompt-row-${info.path}`} testid={`prompt-row-${info.path}`}
dimmed={!fileExists}
> >
<Toggle <Toggle
checked={info.enabled && fileExists} checked={info.enabled && fileExists}
@@ -224,86 +314,127 @@ export function AgentSection({
return ( return (
<> <>
{/* ── Models ── */} <SettingsGroup label="Models" testid="group-models">
{BACKGROUND_MODEL_ROLES.map((role) => { {BACKGROUND_MODEL_ROLES.map((role) => {
const override = agent?.models?.[role]; const override = agent?.models?.[role];
const modelId = override?.model?.id ?? ""; const modelId = override?.model?.id ?? "";
const thinking = override?.thinkingLevel ?? ""; const thinking = override?.thinkingLevel ?? "";
const chosen = catalog?.find((m) => m.id === modelId); const chosen = catalog?.find((m) => m.id === modelId);
const levels = chosen ? chosen.supportedLevels : THINKING_LEVELS; // The thinking levels this role can pick: the chosen
return ( // model's when one is pinned; the standard list when the
<SettingsRow // config pin applies (the pin's model is unknown client-side).
key={role} const levels = chosen ? chosen.supportedLevels : THINKING_LEVELS;
label={ROLE_LABEL[role]} const thisMenuOpen =
hint={modelId ? undefined : "Default — the config pin applies"} openMenu?.role === role ? openMenu.kind : null;
testid={`role-row-${role}`} return (
> <SettingsRow
<span className="flex items-center gap-2"> key={role}
<ThinSelect label={ROLE_LABEL[role]}
value={modelId} hint={modelId ? undefined : "Default — the config pin applies"}
ariaLabel={`${ROLE_LABEL[role]} model`} testid={`role-row-${role}`}
testid={`role-model-${role}`} >
onChange={(id) => onSectionChange(composeRoleModel(agent, role, id, thinking))} {/* The anchor for the outside-click contract is the
span holding BOTH triggers of the open menu. */}
<span
ref={thisMenuOpen ? menuAnchorRef : undefined}
className={`relative flex items-center gap-2 ${thisMenuOpen ? "z-10" : ""}`}
> >
<option value="">Default</option> <MenuTrigger
{/* The stored id stays VISIBLE even when the catalog value={modelId ? prettyModel(modelId) : ""}
hasn't loaded (SSR) or no longer contains it — emptyLabel="Default"
a select that silently shows "Default" over a ariaLabel={`${ROLE_LABEL[role]} model`}
live override would lie about the record. */} testid={`role-model-${role}`}
{modelId && !catalog?.some((m) => m.id === modelId) && ( open={thisMenuOpen === "model"}
<option value={modelId}>{modelId}</option> onClick={() => setOpenMenu(thisMenuOpen === "model" ? null : { role, kind: "model" })}
/>
<MenuTrigger
value={thinking}
emptyLabel="Default"
ariaLabel={`${ROLE_LABEL[role]} thinking`}
testid={`role-thinking-${role}`}
open={thisMenuOpen === "thinking"}
onClick={() => setOpenMenu(thisMenuOpen === "thinking" ? null : { role, kind: "thinking" })}
/>
{thisMenuOpen === "model" && (
<div
data-testid={`role-model-menu-${role}`}
className="absolute top-full right-0 z-40 mt-1 flex w-[22rem] flex-col rounded-xl border bg-card p-2 text-xs shadow-xl"
>
{defaultRow(
modelId === "",
() => {
onSectionChange(composeRoleModel(agent, role, "", thinking));
setOpenMenu(null);
},
)}
<ModelMenuList
models={catalog ?? undefined}
effectiveId={modelId || undefined}
onPick={(entry) => {
onSectionChange(composeRoleModel(agent, role, entry.id, thinking));
setOpenMenu(null);
}}
footer="Applied to every run of this role"
/>
</div>
)} )}
{catalog?.map((m) => ( {thisMenuOpen === "thinking" && (
<option key={m.id} value={m.id}> <div
{m.name} data-testid={`role-thinking-menu-${role}`}
</option> className="absolute top-full right-0 z-40 mt-1 flex w-44 flex-col gap-0.5 rounded-xl border bg-card p-2 text-xs shadow-xl"
))} >
</ThinSelect> {defaultRow(
<ThinSelect thinking === "",
value={thinking} () => {
ariaLabel={`${ROLE_LABEL[role]} thinking`} onSectionChange(composeRoleModel(agent, role, modelId, ""));
testid={`role-thinking-${role}`} setOpenMenu(null);
onChange={(level) => onSectionChange(composeRoleModel(agent, role, modelId, level))} },
> )}
<option value="">Default</option> <ThinkingMenuList
{levels.map((l) => ( levels={levels.map(String)}
<option key={l} value={l}> effectiveLevel={thinking || undefined}
{l} onPick={(level) => {
</option> onSectionChange(composeRoleModel(agent, role, modelId, level));
))} setOpenMenu(null);
</ThinSelect> }}
</span> />
</div>
)}
</span>
</SettingsRow>
);
})}
</SettingsGroup>
<SettingsGroup label="Tools" testid="group-tools">
{TOGGLE_TOOLS.map((id) => (
<SettingsRow key={id} label={`Tool · ${id}`} testid={`tool-row-${id}`}>
<Toggle
checked={agent?.tools?.[id] ?? true}
label={`tool ${id}`}
onChange={(on) => onSectionChange(composeTool(agent, id, on))}
/>
</SettingsRow> </SettingsRow>
); ))}
})} </SettingsGroup>
{/* ── Tools ── */} <SettingsGroup label="System prompts" testid="group-prompts">
{TOGGLE_TOOLS.map((id) => ( {prompts === null ? (
<SettingsRow key={id} label={`Tool · ${id}`} testid={`tool-row-${id}`}> <p className="text-xs text-muted-foreground/70">Loading prompt segments…</p>
<Toggle ) : (
checked={agent?.tools?.[id] ?? true} <div className="flex flex-col gap-4">
label={`tool ${id}`} {(["prober", "teacher"] as const).map((who) => (
onChange={(on) => onSectionChange(composeTool(agent, id, on))} <div key={who}>
/> <p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">{who}</p>
</SettingsRow> <div className="flex flex-col gap-2">
))} {prompts[who].map((info) => segmentRow(info))}
</div>
{/* ── Prompts ── */}
{prompts === null ? (
<p className="text-xs text-muted-foreground/70">Loading prompt segments…</p>
) : (
<div className="flex flex-col gap-4">
{(["prober", "teacher"] as const).map((who) => (
<div key={who}>
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">{who}</p>
<div className="flex flex-col gap-2">
{prompts[who].map((info) => segmentRow(info))}
</div> </div>
</div> ))}
))} {segmentStatus && <p className="text-xs text-muted-foreground/70">{segmentStatus}</p>}
{segmentStatus && <p className="text-xs text-muted-foreground/70">{segmentStatus}</p>} </div>
</div> )}
)} </SettingsGroup>
</> </>
); );
} }
+17 -17
View File
@@ -11,8 +11,9 @@
* light palette is the design wave's work, and a lying control is worse * light palette is the design wave's work, and a lying control is worse
* than an honest disabled one. * than an honest disabled one.
* *
* Graph is the dependency ladder (owner decision, 2026-09-19): a disabled * Graph graying (owner, 2026-09-20) follows the graph viewer's render-panel
* parent grays its dependents — stored values persist but resolve inert. * 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"; import type { AppearanceSettings, GraphSettings } from "@/src/core";
@@ -33,14 +34,14 @@ export function AppearanceSection({
return ( return (
<> <>
<SettingsRow label="Mode" hint="Light mode ships with the design wave" testid="row-mode"> <SettingsRow label="Mode" hint="Light ships with the design wave" testid="row-mode" dimmed>
<ThinSelect value="dark" ariaLabel="mode" onChange={() => {}} testid="select-mode" disabled> <ThinSelect value="dark" ariaLabel="mode" onChange={() => {}} testid="select-mode" disabled>
<option value="dark">Dark</option> <option value="dark">Dark</option>
</ThinSelect> </ThinSelect>
</SettingsRow> </SettingsRow>
<SettingsRow <SettingsRow
label="Background" label="Background"
hint={`One color; raised/accent/edges derive from it. Empty = the dark default (${resolved.background ?? "#141414"})`} hint={`Empty = ${resolved.background ?? "#141414"}`}
testid="row-background" testid="row-background"
> >
<CommitInput <CommitInput
@@ -53,7 +54,7 @@ export function AppearanceSection({
</SettingsRow> </SettingsRow>
<SettingsRow <SettingsRow
label="Reading font" label="Reading font"
hint="Font family name, by name — everything that reads today's Source Sans 3. Empty = Source Sans 3" hint="Empty = Source Sans 3"
testid="row-font" testid="row-font"
> >
<CommitInput <CommitInput
@@ -64,7 +65,7 @@ export function AppearanceSection({
onCommit={(v) => onSectionChange({ ...appearance, fontFamily: v === "" ? null : v })} onCommit={(v) => onSectionChange({ ...appearance, fontFamily: v === "" ? null : v })}
/> />
</SettingsRow> </SettingsRow>
<SettingsRow label="Reading font size" hint={`${resolved.proseFontSize}px — prose, inputs, session titles scale together`} testid="row-prose-size"> <SettingsRow label="Reading font size" hint={`${resolved.proseFontSize}px`} testid="row-prose-size">
<CommitInput <CommitInput
type="number" type="number"
value={String(appearance?.proseFontSize ?? "")} value={String(appearance?.proseFontSize ?? "")}
@@ -78,14 +79,14 @@ export function AppearanceSection({
}} }}
/> />
</SettingsRow> </SettingsRow>
<SettingsRow label="Session names in serif" hint="CMU Serif titles on/off" testid="row-session-serif"> <SettingsRow label="Session names in serif" testid="row-session-serif">
<Toggle <Toggle
checked={appearance?.sessionNameSerif ?? true} checked={appearance?.sessionNameSerif ?? true}
label="session names serif" label="session names serif"
onChange={(on) => onSectionChange({ ...appearance, sessionNameSerif: on })} onChange={(on) => onSectionChange({ ...appearance, sessionNameSerif: on })}
/> />
</SettingsRow> </SettingsRow>
<SettingsRow label="Heading weight" hint={`${resolved.mdRendering.headingWeight} — CSS resolves the nearest cut`} testid="row-heading-weight"> <SettingsRow label="Heading weight" hint={`${resolved.mdRendering.headingWeight}`} testid="row-heading-weight">
<CommitInput <CommitInput
type="number" type="number"
value={String(md?.headingWeight ?? "")} value={String(md?.headingWeight ?? "")}
@@ -102,7 +103,7 @@ export function AppearanceSection({
}} }}
/> />
</SettingsRow> </SettingsRow>
<SettingsRow label="Bold color" hint="**Bold** spans in teaching prose. Empty = the default ink" testid="row-bold-color"> <SettingsRow label="Bold color" hint="Empty = #c96b4a" testid="row-bold-color">
<CommitInput <CommitInput
value={md?.boldColor ?? ""} value={md?.boldColor ?? ""}
placeholder="#c96b4a" placeholder="#c96b4a"
@@ -111,7 +112,7 @@ export function AppearanceSection({
onCommit={(v) => onSectionChange({ ...appearance, mdRendering: { ...md, boldColor: v === "" ? null : v } })} onCommit={(v) => onSectionChange({ ...appearance, mdRendering: { ...md, boldColor: v === "" ? null : v } })}
/> />
</SettingsRow> </SettingsRow>
<SettingsRow label="Italic color" hint="*Italic* spans. Empty = the default ink" testid="row-italic-color"> <SettingsRow label="Italic color" hint="Empty = #6fa9d6" testid="row-italic-color">
<CommitInput <CommitInput
value={md?.italicColor ?? ""} value={md?.italicColor ?? ""}
placeholder="#6fa9d6" placeholder="#6fa9d6"
@@ -128,12 +129,11 @@ const LADDER: readonly {
key: keyof GraphSettings; key: keyof GraphSettings;
label: string; label: string;
needs: keyof GraphSettings | null; needs: keyof GraphSettings | null;
hint: string;
}[] = [ }[] = [
{ key: "enabled", label: "Graph", needs: null, hint: "Master: off = no extraction, embedding, or injection" }, { key: "enabled", label: "Graph", needs: null },
{ key: "embedding", label: "Embedding", needs: "enabled", hint: "Vectors + graph-context injection" }, { key: "embedding", label: "Embedding", needs: "enabled" },
{ key: "clustering", label: "Clustering", needs: "embedding", hint: "Cluster computation + settle" }, { key: "clustering", label: "Clustering", needs: "embedding" },
{ key: "labeling", label: "Labeling", needs: "clustering", hint: "The LLM cluster-namer" }, { key: "labeling", label: "Labeling", needs: "clustering" },
]; ];
export function GraphSection({ export function GraphSection({
@@ -159,11 +159,11 @@ export function GraphSection({
}; };
return ( return (
<> <>
{LADDER.map(({ key, label, needs, hint }) => { {LADDER.map(({ key, label, needs }) => {
// Touchable only when every ancestor is EFFECTIVELY on. // Touchable only when every ancestor is EFFECTIVELY on.
const parentOn = needs === null ? true : effective[needs]; const parentOn = needs === null ? true : effective[needs];
return ( return (
<SettingsRow key={key} label={label} hint={!parentOn ? `${hint} — needs ${needs} on` : hint} testid={`graph-row-${key}`}> <SettingsRow key={key} label={label} testid={`graph-row-${key}`} dimmed={!parentOn}>
<Toggle <Toggle
checked={stored[key]} checked={stored[key]}
disabled={!parentOn} disabled={!parentOn}
+39 -15
View File
@@ -1,16 +1,17 @@
"use client"; "use client";
/** /**
* Thin settings primitives (thin settings wave): the minimal control kit * Thin settings primitives (settings-app wave): the control kit the
* the settings page needs — deliberately plain. No design system, no * settings dialog needs. Sections → groups → rows; everything SSRs clean
* animation polish: this wave is a WORKING validation surface over the * (render tests pin the sections' markup).
* settings record, not the designed settings app (that's a later wave).
* *
* Everything SSRs clean (render tests pin the sections' markup). * The dependency graying (owner, 2026-09-20) follows the graph viewer's
* render-panel convention: the WHOLE row dims (opacity-40, label and
* control together) and the control itself locks — the row owns the dim.
*/ */
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
/** A section block: header + rows, separated by the app's border color. */ /** A section block: pronounced mono heading + content. */
export function SettingsSection({ export function SettingsSection({
title, title,
hint, hint,
@@ -18,20 +19,39 @@ export function SettingsSection({
testid, testid,
}: { }: {
title: string; title: string;
/** One-line quiet note under the header (what the section controls). */ /** One quiet line under the header — only where it earns its place. */
hint?: string; hint?: string;
children: React.ReactNode; children: React.ReactNode;
testid?: string; testid?: string;
}) { }) {
return ( return (
<section data-testid={testid} className="border-t border-border px-6 py-5 first:border-t-0"> <section data-testid={testid} className="px-6 py-5">
<h2 className="text-xs uppercase tracking-widest text-muted-foreground/70">{title}</h2> <h2 className="text-sm uppercase tracking-widest text-foreground">{title}</h2>
{hint && <p className="mt-1 text-xs text-muted-foreground/70">{hint}</p>} {hint && <p className="mt-1 text-xs text-muted-foreground">{hint}</p>}
<div className="mt-3 flex flex-col gap-3">{children}</div> <div className="mt-4 flex flex-col gap-3">{children}</div>
</section> </section>
); );
} }
/** 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 (
<div data-testid={testid} className="mt-5 border-t border-border/60 pt-4 first:mt-0 first:border-t-0 first:pt-0">
<p className="mb-2 text-xs uppercase tracking-widest text-muted-foreground">{label}</p>
<div className="flex flex-col gap-2">{children}</div>
</div>
);
}
/** One label + one control. Label left (with optional muted hint), control /** One label + one control. Label left (with optional muted hint), control
* right — the settings row shape. */ * right — the settings row shape. */
export function SettingsRow({ export function SettingsRow({
@@ -39,14 +59,18 @@ export function SettingsRow({
hint, hint,
children, children,
testid, testid,
dimmed,
}: { }: {
label: string; label: string;
hint?: string; hint?: string;
children: React.ReactNode; children: React.ReactNode;
testid?: string; testid?: string;
/** Dependency graying, graph-view style: the whole row dims when the
* control is inert (parent off, no file yet, …). */
dimmed?: boolean;
}) { }) {
return ( return (
<div data-testid={testid} className="flex items-center justify-between gap-4 py-1"> <div data-testid={testid} className={cn("flex items-center justify-between gap-4 py-1", dimmed && "opacity-40")}>
<span className="min-w-0"> <span className="min-w-0">
<span className="block font-prose text-(length:--lh-prose-size)">{label}</span> <span className="block font-prose text-(length:--lh-prose-size)">{label}</span>
{hint && <span className="block text-xs text-muted-foreground/70">{hint}</span>} {hint && <span className="block text-xs text-muted-foreground/70">{hint}</span>}
@@ -81,12 +105,12 @@ export function Toggle({
className={cn( className={cn(
"relative h-4.5 w-8 shrink-0 rounded-full border border-border transition-colors", "relative h-4.5 w-8 shrink-0 rounded-full border border-border transition-colors",
checked ? "bg-foreground" : "bg-muted", checked ? "bg-foreground" : "bg-muted",
disabled && "cursor-not-allowed opacity-40", disabled && "cursor-not-allowed",
)} )}
> >
<span <span
className={cn( className={cn(
"absolute top-0.5 size-3 rounded-full transition-transform", "absolute left-0 top-0.5 size-3 rounded-full transition-transform",
checked ? "translate-x-4.5 bg-background" : "translate-x-0.5 bg-muted-foreground", checked ? "translate-x-4.5 bg-background" : "translate-x-0.5 bg-muted-foreground",
)} )}
/> />
@@ -119,7 +143,7 @@ export function ThinSelect({
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
className={cn( className={cn(
"h-7 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground outline-none", "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} {children}
+1 -1
View File
@@ -155,7 +155,7 @@ export function KeyboardSection() {
); );
})} })}
<div className="mt-1 flex items-center justify-between"> <div className="mt-1 flex items-center justify-between">
<span className="text-xs text-muted-foreground/70">Stored in this browser (lh:keymap) — overrides only.</span> <span className="text-xs text-muted-foreground/70">Stored on this device (lh:keymap) — overrides only.</span>
<button <button
type="button" type="button"
onClick={resetAll} onClick={resetAll}
+178
View File
@@ -0,0 +1,178 @@
"use client";
/**
* The model menu (extracted 2026-09-20 from model-picker.tsx, owner ask:
* the agent settings' role selects use the EXACT same dropdown as the
* chat model selector — same look, same machine). The fuzzy search +
* arrow-walk + Enter pick machine lives here once; each mount owns only
* the popover shell (positioning, outside-click, Esc — the chat control
* opens UP from the bottom bar, the settings rows open DOWN inside the
* dialog).
*
* Keyboard machine (owner polish, 2026-09-17): 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.
*/
import { useEffect, useRef, useState } from "react";
import type { ModelCatalogEntry } from "@/src/core";
import { fuzzyScore } from "@/src/core";
import { formatTokens } from "@/components/usage-format";
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)}`;
}
/** The model dropdown body — fuzzy search + ranked list. `models ===
* undefined` is the catalog-loading state; an empty result list gets
* its own quiet line. */
export function ModelMenuList({
models,
effectiveId,
onPick,
footer,
}: {
/** The full catalog — undefined while it loads. */
models: ModelCatalogEntry[] | undefined;
/** Currently effective id (the row gets the quiet highlight). */
effectiveId: string | undefined;
/** Receives the chosen entry. */
onPick: (entry: ModelCatalogEntry) => void;
/** Quiet footer note under the list (context-specific). */
footer?: string;
}) {
const [query, setQuery] = useState("");
const [menuIdx, setMenuIdx] = useState<number | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
useEffect(() => {
searchRef.current?.focus();
}, []);
// Keep the arrow-walked result visible in the scrolling list.
useEffect(() => {
if (menuIdx === null) return;
listRef.current?.querySelector(`[data-idx="${menuIdx}"]`)?.scrollIntoView({ block: "nearest" });
}, [menuIdx]);
// The filtered, ranked model list.
const results = (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 (
<>
<input
ref={searchRef}
data-testid="model-search"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setMenuIdx(null); // new query → the walk restarts on Enter/↓
}}
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();
onPick(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"
/>
{!models && <div className="px-2 py-3 text-center text-muted-foreground">Loading models…</div>}
{models && results.length === 0 && (
<div className="px-2 py-3 text-center text-muted-foreground">No models match.</div>
)}
<ul ref={listRef} data-testid="model-list" className="flex max-h-72 flex-col overflow-y-auto px-1">
{results.map((m, i) => (
<li key={`${m.provider}/${m.id}`}>
<button
type="button"
data-testid={`model-option-${m.id.replace(/[^a-z0-9-]/gi, "_")}`}
data-idx={i}
onClick={() => onPick(m)}
className={`flex w-full flex-col gap-0.5 rounded-lg px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted ${
i === menuIdx ? "bg-accent/40 ring-1 ring-inset ring-border" : ""
}${m.id === effectiveId && i !== menuIdx ? " bg-muted/60" : ""}`}
>
<span className="flex items-center justify-between gap-2">
<span className="truncate text-card-foreground">{m.name}</span>
<span className="shrink-0 tabular-nums text-muted-foreground" title="USD per 1M tokens in/out">
{priceLabel(m.cost)}
</span>
</span>
<span className="flex items-center justify-between gap-2 text-muted-foreground">
<span className="truncate">{m.id}</span>
<span className="shrink-0 tabular-nums">{formatTokens(m.contextWindow)}</span>
</span>
</button>
</li>
))}
</ul>
{footer && (
<div className="mt-1 border-t px-2 pt-1.5 text-xs text-muted-foreground">{footer}</div>
)}
</>
);
}
/** 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 ? (
<div className="px-2 py-3 text-center text-muted-foreground">{empty ?? "No thinking levels."}</div>
) : (
levels.map((level) => (
<button
key={level}
type="button"
data-testid={`thinking-option-${level}`}
onClick={() => onPick(level)}
className={`flex items-center justify-between rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-muted ${
level === effectiveLevel ? "bg-muted/60 text-card-foreground" : "text-card-foreground"
}`}
>
<span>{level}</span>
{level === effectiveLevel && <span aria-hidden>✓</span>}
</button>
))
)}
{footer && (
<div className="mt-1 border-t px-2 pt-1.5 text-xs text-muted-foreground">{footer}</div>
)}
</>
);
}
+9 -26
View File
@@ -42,28 +42,15 @@ import { AgentSection } from "./agent-section.tsx";
import { AppearanceSection, GraphSection } from "./appearance-section.tsx"; import { AppearanceSection, GraphSection } from "./appearance-section.tsx";
import { KeyboardSection } from "./keyboard-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 = [ const SECTIONS = [
{ id: "general", label: "General", hint: "App-level behavior" }, { id: "general", label: "General" },
{ { id: "agent", label: "Agent" },
id: "agent", { id: "appearance", label: "Appearance" },
label: "Agent", { id: "graph", label: "Graph" },
hint: "Background-role models, tools, and prompt overrides", { id: "keyboard", label: "Keyboard", hint: "Stored on this device" },
},
{
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; ] as const;
type SectionId = (typeof SECTIONS)[number]["id"]; type SectionId = (typeof SECTIONS)[number]["id"];
@@ -253,7 +240,6 @@ export function SettingsDialogBody({ onClose }: { onClose: () => void }) {
{active === "general" && ( {active === "general" && (
<SettingsSection <SettingsSection
title="General" title="General"
hint="App-level behavior"
testid="section-general" testid="section-general"
> >
<SettingsRow <SettingsRow
@@ -274,7 +260,6 @@ export function SettingsDialogBody({ onClose }: { onClose: () => void }) {
{active === "agent" && ( {active === "agent" && (
<SettingsSection <SettingsSection
title="Agent" title="Agent"
hint="Background-role models, tools, and prompt overrides"
testid="section-agent" testid="section-agent"
> >
<AgentSection <AgentSection
@@ -288,7 +273,6 @@ export function SettingsDialogBody({ onClose }: { onClose: () => void }) {
{active === "appearance" && ( {active === "appearance" && (
<SettingsSection <SettingsSection
title="Appearance" title="Appearance"
hint="Applied live as CSS variables"
testid="section-appearance" testid="section-appearance"
> >
<AppearanceSection <AppearanceSection
@@ -302,7 +286,6 @@ export function SettingsDialogBody({ onClose }: { onClose: () => void }) {
{active === "graph" && ( {active === "graph" && (
<SettingsSection <SettingsSection
title="Graph" title="Graph"
hint="Dependency ladder — a disabled rung grays everything below it"
testid="section-graph" testid="section-graph"
> >
<GraphSection <GraphSection
@@ -316,7 +299,7 @@ export function SettingsDialogBody({ onClose }: { onClose: () => void }) {
{active === "keyboard" && ( {active === "keyboard" && (
<SettingsSection <SettingsSection
title="Keyboard" title="Keyboard"
hint="Rebinds apply instantly — stored in this browser" hint="Stored on this device"
testid="section-keyboard" testid="section-keyboard"
> >
<KeyboardSection /> <KeyboardSection />
+47 -8
View File
@@ -15,6 +15,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { TOGGLE_TOOLS, AgentSection } from "../components/settings/agent-section.tsx"; import { TOGGLE_TOOLS, AgentSection } from "../components/settings/agent-section.tsx";
import { AppearanceSection, GraphSection } from "../components/settings/appearance-section.tsx"; import { AppearanceSection, GraphSection } from "../components/settings/appearance-section.tsx";
import { KeyboardSection } from "../components/settings/keyboard-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"; import { KEYBOARD_ACTIONS } from "../src/core/index.ts";
/** The full opening tag of the element carrying a testid — attribute /** 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); 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(
<SettingsDialogProvider>
<p>the app</p>
</SettingsDialogProvider>,
);
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(<SettingsDialogBody onClose={() => {}} />);
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)", () => { 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(<AgentSection agent={undefined} onSectionChange={() => {}} />); const html = renderToStaticMarkup(<AgentSection agent={undefined} onSectionChange={() => {}} />);
for (const role of ["researcher", "demoBuilder", "visualMaker", "extractor", "auditor", "labeler"]) { 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-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", () => { it("renders the owner's FINAL tool list — research, demo, visual, pdf", () => {
@@ -59,9 +96,9 @@ describe("AgentSection (thin SSR)", () => {
onSectionChange={() => {}} onSectionChange={() => {}}
/>, />,
); );
// SSR has no catalog: the stored id renders as its own option (a select // The trigger shows the stored id even with no catalog loaded (a
// that showed "Default" over a live override would lie about the record). // trigger reading "Default" over a live override would lie).
assert.ok(html.includes(`value="z-ai/glm-4.7-flash" selected=""`), "the override id is selected, visible"); 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-demo").includes('aria-checked="false"'), "demo off");
assert.ok(tagOf(html, "toggle-tool-visual").includes('aria-checked="true"'), "visual still on"); 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", () => { it("mode is rendered DISABLED — dark-only this wave, no lying control", () => {
const html = renderToStaticMarkup(<AppearanceSection appearance={undefined} onSectionChange={() => {}} />); const html = renderToStaticMarkup(<AppearanceSection appearance={undefined} onSectionChange={() => {}} />);
assert.ok(tagOf(html, "select-mode").includes("disabled"), "mode select disabled"); 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", () => { 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="#141414"`));
assert.ok(html.includes(`placeholder="Source Sans 3"`)); assert.ok(html.includes(`placeholder="Source Sans 3"`));
assert.ok(html.includes(`placeholder="#c96b4a"`)); 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", () => { it("a set background shows in the input, not the placeholder", () => {