Files
learnharness/components/settings/model-menu.tsx
T
amosblomqvist 48fbd988f1 Wave-5 chunk B: three model slots, search-engine row, first-run setup flow
Chunk B (owner-designed, model-slots wave) on the merged engine surface
(main 1d66a17):

AGENT SECTION — SLOTS ONLY (owner ruling)
- The six per-role model rows are GONE; three slot rows instead:
  Strong (Prober + Teacher), Cheap (five workhorse roles), Vision
  (visual maker + PDF reader), roster + resolved-default in every hint.
- Slot dropdowns share the app's menu language over the FEDERATED
  catalog (/api/models = keyed providers only, fresh per call — saving
  a key in Keys immediately widens the picker); the vision slot
  filters to image-capable models (Model.input); the menu's highlight
  now matches provider+id (ids can collide across providers).
- Slots write the new models section (PUT /api/settings/models) through
  the dialog's write seam; per-role overrides stay schema-only.
- ModelMenuList rows read 'provider · id' — federation makes the
  provider bookkeeping-relevant. Usage stamps render provider-qualified
  (prettyModel(qualified): 'Glm 5.3 Flash · openrouter') in the stats
  bar's turn lines.

SEARCH ENGINE (search wave: no default, owner)
- A row in Tools: Tavily/Brave via the shared-menu look, Automatic =
  the pin removed (auto-activation). The live reason line comes from
  /api/search; no engine key at all grays the whole row + disables the
  trigger (a keyless choice would only block auto-activation).

FIRST-RUN SETUP FLOW (owner design A: composer CTA + send interception)
- Chats cannot start until all three slots are picked — no defaults
  for 33 providers (slotsConfigured: all three slot models required).
- The graph view's new-session dock (home) shows a 'Set up your models'
  CTA while unconfigured; submitting a topic opens the SetupDialog WITH
  THE DRAFT PRESERVED; Save stores the models section and the queued
  message sends. Same interception on /chat/new sends.
- STAGED BY DEPENDENCY (owner: keys first, models after): no keyed
  provider → stage 1 deep-links the settings dialog on Keys (open now
  takes a section); a saved key reopens at stage 2 on the next submit.
- Stage 2: Strong → Cheap → Vision pickers (federated, vision-filtered,
  all three required). Closable without configuring — browsing stays
  possible; the gate is the send, not the dialog. Layered Esc: an open
  slot menu eats Esc before the dialog.
- Two closure bugs caught live: onDone→start/send captured the stale
  pre-save 'configured' (the flow now forces the replayed send); menu-Esc
  used to close the whole dialog.

Settings-dialog provider: open(section?) lands on a section (stage 1
deep-link). Tests: composeSlotModel/composeSearchEngine pure, slot SSR
replacing role rows, slotsConfigured/setupStage first-run gates,
provider-qualified prettyModel. Gates: typecheck, lint, 792/792, build.
2026-09-22 13:54:29 +03:00

274 lines
9.4 KiB
TypeScript

"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,
effectiveProvider,
onPick,
footer,
}: {
/** The full catalog — undefined while it loads. */
models: ModelCatalogEntry[] | undefined;
/** Currently effective id (the row gets the quiet highlight). */
effectiveId: string | undefined;
/** The effective id's provider (model-slots wave: the catalog
* federates multiple providers and ids can collide across them —
* the highlight must match BOTH coordinates or it lands on the
* wrong row). */
effectiveProvider?: string;
/** 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 && m.provider === (effectiveProvider ?? m.provider) && 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.provider} · {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>
)}
</>
);
}
/** The provider-picker dropdown body (keys wave-5 polish, 2026-09-21:
* the owner wanted the Add-provider control to match the app's dropdown
* language) — the same fuzzy-search + arrow-walk + Enter machine over
* the pi-ai provider table. Rows read: provider label, its env var in
* quiet mono on the right. */
export function ProviderMenuList({
providers,
onPick,
}: {
providers: { id: string; envVar: string; label: string }[];
onPick: (provider: { id: string; envVar: string; label: string }) => void;
}) {
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();
}, []);
useEffect(() => {
if (menuIdx === null) return;
listRef.current?.querySelector(`[data-idx="${menuIdx}"]`)?.scrollIntoView({ block: "nearest" });
}, [menuIdx]);
const results = providers
.map((p) => ({ p, s: fuzzyScore(query, p.id, p.envVar, p.label) }))
.filter((r) => r.s !== null)
.sort((a, b) => b.s! - a.s! || a.p.id.localeCompare(b.p.id))
.map((r) => r.p);
return (
<>
<input
ref={searchRef}
data-testid="provider-search"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setMenuIdx(null);
}}
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 providers…"
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"
/>
{results.length === 0 && (
<div className="px-2 py-3 text-center text-muted-foreground">No providers match.</div>
)}
<ul ref={listRef} data-testid="provider-list" className="flex max-h-72 flex-col overflow-y-auto px-1">
{results.map((p, i) => (
<li key={p.id}>
<button
type="button"
data-testid={`provider-option-${p.id.replace(/[^a-z0-9-]/gi, "_")}`}
data-idx={i}
onClick={() => onPick(p)}
className={`flex w-full items-center justify-between gap-3 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" : ""
}`}
>
<span className="truncate text-card-foreground">{p.label}</span>
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">{p.envVar}</span>
</button>
</li>
))}
</ul>
</>
);
}