diff --git a/src/web-ui/src/app/global-search/bitfunControlBridge.test.ts b/src/web-ui/src/app/global-search/bitfunControlBridge.test.ts index 51fb04cdee..c8bd6b1721 100644 --- a/src/web-ui/src/app/global-search/bitfunControlBridge.test.ts +++ b/src/web-ui/src/app/global-search/bitfunControlBridge.test.ts @@ -79,6 +79,29 @@ describe('BitFunControl presentation bridge', () => { expect(reconcile).not.toHaveBeenCalled(); }); + it('acknowledges a runtime-applied pending appearance before its config mutation completes', async () => { + vi.spyOn(appearanceService, 'getSnapshot').mockReturnValue({ + ...appearanceService.getSnapshot(), + status: 'applying', + selectedAppearanceId: 'system', + pendingSelectionId: 'bitfun-dark', + }); + const pendingApplied = vi.spyOn(appearanceService, 'hasAppliedPendingSelection') + .mockReturnValue(true); + const reload = vi.spyOn(configManager, 'applyExternalReload').mockResolvedValue(undefined); + const reconcile = vi.spyOn(appearanceService, 'reconcilePersistedState').mockResolvedValue(undefined); + + await expect(applyBitFunControlEffect({ + capabilityId: 'setting.application.appearance', + optionId: 'theme', + changedPaths: ['appearance.selection'], + value: 'bitfun-dark', + })).resolves.toEqual({ status: 'alreadyApplied' }); + expect(pendingApplied).toHaveBeenCalledWith('bitfun-dark'); + expect(reload).not.toHaveBeenCalled(); + expect(reconcile).not.toHaveBeenCalled(); + }); + it('applies an Agent appearance mutation through persisted-state reconciliation', async () => { vi.spyOn(appearanceService, 'getSnapshot').mockReturnValue({ ...appearanceService.getSnapshot(), diff --git a/src/web-ui/src/app/global-search/bitfunControlBridge.ts b/src/web-ui/src/app/global-search/bitfunControlBridge.ts index c8506b5191..0914de9f52 100644 --- a/src/web-ui/src/app/global-search/bitfunControlBridge.ts +++ b/src/web-ui/src/app/global-search/bitfunControlBridge.ts @@ -118,9 +118,11 @@ async function handleRequest(request: BitFunControlRequest): Promise { } function appearanceAlreadyApplied(event: BitFunControlAppliedEvent): boolean { - return event.changedPaths.includes('appearance.selection') - && typeof event.value === 'string' - && appearanceService.getSnapshot().selectedAppearanceId === event.value; + if (!event.changedPaths.includes('appearance.selection') || typeof event.value !== 'string') { + return false; + } + return appearanceService.getSnapshot().selectedAppearanceId === event.value + || appearanceService.hasAppliedPendingSelection(event.value); } function languageAlreadyApplied(event: BitFunControlAppliedEvent): boolean { diff --git a/src/web-ui/src/infrastructure/appearance/runtime/AppearanceService.test.ts b/src/web-ui/src/infrastructure/appearance/runtime/AppearanceService.test.ts index cf6b5b46d0..7784407b29 100644 --- a/src/web-ui/src/infrastructure/appearance/runtime/AppearanceService.test.ts +++ b/src/web-ui/src/infrastructure/appearance/runtime/AppearanceService.test.ts @@ -143,6 +143,29 @@ describe('AppearanceService', () => { }); }); + it('reports a pending selection after its runtime apply while persistence is in flight', async () => { + configMocks.getConfig.mockResolvedValue('system'); + const pendingWrite = deferred(); + configMocks.setConfig.mockReturnValueOnce(pendingWrite.promise); + const { service } = createService(); + await service.initialize(); + + const selection = service.select('bitfun-dark'); + await vi.waitFor(() => expect(configMocks.setConfig).toHaveBeenCalledOnce()); + + expect(service.getSnapshot()).toMatchObject({ + status: 'applying', + selectedAppearanceId: 'system', + pendingSelectionId: 'bitfun-dark', + }); + expect(service.hasAppliedPendingSelection('bitfun-dark')).toBe(true); + expect(service.hasAppliedPendingSelection('bitfun-light')).toBe(false); + + pendingWrite.resolve(undefined); + await selection; + expect(service.hasAppliedPendingSelection('bitfun-dark')).toBe(false); + }); + it('reconciles externally persisted selections without writing them again', async () => { configMocks.getConfig.mockResolvedValueOnce('system').mockResolvedValueOnce('bitfun-dark'); const { service } = createService(); diff --git a/src/web-ui/src/infrastructure/appearance/runtime/AppearanceService.ts b/src/web-ui/src/infrastructure/appearance/runtime/AppearanceService.ts index 316c569bcd..dbbc55903e 100644 --- a/src/web-ui/src/infrastructure/appearance/runtime/AppearanceService.ts +++ b/src/web-ui/src/infrastructure/appearance/runtime/AppearanceService.ts @@ -174,6 +174,14 @@ export class AppearanceService { return this.snapshot; } + hasAppliedPendingSelection(id: AppearanceSelectionId): boolean { + if (this.snapshot.status !== 'applying' || this.snapshot.pendingSelectionId !== id) { + return false; + } + const resolvedId = id === SYSTEM_APPEARANCE_ID ? getSystemAppearanceId() : id; + return this.runtime.getSnapshot()?.id === resolvedId; + } + getPackage(id: string): Promise { const builtin = getBuiltinAppearance(id); if (builtin) return Promise.resolve(builtin);