From d11e82aeead13820b4c56b60c5cc2f2e1e18e1c7 Mon Sep 17 00:00:00 2001 From: brokemac79 Date: Sat, 30 May 2026 20:53:47 +0100 Subject: [PATCH] fix(ui): keep selected chat model visible after session switch Fixes #86597. Thanks @brokemac79. --- ui/src/test-helpers/control-ui-e2e.ts | 11 ++++ ui/src/ui/chat/session-controls.ts | 2 + ui/src/ui/e2e/chat-flow.e2e.test.ts | 84 +++++++++++++++++++++++++++ ui/src/ui/views/chat.test.ts | 75 ++++++++++++++++++++++++ 4 files changed, 172 insertions(+) diff --git a/ui/src/test-helpers/control-ui-e2e.ts b/ui/src/test-helpers/control-ui-e2e.ts index 2ddf7bb05ed..c616e003da0 100644 --- a/ui/src/test-helpers/control-ui-e2e.ts +++ b/ui/src/test-helpers/control-ui-e2e.ts @@ -109,6 +109,17 @@ export async function startControlUiE2eServer(): Promise { publicDir: path.join(uiRoot, "public"), resolve: { alias: { + "@openclaw/net-policy/ip": path.join(repoRoot, "packages/net-policy/src/ip.ts"), + "@openclaw/net-policy/ipv4": path.join(repoRoot, "packages/net-policy/src/ipv4.ts"), + "@openclaw/net-policy/redact-sensitive-url": path.join( + repoRoot, + "packages/net-policy/src/redact-sensitive-url.ts", + ), + "@openclaw/net-policy/url-userinfo": path.join( + repoRoot, + "packages/net-policy/src/url-userinfo.ts", + ), + "@openclaw/net-policy": path.join(repoRoot, "packages/net-policy/src/index.ts"), json5: json5EsmPath, }, }, diff --git a/ui/src/ui/chat/session-controls.ts b/ui/src/ui/chat/session-controls.ts index e616c5bdf9c..ba86c188ec0 100644 --- a/ui/src/ui/chat/session-controls.ts +++ b/ui/src/ui/chat/session-controls.ts @@ -1,4 +1,5 @@ import { html } from "lit"; +import { live } from "lit/directives/live.js"; import { repeat } from "lit/directives/repeat.js"; import { t } from "../../i18n/index.ts"; import { createChatSessionsLoadOverrides } from "../app-chat.ts"; @@ -810,6 +811,7 @@ function renderChatModelSelect(state: AppViewState) { data-chat-model-select="true" aria-label=${t("chat.selectors.model")} title=${selectedLabel} + .value=${live(currentOverride)} ?disabled=${disabled} @change=${async (e: Event) => { const next = (e.target as HTMLSelectElement).value.trim(); diff --git a/ui/src/ui/e2e/chat-flow.e2e.test.ts b/ui/src/ui/e2e/chat-flow.e2e.test.ts index ba133502d65..7ba234ad91b 100644 --- a/ui/src/ui/e2e/chat-flow.e2e.test.ts +++ b/ui/src/ui/e2e/chat-flow.e2e.test.ts @@ -46,6 +46,33 @@ async function waitForRequests( throw new Error(`Timed out waiting for ${count} ${method} requests`); } +function chatSessionListResponse() { + return { + count: 2, + defaults: { + contextTokens: null, + model: "gpt-5.5", + modelProvider: "openai", + }, + path: "", + sessions: [ + { + key: "agent:main:session-a", + kind: "direct", + label: "Session A", + updatedAt: 2, + }, + { + key: "agent:main:session-b", + kind: "direct", + label: "Session B", + updatedAt: 1, + }, + ], + ts: Date.now(), + }; +} + describeControlUiE2e("Control UI mocked Gateway E2E", () => { beforeAll(async () => { if (!chromiumAvailable) { @@ -201,6 +228,63 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { } }); + it("keeps a session model override selected after switching away and back", async () => { + const context = await browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + methodResponses: { + "sessions.list": chatSessionListResponse(), + }, + models: [ + { id: "gpt-5.5", name: "GPT-5.5", provider: "openai" }, + { id: "claude-opus-4.5", name: "Claude Opus 4.5", provider: "bedrock" }, + ], + sessionKey: "agent:main:session-a", + }); + + try { + await page.goto(`${server.baseUrl}chat`); + + const main = page.getByRole("main"); + const modelSelect = main.locator('select[data-chat-model-select="true"]'); + await modelSelect.waitFor({ state: "visible", timeout: 10_000 }); + expect(await modelSelect.inputValue()).toBe(""); + + await modelSelect.selectOption("bedrock/claude-opus-4.5"); + const patchRequest = await gateway.waitForRequest("sessions.patch"); + expect(requireRecord(patchRequest.params)).toMatchObject({ + key: "agent:main:session-a", + model: "bedrock/claude-opus-4.5", + }); + expect(await modelSelect.inputValue()).toBe("bedrock/claude-opus-4.5"); + + await main.getByRole("button", { name: "Chat session" }).click(); + await page + .locator('button[data-chat-session-picker-option="true"][data-session-key="agent:main:session-b"]') + .click(); + await main.getByRole("button", { name: "Chat session" }).getByText("Session B").waitFor({ + timeout: 10_000, + }); + expect(await modelSelect.inputValue()).toBe(""); + + await main.getByRole("button", { name: "Chat session" }).click(); + await page + .locator('button[data-chat-session-picker-option="true"][data-session-key="agent:main:session-a"]') + .click(); + await main.getByRole("button", { name: "Chat session" }).getByText("Session A").waitFor({ + timeout: 10_000, + }); + + expect(await modelSelect.inputValue()).toBe("bedrock/claude-opus-4.5"); + } finally { + await context.close(); + } + }); + it("refreshes history after a tool-call window disconnects and reconnects", async () => { const context = await browser.newContext({ locale: "en-US", diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index cde907f8ebb..a478dcbbff9 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -2340,6 +2340,81 @@ describe("chat session controls", () => { expect(rerendered.value).toBe("openai/gpt-5-mini"); }); + it("keeps the selected model visible after switching away and back to a session", async () => { + const sessionA = "agent:main:session-a"; + const sessionB = "agent:main:session-b"; + const catalog = createModelCatalog(...DEFAULT_CHAT_MODEL_CATALOG, { + id: "claude-opus-4.5", + name: "Claude Opus 4.5", + provider: "bedrock", + }); + const { state } = createChatHeaderState({ models: catalog }); + let rows: GatewaySessionRow[] = [ + { key: sessionA, kind: "direct", label: "Session A", updatedAt: 2 }, + { key: sessionB, kind: "direct", label: "Session B", updatedAt: 1 }, + ]; + const request = vi.fn(async (method: string, params: Record = {}) => { + if (method === "sessions.patch") { + const key = typeof params.key === "string" ? params.key : ""; + const nextModel = typeof params.model === "string" ? params.model.trim() : ""; + rows = rows.map((row) => { + if (row.key !== key) { + return row; + } + const nextRow: GatewaySessionRow = { ...row }; + if (!nextModel) { + delete nextRow.model; + delete nextRow.modelProvider; + return nextRow; + } + const slashIndex = nextModel.indexOf("/"); + if (slashIndex > 0) { + nextRow.modelProvider = nextModel.slice(0, slashIndex); + } else { + delete nextRow.modelProvider; + } + nextRow.model = slashIndex > 0 ? nextModel.slice(slashIndex + 1) : nextModel; + return nextRow; + }); + return { ok: true, key }; + } + if (method === "sessions.list") { + return createSessionsResultFromRows(rows); + } + if (method === "chat.history") { + return { messages: [] }; + } + if (method === "tools.effective") { + return { agentId: "main", profile: "coding", groups: [] }; + } + throw new Error(`Unexpected request: ${method}`); + }); + state.client = { request } as unknown as GatewayBrowserClient; + state.sessionKey = sessionA; + state.settings.sessionKey = sessionA; + state.sessionsResult = createSessionsResultFromRows(rows); + const container = document.createElement("div"); + render(renderChatSessionSelect(state), container); + + const modelSelect = getChatModelSelect(container); + expect(modelSelect.value).toBe(""); + + modelSelect.value = "bedrock/claude-opus-4.5"; + modelSelect.dispatchEvent(new Event("change", { bubbles: true })); + await flushTasks(); + + state.sessionKey = sessionB; + state.settings.sessionKey = sessionB; + render(renderChatSessionSelect(state), container); + expect(getChatModelSelect(container).value).toBe(""); + + state.sessionKey = sessionA; + state.settings.sessionKey = sessionA; + render(renderChatSessionSelect(state), container); + + expect(getChatModelSelect(container).value).toBe("bedrock/claude-opus-4.5"); + }); + it("uses default thinking options when the active session is absent", () => { const { state } = createChatHeaderState({ omitSessionFromList: true }); state.sessionsResult = createSessionsListResult({