mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:10:45 +00:00
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:
committed by
GitHub
parent
3a6d3dfa06
commit
61b0cd3781
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user