Skip to content

Commit 11117f4

Browse files
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 <dltkr717@gmail.com>
1 parent 20004f3 commit 11117f4

7 files changed

Lines changed: 71 additions & 20 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Fixed
66
- 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!
77
- 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!
8+
- 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!
89

910
## 0.32.5 — 2026-06-09
1011

Sources/CodexBar/StatusItemController+Actions.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,10 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate {
4242
}
4343

4444
func refreshOpenMenusAfterExplicitStoreAction() {
45-
self.invalidateMenus(refreshOpenMenus: true)
45+
self.invalidateMenus(
46+
refreshOpenMenus: true,
47+
deferOpenParentMenuRebuild: true,
48+
allowStaleContentDuringDataRefresh: true)
4649
}
4750

4851
@objc func refreshNow() {

Sources/CodexBar/StatusItemController+MenuTracking.swift

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -240,14 +240,25 @@ extension StatusItemController {
240240
}
241241

242242
func refreshOpenMenuIfStillVisible(_ menu: NSMenu, provider: UsageProvider?) {
243-
self.scheduleOpenMenuRebuildIfStillVisible(menu, provider: provider)
243+
let key = ObjectIdentifier(menu)
244+
guard self.openMenus[key] != nil else { return }
245+
if self.isHostedSubviewMenu(menu) {
246+
self.scheduleOpenMenuRebuildIfStillVisible(menu, provider: provider)
247+
return
248+
}
249+
self.invalidateMenus(
250+
refreshOpenMenus: true,
251+
deferOpenParentMenuRebuild: true,
252+
allowStaleContentDuringDataRefresh: true)
244253
}
245254

246255
func rebuildOpenMenuIfStillVisible(_ menu: NSMenu, provider: UsageProvider?) {
247-
guard self.openMenus[ObjectIdentifier(menu)] != nil else { return }
256+
let key = ObjectIdentifier(menu)
257+
guard self.openMenus[key] != nil else { return }
248258
guard self.isHostedSubviewMenu(menu) || !self.hasOpenHostedSubviewMenu() else { return }
249259
self.populateMenu(menu, provider: provider)
250260
self.markMenuFresh(menu)
261+
self.parentMenuRebuildsDeferredDuringTracking.remove(key)
251262
self.applyIcon(phase: nil)
252263
#if DEBUG
253264
self._test_openMenuRebuildObserver?(menu)

Sources/CodexBar/StatusItemController+ProviderNavigation.swift

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import AppKit
12
import CodexBarCore
23

34
extension StatusItemController {
@@ -34,7 +35,10 @@ extension StatusItemController {
3435
self.applyIcon(phase: phase)
3536
}
3637

37-
func navigateProviderSwitcher(_ direction: StatusItemMenuProviderNavigationDirection) {
38+
func navigateProviderSwitcher(
39+
_ direction: StatusItemMenuProviderNavigationDirection,
40+
menu: NSMenu? = nil)
41+
{
3842
guard self.shouldMergeIcons else { return }
3943
let enabledProviders = self.store.enabledProvidersForDisplay()
4044
guard enabledProviders.count > 1 else { return }
@@ -59,6 +63,12 @@ extension StatusItemController {
5963
let delta = direction == .next ? 1 : -1
6064
let nextIndex = (currentIndex + delta + selections.count) % selections.count
6165
let selection = selections[nextIndex]
66+
let menuProvider: UsageProvider = switch selection {
67+
case .overview:
68+
self.navigationResolvedProvider(enabledProviders: enabledProviders) ?? .codex
69+
case let .provider(provider):
70+
provider
71+
}
6272
self.preservingMergedSwitcherContentCachesDuringInvalidation {
6373
switch selection {
6474
case .overview:
@@ -70,7 +80,13 @@ extension StatusItemController {
7080
self.lastMenuProvider = provider
7181
}
7282
self.lastMergedSwitcherSelection = selection
73-
self.refreshProviderSelectionDependentUI(refreshOpenMenus: true, deferRendering: true)
83+
self.refreshProviderSelectionDependentUI(deferRendering: true)
84+
}
85+
let trackedMenu = menu ?? self.providerSwitcherShortcutMenuID.flatMap { self.openMenus[$0] }
86+
if let trackedMenu {
87+
self.requestProviderSwitcherMenuRebuild(
88+
trackedMenu,
89+
provider: menuProvider)
7490
}
7591
}
7692

Sources/CodexBar/StatusItemController+ProviderSwitcher.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ extension StatusItemController {
109109
return self.selectProviderSwitcherSegment(at: index, menu: menu)
110110
}
111111
if let direction = StatusItemMenu.providerNavigationDirection(for: event) {
112-
self.navigateProviderSwitcher(direction)
112+
self.navigateProviderSwitcher(direction, menu: menu)
113113
return true
114114
}
115115
return false

Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -629,7 +629,7 @@ extension StatusMenuTests {
629629
}
630630

631631
@Test
632-
func `explicit store actions refresh a visible open menu`() async {
632+
func `explicit store actions defer visible parent menu rebuild`() async {
633633
self.disableMenuCardsForTesting()
634634
let settings = self.makeSettings()
635635
settings.statusChecksEnabled = false
@@ -660,17 +660,18 @@ extension StatusMenuTests {
660660
defer { controller._test_openMenuRebuildObserver = nil }
661661

662662
controller.refreshOpenMenusAfterExplicitStoreAction()
663-
for _ in 0..<20 where rebuildCount == 0 {
663+
for _ in 0..<20 {
664664
await Task.yield()
665665
}
666666

667667
#expect(controller.menuContentVersion != openedVersion)
668-
#expect(rebuildCount == 1)
669-
#expect(controller.menuVersions[key] != openedVersion)
668+
#expect(rebuildCount == 0)
669+
#expect(controller.menuVersions[key] == openedVersion)
670+
#expect(controller.parentMenuRebuildsDeferredDuringTracking.contains(key))
670671
}
671672

672673
@Test
673-
func `repeated explicit store actions coalesce to one open menu rebuild`() async {
674+
func `repeated explicit store actions keep parent rebuild deferred`() async {
674675
self.disableMenuCardsForTesting()
675676
let settings = self.makeSettings()
676677
settings.statusChecksEnabled = false
@@ -703,16 +704,17 @@ extension StatusMenuTests {
703704
controller.refreshOpenMenusAfterExplicitStoreAction()
704705
controller.refreshOpenMenusAfterExplicitStoreAction()
705706

706-
for _ in 0..<20 where rebuildCount == 0 {
707+
for _ in 0..<20 {
707708
await Task.yield()
708709
}
709710

710-
#expect(rebuildCount == 1)
711-
#expect(controller.menuVersions[key] == controller.menuContentVersion)
711+
#expect(rebuildCount == 0)
712+
#expect(controller.menuVersions[key] != controller.menuContentVersion)
713+
#expect(controller.parentMenuRebuildsDeferredDuringTracking.contains(key))
712714
}
713715

714716
@Test
715-
func `explicit refresh rebuilds stale parent after hosted submenu closes`() async {
717+
func `explicit refresh keeps stale parent deferred after hosted submenu closes`() async {
716718
self.disableMenuCardsForTesting()
717719
let settings = self.makeSettings()
718720
settings.statusChecksEnabled = false
@@ -755,13 +757,14 @@ extension StatusMenuTests {
755757
#expect(controller.menuVersions[menuKey] == openedVersion)
756758

757759
controller.menuDidClose(submenu)
758-
for _ in 0..<20 where rebuildCount == 0 {
760+
for _ in 0..<20 {
759761
await Task.yield()
760762
}
761763

762764
#expect(controller.openMenus[submenuKey] == nil)
763-
#expect(rebuildCount == 1)
764-
#expect(controller.menuVersions[menuKey] == controller.menuContentVersion)
765+
#expect(rebuildCount == 0)
766+
#expect(controller.menuVersions[menuKey] == openedVersion)
767+
#expect(controller.parentMenuRebuildsDeferredDuringTracking.contains(menuKey))
765768
}
766769

767770
@Test

Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -494,20 +494,37 @@ struct StatusMenuSwitcherClickTests {
494494
updater: DisabledUpdaterController(),
495495
preferencesSelection: PreferencesSelection(),
496496
statusBar: self.makeStatusBarForTesting())
497+
controller.menuRefreshEnabledOverrideForTesting = true
498+
defer { controller.releaseStatusItemsForTesting() }
497499

498500
let menu = try #require(controller.makeMenu() as? StatusItemMenu)
499501
controller.menuWillOpen(menu)
500502
#expect(menu.items.first?.view is ProviderSwitcherView)
503+
store.tokenRefreshInFlight.insert(.codex)
504+
defer { store.tokenRefreshInFlight.remove(.codex) }
505+
var rebuildCount = 0
506+
controller._test_openMenuRebuildObserver = { _ in
507+
rebuildCount += 1
508+
}
509+
defer { controller._test_openMenuRebuildObserver = nil }
501510

502511
#expect(try menu.performKeyEquivalent(with: Self.arrowKeyEvent(keyCode: 124)) == true)
503-
await Task.yield()
512+
for _ in 0..<100 where rebuildCount == 0 {
513+
await Task.yield()
514+
try? await Task.sleep(for: .milliseconds(5))
515+
}
504516
#expect(settings.mergedMenuLastSelectedWasOverview == false)
505517
#expect(settings.selectedMenuProvider == .claude)
518+
#expect(rebuildCount == 1)
506519

507520
#expect(try menu.performKeyEquivalent(with: Self.arrowKeyEvent(keyCode: 123)) == true)
508-
await Task.yield()
521+
for _ in 0..<100 where rebuildCount == 1 {
522+
await Task.yield()
523+
try? await Task.sleep(for: .milliseconds(5))
524+
}
509525
#expect(settings.mergedMenuLastSelectedWasOverview == false)
510526
#expect(settings.selectedMenuProvider == .codex)
527+
#expect(rebuildCount == 2)
511528
}
512529

513530
@Test

0 commit comments

Comments
 (0)