diff --git a/ui/src/app/settings.node.test.ts b/ui/src/app/settings.node.test.ts index 573c685b6c34..e9bbb7f7be33 100644 --- a/ui/src/app/settings.node.test.ts +++ b/ui/src/app/settings.node.test.ts @@ -781,6 +781,21 @@ describe("loadSettings default gateway URL derivation", () => { expect(Object.hasOwn(reset, "textScale")).toBe(false); }); + it("treats the legacy always-persisted default text scale as inherited", () => { + setTestLocation({ + protocol: "https:", + host: "gateway.example:8443", + pathname: "/", + }); + const gatewayUrl = expectedGatewayUrl(""); + const scopedKey = `openclaw.control.settings.v1:${gatewayUrl}`; + localStorage.setItem(scopedKey, JSON.stringify({ gatewayUrl, textScale: 100 })); + + expect(loadSettings().textScale).toBeUndefined(); + saveSettings(loadSettings()); + expect(JSON.parse(localStorage.getItem(scopedKey) ?? "{}")).not.toHaveProperty("textScale"); + }); + it("persists and parses a chat split layout", () => { setTestLocation({ protocol: "https:", diff --git a/ui/src/app/settings.ts b/ui/src/app/settings.ts index 6dc720b94d1b..d229a26e5753 100644 --- a/ui/src/app/settings.ts +++ b/ui/src/app/settings.ts @@ -550,7 +550,10 @@ export function loadSettings(): UiSettings { : defaults.showAdvancedSettings, pinnedAgentIds: normalizePinnedAgentIds(parsed.pinnedAgentIds), textScale: - typeof parsed.textScale === "number" ? normalizeTextScale(parsed.textScale) : undefined, + typeof parsed.textScale === "number" && + normalizeTextScale(parsed.textScale) !== UI_APPEARANCE_DEFAULTS.textScale + ? normalizeTextScale(parsed.textScale) + : undefined, customTheme: customTheme ?? undefined, locale: isSupportedLocale(parsed.locale) ? parsed.locale : undefined, ...(parsed.lobsterPetVisits === false ? { lobsterPetVisits: false } : {}), diff --git a/ui/src/components/config-form.node.collection.ts b/ui/src/components/config-form.node.collection.ts index cc5bb737da8b..e389fad1996b 100644 --- a/ui/src/components/config-form.node.collection.ts +++ b/ui/src/components/config-form.node.collection.ts @@ -64,8 +64,9 @@ function collectionDefaultPresentation(params: ConfigNodeRenderParams, effective revealSensitive: params.revealSensitive ?? false, isSensitivePathRevealed: params.isSensitivePathRevealed, }).isRedacted; + const description = renderSchemaDefaultDescription(params.schema, params.value); return { - description: redacted ? nothing : renderSchemaDefaultDescription(params.schema, params.value), + description: redacted ? (nothing as typeof nothing) : description, action: renderRestoreDefaultButton({ ...params, disabled: params.disabled || redacted, diff --git a/ui/src/pages/config/memory-dreaming.test.ts b/ui/src/pages/config/memory-dreaming.test.ts index e72f4447cdbd..69f5929dd334 100644 --- a/ui/src/pages/config/memory-dreaming.test.ts +++ b/ui/src/pages/config/memory-dreaming.test.ts @@ -159,6 +159,37 @@ describe("renderDreamingSettings", () => { expect(onPatch).toHaveBeenCalledWith(["phases", "light", "lookbackDays"], undefined); }); + it("keeps malformed explicit values resettable while displaying runtime defaults", () => { + const onPatch = vi.fn(); + const container = renderInto( + { + frequency: 42, + verboseLogging: "yes", + storage: { mode: 42, separateReports: "yes" }, + }, + onPatch, + ); + + for (const title of [ + "Dreaming frequency", + "Verbose logging", + "Storage mode", + "Separate reports", + ]) { + const row = rowFor(container, title); + expect(row.textContent).toContain("Default:"); + row.querySelector('button[aria-label="Reset to default"]')?.click(); + } + expect(numberInput(container, "Dreaming frequency").value).toBe(""); + expect(toggleStates(container)["Schedule/Verbose logging"]).toBe(false); + expect(selectedSegment(container)).toBe("separate"); + expect(toggleStates(container)["Storage/Separate reports"]).toBe(false); + expect(onPatch).toHaveBeenCalledWith(["frequency"], undefined); + expect(onPatch).toHaveBeenCalledWith(["verboseLogging"], undefined); + expect(onPatch).toHaveBeenCalledWith(["storage", "mode"], undefined); + expect(onPatch).toHaveBeenCalledWith(["storage", "separateReports"], undefined); + }); + it("locks every global dreaming control when config mutation is unavailable", () => { const container = renderInto(null, vi.fn(), true); diff --git a/ui/src/pages/config/memory-dreaming.ts b/ui/src/pages/config/memory-dreaming.ts index d22364012833..c65d06465054 100644 --- a/ui/src/pages/config/memory-dreaming.ts +++ b/ui/src/pages/config/memory-dreaming.ts @@ -272,6 +272,20 @@ function readAtPath(root: Record | null, path: readonly string[ return undefined; } +function hasAtPath(root: Record | null, path: readonly string[]): boolean { + let current: Record | null = root; + for (const [index, key] of path.entries()) { + if (!current || !Object.hasOwn(current, key)) { + return false; + } + if (index === path.length - 1) { + return true; + } + current = asConfigRecord(current[key]); + } + return false; +} + function normalizeStorageMode(value: unknown): StorageMode { return STORAGE_MODES.find((mode) => mode === value) ?? DEFAULT_STORAGE_MODE; } @@ -297,12 +311,7 @@ function parseDreamingNumber(raw: string, bounds: DreamingNumberBounds): number function renderField(props: DreamingSettingsProps, spec: DreamingFieldSpec) { const value = readAtPath(props.dreaming, spec.path); - const overridden = - spec.kind === "toggle" - ? typeof value === "boolean" - : spec.kind === "number" - ? typeof value === "number" - : typeof value === "string"; + const overridden = hasAtPath(props.dreaming, spec.path); const defaultValue = spec.kind === "toggle" ? spec.fallback @@ -391,7 +400,7 @@ export function renderDreamingSettings(props: DreamingSettingsProps): TemplateRe const storageMode = normalizeStorageMode(storageModeValue); const storageDefaultState = renderSettingsDefaultState({ value: t("memoryPage.dreaming.storage.modes.separate"), - overridden: typeof storageModeValue === "string", + overridden: hasAtPath(props.dreaming, ["storage", "mode"]), disabled: props.disabled, onReset: () => props.onPatch(["storage", "mode"], undefined), }); diff --git a/ui/src/pages/model-providers/model-behavior.ts b/ui/src/pages/model-providers/model-behavior.ts new file mode 100644 index 000000000000..48980c2e94e3 --- /dev/null +++ b/ui/src/pages/model-providers/model-behavior.ts @@ -0,0 +1,21 @@ +import type { FastMode } from "../../api/types.ts"; + +type ModelBehaviorConfig = { + thinkingLevel: string | undefined; + thinkingOverridden: boolean; + fastMode: FastMode | undefined; + fastModeOverridden: boolean; +}; + +export function readModelBehaviorConfig( + agentsDefaults: Record | null, +): ModelBehaviorConfig { + const thinkingValue = agentsDefaults?.thinkingDefault; + const fastValue = agentsDefaults?.fastModeDefault; + return { + thinkingLevel: typeof thinkingValue === "string" ? thinkingValue : undefined, + thinkingOverridden: agentsDefaults !== null && Object.hasOwn(agentsDefaults, "thinkingDefault"), + fastMode: fastValue === "auto" || typeof fastValue === "boolean" ? fastValue : undefined, + fastModeOverridden: agentsDefaults !== null && Object.hasOwn(agentsDefaults, "fastModeDefault"), + }; +} diff --git a/ui/src/pages/model-providers/model-providers-page.test.ts b/ui/src/pages/model-providers/model-providers-page.test.ts index 5148834e5c69..4cccb2572167 100644 --- a/ui/src/pages/model-providers/model-providers-page.test.ts +++ b/ui/src/pages/model-providers/model-providers-page.test.ts @@ -233,6 +233,35 @@ describe("ModelProvidersPage agent scope", () => { ]); }); + it("keeps invalid explicit thinking and fast values resettable", async () => { + const { context, runtimeConfig } = createHarness("main"); + runtimeConfig.state.configForm = { + agents: { defaults: { thinkingDefault: 42, fastModeDefault: "bogus" } }, + }; + const page = appendPage(context); + await vi.waitFor(() => expect(page.querySelector("#settings-model-behavior")).not.toBeNull()); + + const behavior = page.querySelector("#settings-model-behavior")!; + const groups = behavior.querySelectorAll("wa-radio-group"); + expect([...groups].map((group) => group.value)).toEqual(["", ""]); + const resetButtons = behavior.querySelectorAll( + 'button[aria-label="Reset to default"]', + ); + expect(resetButtons).toHaveLength(2); + resetButtons.forEach((button) => button.click()); + + expect(runtimeConfig.removeFormValue).toHaveBeenNthCalledWith(1, [ + "agents", + "defaults", + "thinkingDefault", + ]); + expect(runtimeConfig.removeFormValue).toHaveBeenNthCalledWith(2, [ + "agents", + "defaults", + "fastModeDefault", + ]); + }); + it("keeps a committed provider-key save successful when config refresh fails", async () => { const { context, runtimeConfig } = createHarness("main"); runtimeConfig.refresh.mockImplementationOnce(async () => { diff --git a/ui/src/pages/model-providers/model-providers-page.ts b/ui/src/pages/model-providers/model-providers-page.ts index d0b87fbb41b6..8a70b1b66d6b 100644 --- a/ui/src/pages/model-providers/model-providers-page.ts +++ b/ui/src/pages/model-providers/model-providers-page.ts @@ -37,6 +37,7 @@ import { MODEL_PROVIDERS_COST_DAYS, type ModelProvidersData, } from "./load.ts"; +import { readModelBehaviorConfig } from "./model-behavior.ts"; import { buildDefaultModelsPatch, buildProviderApiKeyPatch, @@ -579,8 +580,7 @@ export class ModelProvidersPage extends OpenClawLightDomElement { success: t("modelProviders.defaults.saved"), replacePaths: DEFAULT_MODELS_REPLACE_PATHS, }); - // Without fresh provider data, clearing this committed draft would show - // the old default models beside a contradictory success message. + // Keep the draft when fresh provider data is unavailable after commit. if ( result.ok && !result.warning && @@ -606,13 +606,8 @@ export class ModelProvidersPage extends OpenClawLightDomElement { asConfigRecord(data.config) ?? {}; const agentsDefaults = asConfigRecord(asConfigRecord(configObject.agents)?.defaults); - const thinkingValue = agentsDefaults?.thinkingDefault; - const thinkingLevel = typeof thinkingValue === "string" ? thinkingValue : undefined; - const fastValue = agentsDefaults?.fastModeDefault; - const fastMode = fastValue === "auto" || typeof fastValue === "boolean" ? fastValue : undefined; - // The overlay update states replace General's old configUpdating prop, - // which config-page derived from this same snapshot (isUpdateBusy); the - // busy gate is behavior-identical to the pre-move General controls. + const modelBehavior = readModelBehaviorConfig(agentsDefaults); + // This keeps the pre-move General busy gate sourced from the same update state. const configBusy = this.configBusy(); const cards = buildModelProviderCards({ ...data, @@ -641,8 +636,7 @@ export class ModelProvidersPage extends OpenClawLightDomElement { configuredModels, defaultModels: defaults, defaultModelsDirty: this.defaultsDraft !== null, - thinkingLevel, - fastMode, + ...modelBehavior, configBusy, unconfiguredProviders: buildUnconfiguredProviderOptions( data.catalogModels, diff --git a/ui/src/pages/model-providers/view.test.ts b/ui/src/pages/model-providers/view.test.ts index 1da7a6d6881f..fbe2d2786155 100644 --- a/ui/src/pages/model-providers/view.test.ts +++ b/ui/src/pages/model-providers/view.test.ts @@ -38,7 +38,9 @@ function props(overrides: Partial = {}): ModelProviders defaultModels: { primary: "openai/gpt-5", fallbacks: [], utilityModel: null }, defaultModelsDirty: false, thinkingLevel: "off", + thinkingOverridden: true, fastMode: false, + fastModeOverridden: true, configBusy: false, unconfiguredProviders: [{ id: "anthropic", displayName: "Anthropic" }], canMutate: true, @@ -192,7 +194,14 @@ describe("renderModelProviders", () => { expect(onFastModeReset).toHaveBeenCalledTimes(2); render( - renderModelProviders(props({ thinkingLevel: undefined, fastMode: undefined })), + renderModelProviders( + props({ + thinkingLevel: undefined, + thinkingOverridden: false, + fastMode: undefined, + fastModeOverridden: false, + }), + ), container, ); const inheritedBehavior = container.querySelector("#settings-model-behavior")!; @@ -218,6 +227,31 @@ describe("renderModelProviders", () => { ).toHaveLength(0); }); + it("keeps invalid explicit model behavior values resettable", () => { + const onThinkingReset = vi.fn(); + const onFastModeReset = vi.fn(); + const container = mount( + props({ + thinkingLevel: undefined, + thinkingOverridden: true, + fastMode: undefined, + fastModeOverridden: true, + onThinkingReset, + onFastModeReset, + }), + ); + const behavior = container.querySelector("#settings-model-behavior")!; + const thinking = settingsRow(behavior, "Thinking"); + const fast = settingsRow(behavior, "Fast mode"); + + expect(text(thinking)).toContain("Default: Model policy"); + expect(text(fast)).toContain("Default: Model policy"); + thinking.querySelector('button[aria-label="Reset to default"]')?.click(); + fast.querySelector('button[aria-label="Reset to default"]')?.click(); + expect(onThinkingReset).toHaveBeenCalledOnce(); + expect(onFastModeReset).toHaveBeenCalledOnce(); + }); + it("restores controlled model behavior when a reset is rejected", () => { const viewProps = props({ thinkingLevel: "high", diff --git a/ui/src/pages/model-providers/view.ts b/ui/src/pages/model-providers/view.ts index 4d28a3e4b364..c74b9658781b 100644 --- a/ui/src/pages/model-providers/view.ts +++ b/ui/src/pages/model-providers/view.ts @@ -50,7 +50,9 @@ type ModelProvidersViewProps = { defaultModels: DefaultModelSelection; defaultModelsDirty: boolean; thinkingLevel: string | undefined; + thinkingOverridden: boolean; fastMode: FastMode | undefined; + fastModeOverridden: boolean; configBusy: boolean; unconfiguredProviders: ProviderOption[]; canMutate: boolean; @@ -126,13 +128,13 @@ function renderModelBehavior(props: ModelProvidersViewProps) { : THINKING_LEVELS; const thinkingDefault = renderSettingsDefaultState({ value: t("quickSettings.model.modelPolicy"), - overridden: props.thinkingLevel !== undefined, + overridden: props.thinkingOverridden, disabled: props.configBusy, onReset: props.onThinkingReset, }); const fastDefault = renderSettingsDefaultState({ value: t("quickSettings.model.modelPolicy"), - overridden: props.fastMode !== undefined, + overridden: props.fastModeOverridden, disabled: props.configBusy, onReset: props.onFastModeReset, });