From a5e06c38d2b599d22daffb77b8c47d8f0cc64486 Mon Sep 17 00:00:00 2001 From: pickaxe <54486432+ProspectOre@users.noreply.github.com> Date: Sun, 31 May 2026 22:53:19 -0700 Subject: [PATCH 1/7] Defer closed menu rebuilds during refresh --- .../CodexBar/StatusItemController+Menu.swift | 8 +- .../StatusItemController+MenuTracking.swift | 105 ++++++++- .../StatusItemController+Shutdown.swift | 3 + Sources/CodexBar/StatusItemController.swift | 5 + .../StatusMenuOpenRefreshTests.swift | 216 ++++++++++++++++++ Tests/CodexBarTests/StatusMenuTests.swift | 7 +- 6 files changed, 334 insertions(+), 10 deletions(-) diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 901820406a..02efb26555 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -83,6 +83,7 @@ extension StatusItemController { } self.cancelDeferredMenuInteractionRefreshTask() + self.cancelClosedMenuRebuild(menu) if self.isHostedSubviewMenu(menu) { self.hydrateHostedSubviewMenuIfNeeded(menu) @@ -122,11 +123,7 @@ extension StatusItemController { self.deferOpenAIDashboardRefreshUntilMenuCloses(reason: "parent menu open") } - if self.menuNeedsRefresh(menu) { - self.populateMenu(menu, provider: provider) - self.markMenuFresh(menu) - // Heights are already set during populateMenu, no need to remeasure - } + self.refreshMenuForOpenIfNeeded(menu, provider: provider) if self.isMenuRefreshEnabled { // Intentionally skip open-menu tracking when refresh is disabled (tests). // If refresh is re-enabled while this menu stays open, it will not be backfilled until next open. @@ -152,6 +149,7 @@ extension StatusItemController { self.removeProviderSwitcherShortcutMonitor() } + self.cancelClosedMenuRebuild(menu) self.openMenus.removeValue(forKey: key) self.menuRefreshTasks.removeValue(forKey: key)?.cancel() self.openMenuRebuildTasks.removeValue(forKey: key)?.cancel() diff --git a/Sources/CodexBar/StatusItemController+MenuTracking.swift b/Sources/CodexBar/StatusItemController+MenuTracking.swift index 625dbdf43d..db2f73a00a 100644 --- a/Sources/CodexBar/StatusItemController+MenuTracking.swift +++ b/Sources/CodexBar/StatusItemController+MenuTracking.swift @@ -2,6 +2,27 @@ import AppKit import CodexBarCore extension StatusItemController { + private static let defaultClosedMenuPreparationDelay: Duration = .milliseconds(350) + + #if DEBUG + private static var closedMenuPreparationDelayForTesting: Duration = .zero + static func setClosedMenuPreparationDelayForTesting(_ delay: Duration) { + self.closedMenuPreparationDelayForTesting = delay + } + + static func resetClosedMenuPreparationDelayForTesting() { + self.closedMenuPreparationDelayForTesting = .zero + } + #endif + + private static var closedMenuPreparationDelay: Duration { + #if DEBUG + closedMenuPreparationDelayForTesting + #else + defaultClosedMenuPreparationDelay + #endif + } + func invalidateMenus( refreshOpenMenus: Bool = false, deferOpenParentMenuRebuild: Bool = false) @@ -19,6 +40,54 @@ extension StatusItemController { deferParentRebuildDuringTracking: deferOpenParentMenuRebuild) return } + self.prepareAttachedClosedMenusIfNeeded() + } + + func prepareAttachedClosedMenusIfNeeded() { + guard self.isMenuRefreshEnabled else { return } + guard self.openMenus.isEmpty else { return } + guard !self.isMenuDataRefreshInFlight else { return } + for menu in self.attachedMenusForClosedPreparation() { + self.rebuildClosedMenuIfNeeded(menu) + } + } + + var isMenuDataRefreshInFlight: Bool { + self.store.isRefreshing || + UsageProvider.allCases.contains { self.store.isTokenRefreshInFlight(for: $0) } + } + + func refreshMenuForOpenIfNeeded(_ menu: NSMenu, provider: UsageProvider?) { + guard self.menuNeedsRefresh(menu) else { return } + if self.isMenuDataRefreshInFlight, !menu.items.isEmpty { + self.deferMenuInteractionRefreshIfNeeded() + return + } + self.populateMenu(menu, provider: provider) + self.markMenuFresh(menu) + } + + private func attachedMenusForClosedPreparation() -> [NSMenu] { + var menus: [NSMenu] = [] + var seen = Set() + + func append(_ menu: NSMenu?) { + guard let menu else { return } + let key = ObjectIdentifier(menu) + guard seen.insert(key).inserted else { return } + menus.append(menu) + } + + append(self.statusItem.menu) + append(self.mergedMenu) + append(self.fallbackMenu) + for item in self.statusItems.values { + append(item.menu) + } + for menu in self.providerMenus.values { + append(menu) + } + return menus } func renderedMenuWidth(for menu: NSMenu) -> CGFloat { @@ -28,11 +97,31 @@ extension StatusItemController { func rebuildClosedMenuIfNeeded(_ menu: NSMenu) { guard !self.hasPreparedForAppShutdown else { return } + guard !self.isMenuDataRefreshInFlight else { return } + let key = ObjectIdentifier(menu) let provider = self.menuProvider(for: menu) - Task { @MainActor [weak self, weak menu] in + self.closedMenuRebuildTokenCounter &+= 1 + let rebuildToken = self.closedMenuRebuildTokenCounter + self.closedMenuRebuildTokens[key] = rebuildToken + self.closedMenuRebuildTasks[key]?.cancel() + self.closedMenuRebuildTasks[key] = Task { @MainActor [weak self, weak menu] in + let delay = Self.closedMenuPreparationDelay + if delay > .zero { + try? await Task.sleep(for: delay) + } + guard !Task.isCancelled else { return } await Task.yield() + guard !Task.isCancelled else { return } guard let self, let menu else { return } + defer { + if self.closedMenuRebuildTokens[key] == rebuildToken { + self.closedMenuRebuildTasks.removeValue(forKey: key) + self.closedMenuRebuildTokens.removeValue(forKey: key) + } + } + guard self.closedMenuRebuildTokens[key] == rebuildToken else { return } guard !self.hasPreparedForAppShutdown else { return } + guard !self.isMenuDataRefreshInFlight else { return } guard self.openMenus[ObjectIdentifier(menu)] == nil else { return } guard self.menuNeedsRefresh(menu) else { return } self.populateMenu(menu, provider: provider) @@ -40,6 +129,20 @@ extension StatusItemController { } } + func cancelClosedMenuRebuild(_ menu: NSMenu) { + let key = ObjectIdentifier(menu) + self.closedMenuRebuildTasks.removeValue(forKey: key)?.cancel() + self.closedMenuRebuildTokens.removeValue(forKey: key) + } + + func cancelAllClosedMenuRebuilds() { + for task in self.closedMenuRebuildTasks.values { + task.cancel() + } + self.closedMenuRebuildTasks.removeAll(keepingCapacity: false) + self.closedMenuRebuildTokens.removeAll(keepingCapacity: false) + } + func menuNeedsRefresh(_ menu: NSMenu) -> Bool { let key = ObjectIdentifier(menu) return self.menuVersions[key] != self.menuContentVersion diff --git a/Sources/CodexBar/StatusItemController+Shutdown.swift b/Sources/CodexBar/StatusItemController+Shutdown.swift index 4d50a9a0d9..0277aca623 100644 --- a/Sources/CodexBar/StatusItemController+Shutdown.swift +++ b/Sources/CodexBar/StatusItemController+Shutdown.swift @@ -46,6 +46,7 @@ extension StatusItemController { for task in self.menuRefreshTasks.values { task.cancel() } + self.cancelAllClosedMenuRebuilds() for task in self.openMenuRebuildTasks.values { task.cancel() } @@ -56,6 +57,8 @@ extension StatusItemController { private func clearShutdownMenuState() { self.removeProviderSwitcherShortcutMonitor() self.menuRefreshTasks.removeAll(keepingCapacity: false) + self.closedMenuRebuildTasks.removeAll(keepingCapacity: false) + self.closedMenuRebuildTokens.removeAll(keepingCapacity: false) self.openMenuRebuildTasks.removeAll(keepingCapacity: false) self.openMenuRebuildTokens.removeAll(keepingCapacity: false) self.openMenuRebuildsClosingHostedSubviewMenus.removeAll(keepingCapacity: false) diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index b8f4df3df0..57a30c4ad0 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -132,6 +132,9 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var fallbackMenu: NSMenu? var openMenus: [ObjectIdentifier: NSMenu] = [:] var menuRefreshTasks: [ObjectIdentifier: Task] = [:] + var closedMenuRebuildTasks: [ObjectIdentifier: Task] = [:] + var closedMenuRebuildTokens: [ObjectIdentifier: Int] = [:] + var closedMenuRebuildTokenCounter = 0 var openMenuRebuildTasks: [ObjectIdentifier: Task] = [:] var openMenuRebuildTokens: [ObjectIdentifier: Int] = [:] var openMenuRebuildTokenCounter = 0 @@ -797,6 +800,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin if self.statusItem.menu !== self.mergedMenu { self.statusItem.menu = self.mergedMenu } + self.prepareAttachedClosedMenusIfNeeded() } private func attachMenus(fallback: UsageProvider? = nil) { @@ -827,6 +831,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin item.menu = nil } } + self.prepareAttachedClosedMenusIfNeeded() } private func rebuildProviderStatusItems() { diff --git a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift index b00caa72ba..bc4a303582 100644 --- a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift @@ -113,6 +113,222 @@ extension StatusMenuTests { #expect(rebuildCount == 0) } + @Test + func `closed attached menu is prepared before next open after invalidation`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let openedVersion = controller.menuVersions[key] + + controller.invalidateMenus() + for _ in 0..<40 where controller.menuVersions[key] == openedVersion { + await Task.yield() + } + + #expect(controller.openMenus.isEmpty) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + @Test + func `closed attached menu preparation waits for store refresh to finish`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let openedVersion = controller.menuVersions[key] + + store.isRefreshing = true + controller.invalidateMenus() + for _ in 0..<40 { + await Task.yield() + } + + #expect(controller.menuVersions[key] == openedVersion) + + store.isRefreshing = false + controller.invalidateMenus() + for _ in 0..<40 where controller.menuVersions[key] == openedVersion { + await Task.yield() + } + + #expect(controller.openMenus.isEmpty) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + @Test + func `closed attached menu preparation waits for token refresh to finish`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let openedVersion = controller.menuVersions[key] + + store.tokenRefreshInFlight.insert(.codex) + controller.invalidateMenus() + for _ in 0..<40 { + await Task.yield() + } + + #expect(controller.menuVersions[key] == openedVersion) + + store.tokenRefreshInFlight.remove(.codex) + controller.invalidateMenus() + for _ in 0..<40 where controller.menuVersions[key] == openedVersion { + await Task.yield() + } + + #expect(controller.openMenus.isEmpty) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + @Test + func `menu open keeps stale nonempty content while store refresh is active`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let openedVersion = controller.menuVersions[key] + let openedItemCount = menu.items.count + + store.isRefreshing = true + defer { store.isRefreshing = false } + controller.invalidateMenus() + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuVersions[key] == openedVersion) + #expect(controller.menuContentVersion != openedVersion) + #expect(menu.items.count == openedItemCount) + #expect(controller.openMenus[key] === menu) + } + + @Test + func `menu open keeps stale nonempty content while token refresh is active`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let openedVersion = controller.menuVersions[key] + let openedItemCount = menu.items.count + + store.tokenRefreshInFlight.insert(.codex) + defer { store.tokenRefreshInFlight.remove(.codex) } + controller.invalidateMenus() + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuVersions[key] == openedVersion) + #expect(controller.menuContentVersion != openedVersion) + #expect(menu.items.count == openedItemCount) + #expect(controller.openMenus[key] === menu) + } + @Test func `explicit store actions refresh a visible open menu`() async { self.disableMenuCardsForTesting() diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index 935c72c1c4..c29d770e32 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -22,11 +22,13 @@ struct StatusMenuTests { let defaults = UserDefaults(suiteName: suite)! defaults.removePersistentDomain(forName: suite) let configStore = testConfigStore(suiteName: suite) - return SettingsStore( + let settings = SettingsStore( userDefaults: defaults, configStore: configStore, zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) + settings.providerDetectionCompleted = true + return settings } func makeCodexStore(settings: SettingsStore, dashboardAuthorized: Bool) -> UsageStore { @@ -85,7 +87,6 @@ struct StatusMenuTests { settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.mergeIcons = false - settings.providerDetectionCompleted = true settings.alibabaCodingPlanAPIRegion = .chinaMainland let fetcher = UsageFetcher() @@ -820,7 +821,6 @@ extension StatusMenuTests { settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.mergeIcons = false - settings.providerDetectionCompleted = true let registry = ProviderRegistry.shared if let codexMeta = registry.metadata[.codex] { @@ -860,7 +860,6 @@ extension StatusMenuTests { settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.mergeIcons = false - settings.providerDetectionCompleted = true let registry = ProviderRegistry.shared try settings.setProviderEnabled(provider: .codex, metadata: #require(registry.metadata[.codex]), enabled: true) From c27f38ac59dac7db7712b624c67fb7e80752f91e Mon Sep 17 00:00:00 2001 From: pickaxe <54486432+ProspectOre@users.noreply.github.com> Date: Sun, 31 May 2026 23:30:02 -0700 Subject: [PATCH 2/7] Clean up closed menu rebuild tasks --- .../StatusItemController+MenuTracking.swift | 3 +- .../StatusMenuOpenRefreshTests.swift | 35 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/StatusItemController+MenuTracking.swift b/Sources/CodexBar/StatusItemController+MenuTracking.swift index db2f73a00a..a7b9c5d94b 100644 --- a/Sources/CodexBar/StatusItemController+MenuTracking.swift +++ b/Sources/CodexBar/StatusItemController+MenuTracking.swift @@ -112,13 +112,14 @@ extension StatusItemController { guard !Task.isCancelled else { return } await Task.yield() guard !Task.isCancelled else { return } - guard let self, let menu else { return } + guard let self else { return } defer { if self.closedMenuRebuildTokens[key] == rebuildToken { self.closedMenuRebuildTasks.removeValue(forKey: key) self.closedMenuRebuildTokens.removeValue(forKey: key) } } + guard let menu else { return } guard self.closedMenuRebuildTokens[key] == rebuildToken else { return } guard !self.hasPreparedForAppShutdown else { return } guard !self.isMenuDataRefreshInFlight else { return } diff --git a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift index bc4a303582..56471df58f 100644 --- a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift @@ -245,6 +245,41 @@ extension StatusMenuTests { #expect(controller.menuVersions[key] == controller.menuContentVersion) } + @Test + func `closed menu rebuild cleanup runs when weak menu disappears`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + var menu: NSMenu? = NSMenu() + let key = ObjectIdentifier(menu!) + + controller.rebuildClosedMenuIfNeeded(menu!) + #expect(controller.closedMenuRebuildTasks[key] != nil) + #expect(controller.closedMenuRebuildTokens[key] != nil) + + menu = nil + for _ in 0..<40 where controller.closedMenuRebuildTasks[key] != nil { + await Task.yield() + } + + #expect(controller.closedMenuRebuildTasks[key] == nil) + #expect(controller.closedMenuRebuildTokens[key] == nil) + } + @Test func `menu open keeps stale nonempty content while store refresh is active`() { self.disableMenuCardsForTesting() From 5e0a3483858841213da4423eb3204c94c84e9ec4 Mon Sep 17 00:00:00 2001 From: pickaxe <54486432+ProspectOre@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:06:21 -0700 Subject: [PATCH 3/7] Log menu refresh lifecycle proof points --- .../StatusItemController+MenuTracking.swift | 20 +++++++++++++++++++ Sources/CodexBar/StatusItemController.swift | 1 + 2 files changed, 21 insertions(+) diff --git a/Sources/CodexBar/StatusItemController+MenuTracking.swift b/Sources/CodexBar/StatusItemController+MenuTracking.swift index a7b9c5d94b..14ade808bb 100644 --- a/Sources/CodexBar/StatusItemController+MenuTracking.swift +++ b/Sources/CodexBar/StatusItemController+MenuTracking.swift @@ -60,6 +60,15 @@ extension StatusItemController { func refreshMenuForOpenIfNeeded(_ menu: NSMenu, provider: UsageProvider?) { guard self.menuNeedsRefresh(menu) else { return } if self.isMenuDataRefreshInFlight, !menu.items.isEmpty { + #if DEBUG + self.menuLogger.debug( + "menu open kept existing content during refresh", + metadata: [ + "items": "\(menu.items.count)", + "provider": provider?.rawValue ?? "nil", + "storeRefreshing": self.store.isRefreshing ? "1" : "0", + ]) + #endif self.deferMenuInteractionRefreshIfNeeded() return } @@ -127,6 +136,17 @@ extension StatusItemController { guard self.menuNeedsRefresh(menu) else { return } self.populateMenu(menu, provider: provider) self.markMenuFresh(menu) + #if DEBUG + if self.lastLoggedClosedMenuRebuildVersion != self.menuContentVersion { + self.lastLoggedClosedMenuRebuildVersion = self.menuContentVersion + self.menuLogger.debug( + "closed menu rebuild completed", + metadata: [ + "items": "\(menu.items.count)", + "provider": provider?.rawValue ?? "nil", + ]) + } + #endif } } diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 57a30c4ad0..8ed7a7d0ad 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -153,6 +153,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var onDeferredMenuInteractionRefreshForTesting: (() -> Void)? var onOpenMenuInvalidationRetryForTesting: (() -> Void)? var isReleasedForTesting = false + var lastLoggedClosedMenuRebuildVersion: Int? var _test_openMenuRefreshYieldOverride: (@MainActor () async -> Void)? var _test_openMenuRebuildObserver: (@MainActor (NSMenu) -> Void)? var _test_codexAmbientLoginRunnerOverride: From 207097bd6cc86f745fa5ffaf8b984be40b50d3d0 Mon Sep 17 00:00:00 2001 From: pickaxe <54486432+ProspectOre@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:36:38 -0700 Subject: [PATCH 4/7] Align closed menu delay test seam --- .../StatusItemController+MenuTracking.swift | 4 +-- Sources/CodexBar/StatusItemController.swift | 22 ++++++++------- .../StatusMenuOpenRefreshTests.swift | 28 +++++++++++++------ 3 files changed, 34 insertions(+), 20 deletions(-) diff --git a/Sources/CodexBar/StatusItemController+MenuTracking.swift b/Sources/CodexBar/StatusItemController+MenuTracking.swift index 14ade808bb..b09b43005e 100644 --- a/Sources/CodexBar/StatusItemController+MenuTracking.swift +++ b/Sources/CodexBar/StatusItemController+MenuTracking.swift @@ -5,13 +5,13 @@ extension StatusItemController { private static let defaultClosedMenuPreparationDelay: Duration = .milliseconds(350) #if DEBUG - private static var closedMenuPreparationDelayForTesting: Duration = .zero + private static var closedMenuPreparationDelayForTesting: Duration = defaultClosedMenuPreparationDelay static func setClosedMenuPreparationDelayForTesting(_ delay: Duration) { self.closedMenuPreparationDelayForTesting = delay } static func resetClosedMenuPreparationDelayForTesting() { - self.closedMenuPreparationDelayForTesting = .zero + self.closedMenuPreparationDelayForTesting = self.defaultClosedMenuPreparationDelay } #endif diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 8ed7a7d0ad..cb8409aaae 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -55,16 +55,6 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } } - #if DEBUG - static func setMenuRefreshEnabledForTesting(_ enabled: Bool) { - self.menuRefreshEnabled = enabled - } - - static func resetMenuRefreshEnabledForTesting() { - self.menuRefreshEnabled = self.defaultMenuRefreshEnabled - } - #endif - #if DEBUG var menuRefreshEnabledOverrideForTesting: Bool? #endif @@ -908,6 +898,18 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } } +#if DEBUG +extension StatusItemController { + static func setMenuRefreshEnabledForTesting(_ enabled: Bool) { + self.menuRefreshEnabled = enabled + } + + static func resetMenuRefreshEnabledForTesting() { + self.menuRefreshEnabled = self.defaultMenuRefreshEnabled + } +} +#endif + extension StatusItemController { private func legacyDefaultItemIndex(forNewProvider provider: UsageProvider) -> Int? { let visibleProviders = self.settings.orderedProviders().filter { self.isVisible($0) } diff --git a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift index 56471df58f..0f54f619c9 100644 --- a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift @@ -32,6 +32,8 @@ extension StatusMenuTests { preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) defer { controller.releaseStatusItemsForTesting() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } controller.menuRefreshEnabledOverrideForTesting = true StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) @@ -131,6 +133,8 @@ extension StatusMenuTests { preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) defer { controller.releaseStatusItemsForTesting() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } controller.menuRefreshEnabledOverrideForTesting = true let menu = controller.makeMenu() @@ -169,6 +173,8 @@ extension StatusMenuTests { preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) defer { controller.releaseStatusItemsForTesting() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } controller.menuRefreshEnabledOverrideForTesting = true let menu = controller.makeMenu() @@ -200,6 +206,9 @@ extension StatusMenuTests { @Test func `closed attached menu preparation waits for token refresh to finish`() async { + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -263,15 +272,18 @@ extension StatusMenuTests { preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) defer { controller.releaseStatusItemsForTesting() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + let key: ObjectIdentifier + do { + let menu = NSMenu() + key = ObjectIdentifier(menu) + controller.rebuildClosedMenuIfNeeded(menu) + #expect(controller.closedMenuRebuildTasks[key] != nil) + #expect(controller.closedMenuRebuildTokens[key] != nil) + } - var menu: NSMenu? = NSMenu() - let key = ObjectIdentifier(menu!) - - controller.rebuildClosedMenuIfNeeded(menu!) - #expect(controller.closedMenuRebuildTasks[key] != nil) - #expect(controller.closedMenuRebuildTokens[key] != nil) - - menu = nil for _ in 0..<40 where controller.closedMenuRebuildTasks[key] != nil { await Task.yield() } From d12032eec155980e499b3f8a23ff48350e20a883 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 2 Jun 2026 01:15:07 +0100 Subject: [PATCH 5/7] fix: require menu rebuild after privacy invalidation --- .../StatusItemController+MenuTracking.swift | 15 ++++++- Sources/CodexBar/StatusItemController.swift | 11 ++++- .../StatusMenuOpenRefreshTests.swift | 45 ++++++++++++++++++- 3 files changed, 66 insertions(+), 5 deletions(-) diff --git a/Sources/CodexBar/StatusItemController+MenuTracking.swift b/Sources/CodexBar/StatusItemController+MenuTracking.swift index b09b43005e..4922f9cc6a 100644 --- a/Sources/CodexBar/StatusItemController+MenuTracking.swift +++ b/Sources/CodexBar/StatusItemController+MenuTracking.swift @@ -25,12 +25,16 @@ extension StatusItemController { func invalidateMenus( refreshOpenMenus: Bool = false, - deferOpenParentMenuRebuild: Bool = false) + deferOpenParentMenuRebuild: Bool = false, + allowStaleContentDuringDataRefresh: Bool = false) { #if DEBUG guard !self.isReleasedForTesting else { return } #endif self.menuContentVersion &+= 1 + if !allowStaleContentDuringDataRefresh { + self.latestRequiredMenuRebuildVersion = self.menuContentVersion + } guard self.isMenuRefreshEnabled else { return } if !self.openMenus.isEmpty { guard refreshOpenMenus else { return } @@ -59,7 +63,7 @@ extension StatusItemController { func refreshMenuForOpenIfNeeded(_ menu: NSMenu, provider: UsageProvider?) { guard self.menuNeedsRefresh(menu) else { return } - if self.isMenuDataRefreshInFlight, !menu.items.isEmpty { + if self.canPreserveStaleMenuContentDuringRefresh(menu) { #if DEBUG self.menuLogger.debug( "menu open kept existing content during refresh", @@ -76,6 +80,13 @@ extension StatusItemController { self.markMenuFresh(menu) } + private func canPreserveStaleMenuContentDuringRefresh(_ menu: NSMenu) -> Bool { + guard self.isMenuDataRefreshInFlight, !menu.items.isEmpty else { return false } + let key = ObjectIdentifier(menu) + guard let menuVersion = self.menuVersions[key] else { return false } + return menuVersion >= self.latestRequiredMenuRebuildVersion + } + private func attachedMenusForClosedPreparation() -> [NSMenu] { var menus: [NSMenu] = [] var seen = Set() diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index cb8409aaae..934a299d8d 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -115,6 +115,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var lastMenuProvider: UsageProvider? var menuProviders: [ObjectIdentifier: UsageProvider] = [:] var menuContentVersion: Int = 0 + var latestRequiredMenuRebuildVersion: Int = 0 var menuVersions: [ObjectIdentifier: Int] = [:] var lastMenuAdjunctReadinessSignature = "" var mergedMenu: NSMenu? @@ -189,6 +190,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin private var lastMergeIcons: Bool private var lastSwitcherShowsIcons: Bool private var lastObservedUsageBarsShowUsed: Bool + private var lastObservedHidePersonalInfo: Bool /// Tracks which `usageBarsShowUsed` mode the provider switcher was built with. /// Used to decide whether we can "smart update" menu content without rebuilding the switcher. var lastSwitcherUsageBarsShowUsed: Bool @@ -351,6 +353,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.lastMergeIcons = settings.mergeIcons self.lastSwitcherShowsIcons = settings.switcherShowsIcons self.lastObservedUsageBarsShowUsed = settings.usageBarsShowUsed + self.lastObservedHidePersonalInfo = settings.hidePersonalInfo self.lastSwitcherUsageBarsShowUsed = settings.usageBarsShowUsed let repairedStatusItemVisibilityKeys = MenuBarStatusItemDefaultsRepair .repairHiddenVisibilityDefaultsIfNeeded(defaults: settings.userDefaults) @@ -442,7 +445,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.observeStoreChanges() self.invalidateMenus( refreshOpenMenus: self.didMenuAdjunctReadinessChange(), - deferOpenParentMenuRebuild: true) + deferOpenParentMenuRebuild: true, + allowStaleContentDuringDataRefresh: true) } } } @@ -634,6 +638,11 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.lastObservedUsageBarsShowUsed = usageBarsShowUsed shouldRefresh = true } + let hidePersonalInfo = self.settings.hidePersonalInfo + if hidePersonalInfo != self.lastObservedHidePersonalInfo { + self.lastObservedHidePersonalInfo = hidePersonalInfo + shouldRefresh = true + } if self.menuLocalizationSignature() != self.lastMenuLocalizationSignature { shouldRefresh = true } diff --git a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift index 0f54f619c9..2f3855c22a 100644 --- a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift @@ -324,7 +324,7 @@ extension StatusMenuTests { store.isRefreshing = true defer { store.isRefreshing = false } - controller.invalidateMenus() + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) controller.menuWillOpen(menu) defer { controller.menuDidClose(menu) } @@ -334,6 +334,47 @@ extension StatusMenuTests { #expect(controller.openMenus[key] === menu) } + @Test + func `menu open rebuilds stale content after privacy setting changes during refresh`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let openedVersion = controller.menuVersions[key] + + store.isRefreshing = true + defer { store.isRefreshing = false } + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + settings.hidePersonalInfo = true + controller.invalidateMenus() + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuVersions[key] == controller.menuContentVersion) + #expect(controller.menuVersions[key] != openedVersion) + } + @Test func `menu open keeps stale nonempty content while token refresh is active`() { self.disableMenuCardsForTesting() @@ -366,7 +407,7 @@ extension StatusMenuTests { store.tokenRefreshInFlight.insert(.codex) defer { store.tokenRefreshInFlight.remove(.codex) } - controller.invalidateMenus() + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) controller.menuWillOpen(menu) defer { controller.menuDidClose(menu) } From 4fcf5ac903899ebd91e95d562970eec89cba1bf8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 2 Jun 2026 01:15:22 +0100 Subject: [PATCH 6/7] docs: note closed menu refresh fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e116013934..abb18fd9d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed - Copilot: treat GitHub Copilot Business token-billing zero-entitlement quotas as unavailable instead of showing misleading 0% used usage (#1258, #1270). Thanks @devYRPauli! +- Menu bar: prepare closed menus after refresh and only reuse stale dropdown content for data-refresh invalidations so merged menu opens stay responsive without bypassing privacy or structure changes (#1261). Thanks @ProspectOre! - OpenAI Web: stop reloading away from login and Cloudflare blocking states so the dashboard WebView does not loop on route corrections (#1259). Thanks @ProspectOre! ## 0.32.2 — 2026-06-01 From 2178073d6aecf707ab3091645a01e58338f62c53 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 2 Jun 2026 01:17:53 +0100 Subject: [PATCH 7/7] fix: include privacy in menu reuse signature --- .../CodexBar/StatusItemController+MenuLocalization.swift | 1 + Sources/CodexBar/StatusItemController.swift | 7 ------- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/Sources/CodexBar/StatusItemController+MenuLocalization.swift b/Sources/CodexBar/StatusItemController+MenuLocalization.swift index 52944810f9..e90f03de77 100644 --- a/Sources/CodexBar/StatusItemController+MenuLocalization.swift +++ b/Sources/CodexBar/StatusItemController+MenuLocalization.swift @@ -4,6 +4,7 @@ extension StatusItemController { func menuLocalizationSignature() -> String { [ codexBarLocalizationSignature(), + self.settings.hidePersonalInfo ? "hide-personal-info" : "show-personal-info", L("Overview"), L("Cost"), ].joined(separator: "|") diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 934a299d8d..cbe0ec801d 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -190,7 +190,6 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin private var lastMergeIcons: Bool private var lastSwitcherShowsIcons: Bool private var lastObservedUsageBarsShowUsed: Bool - private var lastObservedHidePersonalInfo: Bool /// Tracks which `usageBarsShowUsed` mode the provider switcher was built with. /// Used to decide whether we can "smart update" menu content without rebuilding the switcher. var lastSwitcherUsageBarsShowUsed: Bool @@ -353,7 +352,6 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.lastMergeIcons = settings.mergeIcons self.lastSwitcherShowsIcons = settings.switcherShowsIcons self.lastObservedUsageBarsShowUsed = settings.usageBarsShowUsed - self.lastObservedHidePersonalInfo = settings.hidePersonalInfo self.lastSwitcherUsageBarsShowUsed = settings.usageBarsShowUsed let repairedStatusItemVisibilityKeys = MenuBarStatusItemDefaultsRepair .repairHiddenVisibilityDefaultsIfNeeded(defaults: settings.userDefaults) @@ -638,11 +636,6 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.lastObservedUsageBarsShowUsed = usageBarsShowUsed shouldRefresh = true } - let hidePersonalInfo = self.settings.hidePersonalInfo - if hidePersonalInfo != self.lastObservedHidePersonalInfo { - self.lastObservedHidePersonalInfo = hidePersonalInfo - shouldRefresh = true - } if self.menuLocalizationSignature() != self.lastMenuLocalizationSignature { shouldRefresh = true }