From 11117f4fa02f67273b6058872a0b3fd24d46e5cd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 10 Jun 2026 07:33:09 +0100 Subject: [PATCH] fix: defer tracked menu refresh rebuilds Defer parent-menu recomposition caused by provider data refreshes until menu tracking ends. Keep explicit provider switching and hosted submenu updates immediate. Co-authored-by: isaacJang --- CHANGELOG.md | 1 + .../StatusItemController+Actions.swift | 5 +++- .../StatusItemController+MenuTracking.swift | 15 +++++++++-- ...tusItemController+ProviderNavigation.swift | 20 ++++++++++++-- ...tatusItemController+ProviderSwitcher.swift | 2 +- .../StatusMenuOpenRefreshTests.swift | 27 ++++++++++--------- .../StatusMenuSwitcherClickTests.swift | 21 +++++++++++++-- 7 files changed, 71 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a45b4d726b..c8a853b18a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Antigravity: exclude model quotas without a remaining fraction from family summaries so they no longer mask tracked usage in the automatic menu-bar metric (#1369). Thanks @Martin-Hausleitner! - Claude: add bundled Fable 5 pricing, account for native 1-hour cache-write usage, and refresh Sonnet 4.6 full-context rates (#1368). Thanks @MoollaMore! +- Menu bar: defer data-refresh rebuilds until the tracked menu closes, avoiding multi-second WindowServer stalls with slower providers such as Grok (#1376). Thanks @jangisaac-dev! ## 0.32.5 — 2026-06-09 diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index cd93de3d8a..67c399e43b 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -42,7 +42,10 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { } func refreshOpenMenusAfterExplicitStoreAction() { - self.invalidateMenus(refreshOpenMenus: true) + self.invalidateMenus( + refreshOpenMenus: true, + deferOpenParentMenuRebuild: true, + allowStaleContentDuringDataRefresh: true) } @objc func refreshNow() { diff --git a/Sources/CodexBar/StatusItemController+MenuTracking.swift b/Sources/CodexBar/StatusItemController+MenuTracking.swift index 2df363724f..9d437f01bc 100644 --- a/Sources/CodexBar/StatusItemController+MenuTracking.swift +++ b/Sources/CodexBar/StatusItemController+MenuTracking.swift @@ -240,14 +240,25 @@ extension StatusItemController { } func refreshOpenMenuIfStillVisible(_ menu: NSMenu, provider: UsageProvider?) { - self.scheduleOpenMenuRebuildIfStillVisible(menu, provider: provider) + let key = ObjectIdentifier(menu) + guard self.openMenus[key] != nil else { return } + if self.isHostedSubviewMenu(menu) { + self.scheduleOpenMenuRebuildIfStillVisible(menu, provider: provider) + return + } + self.invalidateMenus( + refreshOpenMenus: true, + deferOpenParentMenuRebuild: true, + allowStaleContentDuringDataRefresh: true) } func rebuildOpenMenuIfStillVisible(_ menu: NSMenu, provider: UsageProvider?) { - guard self.openMenus[ObjectIdentifier(menu)] != nil else { return } + let key = ObjectIdentifier(menu) + guard self.openMenus[key] != nil else { return } guard self.isHostedSubviewMenu(menu) || !self.hasOpenHostedSubviewMenu() else { return } self.populateMenu(menu, provider: provider) self.markMenuFresh(menu) + self.parentMenuRebuildsDeferredDuringTracking.remove(key) self.applyIcon(phase: nil) #if DEBUG self._test_openMenuRebuildObserver?(menu) diff --git a/Sources/CodexBar/StatusItemController+ProviderNavigation.swift b/Sources/CodexBar/StatusItemController+ProviderNavigation.swift index 6611fdd528..ca096bb6f7 100644 --- a/Sources/CodexBar/StatusItemController+ProviderNavigation.swift +++ b/Sources/CodexBar/StatusItemController+ProviderNavigation.swift @@ -1,3 +1,4 @@ +import AppKit import CodexBarCore extension StatusItemController { @@ -34,7 +35,10 @@ extension StatusItemController { self.applyIcon(phase: phase) } - func navigateProviderSwitcher(_ direction: StatusItemMenuProviderNavigationDirection) { + func navigateProviderSwitcher( + _ direction: StatusItemMenuProviderNavigationDirection, + menu: NSMenu? = nil) + { guard self.shouldMergeIcons else { return } let enabledProviders = self.store.enabledProvidersForDisplay() guard enabledProviders.count > 1 else { return } @@ -59,6 +63,12 @@ extension StatusItemController { let delta = direction == .next ? 1 : -1 let nextIndex = (currentIndex + delta + selections.count) % selections.count let selection = selections[nextIndex] + let menuProvider: UsageProvider = switch selection { + case .overview: + self.navigationResolvedProvider(enabledProviders: enabledProviders) ?? .codex + case let .provider(provider): + provider + } self.preservingMergedSwitcherContentCachesDuringInvalidation { switch selection { case .overview: @@ -70,7 +80,13 @@ extension StatusItemController { self.lastMenuProvider = provider } self.lastMergedSwitcherSelection = selection - self.refreshProviderSelectionDependentUI(refreshOpenMenus: true, deferRendering: true) + self.refreshProviderSelectionDependentUI(deferRendering: true) + } + let trackedMenu = menu ?? self.providerSwitcherShortcutMenuID.flatMap { self.openMenus[$0] } + if let trackedMenu { + self.requestProviderSwitcherMenuRebuild( + trackedMenu, + provider: menuProvider) } } diff --git a/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift b/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift index 034d1699e0..b8eb310ce2 100644 --- a/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift +++ b/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift @@ -109,7 +109,7 @@ extension StatusItemController { return self.selectProviderSwitcherSegment(at: index, menu: menu) } if let direction = StatusItemMenu.providerNavigationDirection(for: event) { - self.navigateProviderSwitcher(direction) + self.navigateProviderSwitcher(direction, menu: menu) return true } return false diff --git a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift index 21e57ae44e..e25496bbab 100644 --- a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift @@ -629,7 +629,7 @@ extension StatusMenuTests { } @Test - func `explicit store actions refresh a visible open menu`() async { + func `explicit store actions defer visible parent menu rebuild`() async { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -660,17 +660,18 @@ extension StatusMenuTests { defer { controller._test_openMenuRebuildObserver = nil } controller.refreshOpenMenusAfterExplicitStoreAction() - for _ in 0..<20 where rebuildCount == 0 { + for _ in 0..<20 { await Task.yield() } #expect(controller.menuContentVersion != openedVersion) - #expect(rebuildCount == 1) - #expect(controller.menuVersions[key] != openedVersion) + #expect(rebuildCount == 0) + #expect(controller.menuVersions[key] == openedVersion) + #expect(controller.parentMenuRebuildsDeferredDuringTracking.contains(key)) } @Test - func `repeated explicit store actions coalesce to one open menu rebuild`() async { + func `repeated explicit store actions keep parent rebuild deferred`() async { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -703,16 +704,17 @@ extension StatusMenuTests { controller.refreshOpenMenusAfterExplicitStoreAction() controller.refreshOpenMenusAfterExplicitStoreAction() - for _ in 0..<20 where rebuildCount == 0 { + for _ in 0..<20 { await Task.yield() } - #expect(rebuildCount == 1) - #expect(controller.menuVersions[key] == controller.menuContentVersion) + #expect(rebuildCount == 0) + #expect(controller.menuVersions[key] != controller.menuContentVersion) + #expect(controller.parentMenuRebuildsDeferredDuringTracking.contains(key)) } @Test - func `explicit refresh rebuilds stale parent after hosted submenu closes`() async { + func `explicit refresh keeps stale parent deferred after hosted submenu closes`() async { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -755,13 +757,14 @@ extension StatusMenuTests { #expect(controller.menuVersions[menuKey] == openedVersion) controller.menuDidClose(submenu) - for _ in 0..<20 where rebuildCount == 0 { + for _ in 0..<20 { await Task.yield() } #expect(controller.openMenus[submenuKey] == nil) - #expect(rebuildCount == 1) - #expect(controller.menuVersions[menuKey] == controller.menuContentVersion) + #expect(rebuildCount == 0) + #expect(controller.menuVersions[menuKey] == openedVersion) + #expect(controller.parentMenuRebuildsDeferredDuringTracking.contains(menuKey)) } @Test diff --git a/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift b/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift index 1da133c675..b8c5e8852b 100644 --- a/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift +++ b/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift @@ -494,20 +494,37 @@ struct StatusMenuSwitcherClickTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + controller.menuRefreshEnabledOverrideForTesting = true + defer { controller.releaseStatusItemsForTesting() } let menu = try #require(controller.makeMenu() as? StatusItemMenu) controller.menuWillOpen(menu) #expect(menu.items.first?.view is ProviderSwitcherView) + store.tokenRefreshInFlight.insert(.codex) + defer { store.tokenRefreshInFlight.remove(.codex) } + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } #expect(try menu.performKeyEquivalent(with: Self.arrowKeyEvent(keyCode: 124)) == true) - await Task.yield() + for _ in 0..<100 where rebuildCount == 0 { + await Task.yield() + try? await Task.sleep(for: .milliseconds(5)) + } #expect(settings.mergedMenuLastSelectedWasOverview == false) #expect(settings.selectedMenuProvider == .claude) + #expect(rebuildCount == 1) #expect(try menu.performKeyEquivalent(with: Self.arrowKeyEvent(keyCode: 123)) == true) - await Task.yield() + for _ in 0..<100 where rebuildCount == 1 { + await Task.yield() + try? await Task.sleep(for: .milliseconds(5)) + } #expect(settings.mergedMenuLastSelectedWasOverview == false) #expect(settings.selectedMenuProvider == .codex) + #expect(rebuildCount == 2) } @Test