fix(ui): preserve reset provenance for invalid defaults

This commit is contained in:
Vincent Koc
2026-08-01 02:13:24 +08:00
parent fefd083043
commit cd2c9a2ed4
10 changed files with 162 additions and 23 deletions
+15
View File
@@ -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:",
+4 -1
View File
@@ -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 } : {}),
@@ -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,
@@ -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<HTMLButtonElement>('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);
+16 -7
View File
@@ -272,6 +272,20 @@ function readAtPath(root: Record<string, unknown> | null, path: readonly string[
return undefined;
}
function hasAtPath(root: Record<string, unknown> | null, path: readonly string[]): boolean {
let current: Record<string, unknown> | 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),
});
@@ -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<string, unknown> | 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"),
};
}
@@ -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<HTMLElement & { value: string }>("wa-radio-group");
expect([...groups].map((group) => group.value)).toEqual(["", ""]);
const resetButtons = behavior.querySelectorAll<HTMLButtonElement>(
'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 () => {
@@ -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,
+35 -1
View File
@@ -38,7 +38,9 @@ function props(overrides: Partial<ModelProvidersViewProps> = {}): 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<HTMLButtonElement>('button[aria-label="Reset to default"]')?.click();
fast.querySelector<HTMLButtonElement>('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",
+4 -2
View File
@@ -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,
});