diff --git a/CHANGELOG.md b/CHANGELOG.md index 1713be4ec0..340e699ca2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.32.5 — Unreleased +### Fixed +- Menu bar: defer merged-menu close rebuilds and cache repeated menu-card height measurements so dismissing or rapidly switching the merged dropdown avoids rebuilding SwiftUI-backed cards on the main thread (#1274, #1286). Thanks @hhh2210! + ## 0.32.4 — 2026-06-02 ### Fixed diff --git a/Sources/CodexBar/StatusItemController+CodexStackedMenu.swift b/Sources/CodexBar/StatusItemController+CodexStackedMenu.swift index c26f9aa857..019be09633 100644 --- a/Sources/CodexBar/StatusItemController+CodexStackedMenu.swift +++ b/Sources/CodexBar/StatusItemController+CodexStackedMenu.swift @@ -32,7 +32,8 @@ extension StatusItemController { menu.addItem(self.makeMenuCardItem( UsageMenuCardView(model: model, width: context.menuWidth), id: "menuCard-\(cardIndex)", - width: context.menuWidth)) + width: context.menuWidth, + heightCacheScope: account.id)) cardIndex += 1 if account.id != section.accounts.last?.id { menu.addItem(.separator()) @@ -48,7 +49,8 @@ extension StatusItemController { menu.addItem(self.makeMenuCardItem( UsageMenuCardView(model: model, width: context.menuWidth), id: "menuCard", - width: context.menuWidth)) + width: context.menuWidth, + heightCacheScope: context.currentProvider.rawValue)) } menu.addItem(.separator()) if self.addStorageMenuCardSection(to: menu, provider: context.currentProvider, width: context.menuWidth) { diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 225331c5b2..9e6dd5572c 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -163,10 +163,9 @@ extension StatusItemController { menu === self.fallbackMenu || self.providerMenus.values.contains { $0 === menu } if !isPersistentMenu { - self.menuProviders.removeValue(forKey: key) - self.menuVersions.removeValue(forKey: key) + self.clearTransientMenuTrackingState(key) } else if self.menuNeedsRefresh(menu) { - self.rebuildClosedMenuIfNeeded(menu) + self.handleClosedPersistentMenuNeedingRefresh(menu) } self.parentMenuRebuildsDeferredDuringTracking.remove(key) self.scheduleDeferredMenuInteractionRefreshIfNeeded() @@ -292,7 +291,8 @@ extension StatusItemController { menuWidth: menuWidth, codexAccountDisplay: codexAccountDisplay, tokenAccountDisplay: tokenAccountDisplay, - openAIContext: openAIContext)) + openAIContext: openAIContext, + descriptor: descriptor)) return } @@ -324,7 +324,8 @@ extension StatusItemController { menuWidth: menuWidth, codexAccountDisplay: codexAccountDisplay, tokenAccountDisplay: tokenAccountDisplay, - openAIContext: openAIContext)) + openAIContext: openAIContext, + descriptor: descriptor)) return } @@ -379,6 +380,7 @@ extension StatusItemController { let codexAccountDisplay: CodexAccountMenuDisplay? let tokenAccountDisplay: TokenAccountMenuDisplay? let openAIContext: OpenAIWebContext + let descriptor: MenuDescriptor } /// Smart update: rebuild everything below the provider switcher while keeping the switcher view intact. @@ -409,16 +411,6 @@ extension StatusItemController { width: context.menuWidth) self.lastTokenAccountMenuDisplay = context.tokenAccountDisplay - let descriptor = MenuDescriptor.build( - provider: context.provider, - store: self.store, - settings: self.settings, - account: self.account, - managedCodexAccountCoordinator: self.managedCodexAccountCoordinator, - codexAccountPromotionCoordinator: self.codexAccountPromotionCoordinator, - updateReady: self.updater.updateStatus.isUpdateReady, - includeContextualActions: context.switcherSelection != .overview) - let menuContext = MenuCardContext( currentProvider: context.currentProvider, selectedProvider: context.provider, @@ -427,7 +419,7 @@ extension StatusItemController { tokenAccountDisplay: context.tokenAccountDisplay, openAIContext: context.openAIContext) self.addPrimaryMenuContent(to: menu, context: menuContext, switcherSelection: context.switcherSelection) - self.addActionableSections(descriptor.sections, to: menu, width: context.menuWidth) + self.addActionableSections(context.descriptor.sections, to: menu, width: context.menuWidth) } } @@ -557,6 +549,7 @@ extension StatusItemController { OverviewMenuCardRowView(model: row.model, storageText: storageText, width: menuWidth), id: identifier, width: menuWidth, + heightCacheScope: row.provider.rawValue, submenu: submenu, onClick: { [weak self, weak menu] in guard let self, let menu else { return } @@ -639,7 +632,8 @@ extension StatusItemController { menu.addItem(self.makeMenuCardItem( UsageMenuCardView(model: model, width: context.menuWidth), id: "menuCard", - width: context.menuWidth)) + width: context.menuWidth, + heightCacheScope: context.currentProvider.rawValue)) if self.addStorageMenuCardSection(to: menu, provider: context.currentProvider, width: context.menuWidth) { menu.addItem(.separator()) } @@ -659,14 +653,16 @@ extension StatusItemController { menu.addItem(self.makeMenuCardItem( UsageMenuCardView(model: model, width: context.menuWidth), id: "menuCard", - width: context.menuWidth)) + width: context.menuWidth, + heightCacheScope: context.currentProvider.rawValue)) menu.addItem(.separator()) } else { for (index, model) in cards.enumerated() { menu.addItem(self.makeMenuCardItem( UsageMenuCardView(model: model, width: context.menuWidth), id: "menuCard-\(index)", - width: context.menuWidth)) + width: context.menuWidth, + heightCacheScope: "\(context.currentProvider.rawValue)-\(index)")) if index < cards.count - 1 { menu.addItem(.separator()) } @@ -1169,86 +1165,6 @@ extension StatusItemController { return enabledProviders } - private func refreshMenuCardHeights(in menu: NSMenu) { - // Re-measure the menu card height right before display to avoid stale/incorrect sizing when content - // changes (e.g. dashboard error lines causing wrapping). - let cardItems = menu.items.filter { item in - (item.representedObject as? String)?.hasPrefix("menuCard") == true - } - for item in cardItems { - guard let view = item.view else { continue } - let width = self.renderedMenuWidth(for: menu) - let height = self.menuCardHeight(for: view, width: width) - view.frame = NSRect( - origin: .zero, - size: NSSize(width: width, height: height)) - } - } - - func makeMenuCardItem( - _ view: some View, - id: String, - width: CGFloat, - submenu: NSMenu? = nil, - submenuIndicatorAlignment: Alignment = .topTrailing, - submenuIndicatorTopPadding: CGFloat = 8, - onClick: (() -> Void)? = nil) -> NSMenuItem - { - if !Self.menuCardRenderingEnabled { - let item = NSMenuItem() - item.isEnabled = true - item.representedObject = id - item.submenu = submenu - if submenu != nil { - item.target = self - item.action = #selector(self.menuCardNoOp(_:)) - } - return item - } - - let highlightState = MenuCardHighlightState() - let wrapped = MenuCardSectionContainerView( - highlightState: highlightState, - showsSubmenuIndicator: submenu != nil, - submenuIndicatorAlignment: submenuIndicatorAlignment, - submenuIndicatorTopPadding: submenuIndicatorTopPadding) - { - view - } - let hosting = MenuCardItemHostingView(rootView: wrapped, highlightState: highlightState, onClick: onClick) - // Set frame with target width immediately - let height = self.menuCardHeight(for: hosting, width: width) - hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: height)) - let item = NSMenuItem() - item.view = hosting - item.isEnabled = true - item.representedObject = id - item.submenu = submenu - if submenu != nil { - item.target = self - item.action = #selector(self.menuCardNoOp(_:)) - } - return item - } - - private func menuCardHeight(for view: NSView, width: CGFloat) -> CGFloat { - let basePadding: CGFloat = 6 - let descenderSafety: CGFloat = 1 - - // Fast path: use protocol-based measurement when available (avoids layout passes) - if let measured = view as? MenuCardMeasuring { - return max(1, ceil(measured.measuredHeight(width: width) + basePadding + descenderSafety)) - } - - // Set frame with target width before measuring. - view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: 1)) - - // Use fittingSize directly - SwiftUI hosting views respect the frame width for wrapping - let fitted = view.fittingSize - - return max(1, ceil(fitted.height + basePadding + descenderSafety)) - } - private func addMenuCardSections( to menu: NSMenu, model: UsageMenuCardView.Model, @@ -1280,13 +1196,18 @@ extension StatusItemController { usageView, id: "menuCardUsage", width: width, + heightCacheScope: provider.rawValue, submenu: usageSubmenu)) } else { let headerView = UsageMenuCardHeaderSectionView( model: model, showDivider: false, width: width) - menu.addItem(self.makeMenuCardItem(headerView, id: "menuCardHeader", width: width)) + menu.addItem(self.makeMenuCardItem( + headerView, + id: "menuCardHeader", + width: width, + heightCacheScope: provider.rawValue)) } if hasStorage || hasCredits || hasExtraUsage || hasCost { @@ -1314,6 +1235,7 @@ extension StatusItemController { creditsView, id: "menuCardCredits", width: width, + heightCacheScope: provider.rawValue, submenu: creditsSubmenu)) if webItems.canShowBuyCredits { menu.addItem(self.makeBuyCreditsItem()) @@ -1333,6 +1255,7 @@ extension StatusItemController { extraUsageView, id: "menuCardExtraUsage", width: width, + heightCacheScope: provider.rawValue, submenu: extraUsageSubmenu)) } if hasCost { @@ -1358,6 +1281,7 @@ extension StatusItemController { storageView, id: "menuCardStorage", width: width, + heightCacheScope: provider.rawValue, submenu: storageSubmenu)) return true } @@ -1611,7 +1535,7 @@ extension StatusItemController { } } - @objc private func menuCardNoOp(_ sender: NSMenuItem) { + @objc func menuCardNoOp(_ sender: NSMenuItem) { _ = sender } diff --git a/Sources/CodexBar/StatusItemController+MenuCardHeightCache.swift b/Sources/CodexBar/StatusItemController+MenuCardHeightCache.swift new file mode 100644 index 0000000000..91fd232bdb --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuCardHeightCache.swift @@ -0,0 +1,32 @@ +import AppKit + +extension StatusItemController { + struct MenuCardHeightCacheKey: Hashable { + let id: String + let scope: String + let width: Int + let version: Int + } + + func cachedMenuCardHeight( + for id: String, + scope: String, + width: CGFloat, + measure: () -> CGFloat) -> CGFloat + { + let key = MenuCardHeightCacheKey( + id: id, + scope: scope, + width: Int((width * 100).rounded()), + version: self.menuContentVersion) + if let cached = self.menuCardHeightCache[key] { + return cached + } + let height = measure() + if self.menuCardHeightCache.count > 256 { + self.menuCardHeightCache.removeAll(keepingCapacity: true) + } + self.menuCardHeightCache[key] = height + return height + } +} diff --git a/Sources/CodexBar/StatusItemController+MenuCardItems.swift b/Sources/CodexBar/StatusItemController+MenuCardItems.swift new file mode 100644 index 0000000000..0111b537a3 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuCardItems.swift @@ -0,0 +1,84 @@ +import AppKit +import SwiftUI + +extension StatusItemController { + func refreshMenuCardHeights(in menu: NSMenu) { + let cardItems = menu.items.filter { item in + (item.representedObject as? String)?.hasPrefix("menuCard") == true + } + for item in cardItems { + guard let view = item.view else { continue } + let width = self.renderedMenuWidth(for: menu) + let id = item.representedObject as? String ?? "menuCard" + let scope = self.menuProvider(for: menu)?.rawValue ?? id + let height = self.cachedMenuCardHeight(for: id, scope: scope, width: width) { + self.menuCardHeight(for: view, width: width) + } + view.frame = NSRect( + origin: .zero, + size: NSSize(width: width, height: height)) + } + } + + func makeMenuCardItem( + _ view: some View, + id: String, + width: CGFloat, + heightCacheScope: String? = nil, + submenu: NSMenu? = nil, + submenuIndicatorAlignment: Alignment = .topTrailing, + submenuIndicatorTopPadding: CGFloat = 8, + onClick: (() -> Void)? = nil) -> NSMenuItem + { + if !Self.menuCardRenderingEnabled { + let item = NSMenuItem() + item.isEnabled = true + item.representedObject = id + item.submenu = submenu + if submenu != nil { + item.target = self + item.action = #selector(self.menuCardNoOp(_:)) + } + return item + } + + let highlightState = MenuCardHighlightState() + let wrapped = MenuCardSectionContainerView( + highlightState: highlightState, + showsSubmenuIndicator: submenu != nil, + submenuIndicatorAlignment: submenuIndicatorAlignment, + submenuIndicatorTopPadding: submenuIndicatorTopPadding) + { + view + } + let hosting = MenuCardItemHostingView(rootView: wrapped, highlightState: highlightState, onClick: onClick) + let height = self.cachedMenuCardHeight(for: id, scope: heightCacheScope ?? id, width: width) { + self.menuCardHeight(for: hosting, width: width) + } + hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: height)) + + let item = NSMenuItem() + item.view = hosting + item.isEnabled = true + item.representedObject = id + item.submenu = submenu + if submenu != nil { + item.target = self + item.action = #selector(self.menuCardNoOp(_:)) + } + return item + } + + private func menuCardHeight(for view: NSView, width: CGFloat) -> CGFloat { + let basePadding: CGFloat = 6 + let descenderSafety: CGFloat = 1 + + if let measured = view as? MenuCardMeasuring { + return max(1, ceil(measured.measuredHeight(width: width) + basePadding + descenderSafety)) + } + + view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: 1)) + let fitted = view.fittingSize + return max(1, ceil(fitted.height + basePadding + descenderSafety)) + } +} diff --git a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift index 4742e30ece..fe739d106b 100644 --- a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift +++ b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift @@ -3,6 +3,8 @@ import CodexBarCore import QuartzCore extension StatusItemController { + private static let providerSwitcherMenuRebuildDebounceNanoseconds: UInt64 = 45_000_000 + func didMenuAdjunctReadinessChange() -> Bool { let signature = self.menuAdjunctReadinessSignature() defer { self.lastMenuAdjunctReadinessSignature = signature } @@ -101,10 +103,17 @@ extension StatusItemController { func deferSwitcherMenuRebuildIfStillVisible(_ menu: NSMenu, provider: UsageProvider?) { self.providerSwitcherUpdateToken &+= 1 let updateToken = self.providerSwitcherUpdateToken + #if DEBUG + let debounceNanoseconds = self._test_providerSwitcherMenuRebuildDebounceNanoseconds ?? ( + self._test_openMenuRebuildObserver == nil ? Self.providerSwitcherMenuRebuildDebounceNanoseconds : 0) + #else + let debounceNanoseconds = Self.providerSwitcherMenuRebuildDebounceNanoseconds + #endif self.scheduleOpenMenuRebuildIfStillVisible( menu, provider: provider, - closeHostedSubviewMenusBeforeRebuild: true) + closeHostedSubviewMenusBeforeRebuild: true, + debounceNanoseconds: debounceNanoseconds) { [weak self] in guard let self else { return false } return self.providerSwitcherUpdateToken == updateToken @@ -115,6 +124,7 @@ extension StatusItemController { _ menu: NSMenu, provider: UsageProvider?, closeHostedSubviewMenusBeforeRebuild: Bool = false, + debounceNanoseconds: UInt64 = 0, beforeRebuild: (@MainActor () -> Bool)? = nil) { let key = ObjectIdentifier(menu) @@ -137,6 +147,9 @@ extension StatusItemController { #else await Task.yield() #endif + if debounceNanoseconds > 0 { + try? await Task.sleep(nanoseconds: debounceNanoseconds) + } guard !Task.isCancelled else { return } guard self.openMenuRebuildTokens[key] == rebuildToken else { return } defer { diff --git a/Sources/CodexBar/StatusItemController+MenuTracking.swift b/Sources/CodexBar/StatusItemController+MenuTracking.swift index 4922f9cc6a..666cb8e081 100644 --- a/Sources/CodexBar/StatusItemController+MenuTracking.swift +++ b/Sources/CodexBar/StatusItemController+MenuTracking.swift @@ -32,6 +32,7 @@ extension StatusItemController { guard !self.isReleasedForTesting else { return } #endif self.menuContentVersion &+= 1 + self.menuCardHeightCache.removeAll(keepingCapacity: true) if !allowStaleContentDuringDataRefresh { self.latestRequiredMenuRebuildVersion = self.menuContentVersion } @@ -52,6 +53,7 @@ extension StatusItemController { guard self.openMenus.isEmpty else { return } guard !self.isMenuDataRefreshInFlight else { return } for menu in self.attachedMenusForClosedPreparation() { + guard !self.closedMenusDeferredUntilNextOpen.contains(ObjectIdentifier(menu)) else { continue } self.rebuildClosedMenuIfNeeded(menu) } } @@ -61,7 +63,24 @@ extension StatusItemController { UsageProvider.allCases.contains { self.store.isTokenRefreshInFlight(for: $0) } } + func clearTransientMenuTrackingState(_ key: ObjectIdentifier) { + self.menuProviders.removeValue(forKey: key) + self.menuVersions.removeValue(forKey: key) + self.closedMenusDeferredUntilNextOpen.remove(key) + } + + func handleClosedPersistentMenuNeedingRefresh(_ menu: NSMenu) { + if menu === self.mergedMenu { + // Closing the merged menu is on the user's dismiss path. Leave stale content attached and let + // menuWillOpen rebuild it, while other closed-menu invalidations can still prepare in the background. + self.closedMenusDeferredUntilNextOpen.insert(ObjectIdentifier(menu)) + } else { + self.rebuildClosedMenuIfNeeded(menu) + } + } + func refreshMenuForOpenIfNeeded(_ menu: NSMenu, provider: UsageProvider?) { + self.closedMenusDeferredUntilNextOpen.remove(ObjectIdentifier(menu)) guard self.menuNeedsRefresh(menu) else { return } if self.canPreserveStaleMenuContentDuringRefresh(menu) { #if DEBUG diff --git a/Sources/CodexBar/StatusItemController+Shutdown.swift b/Sources/CodexBar/StatusItemController+Shutdown.swift index 0277aca623..15a9c6801a 100644 --- a/Sources/CodexBar/StatusItemController+Shutdown.swift +++ b/Sources/CodexBar/StatusItemController+Shutdown.swift @@ -59,12 +59,14 @@ extension StatusItemController { self.menuRefreshTasks.removeAll(keepingCapacity: false) self.closedMenuRebuildTasks.removeAll(keepingCapacity: false) self.closedMenuRebuildTokens.removeAll(keepingCapacity: false) + self.closedMenusDeferredUntilNextOpen.removeAll(keepingCapacity: false) self.openMenuRebuildTasks.removeAll(keepingCapacity: false) self.openMenuRebuildTokens.removeAll(keepingCapacity: false) self.openMenuRebuildsClosingHostedSubviewMenus.removeAll(keepingCapacity: false) self.parentMenuRebuildsDeferredDuringTracking.removeAll(keepingCapacity: false) self.openMenus.removeAll(keepingCapacity: false) self.highlightedMenuItems.removeAll(keepingCapacity: false) + self.menuCardHeightCache.removeAll(keepingCapacity: false) self.menuProviders.removeAll(keepingCapacity: false) self.menuVersions.removeAll(keepingCapacity: false) self.providerMenus.removeAll(keepingCapacity: false) diff --git a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift index 2e6639216d..8f295684df 100644 --- a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift +++ b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift @@ -24,6 +24,7 @@ extension StatusItemController { }, id: "usageHistorySubmenu", width: width, + heightCacheScope: provider.rawValue, submenu: submenu, submenuIndicatorAlignment: .trailing, submenuIndicatorTopPadding: 0) diff --git a/Sources/CodexBar/StatusItemController+ZaiHourlyChartMenu.swift b/Sources/CodexBar/StatusItemController+ZaiHourlyChartMenu.swift index 3751e1c61f..4ccb1a081e 100644 --- a/Sources/CodexBar/StatusItemController+ZaiHourlyChartMenu.swift +++ b/Sources/CodexBar/StatusItemController+ZaiHourlyChartMenu.swift @@ -24,6 +24,7 @@ extension StatusItemController { }, id: "zaiHourlyUsageSubmenu", width: width, + heightCacheScope: provider.rawValue, submenu: submenu, submenuIndicatorAlignment: .trailing, submenuIndicatorTopPadding: 0) diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index cbe0ec801d..fa60ca95a1 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -117,6 +117,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var menuContentVersion: Int = 0 var latestRequiredMenuRebuildVersion: Int = 0 var menuVersions: [ObjectIdentifier: Int] = [:] + var menuCardHeightCache: [MenuCardHeightCacheKey: CGFloat] = [:] var lastMenuAdjunctReadinessSignature = "" var mergedMenu: NSMenu? var providerMenus: [UsageProvider: NSMenu] = [:] @@ -126,6 +127,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var closedMenuRebuildTasks: [ObjectIdentifier: Task] = [:] var closedMenuRebuildTokens: [ObjectIdentifier: Int] = [:] var closedMenuRebuildTokenCounter = 0 + var closedMenusDeferredUntilNextOpen: Set = [] var openMenuRebuildTasks: [ObjectIdentifier: Task] = [:] var openMenuRebuildTokens: [ObjectIdentifier: Int] = [:] var openMenuRebuildTokenCounter = 0 @@ -147,6 +149,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var lastLoggedClosedMenuRebuildVersion: Int? var _test_openMenuRefreshYieldOverride: (@MainActor () async -> Void)? var _test_openMenuRebuildObserver: (@MainActor (NSMenu) -> Void)? + var _test_providerSwitcherMenuRebuildDebounceNanoseconds: UInt64? var _test_codexAmbientLoginRunnerOverride: (@MainActor (TimeInterval) async -> CodexLoginRunner.Result)? #endif diff --git a/Tests/CodexBarTests/StatusMenuHeightCacheTests.swift b/Tests/CodexBarTests/StatusMenuHeightCacheTests.swift new file mode 100644 index 0000000000..464af56166 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuHeightCacheTests.swift @@ -0,0 +1,104 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +extension StatusMenuTests { + @Test + func `menu card height cache is reused within one content version`() { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + + 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() } + + let menu = controller.makeMenu() + controller.populateMenu(menu, provider: .codex) + let firstKeys = Set(controller.menuCardHeightCache.keys) + + #expect(!firstKeys.isEmpty) + + controller.populateMenu(menu, provider: .codex) + #expect(Set(controller.menuCardHeightCache.keys) == firstKeys) + + controller.invalidateMenus() + #expect(controller.menuCardHeightCache.isEmpty) + } + + @Test + func `menu card height cache scopes same row ids by provider`() { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: provider == .codex || provider == .claude) + } + + let fetcher = UsageFetcher() + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 12, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "claude@example.com", + accountOrganization: nil, + loginMethod: "Claude Pro")), + provider: .claude) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.populateMenu(menu, provider: .codex) + controller.populateMenu(menu, provider: .claude) + + let scopes = Set(controller.menuCardHeightCache.keys.map(\.scope)) + #expect(scopes.contains(UsageProvider.codex.rawValue)) + #expect(scopes.contains(UsageProvider.claude.rawValue)) + } +} diff --git a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift index 3d622dd3d0..bec5681a76 100644 --- a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift @@ -345,6 +345,50 @@ extension StatusMenuTests { #expect(controller.closedMenuRebuildTokens[key] == nil) } + @Test + func `merged menu close defers stale rebuild until next open`() 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() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + controller.menuWillOpen(menu) + + let key = ObjectIdentifier(menu) + let openedVersion = controller.menuVersions[key] + controller.invalidateMenus(refreshOpenMenus: false) + #expect(controller.menuNeedsRefresh(menu)) + + controller.menuDidClose(menu) + await self.waitUntilClosedMenuRebuildRemainsDeferred(controller, key: key, openedVersion: openedVersion) + + #expect(controller.closedMenuRebuildTasks[key] == nil) + #expect(controller.menuVersions[key] == openedVersion) + + controller.menuWillOpen(menu) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + @Test func `menu open keeps stale nonempty content while store refresh is active`() { self.disableMenuCardsForTesting() @@ -654,6 +698,55 @@ extension StatusMenuTests { #expect(controller.menuVersions[menuKey] == controller.menuContentVersion) } + @Test + func `rapid switcher rebuild requests coalesce before populating open menu`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + 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() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let menuKey = ObjectIdentifier(menu) + controller.openMenus[menuKey] = menu + controller.menuRefreshEnabledOverrideForTesting = true + controller._test_providerSwitcherMenuRebuildDebounceNanoseconds = 50_000_000 + defer { controller._test_providerSwitcherMenuRebuildDebounceNanoseconds = nil } + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + controller.deferSwitcherMenuRebuildIfStillVisible(menu, provider: .codex) + try? await Task.sleep(nanoseconds: 10_000_000) + controller.deferSwitcherMenuRebuildIfStillVisible(menu, provider: .codex) + + try? await Task.sleep(nanoseconds: 25_000_000) + #expect(rebuildCount == 0) + + for _ in 0..<20 where rebuildCount == 0 { + await Task.yield() + try? await Task.sleep(nanoseconds: 5_000_000) + } + + #expect(rebuildCount == 1) + try? await Task.sleep(nanoseconds: 75_000_000) + #expect(rebuildCount == 1) + } + @Test func `codex parent menu open defers stale OpenAI web refresh until tracking ends`() async { self.disableMenuCardsForTesting() @@ -1389,6 +1482,19 @@ extension StatusMenuTests { #expect(controller.menuVersions[key] == controller.menuContentVersion) } + private func waitUntilClosedMenuRebuildRemainsDeferred( + _ controller: StatusItemController, + key: ObjectIdentifier, + openedVersion: Int?) async + { + for _ in 0..<40 + where controller.closedMenuRebuildTasks[key] != nil || + controller.menuVersions[key] != openedVersion + { + await Task.yield() + } + } + private func makeOpenAIDashboard( dailyBreakdown: [OpenAIDashboardDailyBreakdown], updatedAt: Date) -> OpenAIDashboardSnapshot