Settings-app refinement round 3 (owner review): toggle polish, nav spacing, portal dropdowns, Default-named hints

- 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: <model>'. 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.
This commit is contained in:
amosblomqvist
2026-09-19 22:29:17 +03:00
parent 0629e71a50
commit fddbacd330
3 changed files with 103 additions and 20 deletions
+96 -17
View File
@@ -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 (
<button
@@ -122,9 +130,9 @@ function MenuTrigger({
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"
className={`flex h-7 items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 ${widthClass}`}
>
<span className="max-w-52 truncate">{value || emptyLabel}</span>
<span className="min-w-0 flex-1 truncate">{value || emptyLabel}</span>
<span aria-hidden className="text-muted-foreground">
▾
</span>
@@ -132,6 +140,55 @@ function MenuTrigger({
);
}
/** The opened dropdown, PORTALED to document.body with fixed positioning:
* the dialog's overflow frame must not clip it — the owner wants the
* menus flowing over the whole screen, outside the settings surface
* (2026-09-20). Right-aligned to its trigger, repositioned while the
* dialog content scrolls under it. */
function AnchoredMenu({
anchorTestid,
menuClass,
testid,
children,
}: {
/** The trigger to hang from — its data-testid. */
anchorTestid: string;
/** The positioned menu's classes (width, padding, frame). */
menuClass: string;
testid: string;
children: React.ReactNode;
}) {
const [style, setStyle] = useState<{ right: number; top: number } | null>(null);
useEffect(() => {
const place = () => {
const anchor = document.querySelector(`[data-testid="${anchorTestid}"]`);
if (!anchor) return;
const r = anchor.getBoundingClientRect();
setStyle({ right: window.innerWidth - r.right, top: r.bottom + 4 });
};
place();
// Capture-phase: catches the dialog's inner content scroll.
window.addEventListener("scroll", place, true);
window.addEventListener("resize", place);
return () => {
window.removeEventListener("scroll", place, true);
window.removeEventListener("resize", place);
};
}, [anchorTestid]);
if (style === null) return null;
return createPortal(
<div
data-testid={testid}
data-lh-menu=""
style={{ position: "fixed", right: style.right, top: style.top, zIndex: 60 }}
className={menuClass}
>
{children}
</div>,
document.body,
);
}
export function AgentSection({
agent,
onSectionChange,
@@ -141,6 +198,11 @@ export function AgentSection({
onSectionChange: (next: AgentSettings) => void;
}) {
const [catalog, setCatalog] = useState<ModelCatalogEntry[] | null>(null);
/** The config-pinned model per background role — GET /api/models'
* rolePins (Platform stream, landing as a standalone commit; master
* routing 2026-09-20). Optional here: until the rebase the rows read
* "Default: config pin" and light up the moment the field arrives. */
const [rolePins, setRolePins] = useState<Record<string, { model: { provider: string; id: string }; thinkingLevel?: string }> | null>(null);
const [prompts, setPrompts] = useState<{ prober: SegmentInfo[]; teacher: SegmentInfo[] } | null>(null);
const [openSegment, setOpenSegment] = useState<string | null>(null);
const [segmentDraft, setSegmentDraft] = useState<Record<string, string>>({});
@@ -153,7 +215,10 @@ export function AgentSection({
useEffect(() => {
fetch("/api/models")
.then((r) => r.json())
.then((body: { models: ModelCatalogEntry[] }) => setCatalog(body.models))
.then((body: { models: ModelCatalogEntry[]; rolePins?: Record<string, { model: { provider: string; id: string }; thinkingLevel?: string }> }) => {
setCatalog(body.models);
if (body.rolePins) setRolePins(body.rolePins);
})
.catch(() => setCatalog(null));
fetch("/api/settings/prompts")
.then((r) => r.json())
@@ -171,6 +236,7 @@ export function AgentSection({
const onPointerDown = (e: PointerEvent) => {
const target = e.target as Node;
if (menuAnchorRef.current?.contains(target)) return;
if (target instanceof Element && target.closest("[data-lh-menu]")) return;
if (target instanceof Element && target.closest('[role="dialog"]')) {
setOpenMenu(null);
}
@@ -319,25 +385,34 @@ export function AgentSection({
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).
// The model whose levels govern the thinking list: the
// override, else the config pin (rolePins makes the
// default's levels resolvable when it's in the catalog);
// the standard ladder as the last fallback.
const effectiveId = modelId || rolePins?.[role]?.model.id || "";
const chosen = catalog?.find((m) => m.id === effectiveId);
const levels = chosen ? chosen.supportedLevels : THINKING_LEVELS;
// The row ALWAYS names the default (owner, 2026-09-20: the
// old "config pin applies" hint said nothing). rolePins
// carries the resolved default; the fallback reads until
// Platform's rolePins lands on main.
const defaultName = rolePins?.[role]?.model.id
? prettyModel(rolePins[role].model.id)
: "config pin";
const thisMenuOpen =
openMenu?.role === role ? openMenu.kind : null;
return (
<SettingsRow
key={role}
label={ROLE_LABEL[role]}
hint={modelId ? undefined : "Default — the config pin applies"}
hint={`Default: ${defaultName}`}
testid={`role-row-${role}`}
>
{/* The anchor for the outside-click contract is the
span holding BOTH triggers of the open menu. */}
<span
ref={thisMenuOpen ? menuAnchorRef : undefined}
className={`relative flex items-center gap-2 ${thisMenuOpen ? "z-10" : ""}`}
className="relative flex items-center gap-2"
>
<MenuTrigger
value={modelId ? prettyModel(modelId) : ""}
@@ -346,6 +421,7 @@ export function AgentSection({
testid={`role-model-${role}`}
open={thisMenuOpen === "model"}
onClick={() => setOpenMenu(thisMenuOpen === "model" ? null : { role, kind: "model" })}
widthClass="min-w-20"
/>
<MenuTrigger
value={thinking}
@@ -354,11 +430,13 @@ export function AgentSection({
testid={`role-thinking-${role}`}
open={thisMenuOpen === "thinking"}
onClick={() => setOpenMenu(thisMenuOpen === "thinking" ? null : { role, kind: "thinking" })}
widthClass="w-20"
/>
{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"
<AnchoredMenu
anchorTestid={`role-model-${role}`}
testid={`role-model-menu-${role}`}
menuClass="flex w-[22rem] flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
>
{defaultRow(
modelId === "",
@@ -376,12 +454,13 @@ export function AgentSection({
}}
footer="Applied to every run of this role"
/>
</div>
</AnchoredMenu>
)}
{thisMenuOpen === "thinking" && (
<div
data-testid={`role-thinking-menu-${role}`}
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"
<AnchoredMenu
anchorTestid={`role-thinking-${role}`}
testid={`role-thinking-menu-${role}`}
menuClass="flex w-44 flex-col gap-0.5 rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
>
{defaultRow(
thinking === "",
@@ -398,7 +477,7 @@ export function AgentSection({
setOpenMenu(null);
}}
/>
</div>
</AnchoredMenu>
)}
</span>
</SettingsRow>
+6 -2
View File
@@ -104,14 +104,18 @@ export function Toggle({
onClick={() => onChange(!checked)}
className={cn(
"relative h-4.5 w-8 shrink-0 rounded-full border border-border transition-colors",
checked ? "bg-foreground" : "bg-muted",
// Owner (2026-09-20): on is a gray track with a WHITE disc (a full-white
// track was too loud); off stays the muted track.
checked ? "bg-muted-foreground" : "bg-muted",
disabled && "cursor-not-allowed",
)}
>
<span
className={cn(
"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",
// 2px inset from each inner edge, both states (owner round: the on
// disc used to touch the pill's right edge).
checked ? "translate-x-4 bg-foreground" : "translate-x-0.5 bg-muted-foreground",
)}
/>
</button>
+1 -1
View File
@@ -193,7 +193,7 @@ export function SettingsDialogBody({ onClose }: { onClose: () => void }) {
<nav
aria-label="Settings sections"
data-testid="settings-nav"
className="flex w-44 shrink-0 flex-col border-r border-border p-2"
className="flex w-44 shrink-0 flex-col gap-1 border-r border-border p-2"
>
{SECTIONS.map((s) => (
<button