fix(ui): keep selected chat model visible after session switch

Fixes #86597. Thanks @brokemac79.
This commit is contained in:
brokemac79
2026-05-30 20:53:47 +01:00
committed by GitHub
parent adcac404e1
commit d11e82aeea
4 changed files with 172 additions and 0 deletions

View File

@@ -109,6 +109,17 @@ export async function startControlUiE2eServer(): Promise<ControlUiE2eServer> {
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,
},
},

View File

@@ -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();

View File

@@ -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",

View File

@@ -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<string, unknown> = {}) => {
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({