From 3d81c85f2a370a153a41b5ef30038d5ddca46ee8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 26 Aug 2026 00:07:02 -0700 Subject: [PATCH] fix(ui): model picker no longer collapses to one model after refresh (#129878) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- src/agents/model-catalog-browse.ts | 17 +-- ...dels-list-result.configured-static.test.ts | 76 ++++++++++- ...-list-result.openai-routes.test-support.ts | 25 +++- .../server-methods/models-list-result.ts | 9 +- src/gateway/server-methods/models.test.ts | 1 + ...chat-flow.model-picker-refresh.e2e.test.ts | 79 +++++++++++ ui/src/i18n/locales/en.ts | 1 - .../chat/chat-pane-session-controls.test.ts | 127 ++++++++++++------ .../pages/chat/chat-pane-session-controls.ts | 4 +- ui/src/pages/chat/chat-pane.test-support.ts | 3 + ui/src/pages/chat/chat-send.test.ts | 8 +- ui/src/pages/chat/chat-state-refresh.ts | 9 +- ui/src/pages/chat/chat-state.test.ts | 76 ++++++----- .../chat/components/chat-model-controls.ts | 5 +- .../chat/components/chat-model-picker.ts | 18 ++- .../pages/new-session/model-control.test.ts | 6 +- ui/src/pages/new-session/model-control.ts | 9 +- 17 files changed, 346 insertions(+), 127 deletions(-) create mode 100644 ui/src/e2e/chat-flow.model-picker-refresh.e2e.test.ts diff --git a/src/agents/model-catalog-browse.ts b/src/agents/model-catalog-browse.ts index 1c15c2be24e5..1656b6b73303 100644 --- a/src/agents/model-catalog-browse.ts +++ b/src/agents/model-catalog-browse.ts @@ -91,16 +91,17 @@ async function loadCatalogForBrowse(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 } : {}), }); } diff --git a/src/gateway/server-methods/models-list-result.configured-static.test.ts b/src/gateway/server-methods/models-list-result.configured-static.test.ts index c74aaca7f965..8d859cf22152 100644 --- a/src/gateway/server-methods/models-list-result.configured-static.test.ts +++ b/src/gateway/server-methods/models-list-result.configured-static.test.ts @@ -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: { diff --git a/src/gateway/server-methods/models-list-result.openai-routes.test-support.ts b/src/gateway/server-methods/models-list-result.openai-routes.test-support.ts index 3afc10c7411b..3cd54d5a4c76 100644 --- a/src/gateway/server-methods/models-list-result.openai-routes.test-support.ts +++ b/src/gateway/server-methods/models-list-result.openai-routes.test-support.ts @@ -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; @@ -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((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: { diff --git a/src/gateway/server-methods/models-list-result.ts b/src/gateway/server-methods/models-list-result.ts index e0d717932b85..dda0043cd6da 100644 --- a/src/gateway/server-methods/models-list-result.ts +++ b/src/gateway/server-methods/models-list-result.ts @@ -464,13 +464,15 @@ export async function buildModelsListResult( let loadedSnapshot: Awaited> | 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 = diff --git a/src/gateway/server-methods/models.test.ts b/src/gateway/server-methods/models.test.ts index fb9d8708e579..7501bf4e8c3f 100644 --- a/src/gateway/server-methods/models.test.ts +++ b/src/gateway/server-methods/models.test.ts @@ -357,6 +357,7 @@ function requestModelsList(params: { loadGatewayModelCatalogSnapshot, logGateway: { debug: vi.fn(), + warn: vi.fn(), }, } as never, }); diff --git a/ui/src/e2e/chat-flow.model-picker-refresh.e2e.test.ts b/ui/src/e2e/chat-flow.model-picker-refresh.e2e.test.ts new file mode 100644 index 000000000000..c5e85f379f08 --- /dev/null +++ b/ui/src/e2e/chat-flow.model-picker-refresh.e2e.test.ts @@ -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(); + } + }); +}); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index b8e245e9251d..1ab302c3bf74 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -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", diff --git a/ui/src/pages/chat/chat-pane-session-controls.test.ts b/ui/src/pages/chat/chat-pane-session-controls.test.ts index d1dea05ee652..cd6ea003b0c6 100644 --- a/ui/src/pages/chat/chat-pane-session-controls.test.ts +++ b/ui/src/pages/chat/chat-pane-session-controls.test.ts @@ -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(".chat-controls__model-picker"); - picker!.open = true; - picker!.dispatchEvent(new Event("toggle")); + const picker = container.querySelector(".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("[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)); + }, + ); }); diff --git a/ui/src/pages/chat/chat-pane-session-controls.ts b/ui/src/pages/chat/chat-pane-session-controls.ts index db200e1efee4..fe15006cb350 100644 --- a/ui/src/pages/chat/chat-pane-session-controls.ts +++ b/ui/src/pages/chat/chat-pane-session-controls.ts @@ -73,9 +73,7 @@ export function resolveChatModelCatalogState( : state.chatModelCatalogError ? "error" : state.chatModelsLoading - ? hasSnapshot - ? "refreshing" - : "loading" + ? "loading" : "ready", }; } diff --git a/ui/src/pages/chat/chat-pane.test-support.ts b/ui/src/pages/chat/chat-pane.test-support.ts index 70a5ba376e42..d7b0e5dd703c 100644 --- a/ui/src/pages/chat/chat-pane.test-support.ts +++ b/ui/src/pages/chat/chat-pane.test-support.ts @@ -337,6 +337,9 @@ export function createTestChatPane(params: { chatHistoryPagination: { hasMore: false }, chatLoading: false, chatMessages: [], + chatModelCatalog: [], + chatModelCatalogError: null, + chatModelsLoading: false, chatQueue: [], chatRunId: null, chatSending: false, diff --git a/ui/src/pages/chat/chat-send.test.ts b/ui/src/pages/chat/chat-send.test.ts index b1361b7b9bff..edaa64d8fdf4 100644 --- a/ui/src/pages/chat/chat-send.test.ts +++ b/ui/src/pages/chat/chat-send.test.ts @@ -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(); 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({ diff --git a/ui/src/pages/chat/chat-state-refresh.ts b/ui/src/pages/chat/chat-state-refresh.ts index ccdbd4df6d56..41763048fa82 100644 --- a/ui/src/pages/chat/chat-state-refresh.ts +++ b/ui/src/pages/chat/chat-state-refresh.ts @@ -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, { diff --git a/ui/src/pages/chat/chat-state.test.ts b/ui/src/pages/chat/chat-state.test.ts index 6f82acdb8f73..98e0c957ec8e 100644 --- a/ui/src/pages/chat/chat-state.test.ts +++ b/ui/src/pages/chat/chat-state.test.ts @@ -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: diff --git a/ui/src/pages/chat/components/chat-model-controls.ts b/ui/src/pages/chat/components/chat-model-controls.ts index 4b6b81b6b42f..e19b23f04e8e 100644 --- a/ui/src/pages/chat/components/chat-model-controls.ts +++ b/ui/src/pages/chat/components/chat-model-controls.ts @@ -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; diff --git a/ui/src/pages/chat/components/chat-model-picker.ts b/ui/src/pages/chat/components/chat-model-picker.ts index d43a7ab9370b..5a977d376431 100644 --- a/ui/src/pages/chat/components/chat-model-picker.ts +++ b/ui/src/pages/chat/components/chat-model-picker.ts @@ -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`
{ }); 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('[data-chat-model-option="openai/gpt-5.6-sol"]') + ?.disabled, + ).toBe(false); container .querySelector('[data-chat-model-option="openai/gpt-5.6-sol"]') diff --git a/ui/src/pages/new-session/model-control.ts b/ui/src/pages/new-session/model-control.ts index 2be1f2444a98..513ad03cd4ef 100644 --- a/ui/src/pages/new-session/model-control.ts +++ b/ui/src/pages/new-session/model-control.ts @@ -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(); }