From 8fdca94ead96c85b7a4e32e9f09841eea5a5a58f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 24 Aug 2026 12:10:13 +0200 Subject: [PATCH] Fix settings resets across app revisions (#3787) * fix(settings): preserve settings from newer builds Strict blob validation treated a single newer setting as corruption and then rewrote defaults. Read settings field by field and retain unrecognized data when materializing or migrating known preferences. * refactor(settings): unify discarded-value warnings Emit settings warnings where each stored value is parsed so validation cannot drift from diagnostics. * refactor(settings): validate persisted settings declaratively Use one loose Zod schema with field defaults so mixed-version settings remain loadable without parallel validation paths. * fix(settings): preserve explicit services preferences Keep compatibility fallbacks from overriding explicit settings and prevent read metadata from reaching migration writes. * refactor(settings): derive migration persistence types Let the schema prove normalized settings coverage and carry its persisted shape through migrations. --- packages/app/src/hooks/use-settings/keys.ts | 8 +- .../app/src/hooks/use-settings/migrations.ts | 21 +- .../src/hooks/use-settings/storage.test.ts | 90 +++- .../app/src/hooks/use-settings/storage.ts | 465 ++++++++---------- 4 files changed, 309 insertions(+), 275 deletions(-) diff --git a/packages/app/src/hooks/use-settings/keys.ts b/packages/app/src/hooks/use-settings/keys.ts index 5483ef10b7..7e3c0cca71 100644 --- a/packages/app/src/hooks/use-settings/keys.ts +++ b/packages/app/src/hooks/use-settings/keys.ts @@ -5,9 +5,9 @@ export const LEGACY_SETTINGS_KEY = "@paseo:settings"; /** * The applied-migration marker deliberately sits outside the settings blob: - * `StoredAppSettingsSchema` is a strict object and `readValidatedJson` clears the value when - * parsing fails, so a new field there would wipe every setting for anyone who downgrades to a - * client that predates it. Keeping the marker as a list of ids also means the next migration is - * a new entry rather than a schema change. + * App settings preserve fields newer builds understand, but this marker remains separate so + * applying a migration never becomes an app-settings field that older builds must interpret. + * Keeping the marker as a list of ids also means the next migration is a new entry rather than + * a schema change. */ export const SETTINGS_MIGRATIONS_KEY = "@paseo:settings-migrations"; diff --git a/packages/app/src/hooks/use-settings/migrations.ts b/packages/app/src/hooks/use-settings/migrations.ts index 4ec632f4d4..62dd39eaee 100644 --- a/packages/app/src/hooks/use-settings/migrations.ts +++ b/packages/app/src/hooks/use-settings/migrations.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { readValidatedJson } from "@/storage/validated-storage"; import { APP_SETTINGS_KEY, SETTINGS_MIGRATIONS_KEY } from "./keys"; -import type { AppSettings, KeyValueStorage } from "./storage"; +import type { AppSettings, KeyValueStorage, PersistedAppSettings } from "./storage"; const AppliedMigrationsSchema = z.strictObject({ applied: z.array(z.string()) }); @@ -22,9 +22,14 @@ const STEER_DEFAULT_MIGRATION = "steer-default"; export async function migrateAppSettings( settings: AppSettings, storage: KeyValueStorage, + stored?: PersistedAppSettings, ): Promise { - const stored = await readValidatedJson(storage, SETTINGS_MIGRATIONS_KEY, AppliedMigrationsSchema); - const applied = new Set(stored?.applied ?? []); + const migrationMarker = await readValidatedJson( + storage, + SETTINGS_MIGRATIONS_KEY, + AppliedMigrationsSchema, + ); + const applied = new Set(migrationMarker?.applied ?? []); if (applied.has(STEER_DEFAULT_MIGRATION)) { return settings; } @@ -32,7 +37,15 @@ export async function migrateAppSettings( const migrated: AppSettings = settings.sendBehavior === "interrupt" ? { ...settings, sendBehavior: "steer" } : settings; if (migrated !== settings) { - await storage.setItem(APP_SETTINGS_KEY, JSON.stringify(migrated)); + const storedSidebarRowItems = stored?.sidebarRowItems ?? {}; + await storage.setItem( + APP_SETTINGS_KEY, + JSON.stringify({ + ...stored, + ...migrated, + sidebarRowItems: { ...storedSidebarRowItems, ...migrated.sidebarRowItems }, + }), + ); } applied.add(STEER_DEFAULT_MIGRATION); diff --git a/packages/app/src/hooks/use-settings/storage.test.ts b/packages/app/src/hooks/use-settings/storage.test.ts index e76b9c7ded..fb7e423120 100644 --- a/packages/app/src/hooks/use-settings/storage.test.ts +++ b/packages/app/src/hooks/use-settings/storage.test.ts @@ -50,6 +50,28 @@ describe("loadAppSettingsFromStorage", () => { }); expect((await loadAppSettingsFromStorage(deps)).sendBehavior).toBe("steer"); }); + + it("keeps valid settings when another build wrote unknown fields or enum values", async () => { + const stored = { + theme: "dark", + contentFontSize: 16, + sendBehavior: "future-mode", + futureSetting: { enabled: true }, + sidebarRowItems: { host: false, futureRowItem: true }, + }; + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify(stored), + }), + }); + + const result = await loadAppSettingsFromStorage(deps); + + expect(result.theme).toBe("dark"); + expect(result.sendBehavior).toBe(DEFAULT_CLIENT_SETTINGS.sendBehavior); + expect(result.sidebarRowItems.host).toBe(false); + expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toEqual(stored); + }); it("migrates a stored interrupt to steer and persists it", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ @@ -63,6 +85,32 @@ describe("loadAppSettingsFromStorage", () => { expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "{}").sendBehavior).toBe( "steer", ); + expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "{}")).not.toHaveProperty( + "needsWrite", + ); + }); + + it("keeps an explicit services choice over the legacy scripts fallback", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ + contentFontSize: 16, + sidebarRowItems: { scripts: false, services: true }, + }), + }), + }); + + expect((await loadAppSettingsFromStorage(deps)).sidebarRowItems.services).toBe(true); + + await saveAppSettings({ + queryClient: new QueryClient(), + updates: { + sidebarRowItems: { ...DEFAULT_SIDEBAR_ROW_ITEMS, services: true }, + }, + deps, + }); + + expect((await loadAppSettingsFromStorage(deps)).sidebarRowItems.services).toBe(true); }); it("keeps an interrupt the user picked after the migration ran", async () => { @@ -108,8 +156,8 @@ describe("loadAppSettingsFromStorage", () => { expect(result).toEqual(DEFAULT_CLIENT_SETTINGS); expect(DEFAULT_CLIENT_SETTINGS.language).toBe("system"); - expect(deps.storage.entries.get(APP_SETTINGS_KEY)).toBe( - JSON.stringify(DEFAULT_CLIENT_SETTINGS), + expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toEqual( + DEFAULT_CLIENT_SETTINGS, ); }); @@ -235,7 +283,11 @@ describe("loadAppSettingsFromStorage", () => { theme: "dark", contentFontSize: DEFAULT_UI_BASE_FONT_SIZE, }); - expect(deps.storage.entries.get(APP_SETTINGS_KEY)).toBe(JSON.stringify(result)); + expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toEqual({ + manageBuiltInDaemon: false, + releaseChannel: "beta", + ...result, + }); }); it("preserves the legacy key's explicit interface size as content size", async () => { @@ -400,6 +452,36 @@ describe("loadSettingsFromStorage", () => { }); describe("saveAppSettings", () => { + it("round-trips fields written by a newer build", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ + theme: "dark", + contentFontSize: 16, + sendBehavior: "future-mode", + futureSetting: { enabled: true }, + sidebarRowItems: { host: false, futureRowItem: true }, + }), + }), + }); + + await loadAppSettingsFromStorage(deps); + await saveAppSettings({ + queryClient: new QueryClient(), + updates: { theme: "light" }, + deps, + }); + + expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toMatchObject({ + theme: "light", + futureSetting: { enabled: true }, + sidebarRowItems: { + host: false, + futureRowItem: true, + }, + }); + }); + it("saves terminal scrollback through app settings persistence", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ @@ -609,7 +691,7 @@ describe("appearance settings", () => { expect(result.uiBaseFontSize).toBe(baseSize); expect(persisted).toMatchObject({ uiBaseFontSize: baseSize }); - expect(persisted).not.toHaveProperty("uiFontSize"); + expect(persisted.uiFontSize).toBe(legacySize); }, ); diff --git a/packages/app/src/hooks/use-settings/storage.ts b/packages/app/src/hooks/use-settings/storage.ts index d1d573f5ee..db7d015482 100644 --- a/packages/app/src/hooks/use-settings/storage.ts +++ b/packages/app/src/hooks/use-settings/storage.ts @@ -2,16 +2,14 @@ import { isSyntaxThemeId, type SyntaxThemeId } from "@getpaseo/highlight"; import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages"; import type { QueryClient } from "@tanstack/react-query"; import type { DesktopSettings } from "@/desktop/settings/desktop-settings"; -import { parseAppLanguage, type AppLanguage } from "@/i18n/locales"; +import type { AppLanguage } from "@/i18n/locales"; import { DEFAULT_SIDEBAR_CHECKS_DISPLAY, - parseSidebarChecksDisplay, type SidebarChecksDisplay, } from "@/components/sidebar/display-preferences/checks-display"; import { DEFAULT_SIDEBAR_ROW_ITEMS, isChecksHiddenByLegacyRowItem, - parseSidebarRowItems, type SidebarRowItems, } from "@/components/sidebar/display-preferences/row-items"; import { isNative } from "@/constants/platform"; @@ -22,7 +20,6 @@ import { type ThemePreference, } from "@/styles/theme"; import { z } from "zod"; -import { readValidatedJson } from "@/storage/validated-storage"; import { APP_SETTINGS_KEY, LEGACY_SETTINGS_KEY } from "./keys"; import { migrateAppSettings } from "./migrations"; @@ -41,17 +38,8 @@ const ThemePreferenceSchema = z.enum([ ...THEME_OPTIONS.map((option) => option.name), PLUGIN_THEME_PREFERENCE, ]); -const VALID_THEMES = new Set(ThemePreferenceSchema.options); /** Where the theme picker lands when the persisted preference cannot be honoured. */ export const DEFAULT_THEME_PREFERENCE = "auto" satisfies ThemePreference; -const VALID_SERVICE_URL_BEHAVIORS = new Set(["ask", "in-app", "external"]); -const VALID_WORKSPACE_TITLE_SOURCES = new Set(["title", "branch"]); -const VALID_SIDEBAR_WORKSPACE_TRAILINGS = new Set([ - "diff", - "timestamp", - "none", -]); -const VALID_TOOL_CALL_DETAIL_LEVELS = new Set(["overview", "detailed"]); export const DEFAULT_TERMINAL_SCROLLBACK_LINES = 10_000; export const MIN_TERMINAL_SCROLLBACK_LINES = 0; export const MAX_TERMINAL_SCROLLBACK_LINES = 1_000_000; @@ -106,58 +94,6 @@ export interface Settings extends AppSettings { releaseChannel: ReleaseChannel; } -// Strict, so every item in SIDEBAR_ROW_ITEMS needs a key here the day it is added: the whole -// settings write is one validation, and one unknown key silently loses every other toggle in it. -// `checks` and `scripts` are gone from the item list and stay here for the COMPAT reads in -// row-items.ts. -const SidebarRowItemsSchema = z.strictObject({ - branch: z.boolean().optional(), - project: z.boolean().optional(), - host: z.boolean().optional(), - changeRequest: z.boolean().optional(), - services: z.boolean().optional(), - labels: z.boolean().optional(), - checks: z.boolean().optional(), - scripts: z.boolean().optional(), -}); - -const StoredAppSettingsSchema = z.strictObject({ - theme: ThemePreferenceSchema.optional(), - pluginThemeId: z.string().nullish(), - language: z - .enum(["system", "ar", "en", "es", "fr", "ja", "ko", "pt-BR", "ru", "zh-CN"]) - .optional(), - sendBehavior: z.enum(["interrupt", "steer", "queue"]).optional(), - serviceUrlBehavior: z.enum(["ask", "in-app", "external"]).optional(), - terminalScrollbackLines: z.union([z.number(), z.string()]).optional(), - useLegacyTerminalRenderer: z.boolean().optional(), - uiFontFamily: z.string().optional(), - monoFontFamily: z.string().optional(), - uiBaseFontSize: z.union([z.number(), z.string()]).optional(), - contentFontSize: z.union([z.number(), z.string()]).optional(), - // COMPAT(uiFontSizeScale): replaced by the literal base size in v0.4, remove after 2027-08-17. - uiFontSize: z.union([z.number(), z.string()]).optional(), - codeFontSize: z.union([z.number(), z.string()]).optional(), - syntaxTheme: z.string().refine(isSyntaxThemeId).optional(), - workspaceTitleSource: z.enum(["title", "branch"]).optional(), - sidebarWorkspaceTrailing: z.enum(["diff", "timestamp", "none"]).optional(), - sidebarRowItems: SidebarRowItemsSchema.optional(), - sidebarChecksDisplay: z.enum(["iconAndText", "icon", "none"]).optional(), - autoExpandReasoning: z.boolean().optional(), - toolCallDetailLevel: z.enum(["overview", "detailed"]).optional(), - compactToolCalls: z.boolean().optional(), - chatOutlineEnabled: z.boolean().optional(), - vimKeybindings: z.boolean().optional(), - openSupportingTabsInSidePanel: z.boolean().optional(), - // COMPAT(rendererDesktopSettings): these fields used to share this renderer-owned key. - manageBuiltInDaemon: z.boolean().optional(), - releaseChannel: z.enum(["stable", "beta"]).optional(), -}); - -const LegacyRendererSettingsSchema = StoredAppSettingsSchema; - -type StoredAppSettings = z.infer; - export const DEFAULT_CLIENT_SETTINGS: AppSettings = { theme: DEFAULT_THEME_PREFERENCE, pluginThemeId: null, @@ -189,6 +125,131 @@ export const DEFAULT_APP_SETTINGS: Settings = { releaseChannel: "stable", }; +function clampedNumber(min: number, max: number) { + return z + .unknown() + .transform((value) => parseClampedFontSize(value, { min, max })) + .pipe(z.number()); +} + +function sanitizedFontFamily() { + return z.unknown().transform(sanitizeFontFamily).pipe(z.string()); +} + +const SidebarRowItemsSchema = z + .looseObject({ + branch: z.boolean().catch(DEFAULT_SIDEBAR_ROW_ITEMS.branch), + project: z.boolean().catch(DEFAULT_SIDEBAR_ROW_ITEMS.project), + host: z.boolean().catch(DEFAULT_SIDEBAR_ROW_ITEMS.host), + changeRequest: z.boolean().catch(DEFAULT_SIDEBAR_ROW_ITEMS.changeRequest), + services: z.boolean().optional().catch(undefined), + labels: z.boolean().catch(DEFAULT_SIDEBAR_ROW_ITEMS.labels), + // COMPAT(sidebarRowItemsChecks): migrated in v0.3.0, remove after 2027-08-05. + checks: z.boolean().optional().catch(undefined), + // COMPAT(sidebarRowItemsScripts): migrated in v0.3.0, remove after 2027-08-05. + scripts: z.boolean().optional().catch(undefined), + }) + .catch(DEFAULT_SIDEBAR_ROW_ITEMS); + +type StoredAppSettingsFallback = AppSettings & { + uiFontSize?: number; + compactToolCalls?: boolean; + manageBuiltInDaemon?: boolean; + releaseChannel?: ReleaseChannel; + needsWrite: boolean; +}; + +const DEFAULT_STORED_APP_SETTINGS = { + ...DEFAULT_CLIENT_SETTINGS, + needsWrite: false, +} satisfies StoredAppSettingsFallback; + +const StoredAppSettingsSchema = z + .looseObject({ + theme: ThemePreferenceSchema.catch(DEFAULT_THEME_PREFERENCE), + pluginThemeId: z.string().nullable().catch(null), + language: z + .enum(["system", "ar", "en", "es", "fr", "ja", "ko", "pt-BR", "ru", "zh-CN"]) + .catch("system"), + sendBehavior: z.enum(["interrupt", "steer", "queue"]).catch("steer"), + serviceUrlBehavior: z.enum(["ask", "in-app", "external"]).catch("ask"), + terminalScrollbackLines: clampedNumber( + MIN_TERMINAL_SCROLLBACK_LINES, + MAX_TERMINAL_SCROLLBACK_LINES, + ).catch(DEFAULT_TERMINAL_SCROLLBACK_LINES), + useLegacyTerminalRenderer: z.boolean().catch(false), + uiFontFamily: sanitizedFontFamily().catch(""), + monoFontFamily: sanitizedFontFamily().catch(""), + uiBaseFontSize: clampedNumber(MIN_UI_BASE_FONT_SIZE, MAX_UI_BASE_FONT_SIZE) + .optional() + .catch(DEFAULT_UI_BASE_FONT_SIZE), + contentFontSize: clampedNumber(MIN_CONTENT_FONT_SIZE, MAX_CONTENT_FONT_SIZE) + .optional() + .catch(DEFAULT_CONTENT_FONT_SIZE), + // COMPAT(uiFontSizeScale): replaced by the literal base size in v0.4, remove after 2027-08-17. + uiFontSize: clampedNumber(11, 24).optional().catch(undefined), + codeFontSize: clampedNumber(MIN_CODE_FONT_SIZE, MAX_CODE_FONT_SIZE).catch( + DEFAULT_CODE_FONT_SIZE, + ), + syntaxTheme: z.string().refine(isSyntaxThemeId).catch("one"), + workspaceTitleSource: z.enum(["title", "branch"]).catch("title"), + sidebarWorkspaceTrailing: z.enum(["diff", "timestamp", "none"]).catch("diff"), + sidebarRowItems: SidebarRowItemsSchema, + sidebarChecksDisplay: z + .enum(["iconAndText", "icon", "none"]) + .optional() + .catch(DEFAULT_SIDEBAR_CHECKS_DISPLAY), + autoExpandReasoning: z.boolean().catch(false), + toolCallDetailLevel: z + .enum(["overview", "detailed"]) + .or(z.literal("concise").transform(() => "overview" as const)) + .optional() + .catch("detailed"), + // COMPAT(compactToolCalls): migrated in v0.1.105, remove after 2027-01-12. + compactToolCalls: z.boolean().optional().catch(undefined), + chatOutlineEnabled: z.boolean().catch(true), + vimKeybindings: z.boolean().catch(false), + openSupportingTabsInSidePanel: z.boolean().catch(true), + // COMPAT(rendererDesktopSettings): these fields used to share this renderer-owned key. + manageBuiltInDaemon: z.boolean().optional().catch(undefined), + releaseChannel: z.enum(["stable", "beta"]).optional().catch(undefined), + }) + .transform((stored) => { + const needsWrite = + (stored.uiBaseFontSize === undefined && stored.uiFontSize !== undefined) || + stored.contentFontSize === undefined; + const uiBaseFontSize = + stored.uiBaseFontSize ?? + (stored.uiFontSize === undefined + ? DEFAULT_UI_BASE_FONT_SIZE + : Math.round((FONT_SIZE.base * stored.uiFontSize) / 16)); + const sidebarChecksDisplay = + stored.sidebarChecksDisplay ?? + (isChecksHiddenByLegacyRowItem(stored.sidebarRowItems) + ? "none" + : DEFAULT_SIDEBAR_CHECKS_DISPLAY); + const toolCallDetailLevel = + stored.toolCallDetailLevel ?? (stored.compactToolCalls ? "overview" : "detailed"); + return { + ...stored, + uiBaseFontSize, + contentFontSize: stored.contentFontSize ?? uiBaseFontSize, + sidebarChecksDisplay, + sidebarRowItems: { + ...stored.sidebarRowItems, + services: + stored.sidebarRowItems.services ?? + (stored.sidebarRowItems.scripts === false ? false : DEFAULT_SIDEBAR_ROW_ITEMS.services), + }, + toolCallDetailLevel, + needsWrite, + }; + }) + .catch(DEFAULT_STORED_APP_SETTINGS); + +type StoredAppSettings = z.output; +export type PersistedAppSettings = Omit; + export interface KeyValueStorage { getItem(key: string): Promise; setItem(key: string, value: string): Promise; @@ -220,16 +281,22 @@ export async function saveAppSettings(input: { const current = normalizeAppSettings(storedCurrent); const next = { ...current, ...input.updates }; input.queryClient.setQueryData(APP_SETTINGS_QUERY_KEY, next); - await input.deps.storage.setItem(APP_SETTINGS_KEY, JSON.stringify(next)); + await writeAppSettings( + input.deps.storage, + (await readSettingsObject(input.deps.storage, APP_SETTINGS_KEY)) ?? + StoredAppSettingsSchema.parse({}), + next, + ); } export async function loadAppSettingsFromStorage(deps: SettingsDeps): Promise { try { const read = await readAppSettings(deps); if (read.needsWrite) { - await deps.storage.setItem(APP_SETTINGS_KEY, JSON.stringify(read.settings)); + await writeAppSettings(deps.storage, read.stored, read.settings); } - return await migrateAppSettings(read.settings, deps.storage); + const { needsWrite: _needsWrite, ...stored } = read.stored; + return await migrateAppSettings(read.settings, deps.storage, stored); } catch (error) { console.error("[AppSettings] Failed to load settings:", error); throw error; @@ -242,23 +309,18 @@ export async function loadAppSettingsFromStorage(deps: SettingsDeps): Promise { - const stored = await readValidatedJson(deps.storage, APP_SETTINGS_KEY, StoredAppSettingsSchema); +): Promise<{ settings: AppSettings; needsWrite: boolean; stored: StoredAppSettings }> { + const stored = await readSettingsObject(deps.storage, APP_SETTINGS_KEY); if (stored) { return { settings: normalizeAppSettings(stored), // COMPAT(uiFontSizeScale): persist the converted base size, remove after 2027-08-17. - needsWrite: - (stored.uiBaseFontSize === undefined && stored.uiFontSize !== undefined) || - stored.contentFontSize === undefined, + needsWrite: stored.needsWrite, + stored, }; } - const legacyStored = await readValidatedJson( - deps.storage, - LEGACY_SETTINGS_KEY, - LegacyRendererSettingsSchema, - ); + const legacyStored = await readSettingsObject(deps.storage, LEGACY_SETTINGS_KEY); if (legacyStored) { return { settings: { @@ -266,10 +328,12 @@ async function readAppSettings( ...pickAppSettingsFromLegacy(legacyStored), } satisfies AppSettings, needsWrite: true, + stored: legacyStored, }; } - return { settings: DEFAULT_CLIENT_SETTINGS, needsWrite: true }; + const defaultStored = StoredAppSettingsSchema.parse({}); + return { settings: DEFAULT_CLIENT_SETTINGS, needsWrite: true, stored: defaultStored }; } export async function loadSettingsFromStorage(deps: SettingsDeps): Promise { @@ -299,189 +363,27 @@ export async function loadSettingsFromStorage(deps: SettingsDeps): Promise { - const result: Partial = {}; - if (typeof stored.useLegacyTerminalRenderer === "boolean") { - result.useLegacyTerminalRenderer = stored.useLegacyTerminalRenderer; - } - if (typeof stored.vimKeybindings === "boolean") { - result.vimKeybindings = stored.vimKeybindings; - } - if (typeof stored.chatOutlineEnabled === "boolean") { - result.chatOutlineEnabled = stored.chatOutlineEnabled; - } - if (typeof stored.openSupportingTabsInSidePanel === "boolean") { - result.openSupportingTabsInSidePanel = stored.openSupportingTabsInSidePanel; - } - return result; -} - -/** - * The settings whose stored value only has to be a member of a fixed set. Grouped like the - * boolean settings are: the numeric and font settings need real parsing and clamping, these - * need a membership check and nothing else. - */ -function pickEnumAppSettings(stored: StoredAppSettings): Partial { - const result: Partial = {}; - if (typeof stored.theme === "string" && VALID_THEMES.has(stored.theme)) { - result.theme = stored.theme; - } - if ( - stored.sendBehavior === "interrupt" || - stored.sendBehavior === "steer" || - stored.sendBehavior === "queue" - ) { - result.sendBehavior = stored.sendBehavior; - } - if ( - typeof stored.serviceUrlBehavior === "string" && - VALID_SERVICE_URL_BEHAVIORS.has(stored.serviceUrlBehavior) - ) { - result.serviceUrlBehavior = stored.serviceUrlBehavior; - } - if (typeof stored.syntaxTheme === "string" && isSyntaxThemeId(stored.syntaxTheme)) { - result.syntaxTheme = stored.syntaxTheme; - } - if ( - typeof stored.workspaceTitleSource === "string" && - VALID_WORKSPACE_TITLE_SOURCES.has(stored.workspaceTitleSource) - ) { - result.workspaceTitleSource = stored.workspaceTitleSource; - } - if ( - typeof stored.sidebarWorkspaceTrailing === "string" && - VALID_SIDEBAR_WORKSPACE_TRAILINGS.has(stored.sidebarWorkspaceTrailing) - ) { - result.sidebarWorkspaceTrailing = stored.sidebarWorkspaceTrailing; - } - return result; -} - -function pickAppSettings(stored: StoredAppSettings): Partial { - const result: Partial = {}; - Object.assign(result, pickEnumAppSettings(stored)); - if (typeof stored.pluginThemeId === "string") { - result.pluginThemeId = stored.pluginThemeId; - } - if (stored.sidebarRowItems !== undefined) { - result.sidebarRowItems = parseSidebarRowItems(stored.sidebarRowItems); - } - const sidebarChecksDisplay = parseStoredSidebarChecksDisplay(stored); - if (sidebarChecksDisplay !== null) { - result.sidebarChecksDisplay = sidebarChecksDisplay; - } - const language = parseAppLanguage(stored.language); - if (language !== null) { - result.language = language; - } - const terminalScrollbackLines = parseTerminalScrollbackLines(stored.terminalScrollbackLines); - if (terminalScrollbackLines !== null) { - result.terminalScrollbackLines = terminalScrollbackLines; - } - const uiFontFamily = sanitizeFontFamily(stored.uiFontFamily); - if (uiFontFamily !== null) { - result.uiFontFamily = uiFontFamily; - } - const monoFontFamily = sanitizeFontFamily(stored.monoFontFamily); - if (monoFontFamily !== null) { - result.monoFontFamily = monoFontFamily; - } - const uiBaseFontSize = parseClampedFontSize(stored.uiBaseFontSize, { - min: MIN_UI_BASE_FONT_SIZE, - max: MAX_UI_BASE_FONT_SIZE, - }); - if (uiBaseFontSize !== null) { - result.uiBaseFontSize = uiBaseFontSize; - } else { - const legacyUiFontSize = parseClampedFontSize(stored.uiFontSize, { - min: 11, - max: 24, - }); - if (legacyUiFontSize !== null) { - result.uiBaseFontSize = Math.round((FONT_SIZE.base * legacyUiFontSize) / 16); - } - } - const contentFontSize = parseClampedFontSize(stored.contentFontSize, { - min: MIN_CONTENT_FONT_SIZE, - max: MAX_CONTENT_FONT_SIZE, - }); - if (contentFontSize !== null) { - result.contentFontSize = contentFontSize; - } else if (stored.contentFontSize === undefined) { - // Existing content followed the interface ramp. Preserve that rendered size - // once, then persist the independent setting during the read migration. - result.contentFontSize = result.uiBaseFontSize ?? DEFAULT_UI_BASE_FONT_SIZE; - } - const codeFontSize = parseClampedFontSize(stored.codeFontSize, { - min: MIN_CODE_FONT_SIZE, - max: MAX_CODE_FONT_SIZE, - }); - if (codeFontSize !== null) { - result.codeFontSize = codeFontSize; - } - Object.assign(result, pickBooleanAppSettings(stored)); - if (typeof stored.autoExpandReasoning === "boolean") { - result.autoExpandReasoning = stored.autoExpandReasoning; - } - const toolCallDetailLevel = parseToolCallDetailLevel(stored); - if (toolCallDetailLevel !== null) { - result.toolCallDetailLevel = toolCallDetailLevel; - } - return result; -} - -function pickAppSettingsFromLegacy( - legacy: z.infer, -): Partial { - const result: Partial = {}; - if (legacy.theme === "dark" || legacy.theme === "light" || legacy.theme === "auto") { - result.theme = legacy.theme; - } - const legacyInterfaceSize = pickAppSettings(legacy).uiBaseFontSize; - if (legacyInterfaceSize !== undefined) { - result.uiBaseFontSize = legacyInterfaceSize; - } - // The legacy key rendered content on the interface ramp. Freeze that - // rendered value into the new independent preference during migration. - result.contentFontSize = legacyInterfaceSize ?? DEFAULT_UI_BASE_FONT_SIZE; - return result; -} - export function parseTerminalScrollbackLines(value: unknown): number | null { let numericValue = NaN; if (typeof value === "number") { @@ -548,10 +450,10 @@ async function loadLegacyDesktopSettingsFromStorage(storage: KeyValueStorage): P releaseChannel?: ReleaseChannel; } = {}; - if (typeof stored.manageBuiltInDaemon === "boolean") { + if (stored.manageBuiltInDaemon !== undefined) { result.manageBuiltInDaemon = stored.manageBuiltInDaemon; } - if (stored.releaseChannel === "stable" || stored.releaseChannel === "beta") { + if (stored.releaseChannel !== undefined) { result.releaseChannel = stored.releaseChannel; } @@ -560,11 +462,48 @@ async function loadLegacyDesktopSettingsFromStorage(storage: KeyValueStorage): P async function loadRendererSettingsPayload( storage: KeyValueStorage, -): Promise | null> { - const current = await readValidatedJson(storage, APP_SETTINGS_KEY, LegacyRendererSettingsSchema); +): Promise { + const current = await readSettingsObject(storage, APP_SETTINGS_KEY); if (current) { return current; } - return readValidatedJson(storage, LEGACY_SETTINGS_KEY, LegacyRendererSettingsSchema); + return readSettingsObject(storage, LEGACY_SETTINGS_KEY); +} + +async function readSettingsObject( + storage: KeyValueStorage, + key: string, +): Promise { + const raw = await storage.getItem(key); + if (raw === null) { + return null; + } + + let decoded: unknown; + try { + decoded = JSON.parse(raw); + } catch { + console.warn(`[AppSettings] Removing corrupt ${key}: invalid JSON.`); + await storage.removeItem(key); + return null; + } + return StoredAppSettingsSchema.parse(decoded); +} + +async function writeAppSettings( + storage: KeyValueStorage, + stored: StoredAppSettings, + settings: AppSettings, +): Promise { + const { needsWrite: _needsWrite, ...persistedStored } = stored; + const storedSidebarRowItems = persistedStored.sidebarRowItems; + await storage.setItem( + APP_SETTINGS_KEY, + JSON.stringify({ + ...persistedStored, + ...settings, + sidebarRowItems: { ...storedSidebarRowItems, ...settings.sidebarRowItems }, + }), + ); }