fix(ui): model picker no longer collapses to one model after refresh (#129878)

* fix(models): stop models.list from silently collapsing to the harness default

Explicit models.list refresh reads now run to completion instead of racing
the 750ms browse deadline, and implicit reads that do hit the deadline fall
back to the published prepared catalog instead of returning an empty
snapshot that the Codex harness augmentation refilled with only the default
model. The Control UI model picker now revalidates silently in the
background: a warm catalog stays rendered and interactive, and the
'Refreshing models…' state is deleted end to end (chat + New Session).

* test(ui): prove the model picker stays interactive during background refresh

* test(ui): give the shared chat-pane test host a model catalog

The background-revalidation change reads chatModelCatalog.length in
refreshPageChat; createTestChatPane omitted the field, breaking the
chat-pane lifecycle suites on CI.
This commit is contained in:
Peter Steinberger
2026-08-26 00:07:02 -07:00
committed by GitHub
parent e8d1ee8e18
commit 3d81c85f2a
17 changed files with 346 additions and 127 deletions
+9 -8
View File
@@ -91,16 +91,17 @@ async function loadCatalogForBrowse<T>(params: {
agentId: params.agentId,
view,
});
// Provider-policy wildcards newly escalate ordinary inventory views to live discovery.
// Keep those implicit loads within the browse deadline; explicit all/configured loads retain
// their existing completion semantics unless the caller requests a timeout.
// Implicit inventory reads stay bounded; explicit refreshes complete unless their caller
// explicitly requests a full-discovery deadline.
const shouldTimeoutFullDiscovery =
params.timeoutFullDiscovery ||
(requiresFullDiscovery && (view === "default" || view === "provider-config"));
if (requiresFullDiscovery && !shouldTimeoutFullDiscovery) {
params.timeoutFullDiscovery === true ||
(params.refresh !== true &&
requiresFullDiscovery &&
(view === "default" || view === "provider-config"));
if ((requiresFullDiscovery || params.refresh === true) && !shouldTimeoutFullDiscovery) {
return await params.loadCatalog({
readOnly: false,
...(params.refresh ? { refresh: true } : {}),
readOnly: !requiresFullDiscovery,
...(requiresFullDiscovery && params.refresh ? { refresh: true } : {}),
});
}
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { withEnvAsync } from "../../test-utils/env.js";
import {
@@ -9,6 +9,80 @@ import {
} from "./models-list-result.openai-routes.test-support.js";
describe("models.list configured static entries", () => {
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
});
it("waits for the complete configured catalog when explicit refresh exceeds the browse deadline", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const catalog = [
{ ...catalogEntry("gpt-5.6-luna", "openai-responses"), name: "Refreshed Luna" },
{ ...catalogEntry("gpt-5.6-sol", "openai-responses"), name: "Refreshed Sol" },
];
const config = {
agents: {
defaults: {
model: { primary: "openai/gpt-5.6-luna" },
models: { "openai/gpt-5.6-luna": {}, "openai/gpt-5.6-sol": {} },
},
},
} as OpenClawConfig;
const result = listModels({
catalog,
catalogLoadDelayMs: 800,
publishedCatalog: catalog.slice(0, 1),
cfg: config,
refresh: true,
view: "configured",
});
let settled = false;
void result.then(() => {
settled = true;
});
await vi.advanceTimersByTimeAsync(750);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(50);
expect((await result).models.map(({ id, name }) => ({ id, name }))).toEqual([
{ id: "gpt-5.6-luna", name: "Refreshed Luna" },
{ id: "gpt-5.6-sol", name: "Refreshed Sol" },
]);
});
it("keeps the published configured catalog when an implicit load exceeds the browse deadline", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const publishedCatalog = [
{ ...catalogEntry("gpt-5.6-luna", "openai-responses"), name: "Published Luna" },
{ ...catalogEntry("gpt-5.6-sol", "openai-responses"), name: "Published Sol" },
];
const config = {
agents: {
defaults: {
model: { primary: "openai/gpt-5.6-luna" },
models: { "openai/gpt-5.6-luna": {}, "openai/gpt-5.6-sol": {} },
},
},
} as OpenClawConfig;
const result = listModels({
catalog: [],
catalogLoadDelayMs: 800,
publishedCatalog,
cfg: config,
view: "configured",
});
await vi.advanceTimersByTimeAsync(750);
expect((await result).models.map(({ id, name }) => ({ id, name }))).toEqual([
{ id: "gpt-5.6-luna", name: "Published Luna" },
{ id: "gpt-5.6-sol", name: "Published Sol" },
]);
});
it("projects a configured runtime model from prepared static facts", async () => {
const config = {
agents: {
@@ -45,6 +45,9 @@ export function registerTestCatalogAccess(
export async function listModels(params: {
agentId?: string;
catalog: ModelCatalogEntry[];
catalogLoadDelayMs?: number;
publishedCatalog?: ModelCatalogEntry[];
refresh?: boolean;
staticEntries?: ModelCatalogEntry[];
cfg?: OpenClawConfig;
discoveryModes?: Record<string, "refreshable" | "runtime" | "static">;
@@ -55,7 +58,7 @@ export async function listModels(params: {
}) {
const agentId = params.agentId ?? "main";
const config = params.cfg ?? ({} as OpenClawConfig);
const loadGatewayModelCatalogSnapshot = async () =>
const createCatalogSnapshot = (entries: ModelCatalogEntry[]) =>
({
agentId,
agentDir: "/tmp/models-list-openai-agent",
@@ -68,24 +71,34 @@ export async function listModels(params: {
}),
metadataSnapshot:
params.metadataSnapshot ?? loadManifestMetadataSnapshot({ config, env: process.env }),
entries: params.catalog,
routeVariants: params.catalog,
entries,
routeVariants: entries,
...(params.staticEntries ? { staticEntries: params.staticEntries } : {}),
authMaterializations: [],
}) satisfies PreparedGatewayModelCatalogSnapshot;
const loadGatewayModelCatalogSnapshot = async () => {
if (params.catalogLoadDelayMs !== undefined) {
await new Promise<void>((resolve) => {
setTimeout(resolve, params.catalogLoadDelayMs);
});
}
return createCatalogSnapshot(params.catalog);
};
registerGatewayModelCatalogPrivateAccess(loadGatewayModelCatalogSnapshot, {
loadDeferred: loadGatewayModelCatalogSnapshot,
readPrepared: loadGatewayModelCatalogSnapshot,
readPrepared: params.publishedCatalog
? async () => createCatalogSnapshot(params.publishedCatalog ?? [])
: loadGatewayModelCatalogSnapshot,
});
const context = {
getRuntimeConfig: () => config,
loadGatewayModelCatalogSnapshot,
logGateway: { debug: () => {} },
logGateway: { debug: () => {}, warn: () => {} },
} as unknown as GatewayRequestContext;
return await buildModelsListResult({
context,
agentId,
params: { view: params.view ?? "all" },
params: { view: params.view ?? "all", ...(params.refresh ? { refresh: true } : {}) },
...(params.discoveryModes
? {
preloadedCatalog: {
@@ -464,13 +464,15 @@ export async function buildModelsListResult(
let loadedSnapshot: Awaited<ReturnType<typeof loadDeferredCatalog>> | undefined;
let loadedReadOnly = true;
let usedPreloadedCatalog = false;
let catalogTimedOut = false;
const handleCatalogTimeout = (timeoutMs: number) => {
catalogTimedOut = true;
if (loggedSlowModelsListCatalog) {
return;
}
loggedSlowModelsListCatalog = true;
params.context.logGateway.debug(
`models.list continuing without model catalog after ${timeoutMs}ms`,
params.context.logGateway.warn(
`models.list catalog load exceeded ${timeoutMs}ms; using the prepared catalog when available`,
);
};
let snapshot = await loadPreparedModelCatalogSnapshotForBrowse({
@@ -555,6 +557,9 @@ export async function buildModelsListResult(
(preloadedCatalog && params.catalogProjector
? undefined
: await readPreparedCatalog(params.context, initialAgentId));
if (catalogTimedOut && ownerSnapshot) {
snapshot = ownerSnapshot;
}
const cfg = ownerSnapshot?.config ?? initialConfig;
const agentId = ownerSnapshot?.agentId ?? initialAgentId;
const workspaceDir =
@@ -357,6 +357,7 @@ function requestModelsList(params: {
loadGatewayModelCatalogSnapshot,
logGateway: {
debug: vi.fn(),
warn: vi.fn(),
},
} as never,
});
@@ -0,0 +1,79 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import type { Page } from "playwright";
import { expect, it } from "vitest";
import {
createChatFlowE2eSuite,
installMockGateway,
requireRecord,
} from "./chat-flow.test-support.ts";
const suite = createChatFlowE2eSuite();
const captureUiProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const proofDir = path.join(process.cwd(), ".artifacts", "control-ui-e2e", "model-picker-refresh");
async function screenshot(page: Page, name: string) {
if (!captureUiProof) {
return;
}
await mkdir(proofDir, { recursive: true });
await page.screenshot({ animations: "disabled", path: path.join(proofDir, name) });
}
suite.define(() => {
it("keeps the warm model list interactive while a picker-open refresh is in flight", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
models: [
{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol", provider: "openai" },
{ id: "gpt-5.6-luna", name: "GPT-5.6 Luna", provider: "openai" },
{ id: "fable-5", name: "Claude Fable 5", provider: "anthropic" },
],
sessionKey: "main",
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
const pane = page.locator('openclaw-chat-pane[aria-hidden="false"]');
const picker = pane.locator(".chat-controls__model-picker");
await picker.locator("[data-chat-model-option]").first().waitFor({ state: "attached" });
// Freeze the operator-signaled revalidation so the in-flight state is observable.
await gateway.deferNext("models.list", { refresh: true });
await picker.locator('[data-chat-model-select="true"]').click();
const request = await gateway.waitForRequest("models.list");
expect(requireRecord(request.params)).toMatchObject({ refresh: true, view: "configured" });
// The warm list stays rendered and selectable with no refresh/loading interstitial.
await expect
.poll(() => picker.locator("[data-chat-model-option]:visible").count())
.toBeGreaterThanOrEqual(3);
await screenshot(page, "01-picker-open-refresh-in-flight.png");
expect(await picker.locator("[data-chat-model-catalog-state]").count()).toBe(0);
expect(
await picker.locator('[data-chat-model-option="openai/gpt-5.6-luna"]').isDisabled(),
).toBe(false);
// The background result still owns the authoritative apply once it lands.
await gateway.resolveDeferred("models.list", {
models: [
{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol", provider: "openai" },
{ id: "gpt-5.6-terra", name: "GPT-5.6 Terra", provider: "openai" },
],
});
await picker
.locator('[data-chat-model-option="openai/gpt-5.6-terra"]')
.waitFor({ state: "visible" });
expect(await picker.locator("[data-chat-model-catalog-state]").count()).toBe(0);
await screenshot(page, "02-picker-after-background-apply.png");
} finally {
await context.close();
}
});
});
-1
View File
@@ -5790,7 +5790,6 @@ export const en: TranslationMap = {
chatOnlyHelp:
"This model can chat, but it cannot use tools. Choose another model for files, commands, web, or media tasks.",
loadingModels: "Loading models…",
refreshingModels: "Refreshing models…",
modelsUnavailable: "Models unavailable",
noModelsAvailable: "No models available",
emptyModelsAction: "Manage models",
@@ -37,14 +37,24 @@ describe("chat model catalog state", () => {
expected: { hasSnapshot: true, status: "ready" },
},
{
label: "refreshing with a cached snapshot",
label: "ready with a cached snapshot",
state: {
chatModelCatalog: cachedCatalog,
chatModelCatalogError: null,
chatModelsLoading: false,
connected: true,
},
expected: { hasSnapshot: true, status: "ready" },
},
{
label: "loading without a cached snapshot",
state: {
chatModelCatalog: [],
chatModelCatalogError: null,
chatModelsLoading: true,
connected: true,
},
expected: { hasSnapshot: true, status: "refreshing" },
expected: { hasSnapshot: false, status: "loading" },
},
{
label: "offline",
@@ -477,48 +487,77 @@ describe("chat pane composer controls", () => {
expect(state.requestUpdate).toHaveBeenCalledOnce();
});
it("refreshes the configured model catalog when the picker opens", async () => {
const container = document.createElement("div");
const request = vi.fn(async () => ({ models: [] }));
const state = {
chatRunId: null,
connected: true,
connectionEpoch: 1,
client: { request },
chatLoading: false,
chatModelCatalog: [],
chatModelCatalogError: null,
sessions: { state: { modelOverrides: {} }, patch: vi.fn() },
chatModelSwitchPromises: {},
sessionKey: "main",
chatModelsLoading: false,
chatSending: false,
sessionsResult: null,
chatStream: null,
requestUpdate: vi.fn(),
} as unknown as ChatPageHost;
const controls = renderChatPaneComposerControls({
state,
selectedSession: undefined,
agentDefaultModel: undefined,
modelAccess: { allowed: true, requiredScope: "operator.write" },
effortAccess: { allowed: true, requiredScope: "operator.write" },
permissionAccess: { allowed: true, requiredScope: "operator.write" },
canSelectFull: true,
toastAnchor: document.createElement("div"),
onModelSetup: vi.fn(),
});
render(controls.composerControls, container);
it.each([
{
label: "warm",
cachedModels: [{ id: "cached-model", name: "Cached Model", provider: "openai" }],
},
{ label: "cold", cachedModels: [] },
])(
"revalidates the $label configured model catalog when the picker opens",
async ({ cachedModels }) => {
const container = document.createElement("div");
const catalog = createDeferred<{ models: typeof cachedModels }>();
const request = vi.fn(() => catalog.promise);
const state = {
chatRunId: null,
connected: true,
connectionEpoch: 1,
client: { request },
chatLoading: false,
chatModelCatalog: cachedModels,
chatModelCatalogError: null,
sessions: {
state: { modelOverrides: {} },
patch: vi.fn(),
refresh: vi.fn().mockResolvedValue(undefined),
},
chatModelSwitchPromises: {},
sessionKey: "main",
chatModelsLoading: false,
chatSending: false,
sessionsResult: null,
chatStream: null,
requestUpdate: vi.fn(),
} as unknown as ChatPageHost;
const controlParams = {
state,
selectedSession: undefined,
agentDefaultModel: undefined,
modelAccess: { allowed: true, requiredScope: "operator.write" } as const,
effortAccess: { allowed: true, requiredScope: "operator.write" } as const,
permissionAccess: { allowed: true, requiredScope: "operator.write" } as const,
canSelectFull: true,
toastAnchor: document.createElement("div"),
onModelSetup: vi.fn(),
};
render(renderChatPaneComposerControls(controlParams).composerControls, container);
const picker = container.querySelector<HTMLDetailsElement>(".chat-controls__model-picker");
picker!.open = true;
picker!.dispatchEvent(new Event("toggle"));
const picker = container.querySelector<HTMLDetailsElement>(".chat-controls__model-picker");
picker!.open = true;
picker!.dispatchEvent(new Event("toggle"));
await vi.waitFor(() => expect(request).toHaveBeenCalledOnce());
expect(request).toHaveBeenCalledWith("models.list", {
view: "configured",
agentId: "main",
refresh: true,
});
});
expect(request).toHaveBeenCalledOnce();
expect(request).toHaveBeenCalledWith("models.list", {
view: "configured",
agentId: "main",
refresh: true,
});
expect(state.chatModelsLoading).toBe(cachedModels.length === 0);
render(renderChatPaneComposerControls(controlParams).composerControls, container);
if (cachedModels.length > 0) {
expect(container.querySelector("[data-chat-model-catalog-state]")).toBeNull();
expect(
container.querySelector<HTMLButtonElement>("[data-chat-model-option]")?.disabled,
).toBe(false);
expect(container.textContent).toContain("Cached Model");
} else {
expect(container.querySelector('[data-chat-model-catalog-state="loading"]')).not.toBeNull();
expect(container.textContent).toContain("Loading models…");
}
const freshModels = [{ id: "fresh-model", name: "Fresh Model", provider: "openai" }];
catalog.resolve({ models: freshModels });
await vi.waitFor(() => expect(state.chatModelCatalog).toEqual(freshModels));
},
);
});
@@ -73,9 +73,7 @@ export function resolveChatModelCatalogState(
: state.chatModelCatalogError
? "error"
: state.chatModelsLoading
? hasSnapshot
? "refreshing"
: "loading"
? "loading"
: "ready",
};
}
@@ -337,6 +337,9 @@ export function createTestChatPane(params: {
chatHistoryPagination: { hasMore: false },
chatLoading: false,
chatMessages: [],
chatModelCatalog: [],
chatModelCatalogError: null,
chatModelsLoading: false,
chatQueue: [],
chatRunId: null,
chatSending: false,
+4 -4
View File
@@ -500,7 +500,7 @@ describe("refreshChat", () => {
await waitForFast(() => expect(host.chatModelCatalog).toEqual([model]));
});
it("renders cached models while startup metadata refreshes", async () => {
it("keeps cached models interactive while startup metadata revalidates silently", async () => {
const startup = createDeferred<unknown>();
const host = makeChatHost({
chatModelSwitchPromises: {},
@@ -527,7 +527,7 @@ describe("refreshChat", () => {
});
expect(host.chatModelCatalog).toEqual([cachedModel]);
expect(asChatPageHost(host).chatModelsLoading).toBe(true);
expect(asChatPageHost(host).chatModelsLoading).toBe(false);
const container = document.createElement("div");
const controls = renderChatPaneComposerControls({
state: asChatPageHost(host),
@@ -541,8 +541,8 @@ describe("refreshChat", () => {
onModelSetup: vi.fn(),
});
render(controls.composerControls, container);
expect(container.querySelector('[data-chat-model-catalog-state="refreshing"]')).not.toBeNull();
expect(container.textContent).toContain("Refreshing models…");
expect(container.querySelector("[data-chat-model-catalog-state]")).toBeNull();
expect(container.textContent).toContain("Cached Model");
expect(container.textContent).not.toContain("Loading models…");
startup.resolve({
+4 -5
View File
@@ -98,8 +98,7 @@ function seedChatModelCatalogFromStore(host: ChatPageHost, client: GatewayBrowse
if (!Array.isArray(cached?.models)) {
return;
}
// A warm snapshot turns mount-time loading into refreshing; the in-flight
// request still owns the authoritative apply.
// A warm snapshot stays interactive; the in-flight request owns the authoritative apply.
host.chatModelCatalog = cached.models;
host.chatModelCatalogError = null;
}
@@ -130,8 +129,8 @@ export async function refreshChatMetadata(
const client = host.client;
const agentId = resolveChatAgentId(host);
const request = { host, client, agentId, version: requestVersion };
host.chatModelsLoading = true;
seedChatModelCatalogFromStore(host, client);
host.chatModelsLoading = host.chatModelCatalog.length === 0;
try {
const result = await loadChatMetadata(client, agentId);
if (!ownsChatMetadataRequest(request)) {
@@ -186,7 +185,7 @@ export async function refreshChatModelCatalogOnDemand(host: ChatPageHost): Promi
host.connected &&
host.connectionEpoch === connectionEpoch &&
resolveChatAgentId(host) === agentId;
host.chatModelsLoading = true;
host.chatModelsLoading = host.chatModelCatalog.length === 0;
host.chatModelCatalogError = null;
host.requestUpdate?.();
try {
@@ -334,8 +333,8 @@ export function refreshPageChat(host: ChatPageHost, opts?: ChatRefreshOptions) {
? ++host.chatMetadataRequestVersion
: null;
if (ownsStartupMetadata && host.client) {
host.chatModelsLoading = true;
seedChatModelCatalogFromStore(host, host.client);
host.chatModelsLoading = host.chatModelCatalog.length === 0;
}
const refresh = refreshChat(host, {
+43 -33
View File
@@ -2406,41 +2406,51 @@ describe("refreshChatMetadata", () => {
} as unknown as ChatPageHost;
}
it("refreshes session metadata after full model discovery completes", async () => {
const refreshSessions = vi.fn().mockResolvedValue(undefined);
const request = vi.fn(async (method: string, params?: unknown) => {
expect(method).toBe("models.list");
expect(params).toEqual({ view: "configured", agentId: "work", refresh: true });
return {
models: [
{
id: "reasoner",
name: "Reasoner",
provider: "dynamic-router",
reasoning: true,
},
],
};
});
const state = createMetadataState(request, {
sessions: { refresh: refreshSessions } as never,
});
it.each([
{
label: "warm",
existingModels: [{ id: "cached-model", name: "Cached Model", provider: "openai" }],
},
{ label: "cold", existingModels: [] },
])(
"refreshes $label session metadata after full model discovery completes",
async ({ existingModels }) => {
const refreshSessions = vi.fn().mockResolvedValue(undefined);
const discovery = createDeferred<{
models: Array<{ id: string; name: string; provider: string; reasoning: boolean }>;
}>();
const request = vi.fn((method: string, params?: unknown) => {
expect(method).toBe("models.list");
expect(params).toEqual({ view: "configured", agentId: "work", refresh: true });
return discovery.promise;
});
const state = createMetadataState(request, {
chatModelCatalog: existingModels,
sessions: { refresh: refreshSessions } as never,
});
await refreshChatModelCatalogOnDemand(state);
const refresh = refreshChatModelCatalogOnDemand(state);
expect(state.chatModelCatalog).toEqual(existingModels);
expect(state.chatModelsLoading).toBe(existingModels.length === 0);
discovery.resolve({
models: [{ id: "reasoner", name: "Reasoner", provider: "dynamic-router", reasoning: true }],
});
await refresh;
expect(state.chatModelCatalog).toEqual([
{
id: "reasoner",
name: "Reasoner",
provider: "dynamic-router",
reasoning: true,
},
]);
expect(refreshSessions).toHaveBeenCalledWith(
expect.objectContaining({ agentId: "work", force: true }),
);
expect(state.chatModelCatalogError).toBeNull();
});
expect(state.chatModelCatalog).toEqual([
{
id: "reasoner",
name: "Reasoner",
provider: "dynamic-router",
reasoning: true,
},
]);
expect(refreshSessions).toHaveBeenCalledWith(
expect.objectContaining({ agentId: "work", force: true }),
);
expect(state.chatModelCatalogError).toBeNull();
},
);
it("applies agent-scoped metadata after a same-agent session switch", async () => {
let resolveMetadata:
@@ -170,7 +170,7 @@ function resolveCatalogTriggerStatus(
if (state.status === "error") {
return optionCount === 0 ? t("chat.modelControls.modelsUnavailable") : undefined;
}
if (!state.hasSnapshot && ["idle", "loading", "refreshing"].includes(state.status)) {
if (!state.hasSnapshot && ["idle", "loading"].includes(state.status)) {
return t("chat.modelControls.loadingModels");
}
if (state.hasSnapshot && optionCount === 0) {
@@ -354,8 +354,7 @@ export function renderChatModelControls(props: ChatModelControlsProps) {
status: props.modelsLoading ? ("loading" as const) : ("ready" as const),
};
const catalogLoadingWithoutSnapshot =
!managedCatalog.hasSnapshot &&
["idle", "loading", "refreshing"].includes(managedCatalog.status);
!managedCatalog.hasSnapshot && ["idle", "loading"].includes(managedCatalog.status);
const catalogTriggerStatus = resolveCatalogTriggerStatus(managedCatalog, modelOptions.length);
const busy =
props.loading || props.sending || Boolean(props.activeRunId) || props.stream !== null;
@@ -23,7 +23,7 @@ import { handleChatComposerDetailsToggle, syncChatPickerOverlay } from "./chat-p
export type ChatModelCatalogState = {
hasSnapshot: boolean;
status: "idle" | "loading" | "refreshing" | "ready" | "error" | "offline";
status: "idle" | "loading" | "ready" | "error" | "offline";
};
type ChatModelPickerParams = {
@@ -250,15 +250,13 @@ function renderCatalogState(
const label =
state.status === "offline"
? t("common.offline")
: state.status === "refreshing"
? t("chat.modelControls.refreshingModels")
: state.status === "error"
? errorLabel
: state.status === "ready"
? hasOptions
? `${t("modelSetup.failure.auth")}. ${t("modelSetup.failureGuidance.auth")}`
: t("chat.modelControls.noModelsAvailable")
: t("chat.modelControls.loadingModels");
: state.status === "error"
? errorLabel
: state.status === "ready"
? hasOptions
? `${t("modelSetup.failure.auth")}. ${t("modelSetup.failureGuidance.auth")}`
: t("chat.modelControls.noModelsAvailable")
: t("chat.modelControls.loadingModels");
return html`
<div
class="chat-controls__model-catalog-state ${hasOptions
@@ -633,8 +633,12 @@ describe("new-session model runtime", () => {
});
let container = renderControl(control, context);
expect(container.querySelector('[data-chat-model-catalog-state="refreshing"]')).not.toBeNull();
expect(container.querySelector("[data-chat-model-catalog-state]")).toBeNull();
expect(container.querySelectorAll("[data-chat-model-option]")).toHaveLength(2);
expect(
container.querySelector<HTMLButtonElement>('[data-chat-model-option="openai/gpt-5.6-sol"]')
?.disabled,
).toBe(false);
container
.querySelector<HTMLButtonElement>('[data-chat-model-option="openai/gpt-5.6-sol"]')
+3 -6
View File
@@ -284,11 +284,11 @@ export class NewSessionModelControl {
};
const cached = peekChatMetadata(client, agentId);
if (Array.isArray(cached?.models)) {
this.publishMetadataCatalog(cached.models, "refreshing");
this.publishMetadataCatalog(cached.models, "ready");
} else {
this.updateMetadataState({
...this.metadataState,
status: this.metadataState.hasSnapshot ? "refreshing" : "loading",
status: this.metadataState.hasSnapshot ? "ready" : "loading",
});
}
@@ -463,10 +463,7 @@ export class NewSessionModelControl {
const cached = peekChatMetadata(client, normalizedAgentId);
if (activeRequestMatches) {
if (cached) {
this.publishMetadataCatalog(
Array.isArray(cached.models) ? cached.models : [],
"refreshing",
);
this.publishMetadataCatalog(Array.isArray(cached.models) ? cached.models : [], "ready");
} else {
this.notify();
}