From 61b0cd37816f858720a258b309fd218300156e76 Mon Sep 17 00:00:00 2001 From: "openclaw-clownfish[bot]" <280122609+openclaw-clownfish[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 01:26:30 -0700 Subject: [PATCH] fix(ui): keep control UI select values stable on load (#74000) Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + ui/src/ui/config-form.browser.test.ts | 44 ++++++++++++++++ ui/src/ui/views/agents-panels-overview.ts | 16 ++++-- ui/src/ui/views/agents-utils.ts | 13 ++++- ui/src/ui/views/agents.test.ts | 62 +++++++++++++++++++++++ ui/src/ui/views/config-form.node.ts | 9 +++- 6 files changed, 137 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf2464d6fe6..31bfc38efcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai - Channels/Telegram: include probed video width and height when sending regular Telegram videos, so portrait clips render with the correct orientation instead of being stretched by clients. (#18915) Thanks @storyarcade. - Docs/Hetzner: clarify that SSH tunnel access requires `AllowTcpForwarding local` before running `ssh -L`, so hardened VPS sshd configs do not block loopback Gateway access. Fixes #54557; carries forward #54564; refs #54954. Thanks @satishkc7, @blackstrype, and @Aftabbs. - Gateway/shutdown: report structured shutdown warnings and HTTP close timeout warnings through `ShutdownResult` while preserving lifecycle hook hardening. Carries forward #41296. Thanks @edenfunf. +- Control UI: keep Agents Overview and config-form select dropdowns on their configured value after options render while preserving inherited agent model placeholders. Fixes #40352; carries forward #52948. Thanks @xiaoquanidea. - Plugins/QA: prebuild the private QA channel runtime before plugin gauntlet source runs so wrapper CPU/RSS measurements are not polluted by private QA dist rebuild work. Thanks @vincentkoc. - Gateway/reload: bound default restart deferral and SIGUSR1 restart drain to five minutes while preserving explicit `deferralTimeoutMs: 0` indefinite waits, so stale active work accounting cannot block config reloads forever. Thanks @vincentkoc. - Active Memory: register the prompt-build hook with the configured recall timeout plus setup grace instead of the 150s maximum budget, so default memory recall cannot delay turn startup for multiple minutes. Thanks @vincentkoc. diff --git a/ui/src/ui/config-form.browser.test.ts b/ui/src/ui/config-form.browser.test.ts index dee630e3e82..563f18df43f 100644 --- a/ui/src/ui/config-form.browser.test.ts +++ b/ui/src/ui/config-form.browser.test.ts @@ -98,6 +98,50 @@ describe("config form renderer", () => { expect(onPatch).toHaveBeenCalledWith(["bind"], "tailnet"); }); + it("keeps dropdown selects on their configured value after options render", () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + const schema = { + type: "object", + properties: { + provider: { + type: "string", + enum: ["anthropic", "codex", "gemini", "openai", "openrouter", "zai"], + }, + bind: { + anyOf: [ + { const: "auto" }, + { const: "lan" }, + { const: "tailnet" }, + { const: "loopback" }, + { const: "public" }, + { const: "off" }, + ], + }, + }, + }; + const analysis = analyzeConfigSchema(schema); + + render( + renderConfigForm({ + schema: analysis.schema, + uiHints: {}, + unsupportedPaths: analysis.unsupportedPaths, + value: { provider: "openai", bind: "tailnet" }, + onPatch, + }), + container, + ); + + const selects = container.querySelectorAll("select.cfg-select"); + expect(selects).toHaveLength(2); + const selectedLabels = Array.from(selects).map((select) => + select.selectedOptions[0]?.textContent?.trim(), + ); + expect(selectedLabels).toContain("openai"); + expect(selectedLabels).toContain("tailnet"); + }); + it("renders map fields from additionalProperties", () => { const onPatch = vi.fn(); const container = document.createElement("div"); diff --git a/ui/src/ui/views/agents-panels-overview.ts b/ui/src/ui/views/agents-panels-overview.ts index 37997f140ba..eb9a03c18c7 100644 --- a/ui/src/ui/views/agents-panels-overview.ts +++ b/ui/src/ui/views/agents-panels-overview.ts @@ -50,6 +50,7 @@ export function renderAgentOverview(params: { onModelFallbacksChange, onSelectPanel, } = params; + const isDefault = Boolean(params.defaultId && agent.id === params.defaultId); const config = resolveAgentConfig(configForm, agent.id); const agentModel = agent.model; const workspaceFromFiles = @@ -73,6 +74,7 @@ export function renderAgentOverview(params: { (defaultModel !== "-" ? normalizeModelValue(defaultModel) : null) || (configForm ? null : resolveModelPrimary(agentModel)); const effectivePrimary = entryPrimary ?? defaultPrimary ?? null; + const selectedPrimary = isDefault ? effectivePrimary : entryPrimary; const modelFallbacks = resolveModelFallbacks(config.entry?.model) ?? resolveModelFallbacks(config.defaults?.model) ?? @@ -80,7 +82,6 @@ export function renderAgentOverview(params: { const fallbackChips = modelFallbacks ?? []; const skillFilter = Array.isArray(config.entry?.skills) ? config.entry?.skills : null; const skillCount = skillFilter?.length ?? null; - const isDefault = Boolean(params.defaultId && agent.id === params.defaultId); const disabled = !configForm || configLoading || configSaving; const removeChip = (index: number) => { @@ -147,19 +148,24 @@ export function renderAgentOverview(params: {
diff --git a/ui/src/ui/views/agents-utils.ts b/ui/src/ui/views/agents-utils.ts index 58612b94270..6c847a938bd 100644 --- a/ui/src/ui/views/agents-utils.ts +++ b/ui/src/ui/views/agents-utils.ts @@ -670,9 +670,11 @@ export function buildModelOptions( configForm: Record | null, current?: string | null, catalog?: ModelCatalogEntry[], + selected?: string | null, ) { const seen = new Set(); const options: ConfiguredModelOption[] = []; + const selectedKey = selected ? normalizeLowercaseStringOrEmpty(selected) : null; const addOption = (value: string, label: string) => { const key = normalizeLowercaseStringOrEmpty(value); if (seen.has(key)) { @@ -702,7 +704,16 @@ export function buildModelOptions( if (options.length === 0) { return nothing; } - return options.map((option) => html``); + return options.map( + (option) => html` + + `, + ); } type CompiledPattern = diff --git a/ui/src/ui/views/agents.test.ts b/ui/src/ui/views/agents.test.ts index cfaf4a0daf3..13001981f85 100644 --- a/ui/src/ui/views/agents.test.ts +++ b/ui/src/ui/views/agents.test.ts @@ -123,6 +123,68 @@ function createProps(overrides: Partial = {}): AgentsProps { } describe("renderAgents", () => { + it("selects the configured primary model on initial render", async () => { + const container = document.createElement("div"); + const configForm = { + agents: { + defaults: { + model: { primary: "openai/gpt-5.4" }, + models: { + "anthropic/claude-sonnet-4-6": {}, + "openai/gpt-5.4": {}, + }, + }, + list: [{ id: "alpha" }, { id: "beta" }], + }, + }; + + render( + renderAgents( + createProps({ + selectedAgentId: "alpha", + config: { + form: configForm, + loading: false, + saving: false, + dirty: false, + }, + }), + ), + container, + ); + + const defaultSelect = await vi.waitFor(() => { + const select = container.querySelector(".agent-model-fields select"); + expect(select?.value).toBe("openai/gpt-5.4"); + return select; + }); + expect(defaultSelect?.selectedOptions[0]?.value).toBe("openai/gpt-5.4"); + + render( + renderAgents( + createProps({ + selectedAgentId: "beta", + config: { + form: configForm, + loading: false, + saving: false, + dirty: false, + }, + }), + ), + container, + ); + + const inheritedSelect = await vi.waitFor(() => { + const select = container.querySelector(".agent-model-fields select"); + expect(select?.value).toBe(""); + return select; + }); + expect(inheritedSelect?.selectedOptions[0]?.textContent?.trim()).toBe( + "Inherit default (openai/gpt-5.4)", + ); + }); + it("remounts overview model controls when switching selected agents", async () => { const container = document.createElement("div"); const configForm = { diff --git a/ui/src/ui/views/config-form.node.ts b/ui/src/ui/views/config-form.node.ts index 9b0cf4f5021..7ca4358de40 100644 --- a/ui/src/ui/views/config-form.node.ts +++ b/ui/src/ui/views/config-form.node.ts @@ -852,8 +852,13 @@ function renderSelect(params: { onPatch(path, val === unset ? undefined : options[Number(val)]); }} > - - ${options.map((opt, idx) => html` `)} + + ${options.map( + (opt, idx) => + html` `, + )}
`;