fix(ui): preserve upstream behavior after performance rebase

This commit is contained in:
c_w_xiaohei
2026-08-26 00:42:36 +08:00
parent 9b9d7069c7
commit 532bdab5e8
34 changed files with 752 additions and 361 deletions
+93
View File
@@ -548,6 +548,99 @@ describe('updateDesktopSettings', () => {
});
});
test('does not broadcast a stale project selection over a newer pending update', async () => {
const firstSave = deferred<SettingsPayload>();
const savedChanges: Array<Partial<SettingsPayload>> = [];
registerSettingsSave(async (changes) => {
savedChanges.push(changes);
if (savedChanges.length === 1) return firstSave.promise;
return changes as SettingsPayload;
});
const syncedSettings: SettingsPayload[] = [];
const handleSettingsSynced = (event: Event) => {
syncedSettings.push((event as CustomEvent<SettingsPayload>).detail);
};
getWindow().addEventListener('openchamber:settings-synced', handleSettingsSynced);
try {
const firstUpdate = updateDesktopSettings({ activeProjectId: 'project-a' });
await delay(250);
const secondUpdate = updateDesktopSettings({ activeProjectId: 'project-b' });
firstSave.resolve({ activeProjectId: 'project-a' });
await firstUpdate;
expect(syncedSettings.at(-1)?.activeProjectId).toBe('project-b');
await secondUpdate;
} finally {
getWindow().removeEventListener('openchamber:settings-synced', handleSettingsSynced);
}
});
test('does not broadcast a stale loaded project selection over a newer pending update', async () => {
const loadedSettings = deferred<{ settings: SettingsPayload; source: 'web' | 'vscode' }>();
registerSettingsApi(async (changes) => changes as SettingsPayload, () => loadedSettings.promise);
invalidateSettingsCache();
const syncedSettings: SettingsPayload[] = [];
const handleSettingsSynced = (event: Event) => {
syncedSettings.push((event as CustomEvent<SettingsPayload>).detail);
};
getWindow().addEventListener('openchamber:settings-synced', handleSettingsSynced);
try {
const sync = syncDesktopSettings();
const update = updateDesktopSettings({ activeProjectId: 'project-b' });
loadedSettings.resolve({
settings: {
activeProjectId: 'project-a',
draftStartersCraftGoalAdded: true,
draftStartersScheduleTaskAdded: true,
},
source: 'web',
});
await sync;
expect(syncedSettings.at(-1)?.activeProjectId).toBe('project-b');
await update;
} finally {
getWindow().removeEventListener('openchamber:settings-synced', handleSettingsSynced);
}
});
test('does not broadcast a stale load after a newer project update has saved', async () => {
const loadedSettings = deferred<{ settings: SettingsPayload; source: 'web' | 'vscode' }>();
registerSettingsApi(async (changes) => changes as SettingsPayload, () => loadedSettings.promise);
invalidateSettingsCache();
const syncedSettings: SettingsPayload[] = [];
const handleSettingsSynced = (event: Event) => {
syncedSettings.push((event as CustomEvent<SettingsPayload>).detail);
};
getWindow().addEventListener('openchamber:settings-synced', handleSettingsSynced);
try {
const sync = syncDesktopSettings();
const update = updateDesktopSettings({ activeProjectId: 'project-b' });
await update;
loadedSettings.resolve({
settings: {
activeProjectId: 'project-a',
draftStartersCraftGoalAdded: true,
draftStartersScheduleTaskAdded: true,
},
source: 'web',
});
await sync;
expect(syncedSettings.at(-1)?.activeProjectId).toBe('project-b');
} finally {
getWindow().removeEventListener('openchamber:settings-synced', handleSettingsSynced);
}
});
test('applies model selector settings from server settings', async () => {
getWindow();
const settings = {
+112 -48
View File
@@ -1669,6 +1669,8 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
};
type SettingsRuntimeContext = { runtimeKey: string; generation: number };
type SettingsMutation = { revision: number; changes: Partial<DesktopSettings> };
type SettingsOperation = SettingsRuntimeContext & { id: number; revision: number };
// Short-lived cache + in-flight dedup for settings fetches to avoid repeated GET calls during startup
let _settingsRuntimeGeneration = 0;
@@ -1679,9 +1681,53 @@ let _pendingSettingsContext: SettingsRuntimeContext | null = null;
let _settingsFlushTimer: ReturnType<typeof setTimeout> | null = null;
let _settingsFlushWaiters: Array<() => void> = [];
let _settingsLifecycleInitialized = false;
let _settingsMutationRevision = 0;
let _settingsOperationId = 0;
let _pendingSettingsRevision = 0;
let _settingsMutations: SettingsMutation[] = [];
const _settingsOperations = new Map<number, SettingsOperation>();
const SETTINGS_CACHE_TTL = 2_000; // 2 seconds — covers the startup burst
const SETTINGS_DEBOUNCE_MS = 200;
const recordSettingsMutation = (changes: Partial<DesktopSettings>): number => {
_settingsMutationRevision += 1;
_settingsMutations.push({ revision: _settingsMutationRevision, changes });
return _settingsMutationRevision;
};
const beginSettingsOperation = (
revision = _settingsMutationRevision,
context = captureSettingsRuntimeContext(),
): SettingsOperation => {
_settingsOperationId += 1;
const operation = { id: _settingsOperationId, revision, ...context };
_settingsOperations.set(operation.id, operation);
return operation;
};
const reconcileSettingsOperation = (
settings: DesktopSettings,
operation: SettingsOperation,
): DesktopSettings => {
let reconciled = settings;
for (const mutation of _settingsMutations) {
if (mutation.revision <= operation.revision) continue;
reconciled = { ...reconciled, ...mutation.changes };
}
return reconciled;
};
const finishSettingsOperation = (operation: SettingsOperation): void => {
if (!isSettingsRuntimeContextCurrent(operation)) return;
_settingsOperations.delete(operation.id);
if (_settingsOperations.size === 0) {
_settingsMutations = [];
return;
}
const oldestOperationRevision = Math.min(...[..._settingsOperations.values()].map(({ revision }) => revision));
_settingsMutations = _settingsMutations.filter((mutation) => mutation.revision > oldestOperationRevision);
};
const captureSettingsRuntimeContext = (): SettingsRuntimeContext => ({
runtimeKey: getRuntimeKey(),
generation: _settingsRuntimeGeneration,
@@ -1707,6 +1753,9 @@ const ensureSettingsRuntimeLifecycle = (): void => {
subscribeRuntimeEndpointChanged((detail) => {
if (detail.runtimeKey === detail.previousRuntimeKey) return;
_settingsRuntimeGeneration += 1;
_settingsMutations = [];
_settingsOperations.clear();
_pendingSettingsRevision = 0;
_settingsCache = null;
_settingsInflight = null;
});
@@ -1780,6 +1829,7 @@ export const syncDesktopSettings = async (): Promise<void> => {
}
ensureSettingsRuntimeLifecycle();
const context = captureSettingsRuntimeContext();
const operation = beginSettingsOperation(_settingsMutationRevision, context);
const persistApis = [getPersistApi(), useSessionDisplayStore.persist];
@@ -1816,8 +1866,12 @@ export const syncDesktopSettings = async (): Promise<void> => {
// Each step is wrapped in try/catch so a failure in one side-effect (e.g.
// a TypeError from writing to a contextBridge-protected global) doesn't
// prevent server settings from reaching the Zustand store.
const applySettings = async (settings: DesktopSettings) => {
const applySettings = async (loadedSettings: DesktopSettings) => {
if (!isSettingsRuntimeContextCurrent(context)) return;
let settings = reconcileSettingsOperation(loadedSettings, operation);
await waitForHydration();
if (!isSettingsRuntimeContextCurrent(context)) return;
settings = reconcileSettingsOperation(loadedSettings, operation);
const shouldPersistCraftGoalMigration = settings.draftStartersCraftGoalAdded !== true
|| settings.draftStartersScheduleTaskAdded !== true;
// `autoSaveEnabled` is new to the settings backend. Until the server has a
@@ -1836,8 +1890,6 @@ export const syncDesktopSettings = async (): Promise<void> => {
} catch (error) {
console.warn('persistToLocalStorage failed:', error);
}
await waitForHydration();
if (!isSettingsRuntimeContextCurrent(context)) return;
if (shouldSeedAutoSaveEnabled) {
authoritativeSettings.autoSaveEnabled = useUIStore.getState().autoSaveEnabled;
}
@@ -1900,6 +1952,8 @@ export const syncDesktopSettings = async (): Promise<void> => {
}
} catch (error) {
console.warn('Failed to synchronise settings:', error);
} finally {
finishSettingsOperation(operation);
}
};
@@ -1907,9 +1961,11 @@ export const syncDesktopSettings = async (): Promise<void> => {
async function _flushSettingsUpdate(): Promise<void> {
const changes = _pendingSettingsChanges;
const context = _pendingSettingsContext;
const revision = _pendingSettingsRevision;
const waiters = _settingsFlushWaiters;
_pendingSettingsChanges = null;
_pendingSettingsContext = null;
_pendingSettingsRevision = 0;
_settingsFlushTimer = null;
_settingsFlushWaiters = [];
try {
@@ -1918,59 +1974,66 @@ async function _flushSettingsUpdate(): Promise<void> {
dispatchSettingsSaveState('saved');
return;
}
const operation = beginSettingsOperation(revision, context);
const runtimeSettings = getRuntimeSettingsAPI();
if (runtimeSettings) {
try {
const runtimeSettings = getRuntimeSettingsAPI();
if (runtimeSettings) {
try {
const updated = await runtimeSettings.save(changes);
if (!isSettingsRuntimeContextCurrent(context)) return;
if (updated) {
const reconciled = reconcileSettingsOperation(updated, operation);
applyDesktopUiPreferences(reconciled);
dispatchSettingsSynced(reconciled);
_settingsCache = null;
}
dispatchSettingsSaveState(updated ? 'saved' : 'error');
return;
} catch (error) {
if (!isSettingsRuntimeContextCurrent(context)) return;
console.warn('Failed to update settings via runtime settings API:', error);
}
}
if (!isSettingsRuntimeContextCurrent(context)) return;
try {
const updated = await runtimeSettings.save(changes);
const response = await runtimeFetch('/api/config/settings', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(changes),
});
if (!isSettingsRuntimeContextCurrent(context)) return;
if (!response.ok) {
console.warn('Failed to update shared settings via API:', response.status, response.statusText);
dispatchSettingsSaveState('error');
return;
}
const updated = sanitizeWebSettings(await response.json().catch(() => null));
if (!isSettingsRuntimeContextCurrent(context)) return;
if (updated) {
applyDesktopUiPreferences(updated);
dispatchSettingsSynced(updated);
const reconciled = reconcileSettingsOperation(updated, operation);
applyDesktopUiPreferences(reconciled);
dispatchSettingsSynced(reconciled);
dispatchSettingsSaveState('saved');
// Invalidate GET cache so next read sees the fresh data
_settingsCache = null;
} else {
dispatchSettingsSaveState('error');
}
dispatchSettingsSaveState(updated ? 'saved' : 'error');
return;
} catch (error) {
if (!isSettingsRuntimeContextCurrent(context)) return;
console.warn('Failed to update settings via runtime settings API:', error);
}
}
if (!isSettingsRuntimeContextCurrent(context)) return;
try {
const response = await runtimeFetch('/api/config/settings', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(changes),
});
if (!isSettingsRuntimeContextCurrent(context)) return;
if (!response.ok) {
console.warn('Failed to update shared settings via API:', response.status, response.statusText);
dispatchSettingsSaveState('error');
return;
}
const updated = sanitizeWebSettings(await response.json().catch(() => null));
if (!isSettingsRuntimeContextCurrent(context)) return;
if (updated) {
applyDesktopUiPreferences(updated);
dispatchSettingsSynced(updated);
dispatchSettingsSaveState('saved');
// Invalidate GET cache so next read sees the fresh data
_settingsCache = null;
} else {
dispatchSettingsSaveState('error');
}
} catch (error) {
if (isSettingsRuntimeContextCurrent(context)) {
console.warn('Failed to update shared settings via API:', error);
dispatchSettingsSaveState('error');
if (isSettingsRuntimeContextCurrent(context)) {
console.warn('Failed to update shared settings via API:', error);
dispatchSettingsSaveState('error');
}
}
} finally {
finishSettingsOperation(operation);
}
} finally {
waiters.forEach((resolve) => resolve());
@@ -1991,6 +2054,7 @@ export const updateDesktopSettings = async (changes: Partial<DesktopSettings>):
_pendingSettingsChanges = { ...(_pendingSettingsChanges ?? {}), ...changes };
_pendingSettingsContext = context;
_pendingSettingsRevision = recordSettingsMutation(changes);
dispatchSettingsSaveState('saving');
if (_settingsFlushTimer) {