fix(ui): stop settings save echoes from clobbering theme preferences

Every updateDesktopSettings PUT replays the server's full settings
document as an openchamber:settings-synced event, and the theme listener
adopted it unconditionally - so switching sessions across directories
(any lastDirectory/activeProjectId write) could flip the theme to
whatever the server document held at that moment. A bootstrap GET with
missing theme fields made it worse: materializeAuthoritativeUiSettings
invented useSystemTheme + openchamber defaults and the persist effect
wrote them back to the server and the scoped localStorage entry.

Theme is now adopted only from bootstrap-grade syncs (the renamed
bootstrap flag, formerly adoptWorkspace), missing fields mean 'not set'
and keep the current preference, and the materializer no longer invents
theme defaults. Cross-window same-instance theme sync still rides the
scoped-key storage event; runtime endpoint switches still adopt the new
instance's server theme.

Closes the 'theme flashes to OpenChamber when switching sessions' report;
same family as the pre-#2897 'color mode forced to light' symptom.
This commit is contained in:
Pablo
2026-08-31 21:18:43 +02:00
parent 715c33b83c
commit a42fb91daf
7 changed files with 174 additions and 53 deletions
+64
View File
@@ -15,6 +15,7 @@ import {
subscribeToSettingsSaveState,
syncDesktopSettings,
updateDesktopSettings,
type SettingsSyncedDetail,
} from './persistence';
import { switchRuntimeEndpoint } from './runtime-switch';
@@ -843,6 +844,69 @@ describe('updateDesktopSettings', () => {
expect(useUIStore.getState().autoSaveEnabled).toBe(true);
expect(saveCalls.some((changes) => changes.autoSaveEnabled === true)).toBe(true);
});
test('does not invent theme defaults when the authoritative snapshot omits theme fields', async () => {
getWindow();
invalidateSettingsCache();
registerSettingsApi(
// SAFETY: this mock echoes back exactly the partial changes it received;
// the tests below only read fields the changes actually contain.
async (changes) => ({ ...changes } as SettingsPayload),
async () => ({
settings: { draftStartersCraftGoalAdded: true, draftStartersScheduleTaskAdded: true },
source: 'web',
}),
);
const synced: SettingsSyncedDetail[] = [];
const listener = (event: Event): void => {
// SAFETY: dispatchSettingsSynced is the only emitter for this key and
// always sends a CustomEvent<SettingsSyncedDetail>.
const detail = (event as CustomEvent<SettingsSyncedDetail>).detail;
if (detail) synced.push(detail);
};
window.addEventListener('openchamber:settings-synced', listener);
try {
await syncDesktopSettings();
} finally {
window.removeEventListener('openchamber:settings-synced', listener);
}
expect(synced.length).toBeGreaterThan(0);
const bootstrapSync = synced.find((detail) => detail.bootstrap);
expect(bootstrapSync).toBeTruthy();
expect(bootstrapSync?.settings.useSystemTheme).toBe(undefined);
expect(bootstrapSync?.settings.lightThemeId).toBe(undefined);
expect(bootstrapSync?.settings.darkThemeId).toBe(undefined);
});
test('marks settings save echoes as non-bootstrap syncs', async () => {
getWindow();
invalidateSettingsCache();
registerSettingsApi(
// SAFETY: this mock echoes back exactly the partial changes it received;
// the assertions below only read fields the changes actually contain.
async (changes) => ({ ...changes } as SettingsPayload),
);
const synced: SettingsSyncedDetail[] = [];
const listener = (event: Event): void => {
// SAFETY: dispatchSettingsSynced is the only emitter for this key and
// always sends a CustomEvent<SettingsSyncedDetail>.
const detail = (event as CustomEvent<SettingsSyncedDetail>).detail;
if (detail) synced.push(detail);
};
window.addEventListener('openchamber:settings-synced', listener);
try {
await updateDesktopSettings({ themeVariant: 'dark' });
} finally {
window.removeEventListener('openchamber:settings-synced', listener);
}
expect(synced.length).toBeGreaterThan(0);
expect(synced.every((detail) => detail.bootstrap === false)).toBe(true);
expect(synced.every((detail) => detail.settings.themeVariant === 'dark')).toBe(true);
});
});
describe('unload lifecycle flush (#2197)', () => {
+19 -15
View File
@@ -20,7 +20,6 @@ import { runtimeFetch } from '@/lib/runtime-fetch';
import { isCapacitorApp } from '@/lib/platform';
import { isTerminalShell } from '@/lib/terminalShell';
import { getRuntimeKey, subscribeRuntimeEndpointChanged, subscribeRuntimeEndpointWillChange } from '@/lib/runtime-switch';
import { DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID } from '@/lib/theme/themes';
import { DEFAULT_OPEN_IN_APP_ID } from '@/lib/openInApps';
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
@@ -197,20 +196,23 @@ const persistToLocalStorage = (settings: DesktopSettings) => {
export interface SettingsSyncedDetail {
settings: DesktopSettings;
/** Whether listeners may adopt cross-window workspace pointers
(activeProjectId / lastDirectory). True only for a bootstrap-grade sync:
the settings document is shared by every window of this server, so a
mid-session reconciliation adopting them would hijack this window's
workspace with another window's choice. */
adoptWorkspace: boolean;
/** Whether listeners may adopt authoritative state that this window owns a
live copy of (workspace pointers, theme). True only for a bootstrap-grade
sync: the settings document is shared by every window of this server, so
a mid-session reconciliation adopting them would hijack this window's
choices with another window's. Every settings save echoes the full
document back as a sync event with bootstrap=false — the echo itself is
not filtered; listeners gate their adoption on this flag and keep their
live state for the fields they own. */
bootstrap: boolean;
}
const dispatchSettingsSynced = (settings: DesktopSettings, adoptWorkspace: boolean): void => {
const dispatchSettingsSynced = (settings: DesktopSettings, bootstrap: boolean): void => {
if (typeof window === 'undefined') {
return;
}
window.dispatchEvent(new CustomEvent<SettingsSyncedDetail>('openchamber:settings-synced', {
detail: { settings, adoptWorkspace },
detail: { settings, bootstrap },
}));
};
@@ -534,9 +536,11 @@ const materializeAuthoritativeUiSettings = (settings: DesktopSettings): DesktopS
const defaults = useUIStore.getInitialState();
return {
useSystemTheme: true,
lightThemeId: DEFAULT_LIGHT_THEME_ID,
darkThemeId: DEFAULT_DARK_THEME_ID,
// Theme fields are deliberately NOT defaulted: the theme authority is the
// ThemeSystemContext (scoped per-runtime entry + bootstrap syncs). A
// server document without theme fields means "not set" — inventing
// defaults here would clobber the window's theme and write it back to the
// server. Absent fields keep the current preferences.
openInAppId: DEFAULT_OPEN_IN_APP_ID,
showReasoningTraces: defaults.showReasoningTraces,
streamingAutoFollowEnabled: defaults.streamingAutoFollowEnabled,
@@ -1896,8 +1900,8 @@ export const invalidateSettingsCache = (): void => {
_settingsCache = null;
};
export const syncDesktopSettings = async (options?: { adoptWorkspace?: boolean }): Promise<void> => {
const adoptWorkspace = options?.adoptWorkspace !== false;
export const syncDesktopSettings = async (options?: { bootstrap?: boolean }): Promise<void> => {
const bootstrap = options?.bootstrap !== false;
if (typeof window === 'undefined') {
return;
}
@@ -2026,7 +2030,7 @@ export const syncDesktopSettings = async (options?: { adoptWorkspace?: boolean }
if (!isSettingsRuntimeContextCurrent(context)) return;
}
dispatchSettingsSynced(authoritativeSettings, adoptWorkspace);
dispatchSettingsSynced(authoritativeSettings, bootstrap);
};
try {