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>
This commit is contained in:
openclaw-clownfish[bot]
2026-04-29 01:26:30 -07:00
committed by GitHub
parent 3a6d3dfa06
commit 61b0cd3781
6 changed files with 137 additions and 8 deletions

View File

@@ -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.

View File

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

View File

@@ -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: {
<label class="field">
<span>Primary model${isDefault ? " (default)" : ""}</span>
<select
.value=${isDefault ? (effectivePrimary ?? "") : (entryPrimary ?? "")}
.value=${selectedPrimary ?? ""}
?disabled=${disabled}
@change=${(e: Event) =>
onModelChange(agent.id, (e.target as HTMLSelectElement).value || null)}
>
${isDefault
? html` <option value="">Not set</option> `
? html` <option value="" ?selected=${!selectedPrimary}>Not set</option> `
: html`
<option value="">
<option value="" ?selected=${!selectedPrimary}>
${defaultPrimary ? `Inherit default (${defaultPrimary})` : "Inherit default"}
</option>
`}
${buildModelOptions(configForm, effectivePrimary ?? undefined, params.modelCatalog)}
${buildModelOptions(
configForm,
effectivePrimary ?? undefined,
params.modelCatalog,
selectedPrimary,
)}
</select>
</label>
<div class="field">

View File

@@ -670,9 +670,11 @@ export function buildModelOptions(
configForm: Record<string, unknown> | null,
current?: string | null,
catalog?: ModelCatalogEntry[],
selected?: string | null,
) {
const seen = new Set<string>();
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`<option value=${option.value}>${option.label}</option>`);
return options.map(
(option) => html`
<option
value=${option.value}
?selected=${selectedKey === normalizeLowercaseStringOrEmpty(option.value)}
>
${option.label}
</option>
`,
);
}
type CompiledPattern =

View File

@@ -123,6 +123,68 @@ function createProps(overrides: Partial<AgentsProps> = {}): 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<HTMLSelectElement>(".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<HTMLSelectElement>(".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 = {

View File

@@ -852,8 +852,13 @@ function renderSelect(params: {
onPatch(path, val === unset ? undefined : options[Number(val)]);
}}
>
<option value=${unset}>Select...</option>
${options.map((opt, idx) => html` <option value=${String(idx)}>${String(opt)}</option> `)}
<option value=${unset} ?selected=${currentIndex < 0}>Select...</option>
${options.map(
(opt, idx) =>
html` <option value=${String(idx)} ?selected=${idx === currentIndex}>
${String(opt)}
</option>`,
)}
</select>
</div>
`;