callback,
useEffect: () => {},
useState: (initialValue) => [initialValue, () => {}],
},
+ SelectMenu,
Switch,
useT: () => (key) => key,
};
@@ -60,7 +64,7 @@ function renderSettingsField() {
context,
import.meta.url
);
- return { exports, Switch };
+ return { exports, Input, SelectMenu, Switch };
}
test("SettingsField uses the compact shared Switch and preserves string persistence", () => {
@@ -87,3 +91,75 @@ test("SettingsField uses the compact shared Switch and preserves string persiste
props.onChange(false);
assert.deepEqual(saved, [{ key: "agent.use_planning", value: "false" }]);
});
+
+test("SettingsField uses the shared Input and preserves numeric constraints and commit behavior", () => {
+ const { exports, Input } = renderSettingsField();
+ const saved = [];
+ const rendered = exports.SettingsField({
+ field: {
+ key: "search.weight",
+ label: "Search weight",
+ type: "float",
+ min: 0,
+ max: 1,
+ step: 0.1,
+ },
+ value: 0.5,
+ onSave: (key, value) => saved.push({ key, value }),
+ isSaved: false,
+ });
+ const inputNode = findComponentNode(rendered, Input);
+
+ assert.ok(inputNode, "expected numeric settings field to render the shared Input");
+ const props = componentProps(inputNode, Input);
+ assert.equal(props.type, "number");
+ assert.equal(props.size, "sm");
+ assert.equal(props.className, "text-right font-mono");
+ assert.equal(props.step, "0.1");
+ assert.equal(props.min, "0");
+ assert.equal(props.max, "1");
+ assert.equal(props.placeholder, "tools.default");
+ assert.equal(props["aria-label"], "Search weight");
+
+ props.onBlur({ currentTarget: { value: "0.75" } });
+ props.onKeyDown({ key: "Escape", currentTarget: { value: "0.9" } });
+ props.onKeyDown({ key: "Enter", currentTarget: { value: "0.9" } });
+ assert.deepEqual(saved, [
+ { key: "search.weight", value: 0.75 },
+ { key: "search.weight", value: 0.9 },
+ ]);
+});
+
+test("SettingsField uses the shared SelectMenu and preserves the default option", () => {
+ const { exports, SelectMenu } = renderSettingsField();
+ const saved = [];
+ const rendered = exports.SettingsField({
+ field: {
+ key: "search.fusion_strategy",
+ label: "Fusion strategy",
+ type: "select",
+ options: ["rrf", "weighted"],
+ },
+ value: "rrf",
+ onSave: (key, value) => saved.push({ key, value }),
+ isSaved: false,
+ });
+ const selectNode = findComponentNode(rendered, SelectMenu);
+
+ assert.ok(selectNode, "expected select settings field to render the shared SelectMenu");
+ const props = componentProps(selectNode, SelectMenu);
+ assert.equal(props.ariaLabel, "Fusion strategy");
+ assert.equal(props.className, "!min-w-0 w-36");
+ assert.deepEqual(JSON.parse(JSON.stringify(props.options)), [
+ { label: "tools.default", value: "" },
+ { label: "rrf", value: "rrf" },
+ { label: "weighted", value: "weighted" },
+ ]);
+
+ props.onChange("weighted");
+ props.onChange("");
+ assert.deepEqual(saved, [
+ { key: "search.fusion_strategy", value: "weighted" },
+ { key: "search.fusion_strategy", value: null },
+ ]);
+});
diff --git a/crates/product/ironclaw_webui/frontend/src/pages/settings/components/settings-field.tsx b/crates/product/ironclaw_webui/frontend/src/pages/settings/components/settings-field.tsx
index 4c562b4a96..a83cd0a2b6 100644
--- a/crates/product/ironclaw_webui/frontend/src/pages/settings/components/settings-field.tsx
+++ b/crates/product/ironclaw_webui/frontend/src/pages/settings/components/settings-field.tsx
@@ -1,6 +1,8 @@
import React from "react";
import { useT } from "../../../lib/i18n";
import { Card } from "../../../design-system/card";
+import { Input } from "../../../design-system/input";
+import { SelectMenu } from "../../../design-system/select-menu";
import { Switch } from "../../../design-system/switch";
function SavedIndicator({ visible }) {
@@ -21,6 +23,12 @@ export function SettingsField({ field, value, onSave, isSaved }) {
const [localValue, setLocalValue] = React.useState("");
const label = field.labelKey ? t(field.labelKey) : field.label || "";
const description = field.descKey ? t(field.descKey) : field.description || "";
+ const selectOptions = field.type === "select"
+ ? [
+ { label: t("tools.default"), value: "" },
+ ...field.options.map((option) => ({ label: option, value: option })),
+ ]
+ : [];
React.useEffect(() => {
if (field.type !== "boolean") {
@@ -65,35 +73,34 @@ export function SettingsField({ field, value, onSave, isSaved }) {
)
: field.type === "select"
? (
-
+ ariaLabel={label}
+ className="!min-w-0 w-36"
+ />
)
: (
-
setLocalValue(e.currentTarget.value)}
- onBlur={(e) => handleCommit(e.currentTarget.value)}
- onKeyDown={(e) => e.key === "Enter" && handleCommit(e.currentTarget.value)}
- step={field.step !== undefined ? String(field.step) : field.type === "float" ? "any" : "1"}
- min={field.min !== undefined ? String(field.min) : undefined}
- max={field.max !== undefined ? String(field.max) : undefined}
- placeholder={t("tools.default")}
- aria-label={label}
- className="h-9 w-36 rounded-md border border-white/12 bg-white/[0.04] px-3 text-right font-mono text-sm text-iron-100 outline-none placeholder:text-iron-700 focus:border-signal/45"
- />
+
+ setLocalValue(e.currentTarget.value)}
+ onBlur={(e) => handleCommit(e.currentTarget.value)}
+ onKeyDown={(e) => e.key === "Enter" && handleCommit(e.currentTarget.value)}
+ step={field.step !== undefined ? String(field.step) : field.type === "float" ? "any" : "1"}
+ min={field.min !== undefined ? String(field.min) : undefined}
+ max={field.max !== undefined ? String(field.max) : undefined}
+ placeholder={t("tools.default")}
+ aria-label={label}
+ size="sm"
+ className="text-right font-mono"
+ />
+
)}
diff --git a/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py b/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py
index 1b5092c9da..dd95528bd3 100644
--- a/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py
+++ b/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py
@@ -1850,9 +1850,9 @@ async def test_reborn_v2_model_capability_tags_persist_after_policy_reload(
selector = page.locator(SEL_V2["settings_model_selector"])
await expect(
- selector.get_by_role("button").locator("[data-capability='text']")
+ selector.get_by_role("combobox").locator("[data-capability='text']")
).to_have_attribute("title", "Text")
- await selector.get_by_role("button").click()
+ await selector.get_by_role("combobox").click()
vision_option = page.get_by_role("option").filter(has_text=vision_model)
await expect(
vision_option.locator("[data-capability='image-input']")
@@ -1919,11 +1919,11 @@ async def _choose_model_preference(
await expect(page.get_by_role("button", name="Add provider")).to_have_count(0)
await expect(page.locator(SEL_V2["settings_model_policy_editor"])).to_have_count(0)
selector = page.locator(SEL_V2["settings_model_selector"])
- button = selector.get_by_role("button")
- await expect(button).to_be_enabled(timeout=15000)
- await button.click()
+ combobox = selector.get_by_role("combobox")
+ await expect(combobox).to_be_enabled(timeout=15000)
+ await combobox.click()
await page.get_by_role("option", name=selected_model, exact=True).click()
- await expect(button).to_contain_text(selected_model)
+ await expect(combobox).to_contain_text(selected_model)
description = page.get_by_text(
"Used for future messages in all conversations.", exact=True
)
@@ -1972,11 +1972,11 @@ async def _assert_model_preference_permissions(
"/settings/inference",
SEL_V2["settings_model_selector"],
)
- button = default_page.locator(SEL_V2["settings_model_selector"]).get_by_role(
- "button"
+ combobox = default_page.locator(SEL_V2["settings_model_selector"]).get_by_role(
+ "combobox"
)
- await expect(button).to_contain_text("mock-model")
- await expect(button).not_to_contain_text(selected_model)
+ await expect(combobox).to_contain_text("mock-model")
+ await expect(combobox).not_to_contain_text(selected_model)
async def _send_model_preference_turn(
@@ -2107,7 +2107,9 @@ async def test_reborn_v2_settings_model_preference_reaches_provider(
SEL_V2["settings_model_selector"],
)
await expect(
- selected_page.locator(SEL_V2["settings_model_selector"]).get_by_role("button")
+ selected_page.locator(SEL_V2["settings_model_selector"]).get_by_role(
+ "combobox"
+ )
).to_contain_text(selected_model)
@@ -3637,7 +3639,7 @@ async def test_reborn_v2_logs_page_passes_scope_to_api_and_renders_context(
).to_contain_text("run-ui")
level_filter = reborn_v2_page.locator(SEL_V2["logs_level_filter"])
- level_trigger = level_filter.get_by_role("button")
+ level_trigger = level_filter.get_by_role("combobox")
await expect(level_trigger).to_have_attribute("aria-haspopup", "listbox")
await expect(level_filter.locator("select")).to_have_count(0)
await reborn_v2_page.locator(SEL_V2["logs_target_filter"]).fill("ironclaw::ui")