Fix model labels and catalog-backed setup initialization

This commit is contained in:
amosblomqvist
2026-10-26 19:43:09 +02:00
parent 3dc88472ee
commit 683eb93bcc
10 changed files with 389 additions and 210 deletions
+54
View File
@@ -0,0 +1,54 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import type { ModelCatalogResponse } from "@/src/core";
import { onSettingsSaved } from "@/components/settings-signal";
// Shared stale-while-revalidate catalog. Metadata refreshes retain the last
// good snapshot; connection changes clear selectable models immediately.
let catalogCache: ModelCatalogResponse | undefined;
let catalogError = false;
let catalogInflight: Promise<void> | undefined;
let catalogGeneration = 0;
let stopCatalogSignals: (() => void) | undefined;
const catalogReaders = new Set<() => void>();
const publishCatalog = () => { for (const reader of catalogReaders) reader(); };
function fetchCatalog(revalidate = false): void {
if (revalidate) { catalogGeneration++; catalogInflight = undefined; }
if (catalogInflight) return;
const generation = catalogGeneration;
catalogError = false;
publishCatalog();
catalogInflight = fetch("/api/models")
.then((r) => { if (!r.ok) throw new Error(`catalog request failed: ${r.status}`); return r.json() as Promise<ModelCatalogResponse>; })
.then((c) => {
if (generation === catalogGeneration) { catalogCache = c; catalogError = false; publishCatalog(); }
})
.catch(() => {
if (generation === catalogGeneration) { catalogError = true; publishCatalog(); }
})
.finally(() => { if (generation === catalogGeneration) catalogInflight = undefined; });
}
export function useModelCatalog(): { catalog: ModelCatalogResponse | undefined; failed: boolean; refresh: () => void } {
const [snapshot, setSnapshot] = useState(() => ({ catalog: catalogCache, failed: catalogError }));
useEffect(() => {
const first = catalogReaders.size === 0;
const read = () => setSnapshot({ catalog: catalogCache, failed: catalogError });
catalogReaders.add(read);
if (first) {
stopCatalogSignals = onSettingsSaved((section) => {
if (section === "keys") {
catalogGeneration++; catalogInflight = undefined; catalogCache = undefined;
fetchCatalog();
} else if (section === "models") fetchCatalog(true);
});
fetchCatalog(true);
} else fetchCatalog();
read();
return () => { catalogReaders.delete(read); if (!catalogReaders.size) { stopCatalogSignals?.(); stopCatalogSignals = undefined; } };
}, []);
const refresh = useCallback(() => fetchCatalog(true), []);
return { ...snapshot, refresh };
}
+4 -48
View File
@@ -8,52 +8,11 @@ import { ModelMenuList, ThinkingMenuList } from "@/components/settings/model-men
import { composeSlotModel } from "@/components/settings/agent-section";
import { MenuShell } from "@/components/menu-shell";
import { Hint } from "@/components/ui/hint";
import { notifySettingsSaved, onSettingsSaved, useSettingsSaved } from "@/components/settings-signal";
import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal";
import { useModelCatalog } from "@/components/model-catalog";
export { useModelCatalog } from "@/components/model-catalog";
import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default";
/** GET /api/models is cached per page load for instant menu opens, but
* the federation is LIVE per call — keys saved mid-session widen the
* catalog immediately server-side. ModelControl therefore re-fetches on
* every model-menu open (stale-while-revalidate: the cached list shows
* instantly, the fresh one swaps in while the menu is open). */
let catalogCache: ModelCatalogResponse | undefined;
let catalogInflight: Promise<void> | undefined;
let catalogGeneration = 0;
let stopCatalogSignals: (() => void) | undefined;
const catalogReaders = new Set<() => void>();
const publishCatalog = () => { for (const reader of catalogReaders) reader(); };
function fetchCatalog(revalidate = false): void {
if (revalidate) { catalogGeneration++; catalogInflight = undefined; }
if (catalogInflight) return;
const generation = catalogGeneration;
catalogInflight = fetch("/api/models")
.then((r) => { if (!r.ok) throw new Error(`catalog request failed: ${r.status}`); return r.json() as Promise<ModelCatalogResponse>; })
.then((c) => { if (generation === catalogGeneration) { catalogCache = c; publishCatalog(); } })
.catch(() => { /* existing unavailable/usage-only posture, no retry timer */ })
.finally(() => { if (generation === catalogGeneration) catalogInflight = undefined; });
}
export function useModelCatalog(): { catalog: ModelCatalogResponse | undefined; refresh: () => void } {
const [catalog, setCatalog] = useState(catalogCache);
useEffect(() => {
const first = catalogReaders.size === 0;
const read = () => setCatalog(catalogCache);
catalogReaders.add(read);
if (first) {
stopCatalogSignals = onSettingsSaved((section) => {
if (section === "keys") {
catalogGeneration++; catalogInflight = undefined; catalogCache = undefined;
publishCatalog(); fetchCatalog();
} else if (section === "models") fetchCatalog(true);
});
fetchCatalog(true);
} else fetchCatalog();
read();
return () => { catalogReaders.delete(read); if (!catalogReaders.size) { stopCatalogSignals?.(); stopCatalogSignals = undefined; } };
}, []);
const refresh = useCallback(() => fetchCatalog(true), []);
return { catalog, refresh };
}
/** provider:id → contextWindow over the cached catalog — the client-side
* twin of the server's catalogContextLimits join (context-meter
* amendment, owner 2026-09-23: pi's live catalog is the ONLY limit
@@ -315,10 +274,7 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod
return (
<div ref={containerRef} className="relative flex items-center gap-1.5" data-testid="model-control">
<Hint
// Trimmed (owner, subtlety round 2026-09-25): "Model selector"
// — the long sentence was way too much. ⌘K opens this same
// menu (registry, override-aware).
label="Model selector"
label={effectiveId ? `${effectiveProvider ? `${effectiveProvider}:` : ""}${effectiveId}` : "Model selector"}
action="open-model-picker"
side="bottom"
>
+13 -39
View File
@@ -55,6 +55,8 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { MenuShell } from "@/components/menu-shell";
import { useSettingsSaved } from "@/components/settings-signal";
import { defaultOrderLevel, populateLevel } from "@/components/thinking-default";
import { useModelCatalog } from "@/components/model-catalog";
import { Hint } from "@/components/ui/hint";
import type React from "react";
import {
COMPACTION_THRESHOLD_FLOOR,
@@ -63,7 +65,6 @@ import {
type AgentSettings,
type CompactionSettings,
type GraphSettings,
type ModelCatalogEntry,
type ModelSlotName,
type ModelsSettings,
} from "@/src/core";
@@ -101,15 +102,6 @@ interface SegmentInfo {
shared: boolean;
}
/** The GET /api/models response fields this section consumes. */
interface ModelsBody {
models: ModelCatalogEntry[];
/** The three tiers' RESOLVED pins (BYOK: NULL per unset tier — no
* factory floor) — machinery only (the thinking list fallback); the
* rows' displays read the record override, never a phantom pin. */
slots: Record<ModelSlotName, { model: { provider: string; id: string }; thinkingLevel?: string } | null>;
}
/** Compose: one tool toggled, the rest of the section carried verbatim.
* Pure — exported for tests. */
export function composeTool(agent: AgentSettings | undefined, id: string, on: boolean): AgentSettings {
@@ -234,8 +226,10 @@ function MenuTrigger({
onClick,
widthClass,
disabled,
hint,
}: {
value: string;
hint?: string;
/** What the trigger shows when there is no override ("Default"). */
emptyLabel: string;
ariaLabel: string;
@@ -250,7 +244,7 @@ function MenuTrigger({
/** The no-key search state grays the whole row; the trigger follows. */
disabled?: boolean;
}) {
return (
const button = (
<button
type="button"
aria-label={ariaLabel}
@@ -269,6 +263,7 @@ function MenuTrigger({
</span>
</button>
);
return hint ? <Hint label={hint}>{button}</Hint> : button;
}
/* The section's dropdowns all mount the shared MenuShell (fix wave chunk
@@ -303,10 +298,9 @@ export function AgentSection({
/** Writes the whole compaction section (replace-PUT discipline). */
onCompactionChange: (next: CompactionSettings) => void;
}) {
const [catalog, setCatalog] = useState<ModelCatalogEntry[] | null>(null);
/** The three tiers' resolved pins — GET /api/models `slots`. The rows'
* Default hints and thinking lists resolve from it. */
const [slots, setSlots] = useState<ModelsBody["slots"] | null>(null);
const { catalog: body, failed: catalogFailed, refresh: refreshCatalog } = useModelCatalog();
const catalog = body?.models;
const slots = body?.slots;
/** The live search state (/api/search) — the row's reason line. */
const [search, setSearch] = useState<{ available: boolean; engine: string | null; reason: string } | null>(null);
/** Which search engines have a key (/api/keys) — the no-keys-anywhere
@@ -341,28 +335,9 @@ export function AgentSection({
.catch(() => {});
}, []);
// The catalog (federated: keyed providers only), the resolved slots,
// the prompt segments, and the search engines' key presence — one
// fetch each per section mount. The models read ALSO re-runs on the
// cross-surface signal (fix wave: another surface's write re-reads
// into the slot rows live).
const modelsRequest = useRef(0);
const modelsMounted = useRef(false);
const readModels = useCallback((connectionChanged = false) => {
const request = ++modelsRequest.current;
if (connectionChanged) setCatalog(null);
fetch("/api/models")
.then((r) => { if (!r.ok) throw new Error("Catalog unavailable"); return r.json(); })
.then((body: ModelsBody) => {
if (!modelsMounted.current || request !== modelsRequest.current) return;
setCatalog(body.models);
setSlots(body.slots);
})
.catch(() => { if (modelsMounted.current && request === modelsRequest.current) setCatalog(null); });
}, []);
// The shared catalog seeds the first frame and refreshes per mount and
// keys/models signal. Settings props still own saved selections/levels.
useSettingsSaved((section) => {
if (section === "models" || section === "keys") readModels(section === "keys");
// THE state-echo root cause: the prompt switches write through the
// agent section, but this view (the toggles' truth) never re-read —
// the settings-saved law (every reader re-reads) was missing this
@@ -371,8 +346,6 @@ export function AgentSection({
});
useEffect(() => {
modelsMounted.current = true;
readModels();
fetch("/api/settings/prompts")
.then((r) => r.json())
.then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body))
@@ -388,7 +361,6 @@ export function AgentSection({
setEngineKeys(present);
})
.catch(() => {});
return () => { modelsMounted.current = false; modelsRequest.current++; };
}, []);
// The reason line re-reads /api/search AFTER an engine write lands —
@@ -615,6 +587,7 @@ export function AgentSection({
return (
<>
<SettingsGroup label="Models" testid="group-models">
{catalogFailed && <p role="alert" className="text-xs text-red-400">Model catalog could not be loaded. Check the connection and retry. <button type="button" className="underline" onClick={refreshCatalog}>Retry</button></p>}
{MODEL_SLOTS.map((slot) => {
const override = models?.[slot];
const modelId = override?.model?.id ?? "";
@@ -652,6 +625,7 @@ export function AgentSection({
{slot !== "strong" && <><ChatGPTBackgroundAccess provider={coord?.provider} /><NativeSubscriptionBackgroundAccess provider={coord?.provider} /></>}
<MenuTrigger
value={modelId ? prettyModel(modelId) : ""}
hint={modelId ? `${provider}:${modelId}` : undefined}
emptyLabel="—"
ariaLabel={`${SLOT_META[slot].label} model`}
testid={`slot-model-${slot}`}
+9 -1
View File
@@ -96,6 +96,14 @@ export function classifyKeys(
};
}
/** A key row names its credential, not whichever provider sorts first.
* Pi exposes Zen and Go separately but both read OPENCODE_API_KEY. */
export function providerKeyLabel(id: string, providers: readonly ProviderKeyInfo[] | undefined): string | undefined {
const matches = providers?.filter((p) => p.envVar === id);
if (matches?.some((p) => p.id === "opencode") && matches.some((p) => p.id === "opencode-go")) return "OpenCode (Zen / Go)";
return matches?.[0]?.label;
}
/** The minimal status line, e.g. "…ab12 · 64 chars" (" · env" marks an
* environment-owned value — the row itself is read-only then). Pure. */
export function keyStatusLine(status: AuthKeyStatus): string {
@@ -417,7 +425,7 @@ export function KeysSection({
{providerRows.map((status) => (
<KeyTableRow
key={status.id}
label={providers?.find((p) => p.envVar === status.id)?.label ?? status.info?.label ?? status.id}
label={providerKeyLabel(status.id, providers) ?? status.info?.label ?? status.id}
docsUrl={status.info?.docsUrl}
status={status}
expanded={openRow === status.id}
+111 -94
View File
@@ -13,7 +13,8 @@
* STAGED BY DEPENDENCY (owner, 2026-09-20: "first keys, then models —
* there's no point presenting the model picker before the user has set a
* key because there are no models to pick then"):
* Stage 1 — no keyed provider: a SELF-CONTAINED first-key screen
* Unknown catalog — neutral loading (or error/Retry), never a key screen.
* Stage 1 — no keyed provider or saved selection: a SELF-CONTAINED first-key screen
* (zero-keys wave, owner 2026-09-25): provider picker + one key
* input + save via PUT /api/keys/:id. The dialog STAYS MOUNTED —
* saving dispatches the "keys" signal, the fetch effect re-runs on
@@ -23,6 +24,8 @@
* Stage 2 — Strong → Cheap → Vision pickers over the FEDERATED catalog
* (keyed providers only), the vision slot filtered to image-capable
* models, in the app's shared dropdown language (ModelMenuList).
* Saved disconnected selections stay visible; Add provider opens the
* connection body when no provider remains. Refreshes preserve edits.
* All three required (owner: no skip, no defaults); Save PUTs the
* models section and reports onDone(models).
*
@@ -41,6 +44,7 @@ import { prettyModel } from "@/components/usage-format";
import { ModelMenuList } from "@/components/settings/model-menu";
import { MenuShell } from "@/components/menu-shell";
import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal";
import { useModelCatalog } from "@/components/model-catalog";
import { Hint } from "@/components/ui/hint";
import { type ProviderKeyInfo } from "@/components/settings/keys-section";
import { ProviderMenuList } from "@/components/settings/model-menu";
@@ -73,27 +77,26 @@ interface ModelsBody {
* (owner's dependency ordering). Exported for tests. */
export function setupStage(
providers: { id: string; keyPresent: boolean }[] | null,
): "keys" | "pickers" {
if (providers === null) return "keys"; // fetch failed → the honest fallback
): "loading" | "keys" | "pickers" {
if (providers === null) return "loading";
return providers.some((p) => p.keyPresent) ? "pickers" : "keys";
}
/** Prefill (owner, BYOK wave 2026-09-22): each pinned tier opens with its
* pick — matched in the FEDERATED catalog (a lapsed key's pin does not
* prefill); unpinned tiers stay empty and carry the "needed" mark.
* First-run = nothing pinned → all three empty + marked. Exported pure
* for tests. */
type ModelSelection = Pick<ModelCatalogEntry, "provider" | "id">;
/** Saved identity is independent of selectable metadata. A disconnected
* pin still prefills; only the menu is restricted to the live catalog. */
export function prefillFromSlots(
models: ModelCatalogEntry[],
slots: Record<ModelSlotName, { model: { provider: string; id: string } } | null> | undefined,
): Partial<Record<ModelSlotName, ModelCatalogEntry>> {
const pre: Partial<Record<ModelSlotName, ModelCatalogEntry>> = {};
): Partial<Record<ModelSlotName, ModelSelection>> {
const pre: Partial<Record<ModelSlotName, ModelSelection>> = {};
for (const slot of SLOT_ORDER) {
const pin = slots?.[slot];
const entry = pin?.model
? models.find((m) => m.provider === pin.model!.provider && m.id === pin.model!.id)
: undefined;
if (entry) pre[slot] = entry;
if (pin?.model) pre[slot] = entry ?? pin.model;
}
return pre;
}
@@ -217,24 +220,39 @@ export function SetupKeysBody({
);
}
export function SetupDialog({
open,
onClose,
/** Saved: the models section landed; the caller marks configured (the
* draft stays put — save never sends). */
onDone,
}: {
function prefillLevels(body: ModelsBody | undefined): Partial<Record<ModelSlotName, ThinkingLevel>> {
const levels: Partial<Record<ModelSlotName, ThinkingLevel>> = {};
for (const slot of SLOT_ORDER) {
const pin = body?.slots?.[slot];
const entry = body?.models.find((m) => m.provider === pin?.model.provider && m.id === pin.model.id);
if (pin?.thinkingLevel !== undefined) levels[slot] = pin.thinkingLevel;
else if (entry) levels[slot] = prefillLevelFromPin(entry, pin);
}
return levels;
}
interface SetupDialogProps {
open: boolean;
onClose: () => void;
/** Saving never sends the caller's draft. */
onDone: (models: ModelsSettings) => void;
}) {
}
export function SetupDialog(props: SetupDialogProps) {
// An open owns its draft. Mount-time cache seeding avoids a key-screen
// first frame, and reopening cannot reuse a previous open's edits.
return props.open ? <OpenSetupDialog {...props} /> : null;
}
function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
const backdropDismiss = useBackdropDismiss(onClose);
const [body, setBody] = useState<ModelsBody | null>(null);
const [failed, setFailed] = useState(false);
const [picked, setPicked] = useState<Partial<Record<ModelSlotName, ModelCatalogEntry>>>({});
/** Per-slot thinking level (chunk 2): populated from the ordering on
* every fresh model pick, editable — an explicit pick always wins. */
const [levels, setLevels] = useState<Partial<Record<ModelSlotName, ThinkingLevel>>>({});
const { catalog, failed, refresh } = useModelCatalog();
const [lastBody, setLastBody] = useState<ModelsBody | undefined>(catalog);
const body = catalog ?? lastBody;
const [picked, setPicked] = useState(() => prefillFromSlots(catalog?.models ?? [], catalog?.slots));
const [levels, setLevels] = useState(() => prefillLevels(catalog));
const editedSlots = useRef(new Set<ModelSlotName>());
const [showKeys, setShowKeys] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [openMenu, setOpenMenu] = useState<{ slot: ModelSlotName; kind: "model" | "thinking" } | null>(null);
@@ -249,9 +267,8 @@ export function SetupDialog({
const [keyError, setKeyError] = useState<string | null>(null);
const [keyMenuOpen, setKeyMenuOpen] = useState(false);
// Stage 1's provider-picker trigger wrapper — the shared shell's anchor.
// Declared with the other hooks: the early `if (!open) return null` sits
// below them all. (Stage 2's slot menus anchor by anchorTestid instead —
// see the slot MenuShell below for why a ref cannot work there.)
// Stage 2's slot menus anchor by anchorTestid instead — see the slot
// MenuShell below for why a freshly attached ref cannot work there.
const keyTriggerRef = useRef<HTMLDivElement>(null);
// The keys signal (zero-keys wave): keys-section saves/deletes AND
@@ -263,50 +280,44 @@ export function SetupDialog({
if (section === "keys") setKeysTick((t) => t + 1);
});
// Fresh /api/models per open AND per keys change — a key saved a
// moment ago (stage 1's own screen) widens the pickers immediately.
useEffect(() => {
if (!open) return;
let cancelled = false;
setBody(null);
setPicked({});
setLevels({});
setError(null);
fetch("/api/keys")
.then((r) => r.json())
.then((b: { providers?: ProviderKeyInfo[] }) => { if (!cancelled) setKeyProviders(b.providers ?? []); })
.catch(() => { if (!cancelled) setKeyProviders([]); });
fetch("/api/models")
.then((r) => { if (!r.ok) throw new Error("Catalog unavailable"); return r.json(); })
.then((b: ModelsBody) => {
if (cancelled) return;
setBody(b);
setFailed(false);
// Prefill (owner, 2026-09-22): each pinned tier opens with its
// pick (matched in the FEDERATED catalog — a lapsed key's pin
// does not prefill); unpinned tiers stay empty and carry the
// "needed" mark. First-run = nothing pinned → all three marked.
const pre = prefillFromSlots(b.models, b.slots);
setPicked(pre);
// Levels prefill with the pins (chunk 2): stored level if still
// supported, else the ordering's level for the pinned model.
const preLevels: Partial<Record<ModelSlotName, ThinkingLevel>> = {};
for (const slot of SLOT_ORDER) {
const entry = pre[slot];
if (entry) preLevels[slot] = prefillLevelFromPin(entry, b.slots?.[slot]);
}
setLevels(preLevels);
})
.catch(() => { if (!cancelled) setFailed(true); });
return () => { cancelled = true; };
}, [open, keysTick]);
}, [keysTick]);
useEffect(() => {
if (!catalog) return;
setLastBody(catalog);
const pre = prefillFromSlots(catalog.models, catalog.slots);
const preLevels = prefillLevels(catalog);
// Fresh metadata is always used, but a response cannot reset an
// edited slot, including a level chosen while the request was pending.
setPicked((prev) => {
const next = { ...prev };
for (const slot of SLOT_ORDER) if (!editedSlots.current.has(slot)) {
if (pre[slot]) next[slot] = pre[slot]; else delete next[slot];
}
return next;
});
setLevels((prev) => {
const next = { ...prev };
for (const slot of SLOT_ORDER) if (!editedSlots.current.has(slot)) {
if (preLevels[slot] !== undefined) next[slot] = preLevels[slot]; else delete next[slot];
}
return next;
});
if (catalog.providers.some((p) => p.keyPresent)) setShowKeys(false);
}, [catalog]);
// Esc + outside-click — layered dismissal, closable WITHOUT
// configuring (browsing stays possible; the gate is the send
// interception, not this dialog). An open slot menu eats Esc first
// (and any click outside it); only a bare Esc closes the dialog.
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
// THE ONE-ESC LAW (owner, keyboard round 2026-10-06): claim at
@@ -341,11 +352,10 @@ export function SetupDialog({
document.removeEventListener("keydown", onKey);
document.removeEventListener("pointerdown", onPointerDown);
};
}, [open, onClose, openMenu, keyMenuOpen]);
}, [onClose, openMenu, keyMenuOpen]);
if (!open) return null;
const stage = failed ? "keys" : setupStage(body ? body.providers : null);
const hasSelections = Object.keys(picked).length > 0 || SLOT_ORDER.some((slot) => body?.slots?.[slot]?.model);
const stage = hasSelections ? "pickers" : setupStage(body ? body.providers : null);
const allPicked = SLOT_ORDER.every((s) => picked[s]);
/** Stage 1's save: PUT the pasted key, dispatch the keys signal, and
@@ -398,6 +408,20 @@ export function SetupDialog({
onDone(models);
}
const keysBody = <SetupKeysBody
providers={keyProviders}
provider={keyProvider}
value={keyValue}
saving={keySaving}
error={keyError}
menuOpen={keyMenuOpen}
menuAnchor={keyTriggerRef}
onToggleMenu={() => setKeyMenuOpen((v) => !v)}
onPick={(p) => { setKeyProvider(p); setKeyMenuOpen(false); setKeyError(null); }}
onValue={setKeyValue}
onSave={() => void saveKey()}
/>;
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80"
@@ -427,43 +451,32 @@ export function SetupDialog({
</Hint>
</header>
{stage === "keys" ? (
<div className="flex flex-col px-6 py-5">
{failed ? (
<p className="text-sm text-foreground">
Model catalog could not be loaded. Check the connection and retry.
</p>
) : (
<SetupKeysBody
providers={keyProviders}
provider={keyProvider}
value={keyValue}
saving={keySaving}
error={keyError}
menuOpen={keyMenuOpen}
menuAnchor={keyTriggerRef}
onToggleMenu={() => setKeyMenuOpen((v) => !v)}
onPick={(p) => {
setKeyProvider(p);
setKeyMenuOpen(false);
setKeyError(null);
}}
onValue={setKeyValue}
onSave={() => void saveKey()}
/>
)}
</div>
{failed && (
<p role="alert" data-testid="setup-catalog-error" className="px-6 py-3 text-sm text-foreground">
Model catalog could not be loaded. Check the connection and retry. <button type="button" data-testid="setup-retry" className="underline" onClick={refresh}>Retry</button>
</p>
)}
{stage === "loading" ? (
!failed && <p data-testid="setup-stage-loading" role="status" className="px-6 py-5 text-xs text-muted-foreground">Loading models…</p>
) : stage === "keys" ? (
<div className="flex flex-col px-6 py-5">{keysBody}</div>
) : (
<div className="flex flex-col gap-4 overflow-y-auto px-6 py-5" data-testid="setup-stage-pickers">
<p className="text-xs text-muted-foreground">
Pick the model for each tier. All three are required before chats can start.
</p>
{!body?.providers.some((p) => p.keyPresent) && <>
<button type="button" data-testid="setup-add-provider" aria-expanded={showKeys} className="self-start text-xs underline" onClick={() => setShowKeys((v) => !v)}>Add provider</button>
{showKeys && keysBody}
</>}
{SLOT_ORDER.map((slot) => {
const entry = picked[slot];
const level = levels[slot];
const levelsForEntry = entry?.supportedLevels ?? [];
const catalog =
slot === "vision" ? body?.models.filter((m) => m.input.includes("image")) : body?.models;
const metadata = catalog?.models.find((m) => m.id === entry?.id && m.provider === entry.provider);
// Unavailable metadata cannot erase or clamp a saved preference.
const levelsForEntry = metadata?.supportedLevels ?? (entry ? Object.keys(THINKING_LABEL) : []);
const available = catalog?.models ?? (failed ? [] : undefined);
const slotCatalog = slot === "vision" ? available?.filter((m) => m.input.includes("image")) : available;
return (
<div key={slot} data-testid={`setup-slot-${slot}`} className="flex flex-col gap-1.5">
<span className="flex items-center gap-1.5">
@@ -481,6 +494,7 @@ export function SetupDialog({
{slot !== "strong" && <><ChatGPTBackgroundAccess provider={entry?.provider} /><NativeSubscriptionBackgroundAccess provider={entry?.provider} /></>}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Hint label={entry ? `${entry.provider}:${entry.id}` : "Pick a model…"}>
<button
type="button"
aria-label={`${SLOT_META[slot].label} model`}
@@ -494,6 +508,7 @@ export function SetupDialog({
▾
</span>
</button>
</Hint>
{/* Anchor by TESTID, not a ref (the settings-section pattern):
React attaches a host element's ref AFTER its descendants'
layout effects run, so a ref freshly attached in this same
@@ -512,10 +527,11 @@ export function SetupDialog({
menuClass="flex w-[22rem] flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
>
<ModelMenuList
models={catalog}
models={slotCatalog}
effectiveId={entry?.id}
effectiveProvider={entry?.provider}
onPick={(m) => {
editedSlots.current.add(slot);
setPicked((prev) => ({ ...prev, [slot]: m }));
// Auto-populate (chunk 2, owner: "populate
// everywhere"): no explicit level → the
@@ -527,7 +543,7 @@ export function SetupDialog({
}));
setOpenMenu(null);
}}
footer={catalog && catalog.length === 0 ? "No image-capable models. Add a provider key with access to a vision model." : undefined}
footer={slotCatalog && slotCatalog.length === 0 && slot === "vision" ? "No image-capable models. Add a provider key with access to a vision model." : undefined}
/>
</MenuShell>
)}
@@ -563,6 +579,7 @@ export function SetupDialog({
levels={levelsForEntry.map(String)}
effectiveLevel={level}
onPick={(lv) => {
editedSlots.current.add(slot);
setLevels((prev) => ({ ...prev, [slot]: lv as ThinkingLevel }));
setOpenMenu(null);
}}
+5 -19
View File
@@ -29,25 +29,11 @@ export function splitQualified(id: string): { provider: string | null; id: strin
return { provider: id.slice(0, colon), id: id.slice(colon + 1) };
}
/**
* "z-ai/glm-5.3-flash" → "glm-5.3-flash" — vendor prefix dropped, the
* name KEPT EXACTLY as its provider writes it (dashes stay, no
* auto-caps — owner rounds 4-5, 2026-10-05); a trailing all-digit
* segment (OpenRouter's date suffix, "qwen3.8-max-0902") is dropped.
* Quiet-chip friendly.
*
* With `qualified` (usage stamps are "provider:id", model-slots wave):
* the provider rides as a quiet suffix — "glm-5.3-flash · openrouter" —
* because with federation the same model via two providers is genuinely
* different bookkeeping. Bare catalog ids are unchanged.
*/
/** Model-only labels omit the vendor path, never versions, dates or variants.
* Only usage callers pass `qualified`: a bare catalog id's colon belongs to
* the model (e.g. :free), not a provider. Usage retains its provider suffix. */
export function prettyModel(id: string, qualified = false): string {
const { provider, id: bare } = splitQualified(id);
const { provider, id: bare } = qualified ? splitQualified(id) : { provider: null, id };
const short = bare.includes("/") ? bare.slice(bare.lastIndexOf("/") + 1) : bare;
const parts = short.split("-");
const trimmed = /^\d+$/.test(parts[parts.length - 1] ?? "") ? parts.slice(0, -1) : parts;
// No auto-capitalization, dashes KEPT (owner rounds 4-5, 2026-10-05):
// model names render exactly as their providers write them.
const pretty = trimmed.join("-");
return qualified && provider ? `${pretty} · ${provider}` : pretty;
return provider ? `${short} · ${provider}` : short;
}
+4 -4
View File
@@ -123,9 +123,9 @@ describe("prefillFromSlots — the SetupDialog prefill (BYOK wave)", () => {
assert.deepEqual(pre, {});
});
it("a pin whose model left the federated catalog does not prefill (lapsed key)", () => {
it("a disconnected saved identity still prefills without selectable metadata", () => {
const pre = prefillFromSlots(catalog, { strong: { model: { provider: "gone", id: "vanished" } }, cheap: null, vision: null });
assert.deepEqual(pre, {});
assert.deepEqual(pre, { strong: { provider: "gone", id: "vanished" } });
});
it("provider collisions never cross-pollute (same id, different provider)", () => {
@@ -158,9 +158,9 @@ describe("prefillLevelFromPin — the setup dialog's level prefill (chunk 2)", (
});
});
describe("setupStage — unchanged by the BYOK wave", () => {
describe("setupStage — unknown is not keyless", () => {
it("keys first (owner ordering), pickers when a provider is keyed", () => {
assert.equal(setupStage(null), "keys");
assert.equal(setupStage(null), "loading");
assert.equal(setupStage([{ id: "x", keyPresent: false }]), "keys");
assert.equal(setupStage([{ id: "x", keyPresent: true }]), "pickers");
});
+176
View File
@@ -0,0 +1,176 @@
import React, { act } from "react";
(globalThis as Record<string, unknown>).React = React;
import assert from "node:assert/strict";
import { afterEach, test } from "node:test";
import { createRoot, type Root } from "react-dom/client";
import { parseHTML } from "linkedom";
import { SetupDialog } from "../components/setup-dialog.tsx";
import { AgentSection } from "../components/settings/agent-section.tsx";
import { providerKeyLabel, classifyKeys } from "../components/settings/keys-section.tsx";
import { useModelCatalog } from "../components/model-catalog.ts";
import { notifySettingsSaved } from "../components/settings-signal.ts";
import type { ModelCatalogEntry, ModelCatalogResponse, ModelsSettings } from "../src/core/index.ts";
const dom = parseHTML("<html><body></body></html>");
Object.assign(globalThis, {
window: dom.window, document: dom.document, Element: dom.window.Element,
HTMLElement: dom.window.HTMLElement, HTMLInputElement: dom.window.HTMLInputElement,
CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true,
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(performance.now()), 0),
cancelAnimationFrame: (id: number) => clearTimeout(id),
});
Object.assign(dom.window, { innerWidth: 1280, innerHeight: 800 });
dom.window.HTMLElement.prototype.scrollIntoView = () => {};
const originalFetch = globalThis.fetch;
let root: Root | undefined;
let host: HTMLDivElement | undefined;
let requests: ReturnType<typeof deferred<Response>>[] = [];
let writes: ModelsSettings[] = [];
let snapshot: ReturnType<typeof useModelCatalog>;
function deferred<T>() { let resolve!: (value: T) => void; const promise = new Promise<T>((done) => { resolve = done; }); return { resolve, promise }; }
const provider = { id: "fixture", envVar: "FIXTURE_KEY", label: "Fixture" };
const a: ModelCatalogEntry = { id: "vendor/model:free", name: "Model free", provider: provider.id, input: ["text", "image"], reasoning: true, supportedLevels: ["off", "low", "high"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 8192, maxTokens: 1024 };
const b: ModelCatalogEntry = { ...a, id: "claude-opus-5-5", name: "Claude Opus 5.5", supportedLevels: ["off", "medium"] };
function catalog(connected = true): ModelCatalogResponse {
return {
models: connected ? [a, b] : [], defaults: null,
rolePins: { researcher: null, demoBuilder: null, visualMaker: null, extractor: null, auditor: null },
slots: { strong: { model: { provider: a.provider, id: a.id }, thinkingLevel: "low" }, cheap: { model: { provider: a.provider, id: a.id }, thinkingLevel: "high" }, vision: { model: { provider: a.provider, id: a.id }, thinkingLevel: "low" } },
providers: [{ ...provider, keyPresent: connected }],
};
}
function Reader() { snapshot = useModelCatalog(); return null; }
function setup() { return <SetupDialog open onClose={() => {}} onDone={() => {}} />; }
function installFetch() {
requests = []; writes = [];
globalThis.fetch = async (input, init) => {
const path = String(input);
if (path === "/api/models") { const request = deferred<Response>(); requests.push(request); return request.promise; }
if (path === "/api/settings/models") { writes.push(JSON.parse(String(init?.body))); return Response.json({ settings: { models: writes.at(-1) } }); }
if (path === "/api/keys") return Response.json({ providers: [provider], keys: [] });
if (path === "/api/settings/prompts") return Response.json({ prober: [], teacher: [] });
if (path === "/api/search") return Response.json({ available: false, engine: null, reason: "fixture" });
throw new Error(`Unexpected request ${path}`);
};
}
async function render(children: React.ReactNode) {
if (!root) { host = document.createElement("div"); document.body.append(host); root = createRoot(host); }
await act(async () => root!.render(children));
}
async function answer(index: number, body: ModelCatalogResponse | null) { await act(async () => requests[index]!.resolve(body ? Response.json(body) : new Response("unavailable", { status: 503 }))); }
function element(testid: string) { const e = document.querySelector<HTMLElement>(`[data-testid="${testid}"]`); assert.ok(e, testid); return e; }
async function click(testid: string) { await act(async () => element(testid).click()); }
function levels() { return [...document.querySelectorAll('[data-testid^="thinking-option-"]')].map((e) => e.textContent); }
async function warm(body = catalog()) { await render(<Reader />); await answer(0, body); }
afterEach(async () => {
await act(async () => root?.unmount()); host?.remove(); root = undefined; host = undefined;
globalThis.fetch = originalFetch;
});
test("cold setup is loading, not keyless; failure/Retry does not invent a key stage", async () => {
installFetch(); await render(setup());
assert.ok(element("setup-stage-loading"));
assert.equal(document.querySelector('[data-testid="setup-stage-keys"]'), null);
await answer(0, null);
assert.ok(element("setup-catalog-error"));
assert.equal(document.querySelector('[data-testid="setup-stage-keys"]'), null);
await click("setup-retry"); assert.ok(element("setup-stage-loading"));
const empty = catalog(false); empty.slots = { strong: null, cheap: null, vision: null };
await answer(1, empty); assert.ok(element("setup-stage-keys"));
});
test("cached setup is immediately usable; fresh metadata/untouched slots update without wiping model or thinking edits; failed refresh still allows exact-ID Save", async () => {
installFetch(); await warm();
await render(<><Reader />{setup()}</>);
assert.equal(requests.length, 2);
assert.ok(element("setup-stage-pickers"));
assert.equal(document.querySelector('[data-testid="setup-stage-keys"]'), null);
assert.equal(element("setup-trigger-strong").textContent, "model:free▾");
await click("setup-trigger-strong"); await click("model-option-claude-opus-5-5");
await click("setup-thinking-strong"); await click("thinking-option-medium");
const fresh = catalog(); fresh.slots.cheap = { model: { provider: b.provider, id: b.id }, thinkingLevel: "off" };
fresh.models[1] = { ...b, supportedLevels: ["off", "medium", "high"] };
await answer(1, fresh);
assert.equal(element("setup-trigger-strong").textContent, "claude-opus-5-5▾");
assert.equal(element("setup-thinking-strong").textContent?.trim(), "medium▾");
assert.equal(element("setup-trigger-cheap").textContent, "claude-opus-5-5▾");
await click("setup-thinking-strong"); assert.deepEqual(levels(), ["off", "medium✓", "high"]);
await click("setup-thinking-strong");
await act(async () => snapshot.refresh()); await answer(2, null);
assert.ok(element("setup-catalog-error")); assert.equal(element("setup-save").hasAttribute("disabled"), false);
await click("setup-save");
assert.deepEqual(writes[0], {
strong: { model: { provider: b.provider, id: b.id }, thinkingLevel: "medium" },
cheap: { model: { provider: b.provider, id: b.id }, thinkingLevel: "off" },
vision: { model: { provider: a.provider, id: a.id }, thinkingLevel: "low" },
});
});
test("disconnect invalidates choices, not saved selections or thinking; all-disconnected setup offers Add provider", async () => {
installFetch(); await warm(); await render(<><Reader />{setup()}</>); await answer(1, catalog());
await click("setup-trigger-strong");
assert.ok(document.querySelector('[data-testid="model-option-claude-opus-5-5"]'));
await act(async () => notifySettingsSaved("keys"));
assert.equal(document.querySelector('[data-testid="model-option-claude-opus-5-5"]'), null, "stale choices disappear before the fresh request resolves");
await answer(2, catalog(false));
assert.equal(element("setup-trigger-strong").textContent, "model:free▾");
assert.equal(element("setup-thinking-strong").textContent?.trim(), "low▾");
assert.ok(element("setup-add-provider")); await click("setup-add-provider"); assert.ok(element("setup-stage-keys"));
await click("setup-thinking-strong"); await click("thinking-option-xhigh");
assert.equal(element("setup-thinking-strong").textContent?.trim(), "xhigh▾");
await click("setup-save"); assert.equal(writes[0]?.strong?.thinkingLevel, "xhigh"); assert.equal(writes[0]?.strong?.model?.id, a.id);
});
test("cold reconnect checks saved pins before choosing the key stage, and reopening gets a new draft", async () => {
installFetch(); await warm();
await act(async () => notifySettingsSaved("keys"));
const stages: Array<string | undefined> = [];
function Probe() {
const { catalog } = useModelCatalog();
React.useLayoutEffect(() => { stages.push(document.querySelector<HTMLElement>('[data-testid^="setup-stage-"]')?.dataset.testid); }, [catalog]);
return null;
}
await render(<><Reader />{setup()}<Probe /></>);
assert.ok(element("setup-stage-loading"));
await answer(1, catalog(false));
assert.deepEqual(stages, ["setup-stage-loading", "setup-stage-pickers"]);
await click("setup-thinking-strong"); await click("thinking-option-xhigh");
await render(<Reader />);
await render(<><Reader />{setup()}</>);
assert.equal(element("setup-thinking-strong").textContent?.trim(), "low▾", "a reopened draft uses saved state, not the abandoned edit");
await answer(2, catalog(false));
});
test("Agent opens with cached thinking metadata; refresh and disconnect never clear saved levels", async () => {
installFetch(); await warm();
const models: ModelsSettings = { strong: { model: { provider: a.provider, id: a.id }, thinkingLevel: "low" } };
let changed: ModelsSettings | undefined;
await render(<><Reader /><AgentSection agent={undefined} models={models} graph={undefined} compaction={undefined} onSectionChange={() => {}} onModelsChange={(next) => { changed = next; }} onCompactionChange={() => {}} /></>);
await click("slot-thinking-strong"); assert.deepEqual(levels(), ["off", "low✓", "high"]);
assert.equal(element("slot-model-strong").textContent, "model:free▾");
await answer(1, catalog());
await act(async () => notifySettingsSaved("keys")); await answer(2, catalog(false));
assert.equal(element("slot-thinking-strong").textContent, "low▾");
await click("thinking-option-xhigh"); assert.equal(changed?.strong?.thinkingLevel, "xhigh"); assert.equal(changed?.strong?.model?.id, a.id);
});
test("shared refresh fences late failure as well as late success, and Retry clears a current error", async () => {
installFetch(); await warm();
await act(async () => snapshot.refresh()); await act(async () => snapshot.refresh());
const fresh = catalog(); fresh.models = [b];
await answer(2, fresh); await answer(1, null);
assert.equal(snapshot.failed, false); assert.equal(snapshot.catalog?.models[0]?.id, b.id);
await act(async () => snapshot.refresh()); await answer(3, null);
assert.equal(snapshot.failed, true); assert.equal(snapshot.catalog?.models[0]?.id, b.id);
await act(async () => snapshot.refresh()); assert.equal(snapshot.failed, false);
await answer(4, fresh);
});
test("OpenCode's saved shared credential names both Pi providers without changing addable-provider semantics", () => {
const providers = [{ id: "opencode", envVar: "OPENCODE_API_KEY", label: "OpenCode Zen" }, { id: "opencode-go", envVar: "OPENCODE_API_KEY", label: "OpenCode Go" }];
assert.equal(providerKeyLabel("OPENCODE_API_KEY", providers), "OpenCode (Zen / Go)");
assert.equal(providerKeyLabel("OPENCODE_API_KEY", [...providers].reverse()), "OpenCode (Zen / Go)");
assert.equal(providerKeyLabel("FIXTURE_KEY", [provider]), "Fixture");
assert.equal(classifyKeys([{ id: "OPENCODE_API_KEY", info: null, present: true, masked: "…fake", source: "file", shadowedByEnv: false }], providers).addable.length, 0);
});
+12 -4
View File
@@ -89,9 +89,15 @@ describe("stats bar", () => {
assert.match(empty, /data-testid="stats-context">\?</);
});
it("prettyModel strips vendor + date suffix, keeps dashes", () => {
it("prettyModel strips only the vendor path, preserving versions, dates and variants", () => {
assert.equal(prettyModel("z-ai/glm-5.3-flash"), "glm-5.3-flash"); // dashes kept (owner round 5)
assert.equal(prettyModel("qwen/qwen3.8-max-0902"), "qwen3.8-max");
assert.equal(prettyModel("qwen/qwen3.8-max-0902"), "qwen3.8-max-0902");
assert.equal(prettyModel("google/gemma-4-27b-it:free"), "gemma-4-27b-it:free");
assert.equal(prettyModel("anthropic/claude-opus-5:batch"), "claude-opus-5:batch");
assert.equal(prettyModel("claude-opus-5"), "claude-opus-5");
assert.equal(prettyModel("claude-opus-5-5"), "claude-opus-5-5");
assert.equal(prettyModel("model:free"), "model:free");
assert.equal(prettyModel("model:variant:free"), "model:variant:free");
});
it("provider-qualified stamps (usage contract, model-slots wave)", () => {
@@ -99,8 +105,10 @@ describe("stats bar", () => {
// the same model via two providers different bookkeeping
assert.equal(prettyModel("openrouter:z-ai/glm-5.3-flash", true), "glm-5.3-flash · openrouter");
assert.equal(prettyModel("openai:gpt-5.2", true), "gpt-5.2 · openai");
// unqualified on a qualified stamp: provider silently dropped (old shape)
assert.equal(prettyModel("openrouter:z-ai/glm-5.3-flash"), "glm-5.3-flash");
assert.equal(prettyModel("openrouter:google/gemma-4-27b-it:free", true), "gemma-4-27b-it:free · openrouter");
assert.equal(prettyModel("opencode:claude-opus-5-5", true), "claude-opus-5-5 · opencode");
// Bare ids are never parsed as usage stamps, even with a colon.
assert.equal(prettyModel("model:free"), "model:free");
// qualified on a bare id: no suffix invented
assert.equal(prettyModel("z-ai/glm-5.3-flash", true), "glm-5.3-flash");
});
+1 -1
View File
@@ -320,7 +320,7 @@ describe("first-run gate — slotsConfigured + setupStage (model-slots wave)", (
});
it("setupStage: keys before models (owner's dependency ordering)", () => {
assert.equal(setupStage(null), "keys", "fetch failed → the honest fallback");
assert.equal(setupStage(null), "loading", "unknown catalog must not claim keys are missing");
assert.equal(setupStage([]), "keys", "no providers = keys");
assert.equal(setupStage([{ id: "zai", keyPresent: false }]), "keys", "provider present but keyless");
assert.equal(setupStage([{ id: "zai", keyPresent: false }, { id: "openai", keyPresent: true }]), "pickers", "one keyed provider widens the pickers");