diff --git a/scripts/control-ui-mock-dev.ts b/scripts/control-ui-mock-dev.ts index 64fcae259ff5..90dcc8c047d1 100644 --- a/scripts/control-ui-mock-dev.ts +++ b/scripts/control-ui-mock-dev.ts @@ -650,6 +650,7 @@ function buildConfigMocks(options: { swarmEnabled?: boolean } = {}) { messages: { queueLimit: 5, responsePrefix: "" }, gateway: { port: 18789, bind: "127.0.0.1" }, agents: { defaults: { thinkingDefault: "medium" } }, + commands: { native: "auto", nativeSkills: "auto" }, models: { mode: "merge" }, ...(options.swarmEnabled ? { tools: { swarm: true } } : {}), channels: { @@ -743,6 +744,22 @@ function buildConfigMocks(options: { swarmEnabled?: boolean } = {}) { }, }, }, + commands: { + type: "object", + title: "Commands", + properties: { + native: { + title: "Native Commands", + default: "auto", + anyOf: [{ type: "boolean" }, { type: "string", const: "auto" }], + }, + nativeSkills: { + title: "Native Skill Commands", + default: "auto", + anyOf: [{ type: "boolean" }, { type: "string", const: "auto" }], + }, + }, + }, gateway: { type: "object", title: "Gateway", diff --git a/src/config/schema-control-ui-contract.test.ts b/src/config/schema-control-ui-contract.test.ts index e6b3c1747374..f624b3d29e07 100644 --- a/src/config/schema-control-ui-contract.test.ts +++ b/src/config/schema-control-ui-contract.test.ts @@ -125,4 +125,19 @@ describe("generated config schema Control UI contract", () => { expect([...new Set(lossyPaths)].toSorted()).toEqual([]); }); + + it("keeps native command settings editable as tri-state controls", () => { + const rawSchema = computeBaseConfigSchemaResponse({ + generatedAt: "control-ui-contract", + }).schema as JsonSchema; + const analysis = analyzeConfigSchema(rawSchema); + + for (const path of ["commands.native", "commands.nativeSkills"]) { + expect(isPathUnsupported(path, analysis.unsupportedPaths)).toBe(false); + expect(analysis.schema ? schemaAtPath(analysis.schema, path) : undefined).toMatchObject({ + enum: [true, false, "auto"], + default: "auto", + }); + } + }); }); diff --git a/ui/src/components/config-form-composition-integrity.browser.test.ts b/ui/src/components/config-form-composition-integrity.browser.test.ts index 012bbaab3430..1b5116a13e35 100644 --- a/ui/src/components/config-form-composition-integrity.browser.test.ts +++ b/ui/src/components/config-form-composition-integrity.browser.test.ts @@ -37,26 +37,103 @@ describe("config form composition integrity", () => { expect(unsupportedUnion.unsupportedPaths).toEqual(["mixed"]); }); - it("keeps literal and typed unions in Raw mode", () => { + it("renders finite boolean unions while keeping open typed unions in Raw mode", () => { const analysis = analyzeConfigSchema({ type: "object", properties: { retention: { anyOf: [{ type: "string" }, { const: false }], }, + guarded: { + anyOf: [{ type: "boolean", not: { const: true } }, { const: "auto" }], + }, + nullableBoolean: { + anyOf: [{ type: ["boolean", "null"] }, { const: "auto" }], + }, + ambiguousBooleanLabel: { + anyOf: [{ type: "boolean" }, { const: "true" }], + }, + overlappingOneOf: { + oneOf: [{ type: "boolean" }, { const: true }], + }, + overlappingAnyOf: { + anyOf: [{ type: "boolean" }, { const: true }], + }, mode: { - oneOf: [{ type: "boolean" }, { enum: ["auto", "manual"] }], + title: "Native Commands", + default: "auto", + anyOf: [{ type: "boolean" }, { type: "string", const: "auto" }], + }, + plainMode: { + title: "Plain Mode", + enum: ["auto", "manual"], + }, + disjointOneOf: { + oneOf: [{ type: "boolean" }, { const: "auto" }], }, }, }); - expect(analysis.unsupportedPaths).toEqual(["retention", "mode"]); + expect(analysis.unsupportedPaths).toEqual([ + "retention", + "guarded", + "nullableBoolean", + "ambiguousBooleanLabel", + "overlappingOneOf", + ]); expect(analysis.schema?.properties?.retention).toMatchObject({ anyOf: [{ type: "string" }, { const: false }], }); expect(analysis.schema?.properties?.mode).toMatchObject({ - oneOf: [{ type: "boolean" }, { enum: ["auto", "manual"] }], + enum: [true, false, "auto"], + default: "auto", }); + expect(analysis.schema?.properties?.overlappingOneOf).toMatchObject({ + oneOf: [{ type: "boolean" }, { const: true }], + }); + expect(analysis.schema?.properties?.overlappingAnyOf).toMatchObject({ + enum: [true, false], + }); + expect(analysis.schema?.properties?.disjointOneOf).toMatchObject({ + enum: [true, false, "auto"], + }); + + const onPatch = vi.fn(); + const container = document.createElement("div"); + render( + renderConfigForm({ + schema: analysis.schema, + uiHints: {}, + unsupportedPaths: analysis.unsupportedPaths, + value: { retention: "30d", mode: "auto", plainMode: "auto" }, + showAdvanced: true, + onShowAdvanced: () => {}, + onPatch, + }), + container, + ); + + const modeControl = [ + ...container.querySelectorAll( + "wa-radio-group.settings-segmented", + ), + ].find((group) => group.querySelector("[slot='label']")?.textContent === "Native Commands"); + expect(modeControl).not.toBeNull(); + const modeOptions = [...(modeControl?.querySelectorAll("wa-radio") ?? [])]; + // Web Awesome radios take their accessible names from their visible default-slot text. + expect(modeOptions.map((option) => option.textContent?.trim())).toEqual(["On", "Off", "Auto"]); + expect(modeOptions.map((option) => option.getAttribute("value"))).toEqual(["0", "1", "2"]); + const plainModeControl = [...container.querySelectorAll("wa-radio-group")].find( + (group) => group.querySelector("[slot='label']")?.textContent === "Plain Mode", + ); + expect( + [...(plainModeControl?.querySelectorAll("wa-radio") ?? [])].map((option) => + option.textContent?.trim(), + ), + ).toEqual(["auto", "manual"]); + modeControl!.value = "1"; + modeControl!.dispatchEvent(new Event("change", { bubbles: true })); + expect(onPatch).toHaveBeenCalledWith(["mode"], false); }); it("marks required-only object branches as form-unsafe", () => { diff --git a/ui/src/components/config-form.analyze.ts b/ui/src/components/config-form.analyze.ts index 0c20ec6b7917..31a01f529277 100644 --- a/ui/src/components/config-form.analyze.ts +++ b/ui/src/components/config-form.analyze.ts @@ -569,6 +569,24 @@ function normalizeUnion( return secretInput; } + // An exact boolean branch is finite, except oneOf cannot absorb boolean literals + // that also match that branch. Open, nullable, or constrained branches stay in Raw mode. + if (literals.length > 0 && remaining.length > 0) { + const booleanBranch = remaining.length === 1 ? remaining[0] : undefined; + const plainBooleanBranch = + booleanBranch?.type === "boolean" && Object.keys(booleanBranch).length === 1; + if ( + !plainBooleanBranch || + literals.includes("true") || + literals.includes("false") || + (schema.anyOf === undefined && literals.some((literal) => typeof literal === "boolean")) + ) { + return null; + } + remaining.pop(); + literals.unshift(true, false); + } + if (literals.length > 0 && remaining.length === 0) { return { schema: { @@ -584,12 +602,6 @@ function normalizeUnion( }; } - // A native field cannot preserve both literal sentinels and an open typed branch. - // Keep the original union for Raw mode instead of silently dropping valid values. - if (literals.length > 0 && remaining.length > 0) { - return null; - } - if (remaining.length === 1) { const remainingSchema = remaining[0]; if (!remainingSchema) { diff --git a/ui/src/components/config-form.node.scalar.ts b/ui/src/components/config-form.node.scalar.ts index e9dac0d6fce8..d1a001179f86 100644 --- a/ui/src/components/config-form.node.scalar.ts +++ b/ui/src/components/config-form.node.scalar.ts @@ -10,6 +10,7 @@ import { numericInputConstraints, } from "./config-form.constraints.ts"; import { + configEnumOptionLabel, getSensitiveRenderState, isSecretRefObject, jsonValue, @@ -527,7 +528,7 @@ export function renderSelect( ${options.map( (option, index) => html` `, )} diff --git a/ui/src/components/config-form.node.shared.ts b/ui/src/components/config-form.node.shared.ts index 9d64d27a4e88..29a495e5ff53 100644 --- a/ui/src/components/config-form.node.shared.ts +++ b/ui/src/components/config-form.node.shared.ts @@ -357,7 +357,7 @@ export function renderSegmentedControl(params: { value: selectedIndex < 0 ? "" : String(selectedIndex), options: params.options.map((option, index) => ({ value: String(index), - label: formatUnknownText(option), + label: configEnumOptionLabel(option, params.options), })), disabled: params.disabled, ariaLabel: params.ariaLabel, @@ -370,6 +370,20 @@ export function renderSegmentedControl(params: { }); } +export function configEnumOptionLabel(option: unknown, options: readonly unknown[]): string { + const presentsBooleanState = options.includes(true) && options.includes(false); + if (!presentsBooleanState) { + return formatUnknownText(option); + } + if (option === true) { + return t("configForm.enumOn"); + } + if (option === false) { + return t("configForm.enumOff"); + } + return option === "auto" ? t("configForm.enumAuto") : formatUnknownText(option); +} + export function renderJsonTextareaControl(params: { schema: JsonSchema; path: Array; diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index d34938251901..19e2e944335c 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -1180,6 +1180,9 @@ export const en: TranslationMap = { usingDefault: "Using default: {value}", resetToDefault: "Reset to default", select: "Select...", + enumOn: "On", + enumOff: "Off", + enumAuto: "Auto", nullValue: "null", jsonValue: "JSON value", invalidJson: "Enter valid JSON before leaving this field.",