Merge model-label, OpenCode key-row and setup initialization fixes
This commit is contained in:
@@ -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 };
|
||||||
|
}
|
||||||
@@ -8,52 +8,11 @@ import { ModelMenuList, ThinkingMenuList } from "@/components/settings/model-men
|
|||||||
import { composeSlotModel } from "@/components/settings/agent-section";
|
import { composeSlotModel } from "@/components/settings/agent-section";
|
||||||
import { MenuShell } from "@/components/menu-shell";
|
import { MenuShell } from "@/components/menu-shell";
|
||||||
import { Hint } from "@/components/ui/hint";
|
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";
|
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
|
/** provider:id → contextWindow over the cached catalog — the client-side
|
||||||
* twin of the server's catalogContextLimits join (context-meter
|
* twin of the server's catalogContextLimits join (context-meter
|
||||||
* amendment, owner 2026-09-23: pi's live catalog is the ONLY limit
|
* 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 (
|
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">
|
||||||
<Hint
|
<Hint
|
||||||
// Trimmed (owner, subtlety round 2026-09-25): "Model selector"
|
label={effectiveId ? `${effectiveProvider ? `${effectiveProvider}:` : ""}${effectiveId}` : "Model selector"}
|
||||||
// — the long sentence was way too much. ⌘K opens this same
|
|
||||||
// menu (registry, override-aware).
|
|
||||||
label="Model selector"
|
|
||||||
action="open-model-picker"
|
action="open-model-picker"
|
||||||
side="bottom"
|
side="bottom"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import { MenuShell } from "@/components/menu-shell";
|
import { MenuShell } from "@/components/menu-shell";
|
||||||
import { useSettingsSaved } from "@/components/settings-signal";
|
import { useSettingsSaved } from "@/components/settings-signal";
|
||||||
import { defaultOrderLevel, populateLevel } from "@/components/thinking-default";
|
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 type React from "react";
|
||||||
import {
|
import {
|
||||||
COMPACTION_THRESHOLD_FLOOR,
|
COMPACTION_THRESHOLD_FLOOR,
|
||||||
@@ -63,7 +65,6 @@ import {
|
|||||||
type AgentSettings,
|
type AgentSettings,
|
||||||
type CompactionSettings,
|
type CompactionSettings,
|
||||||
type GraphSettings,
|
type GraphSettings,
|
||||||
type ModelCatalogEntry,
|
|
||||||
type ModelSlotName,
|
type ModelSlotName,
|
||||||
type ModelsSettings,
|
type ModelsSettings,
|
||||||
} from "@/src/core";
|
} from "@/src/core";
|
||||||
@@ -101,15 +102,6 @@ interface SegmentInfo {
|
|||||||
shared: boolean;
|
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.
|
/** Compose: one tool toggled, the rest of the section carried verbatim.
|
||||||
* Pure — exported for tests. */
|
* Pure — exported for tests. */
|
||||||
export function composeTool(agent: AgentSettings | undefined, id: string, on: boolean): AgentSettings {
|
export function composeTool(agent: AgentSettings | undefined, id: string, on: boolean): AgentSettings {
|
||||||
@@ -234,8 +226,10 @@ function MenuTrigger({
|
|||||||
onClick,
|
onClick,
|
||||||
widthClass,
|
widthClass,
|
||||||
disabled,
|
disabled,
|
||||||
|
hint,
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
value: string;
|
||||||
|
hint?: string;
|
||||||
/** What the trigger shows when there is no override ("Default"). */
|
/** What the trigger shows when there is no override ("Default"). */
|
||||||
emptyLabel: string;
|
emptyLabel: string;
|
||||||
ariaLabel: string;
|
ariaLabel: string;
|
||||||
@@ -250,7 +244,7 @@ function MenuTrigger({
|
|||||||
/** The no-key search state grays the whole row; the trigger follows. */
|
/** The no-key search state grays the whole row; the trigger follows. */
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
const button = (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
@@ -269,6 +263,7 @@ function MenuTrigger({
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
return hint ? <Hint label={hint}>{button}</Hint> : button;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The section's dropdowns all mount the shared MenuShell (fix wave chunk
|
/* 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). */
|
/** Writes the whole compaction section (replace-PUT discipline). */
|
||||||
onCompactionChange: (next: CompactionSettings) => void;
|
onCompactionChange: (next: CompactionSettings) => void;
|
||||||
}) {
|
}) {
|
||||||
const [catalog, setCatalog] = useState<ModelCatalogEntry[] | null>(null);
|
const { catalog: body, failed: catalogFailed, refresh: refreshCatalog } = useModelCatalog();
|
||||||
/** The three tiers' resolved pins — GET /api/models `slots`. The rows'
|
const catalog = body?.models;
|
||||||
* Default hints and thinking lists resolve from it. */
|
const slots = body?.slots;
|
||||||
const [slots, setSlots] = useState<ModelsBody["slots"] | null>(null);
|
|
||||||
/** The live search state (/api/search) — the row's reason line. */
|
/** The live search state (/api/search) — the row's reason line. */
|
||||||
const [search, setSearch] = useState<{ available: boolean; engine: string | null; reason: string } | null>(null);
|
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
|
/** Which search engines have a key (/api/keys) — the no-keys-anywhere
|
||||||
@@ -341,28 +335,9 @@ export function AgentSection({
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// The catalog (federated: keyed providers only), the resolved slots,
|
// The shared catalog seeds the first frame and refreshes per mount and
|
||||||
// the prompt segments, and the search engines' key presence — one
|
// keys/models signal. Settings props still own saved selections/levels.
|
||||||
// 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); });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useSettingsSaved((section) => {
|
useSettingsSaved((section) => {
|
||||||
if (section === "models" || section === "keys") readModels(section === "keys");
|
|
||||||
// THE state-echo root cause: the prompt switches write through the
|
// THE state-echo root cause: the prompt switches write through the
|
||||||
// agent section, but this view (the toggles' truth) never re-read —
|
// agent section, but this view (the toggles' truth) never re-read —
|
||||||
// the settings-saved law (every reader re-reads) was missing this
|
// the settings-saved law (every reader re-reads) was missing this
|
||||||
@@ -371,8 +346,6 @@ export function AgentSection({
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
modelsMounted.current = true;
|
|
||||||
readModels();
|
|
||||||
fetch("/api/settings/prompts")
|
fetch("/api/settings/prompts")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body))
|
.then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body))
|
||||||
@@ -388,7 +361,6 @@ export function AgentSection({
|
|||||||
setEngineKeys(present);
|
setEngineKeys(present);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
return () => { modelsMounted.current = false; modelsRequest.current++; };
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// The reason line re-reads /api/search AFTER an engine write lands —
|
// The reason line re-reads /api/search AFTER an engine write lands —
|
||||||
@@ -615,6 +587,7 @@ export function AgentSection({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingsGroup label="Models" testid="group-models">
|
<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) => {
|
{MODEL_SLOTS.map((slot) => {
|
||||||
const override = models?.[slot];
|
const override = models?.[slot];
|
||||||
const modelId = override?.model?.id ?? "";
|
const modelId = override?.model?.id ?? "";
|
||||||
@@ -652,6 +625,7 @@ export function AgentSection({
|
|||||||
{slot !== "strong" && <><ChatGPTBackgroundAccess provider={coord?.provider} /><NativeSubscriptionBackgroundAccess provider={coord?.provider} /></>}
|
{slot !== "strong" && <><ChatGPTBackgroundAccess provider={coord?.provider} /><NativeSubscriptionBackgroundAccess provider={coord?.provider} /></>}
|
||||||
<MenuTrigger
|
<MenuTrigger
|
||||||
value={modelId ? prettyModel(modelId) : ""}
|
value={modelId ? prettyModel(modelId) : ""}
|
||||||
|
hint={modelId ? `${provider}:${modelId}` : undefined}
|
||||||
emptyLabel="—"
|
emptyLabel="—"
|
||||||
ariaLabel={`${SLOT_META[slot].label} model`}
|
ariaLabel={`${SLOT_META[slot].label} model`}
|
||||||
testid={`slot-model-${slot}`}
|
testid={`slot-model-${slot}`}
|
||||||
|
|||||||
@@ -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
|
/** The minimal status line, e.g. "…ab12 · 64 chars" (" · env" marks an
|
||||||
* environment-owned value — the row itself is read-only then). Pure. */
|
* environment-owned value — the row itself is read-only then). Pure. */
|
||||||
export function keyStatusLine(status: AuthKeyStatus): string {
|
export function keyStatusLine(status: AuthKeyStatus): string {
|
||||||
@@ -417,7 +425,7 @@ export function KeysSection({
|
|||||||
{providerRows.map((status) => (
|
{providerRows.map((status) => (
|
||||||
<KeyTableRow
|
<KeyTableRow
|
||||||
key={status.id}
|
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}
|
docsUrl={status.info?.docsUrl}
|
||||||
status={status}
|
status={status}
|
||||||
expanded={openRow === status.id}
|
expanded={openRow === status.id}
|
||||||
|
|||||||
+111
-94
@@ -13,7 +13,8 @@
|
|||||||
* STAGED BY DEPENDENCY (owner, 2026-09-20: "first keys, then models —
|
* 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
|
* there's no point presenting the model picker before the user has set a
|
||||||
* key because there are no models to pick then"):
|
* 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
|
* (zero-keys wave, owner 2026-09-25): provider picker + one key
|
||||||
* input + save via PUT /api/keys/:id. The dialog STAYS MOUNTED —
|
* input + save via PUT /api/keys/:id. The dialog STAYS MOUNTED —
|
||||||
* saving dispatches the "keys" signal, the fetch effect re-runs on
|
* 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
|
* Stage 2 — Strong → Cheap → Vision pickers over the FEDERATED catalog
|
||||||
* (keyed providers only), the vision slot filtered to image-capable
|
* (keyed providers only), the vision slot filtered to image-capable
|
||||||
* models, in the app's shared dropdown language (ModelMenuList).
|
* 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
|
* All three required (owner: no skip, no defaults); Save PUTs the
|
||||||
* models section and reports onDone(models).
|
* models section and reports onDone(models).
|
||||||
*
|
*
|
||||||
@@ -41,6 +44,7 @@ import { prettyModel } from "@/components/usage-format";
|
|||||||
import { ModelMenuList } from "@/components/settings/model-menu";
|
import { ModelMenuList } from "@/components/settings/model-menu";
|
||||||
import { MenuShell } from "@/components/menu-shell";
|
import { MenuShell } from "@/components/menu-shell";
|
||||||
import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal";
|
import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal";
|
||||||
|
import { useModelCatalog } from "@/components/model-catalog";
|
||||||
import { Hint } from "@/components/ui/hint";
|
import { Hint } from "@/components/ui/hint";
|
||||||
import { type ProviderKeyInfo } from "@/components/settings/keys-section";
|
import { type ProviderKeyInfo } from "@/components/settings/keys-section";
|
||||||
import { ProviderMenuList } from "@/components/settings/model-menu";
|
import { ProviderMenuList } from "@/components/settings/model-menu";
|
||||||
@@ -73,27 +77,26 @@ interface ModelsBody {
|
|||||||
* (owner's dependency ordering). Exported for tests. */
|
* (owner's dependency ordering). Exported for tests. */
|
||||||
export function setupStage(
|
export function setupStage(
|
||||||
providers: { id: string; keyPresent: boolean }[] | null,
|
providers: { id: string; keyPresent: boolean }[] | null,
|
||||||
): "keys" | "pickers" {
|
): "loading" | "keys" | "pickers" {
|
||||||
if (providers === null) return "keys"; // fetch failed → the honest fallback
|
if (providers === null) return "loading";
|
||||||
return providers.some((p) => p.keyPresent) ? "pickers" : "keys";
|
return providers.some((p) => p.keyPresent) ? "pickers" : "keys";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Prefill (owner, BYOK wave 2026-09-22): each pinned tier opens with its
|
type ModelSelection = Pick<ModelCatalogEntry, "provider" | "id">;
|
||||||
* pick — matched in the FEDERATED catalog (a lapsed key's pin does not
|
|
||||||
* prefill); unpinned tiers stay empty and carry the "needed" mark.
|
/** Saved identity is independent of selectable metadata. A disconnected
|
||||||
* First-run = nothing pinned → all three empty + marked. Exported pure
|
* pin still prefills; only the menu is restricted to the live catalog. */
|
||||||
* for tests. */
|
|
||||||
export function prefillFromSlots(
|
export function prefillFromSlots(
|
||||||
models: ModelCatalogEntry[],
|
models: ModelCatalogEntry[],
|
||||||
slots: Record<ModelSlotName, { model: { provider: string; id: string } } | null> | undefined,
|
slots: Record<ModelSlotName, { model: { provider: string; id: string } } | null> | undefined,
|
||||||
): Partial<Record<ModelSlotName, ModelCatalogEntry>> {
|
): Partial<Record<ModelSlotName, ModelSelection>> {
|
||||||
const pre: Partial<Record<ModelSlotName, ModelCatalogEntry>> = {};
|
const pre: Partial<Record<ModelSlotName, ModelSelection>> = {};
|
||||||
for (const slot of SLOT_ORDER) {
|
for (const slot of SLOT_ORDER) {
|
||||||
const pin = slots?.[slot];
|
const pin = slots?.[slot];
|
||||||
const entry = pin?.model
|
const entry = pin?.model
|
||||||
? models.find((m) => m.provider === pin.model!.provider && m.id === pin.model!.id)
|
? models.find((m) => m.provider === pin.model!.provider && m.id === pin.model!.id)
|
||||||
: undefined;
|
: undefined;
|
||||||
if (entry) pre[slot] = entry;
|
if (pin?.model) pre[slot] = entry ?? pin.model;
|
||||||
}
|
}
|
||||||
return pre;
|
return pre;
|
||||||
}
|
}
|
||||||
@@ -217,24 +220,39 @@ export function SetupKeysBody({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SetupDialog({
|
function prefillLevels(body: ModelsBody | undefined): Partial<Record<ModelSlotName, ThinkingLevel>> {
|
||||||
open,
|
const levels: Partial<Record<ModelSlotName, ThinkingLevel>> = {};
|
||||||
onClose,
|
for (const slot of SLOT_ORDER) {
|
||||||
/** Saved: the models section landed; the caller marks configured (the
|
const pin = body?.slots?.[slot];
|
||||||
* draft stays put — save never sends). */
|
const entry = body?.models.find((m) => m.provider === pin?.model.provider && m.id === pin.model.id);
|
||||||
onDone,
|
if (pin?.thinkingLevel !== undefined) levels[slot] = pin.thinkingLevel;
|
||||||
}: {
|
else if (entry) levels[slot] = prefillLevelFromPin(entry, pin);
|
||||||
|
}
|
||||||
|
return levels;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SetupDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
/** Saving never sends the caller's draft. */
|
||||||
onDone: (models: ModelsSettings) => void;
|
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 backdropDismiss = useBackdropDismiss(onClose);
|
||||||
const [body, setBody] = useState<ModelsBody | null>(null);
|
const { catalog, failed, refresh } = useModelCatalog();
|
||||||
const [failed, setFailed] = useState(false);
|
const [lastBody, setLastBody] = useState<ModelsBody | undefined>(catalog);
|
||||||
const [picked, setPicked] = useState<Partial<Record<ModelSlotName, ModelCatalogEntry>>>({});
|
const body = catalog ?? lastBody;
|
||||||
/** Per-slot thinking level (chunk 2): populated from the ordering on
|
const [picked, setPicked] = useState(() => prefillFromSlots(catalog?.models ?? [], catalog?.slots));
|
||||||
* every fresh model pick, editable — an explicit pick always wins. */
|
const [levels, setLevels] = useState(() => prefillLevels(catalog));
|
||||||
const [levels, setLevels] = useState<Partial<Record<ModelSlotName, ThinkingLevel>>>({});
|
const editedSlots = useRef(new Set<ModelSlotName>());
|
||||||
|
const [showKeys, setShowKeys] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [openMenu, setOpenMenu] = useState<{ slot: ModelSlotName; kind: "model" | "thinking" } | 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 [keyError, setKeyError] = useState<string | null>(null);
|
||||||
const [keyMenuOpen, setKeyMenuOpen] = useState(false);
|
const [keyMenuOpen, setKeyMenuOpen] = useState(false);
|
||||||
// Stage 1's provider-picker trigger wrapper — the shared shell's anchor.
|
// Stage 1's provider-picker trigger wrapper — the shared shell's anchor.
|
||||||
// Declared with the other hooks: the early `if (!open) return null` sits
|
// Stage 2's slot menus anchor by anchorTestid instead — see the slot
|
||||||
// below them all. (Stage 2's slot menus anchor by anchorTestid instead —
|
// MenuShell below for why a freshly attached ref cannot work there.
|
||||||
// see the slot MenuShell below for why a ref cannot work there.)
|
|
||||||
const keyTriggerRef = useRef<HTMLDivElement>(null);
|
const keyTriggerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// The keys signal (zero-keys wave): keys-section saves/deletes AND
|
// 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);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setBody(null);
|
|
||||||
setPicked({});
|
|
||||||
setLevels({});
|
|
||||||
setError(null);
|
|
||||||
fetch("/api/keys")
|
fetch("/api/keys")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((b: { providers?: ProviderKeyInfo[] }) => { if (!cancelled) setKeyProviders(b.providers ?? []); })
|
.then((b: { providers?: ProviderKeyInfo[] }) => { if (!cancelled) setKeyProviders(b.providers ?? []); })
|
||||||
.catch(() => { if (!cancelled) setKeyProviders([]); });
|
.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; };
|
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
|
// Esc + outside-click — layered dismissal, closable WITHOUT
|
||||||
// configuring (browsing stays possible; the gate is the send
|
// configuring (browsing stays possible; the gate is the send
|
||||||
// interception, not this dialog). An open slot menu eats Esc first
|
// interception, not this dialog). An open slot menu eats Esc first
|
||||||
// (and any click outside it); only a bare Esc closes the dialog.
|
// (and any click outside it); only a bare Esc closes the dialog.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key !== "Escape") return;
|
if (e.key !== "Escape") return;
|
||||||
// THE ONE-ESC LAW (owner, keyboard round 2026-10-06): claim at
|
// 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("keydown", onKey);
|
||||||
document.removeEventListener("pointerdown", onPointerDown);
|
document.removeEventListener("pointerdown", onPointerDown);
|
||||||
};
|
};
|
||||||
}, [open, onClose, openMenu, keyMenuOpen]);
|
}, [onClose, openMenu, keyMenuOpen]);
|
||||||
|
|
||||||
if (!open) return 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 stage = failed ? "keys" : setupStage(body ? body.providers : null);
|
|
||||||
const allPicked = SLOT_ORDER.every((s) => picked[s]);
|
const allPicked = SLOT_ORDER.every((s) => picked[s]);
|
||||||
|
|
||||||
/** Stage 1's save: PUT the pasted key, dispatch the keys signal, and
|
/** Stage 1's save: PUT the pasted key, dispatch the keys signal, and
|
||||||
@@ -398,6 +408,20 @@ export function SetupDialog({
|
|||||||
onDone(models);
|
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(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80"
|
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80"
|
||||||
@@ -427,43 +451,32 @@ export function SetupDialog({
|
|||||||
</Hint>
|
</Hint>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{stage === "keys" ? (
|
{failed && (
|
||||||
<div className="flex flex-col px-6 py-5">
|
<p role="alert" data-testid="setup-catalog-error" className="px-6 py-3 text-sm text-foreground">
|
||||||
{failed ? (
|
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 className="text-sm text-foreground">
|
</p>
|
||||||
Model catalog could not be loaded. Check the connection and retry.
|
)}
|
||||||
</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>
|
||||||
<SetupKeysBody
|
) : stage === "keys" ? (
|
||||||
providers={keyProviders}
|
<div className="flex flex-col px-6 py-5">{keysBody}</div>
|
||||||
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>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-4 overflow-y-auto px-6 py-5" data-testid="setup-stage-pickers">
|
<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">
|
<p className="text-xs text-muted-foreground">
|
||||||
Pick the model for each tier. All three are required before chats can start.
|
Pick the model for each tier. All three are required before chats can start.
|
||||||
</p>
|
</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) => {
|
{SLOT_ORDER.map((slot) => {
|
||||||
const entry = picked[slot];
|
const entry = picked[slot];
|
||||||
const level = levels[slot];
|
const level = levels[slot];
|
||||||
const levelsForEntry = entry?.supportedLevels ?? [];
|
const metadata = catalog?.models.find((m) => m.id === entry?.id && m.provider === entry.provider);
|
||||||
const catalog =
|
// Unavailable metadata cannot erase or clamp a saved preference.
|
||||||
slot === "vision" ? body?.models.filter((m) => m.input.includes("image")) : body?.models;
|
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 (
|
return (
|
||||||
<div key={slot} data-testid={`setup-slot-${slot}`} className="flex flex-col gap-1.5">
|
<div key={slot} data-testid={`setup-slot-${slot}`} className="flex flex-col gap-1.5">
|
||||||
<span className="flex items-center 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} /></>}
|
{slot !== "strong" && <><ChatGPTBackgroundAccess provider={entry?.provider} /><NativeSubscriptionBackgroundAccess provider={entry?.provider} /></>}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
|
<Hint label={entry ? `${entry.provider}:${entry.id}` : "Pick a model…"}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`${SLOT_META[slot].label} model`}
|
aria-label={`${SLOT_META[slot].label} model`}
|
||||||
@@ -494,6 +508,7 @@ export function SetupDialog({
|
|||||||
▾
|
▾
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
</Hint>
|
||||||
{/* Anchor by TESTID, not a ref (the settings-section pattern):
|
{/* Anchor by TESTID, not a ref (the settings-section pattern):
|
||||||
React attaches a host element's ref AFTER its descendants'
|
React attaches a host element's ref AFTER its descendants'
|
||||||
layout effects run, so a ref freshly attached in this same
|
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"
|
menuClass="flex w-[22rem] flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
|
||||||
>
|
>
|
||||||
<ModelMenuList
|
<ModelMenuList
|
||||||
models={catalog}
|
models={slotCatalog}
|
||||||
effectiveId={entry?.id}
|
effectiveId={entry?.id}
|
||||||
effectiveProvider={entry?.provider}
|
effectiveProvider={entry?.provider}
|
||||||
onPick={(m) => {
|
onPick={(m) => {
|
||||||
|
editedSlots.current.add(slot);
|
||||||
setPicked((prev) => ({ ...prev, [slot]: m }));
|
setPicked((prev) => ({ ...prev, [slot]: m }));
|
||||||
// Auto-populate (chunk 2, owner: "populate
|
// Auto-populate (chunk 2, owner: "populate
|
||||||
// everywhere"): no explicit level → the
|
// everywhere"): no explicit level → the
|
||||||
@@ -527,7 +543,7 @@ export function SetupDialog({
|
|||||||
}));
|
}));
|
||||||
setOpenMenu(null);
|
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>
|
</MenuShell>
|
||||||
)}
|
)}
|
||||||
@@ -563,6 +579,7 @@ export function SetupDialog({
|
|||||||
levels={levelsForEntry.map(String)}
|
levels={levelsForEntry.map(String)}
|
||||||
effectiveLevel={level}
|
effectiveLevel={level}
|
||||||
onPick={(lv) => {
|
onPick={(lv) => {
|
||||||
|
editedSlots.current.add(slot);
|
||||||
setLevels((prev) => ({ ...prev, [slot]: lv as ThinkingLevel }));
|
setLevels((prev) => ({ ...prev, [slot]: lv as ThinkingLevel }));
|
||||||
setOpenMenu(null);
|
setOpenMenu(null);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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) };
|
return { provider: id.slice(0, colon), id: id.slice(colon + 1) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Model-only labels omit the vendor path, never versions, dates or variants.
|
||||||
* "z-ai/glm-5.3-flash" → "glm-5.3-flash" — vendor prefix dropped, the
|
* Only usage callers pass `qualified`: a bare catalog id's colon belongs to
|
||||||
* name KEPT EXACTLY as its provider writes it (dashes stay, no
|
* the model (e.g. :free), not a provider. Usage retains its provider suffix. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
export function prettyModel(id: string, qualified = false): string {
|
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 short = bare.includes("/") ? bare.slice(bare.lastIndexOf("/") + 1) : bare;
|
||||||
const parts = short.split("-");
|
return provider ? `${short} · ${provider}` : short;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
@@ -123,9 +123,9 @@ describe("prefillFromSlots — the SetupDialog prefill (BYOK wave)", () => {
|
|||||||
assert.deepEqual(pre, {});
|
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 });
|
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)", () => {
|
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", () => {
|
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: false }]), "keys");
|
||||||
assert.equal(setupStage([{ id: "x", keyPresent: true }]), "pickers");
|
assert.equal(setupStage([{ id: "x", keyPresent: true }]), "pickers");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -89,9 +89,15 @@ describe("stats bar", () => {
|
|||||||
assert.match(empty, /data-testid="stats-context">\?</);
|
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("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)", () => {
|
it("provider-qualified stamps (usage contract, model-slots wave)", () => {
|
||||||
@@ -99,8 +105,10 @@ describe("stats bar", () => {
|
|||||||
// the same model via two providers different bookkeeping
|
// 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("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");
|
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:google/gemma-4-27b-it:free", true), "gemma-4-27b-it:free · openrouter");
|
||||||
assert.equal(prettyModel("openrouter:z-ai/glm-5.3-flash"), "glm-5.3-flash");
|
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
|
// qualified on a bare id: no suffix invented
|
||||||
assert.equal(prettyModel("z-ai/glm-5.3-flash", true), "glm-5.3-flash");
|
assert.equal(prettyModel("z-ai/glm-5.3-flash", true), "glm-5.3-flash");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -320,7 +320,7 @@ describe("first-run gate — slotsConfigured + setupStage (model-slots wave)", (
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("setupStage: keys before models (owner's dependency ordering)", () => {
|
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([]), "keys", "no providers = keys");
|
||||||
assert.equal(setupStage([{ id: "zai", keyPresent: false }]), "keys", "provider present but keyless");
|
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");
|
assert.equal(setupStage([{ id: "zai", keyPresent: false }, { id: "openai", keyPresent: true }]), "pickers", "one keyed provider widens the pickers");
|
||||||
|
|||||||
Reference in New Issue
Block a user