mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(ui): preserve reset provenance for invalid defaults
This commit is contained in:
@@ -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:",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user