From df924aa532ccf75bc76a349020e3649da9efec80 Mon Sep 17 00:00:00 2001 From: c_w_xiaohei <1641233466@qq.com> Date: Tue, 25 Aug 2026 01:57:32 +0800 Subject: [PATCH] fix(ui): bound settings mutation history Skip mutation history when no settings operation is in flight and merge updates that share the same operation visibility window. This keeps stale-response reconciliation unchanged while preventing history from growing with repeated debounced updates. Add a repeated-update regression covering an older pending load and latest-value reconciliation. Tests: bun test src/lib/persistence.test.ts --- packages/ui/src/lib/persistence.test.ts | 37 +++++++++++++++++++++++++ packages/ui/src/lib/persistence.ts | 14 +++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/lib/persistence.test.ts b/packages/ui/src/lib/persistence.test.ts index ff72c1b8..c698f711 100644 --- a/packages/ui/src/lib/persistence.test.ts +++ b/packages/ui/src/lib/persistence.test.ts @@ -641,6 +641,43 @@ describe('updateDesktopSettings', () => { } }); + test('preserves only the latest settings values across repeated pending updates', 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).detail); + }; + getWindow().addEventListener('openchamber:settings-synced', handleSettingsSynced); + + try { + const sync = syncDesktopSettings(); + const updates = Array.from({ length: 100 }, (_, index) => updateDesktopSettings({ + activeProjectId: `project-${index}`, + showReasoningTraces: index % 2 === 0, + })); + + loadedSettings.resolve({ + settings: { + activeProjectId: 'stale-project', + showReasoningTraces: true, + draftStartersCraftGoalAdded: true, + draftStartersScheduleTaskAdded: true, + }, + source: 'web', + }); + await sync; + + expect(syncedSettings.at(-1)?.activeProjectId).toBe('project-99'); + expect(syncedSettings.at(-1)?.showReasoningTraces).toBe(false); + + await Promise.all(updates); + } finally { + getWindow().removeEventListener('openchamber:settings-synced', handleSettingsSynced); + } + }); + test('applies model selector settings from server settings', async () => { getWindow(); const settings = { diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index ea9fef52..898ff502 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -1679,7 +1679,19 @@ class SettingsMutationTracker { record(changes: Partial): number { this.revision += 1; - this.mutations.push({ revision: this.revision, changes }); + if (this.operations.size > 0) { + const latest = this.mutations.at(-1); + // A new segment is only needed when an operation started after the last one. + const crossedOperationBoundary = latest + ? [...this.operations].some((operation) => operation.revision >= latest.revision) + : true; + if (latest && !crossedOperationBoundary) { + latest.revision = this.revision; + latest.changes = { ...latest.changes, ...changes }; + } else { + this.mutations.push({ revision: this.revision, changes }); + } + } return this.revision; }