diff --git a/CHANGELOG.md b/CHANGELOG.md index 30d632a8a8..bf51835e7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ ### Fixed - Menus: stop completed provider cards and plan-utilization rows from remaining in “Refreshing…” while unrelated provider or token-cost work is still running. Thanks @Yuxin-Qiao! +- Menu: keep native hover highlights aligned by deferring geometry-changing open-menu rebuilds until the pointer leaves the row. Thanks @Zihao-Qi! - Codex accounts: isolate authenticated OAuth and browser-cookie requests from shared URL caches and cookie stores, preventing one account's cached quota or identity response from appearing under another account and triggering false reset alerts (#1987, #2019). Thanks @harjothkhara! - Claude OAuth: honor the app's never-prompt policy in the bundled CLI and defer stale cache cleanup without touching Keychain until access is re-enabled. Thanks @Yuxin-Qiao! - CLI server: retain timed-out route and provider work until it actually exits, preventing repeated requests or config changes from stacking background fetches. Thanks @Yuxin-Qiao! diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 76a31568e8..56b2cd4385 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -202,6 +202,7 @@ extension StatusItemController { let key = ObjectIdentifier(menu) let previous = self.highlightedMenuItems[key] guard previous !== item else { return } + let previousWasNative = self.isNativeMenuItemHighlighted(in: menu) if let previous { (previous.view as? MenuCardHighlighting)?.setHighlighted(false) @@ -216,6 +217,10 @@ extension StatusItemController { } else { self.highlightedMenuItems.removeValue(forKey: key) } + + if previousWasNative, !self.isNativeMenuItemHighlighted(in: menu) { + self.resumeMenuRebuildDeferredForNativeHighlightIfNeeded(menu) + } } func populateMenu(_ menu: NSMenu, provider: UsageProvider?) { diff --git a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift index c4764814cd..07abec81da 100644 --- a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift +++ b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift @@ -8,7 +8,6 @@ extension StatusItemController { private struct ScheduledOpenMenuRebuild { let provider: UsageProvider? let shouldCloseHostedSubviewMenus: Bool - let resyncReadinessBaselineAfterRebuild: Bool let beforeRebuild: (@MainActor () -> Bool)? } @@ -279,7 +278,6 @@ extension StatusItemController { request: ScheduledOpenMenuRebuild( provider: provider, shouldCloseHostedSubviewMenus: true, - resyncReadinessBaselineAfterRebuild: false, beforeRebuild: beforeRebuild)) } } @@ -293,6 +291,9 @@ extension StatusItemController { beforeRebuild: (@MainActor () -> Bool)? = nil) { let key = ObjectIdentifier(menu) + if resyncReadinessBaselineAfterRebuild { + self.pendingMenuBaselineResyncs.insert(key) + } if closeHostedSubviewMenusBeforeRebuild { self.openMenuRebuildsClosingHostedSubviewMenus.insert(key) } @@ -321,7 +322,6 @@ extension StatusItemController { request: ScheduledOpenMenuRebuild( provider: provider, shouldCloseHostedSubviewMenus: shouldCloseHostedSubviewMenus, - resyncReadinessBaselineAfterRebuild: resyncReadinessBaselineAfterRebuild, beforeRebuild: beforeRebuild)) } } @@ -345,7 +345,8 @@ extension StatusItemController { self.closeHostedSubviewMenusForParentSwitch() } self.rebuildOpenMenuIfStillVisible(menu, provider: request.provider) - if request.resyncReadinessBaselineAfterRebuild, !self.menuNeedsRefresh(menu) { + if self.pendingMenuBaselineResyncs.contains(key), !self.menuNeedsRefresh(menu) { + self.pendingMenuBaselineResyncs.remove(key) self.resyncMenuAdjunctReadinessBaseline() } } diff --git a/Sources/CodexBar/StatusItemController+MenuTracking.swift b/Sources/CodexBar/StatusItemController+MenuTracking.swift index 43118d1e5a..9b032175bc 100644 --- a/Sources/CodexBar/StatusItemController+MenuTracking.swift +++ b/Sources/CodexBar/StatusItemController+MenuTracking.swift @@ -130,12 +130,14 @@ extension StatusItemController { self.openMenuRebuildTasks.removeValue(forKey: key)?.cancel() self.openMenuRebuildRequests.cancel(for: key) self.openMenuRebuildsClosingHostedSubviewMenus.remove(key) + self.pendingMenuBaselineResyncs.remove(key) } func clearMenuHighlight(_ key: ObjectIdentifier) { if let highlightedView = self.highlightedMenuItems.removeValue(forKey: key)?.view { (highlightedView as? MenuCardHighlighting)?.setHighlighted(false) } + self.nativeHighlightDeferredMenuRebuilds.removeValue(forKey: key) } func removeMenuLifecycleState(_ key: ObjectIdentifier) { @@ -394,16 +396,48 @@ extension StatusItemController { func rebuildOpenMenuIfStillVisible(_ menu: NSMenu, provider: UsageProvider?) { 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.menuSession.clearParentRebuildDeferral(key) - self.applyIcon(phase: nil) + let isHostedSubviewMenu = self.isHostedSubviewMenu(menu) + guard isHostedSubviewMenu || !self.hasOpenHostedSubviewMenu() else { return } + guard !self.isNativeMenuItemHighlighted(in: menu) else { + self.nativeHighlightDeferredMenuRebuilds[key] = NativeHighlightDeferredMenuRebuild(provider: provider) + return + } + self.nativeHighlightDeferredMenuRebuilds.removeValue(forKey: key) + if isHostedSubviewMenu { + self.refreshHostedSubviewMenu(menu) + } else { + self.populateMenu(menu, provider: provider) + self.markMenuFresh(menu) + self.menuSession.clearParentRebuildDeferral(key) + self.applyIcon(phase: nil) + } #if DEBUG self._test_openMenuRebuildObserver?(menu) #endif } + func isNativeMenuItemHighlighted(in menu: NSMenu) -> Bool { + let key = ObjectIdentifier(menu) + guard let item = self.highlightedMenuItems[key], item.menu === menu else { return false } + return item.isEnabled && item.view == nil + } + + func resumeMenuRebuildDeferredForNativeHighlightIfNeeded(_ menu: NSMenu) { + let key = ObjectIdentifier(menu) + guard let deferredRebuild = self.nativeHighlightDeferredMenuRebuilds[key] else { return } + guard self.openMenus[key] === menu else { + self.nativeHighlightDeferredMenuRebuilds.removeValue(forKey: key) + self.pendingMenuBaselineResyncs.remove(key) + return + } + let isHostedSubviewMenu = self.isHostedSubviewMenu(menu) + guard isHostedSubviewMenu || !self.hasOpenHostedSubviewMenu() else { return } + self.nativeHighlightDeferredMenuRebuilds.removeValue(forKey: key) + self.scheduleOpenMenuRebuildIfStillVisible( + menu, + provider: deferredRebuild.provider) + } + func refreshOpenMenusIfNeeded() { guard self.isMenuRefreshEnabled else { return } guard !self.openMenus.isEmpty else { return } @@ -423,6 +457,21 @@ extension StatusItemController { } self.parentMenuRebuildPendingAfterHostedSubviewClose = false self.refreshOpenMenusIfNeeded(allowsParentRebuild: true) + self.resumeParentMenuRebuildsDeferredForNativeHighlightAfterHostedSubviewClose() + } + + private func resumeParentMenuRebuildsDeferredForNativeHighlightAfterHostedSubviewClose() { + guard !self.hasOpenHostedSubviewMenu() else { return } + let deferredParents = self.openMenus.values.filter { menu in + let key = ObjectIdentifier(menu) + return !self.isHostedSubviewMenu(menu) && + self.nativeHighlightDeferredMenuRebuilds[key] != nil + } + // Schedule the saved explicit request after the generic dirty-menu pass, even when the native + // highlight is still active. The scheduled rebuild will defer again, preserving its provider. + for menu in deferredParents { + self.resumeMenuRebuildDeferredForNativeHighlightIfNeeded(menu) + } } func completeParentMenuRebuildAfterHostedSubviewCloseIfNeeded() { @@ -485,7 +534,7 @@ extension StatusItemController { hasOpenHostedSubviewMenu: Bool) { if self.isHostedSubviewMenu(menu) { - self.refreshHostedSubviewMenu(menu) + self.scheduleOpenMenuRebuildIfStillVisible(menu, provider: self.menuProvider(for: menu)) return } guard allowsParentRebuild else { return } diff --git a/Sources/CodexBar/StatusItemController+Shutdown.swift b/Sources/CodexBar/StatusItemController+Shutdown.swift index a5a6ce8827..1ca78b57a9 100644 --- a/Sources/CodexBar/StatusItemController+Shutdown.swift +++ b/Sources/CodexBar/StatusItemController+Shutdown.swift @@ -80,6 +80,8 @@ extension StatusItemController { self.menuSession.clearMenuTracking() self.openMenus.removeAll(keepingCapacity: false) self.highlightedMenuItems.removeAll(keepingCapacity: false) + self.nativeHighlightDeferredMenuRebuilds.removeAll(keepingCapacity: false) + self.pendingMenuBaselineResyncs.removeAll(keepingCapacity: false) self.menuCardHeightCache.removeAll(keepingCapacity: false) self.measuredStandardMenuWidthCache.removeAll(keepingCapacity: false) self.mergedSwitcherContentCaches.removeAll(keepingCapacity: false) diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 9491284c53..c29df1dfab 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -33,6 +33,10 @@ extension StatusItemControlling { func prepareForAppShutdown() {} } +struct NativeHighlightDeferredMenuRebuild { + let provider: UsageProvider? +} + @MainActor final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControlling { // Disable SwiftUI menu cards + menu refresh work in tests to avoid swiftpm-testing-helper crashes. @@ -168,6 +172,10 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var deferredOpenAIDashboardRefreshReason: String? var deferredMenuInteractionRefreshTask: Task? var highlightedMenuItems: [ObjectIdentifier: NSMenuItem] = [:] + /// Open-menu rebuilds paused so AppKit's native selection background cannot retain stale geometry. + var nativeHighlightDeferredMenuRebuilds: [ObjectIdentifier: NativeHighlightDeferredMenuRebuild] = [:] + /// Baseline resync intent survives rebuild coalescing and any native-row or hosted-submenu deferral. + var pendingMenuBaselineResyncs: Set = [] var providerSwitcherShortcutEventMonitor: ProviderSwitcherShortcutEventMonitor? var providerSwitcherShortcutMenuID: ObjectIdentifier? var providerSwitcherPointerInteractionMenuID: ObjectIdentifier? diff --git a/Tests/CodexBarTests/StatusItemControllerShutdownTests.swift b/Tests/CodexBarTests/StatusItemControllerShutdownTests.swift index 34e1ea0cd4..69e185a9df 100644 --- a/Tests/CodexBarTests/StatusItemControllerShutdownTests.swift +++ b/Tests/CodexBarTests/StatusItemControllerShutdownTests.swift @@ -43,6 +43,8 @@ struct StatusItemControllerShutdownTests { controller.menuRefreshTasks[key] = Task { try? await Task.sleep(for: .seconds(30)) } controller.menuReadinessSignatures[key] = "readiness" controller.menuIdentitySignatures[key] = "identity" + controller.nativeHighlightDeferredMenuRebuilds[key] = .init(provider: .codex) + controller.pendingMenuBaselineResyncs.insert(key) #expect(controller.openMenus[key] === menu) #expect(controller.mergedMenu != nil) @@ -56,6 +58,8 @@ struct StatusItemControllerShutdownTests { #expect(controller.menuRefreshTasks.isEmpty) #expect(controller.menuReadinessSignatures.isEmpty) #expect(controller.menuIdentitySignatures.isEmpty) + #expect(controller.nativeHighlightDeferredMenuRebuilds.isEmpty) + #expect(controller.pendingMenuBaselineResyncs.isEmpty) #expect(controller.providerSwitcherShortcutEventMonitor == nil) #expect(controller.statusItem.menu == nil) #expect(controller.statusItems.isEmpty) diff --git a/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift b/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift index d95f2d130f..3d64fffa01 100644 --- a/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift +++ b/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift @@ -148,6 +148,8 @@ struct StatusItemControllerSplitLifecycleTests { _ = controller.openMenuRebuildRequests.replaceRequest(for: key) controller.openMenuRebuildsClosingHostedSubviewMenus.insert(key) controller.highlightedMenuItems[key] = NSMenuItem(title: "Highlighted", action: nil, keyEquivalent: "") + controller.nativeHighlightDeferredMenuRebuilds[key] = .init(provider: .codex) + controller.pendingMenuBaselineResyncs.insert(key) } settings.mergeIcons = true @@ -168,6 +170,8 @@ struct StatusItemControllerSplitLifecycleTests { #expect(controller.openMenuRebuildRequests.tokens[key] == nil) #expect(!controller.openMenuRebuildsClosingHostedSubviewMenus.contains(key)) #expect(controller.highlightedMenuItems[key] == nil) + #expect(controller.nativeHighlightDeferredMenuRebuilds[key] == nil) + #expect(!controller.pendingMenuBaselineResyncs.contains(key)) } } diff --git a/Tests/CodexBarTests/StatusMenuHighlightTests.swift b/Tests/CodexBarTests/StatusMenuHighlightTests.swift index 96fccbb561..e69ca422e7 100644 --- a/Tests/CodexBarTests/StatusMenuHighlightTests.swift +++ b/Tests/CodexBarTests/StatusMenuHighlightTests.swift @@ -52,4 +52,494 @@ extension StatusMenuTests { #expect(secondView.states == [true]) #expect(thirdView.states.isEmpty) } + + @Test + func `native highlight preserves coalesced baseline resync until pointer leaves native rows`() 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) + defer { controller.menuDidClose(menu) } + let key = ObjectIdentifier(menu) + controller.cancelMenuWork(key) + controller.openMenus[key] = menu + let planUsage = NSMenuItem(title: "Plan Usage", action: nil, keyEquivalent: "") + planUsage.isEnabled = true + let cost = NSMenuItem(title: "Cost", action: nil, keyEquivalent: "") + cost.isEnabled = true + menu.addItem(planUsage) + menu.addItem(cost) + + controller.menu(menu, willHighlight: planUsage) + #expect(controller.highlightedMenuItems[key] === planUsage) + #expect(controller.isNativeMenuItemHighlighted(in: menu)) + controller.lastMenuAdjunctReadinessSignature = "stale-baseline" + controller.menuSession.invalidate(allowsStaleContent: false, requiresRebuild: true) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in rebuildCount += 1 } + defer { controller._test_openMenuRebuildObserver = nil } + controller.scheduleOpenMenuRebuildIfStillVisible( + menu, + provider: .codex, + resyncReadinessBaselineAfterRebuild: true) + controller.scheduleOpenMenuRebuildIfStillVisible(menu, provider: .codex) + for _ in 0..<20 where controller.nativeHighlightDeferredMenuRebuilds[key] == nil { + await Task.yield() + } + + #expect(rebuildCount == 0) + #expect(controller.nativeHighlightDeferredMenuRebuilds[key] != nil) + #expect(controller.pendingMenuBaselineResyncs.contains(key)) + #expect(controller.menuNeedsRefresh(menu)) + + controller.menu(menu, willHighlight: cost) + for _ in 0..<20 { + await Task.yield() + } + #expect(rebuildCount == 0) + #expect(controller.nativeHighlightDeferredMenuRebuilds[key] != nil) + + controller.menu(menu, willHighlight: nil) + for _ in 0..<20 where rebuildCount == 0 { + await Task.yield() + } + + #expect(rebuildCount == 1) + #expect(controller.nativeHighlightDeferredMenuRebuilds[key] == nil) + #expect(!controller.pendingMenuBaselineResyncs.contains(key)) + #expect(!controller.menuNeedsRefresh(menu)) + #expect(controller.lastMenuAdjunctReadinessSignature == controller.menuAdjunctReadinessSignature()) + } + + @Test + func `native highlight preserves explicit rebuild even when menu is already fresh`() 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.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + defer { controller.menuDidClose(menu) } + + let nativeItem = NSMenuItem(title: "Plan Usage", action: nil, keyEquivalent: "") + nativeItem.isEnabled = true + menu.addItem(nativeItem) + controller.menu(menu, willHighlight: nativeItem) + #expect(!controller.menuNeedsRefresh(menu)) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in rebuildCount += 1 } + defer { controller._test_openMenuRebuildObserver = nil } + controller.scheduleOpenMenuRebuildIfStillVisible(menu, provider: .claude) + for _ in 0..<20 where controller.nativeHighlightDeferredMenuRebuilds[key] == nil { + await Task.yield() + } + + #expect(rebuildCount == 0) + #expect(controller.nativeHighlightDeferredMenuRebuilds[key]?.provider == .claude) + #expect(!controller.menuNeedsRefresh(menu)) + + controller.menu(menu, willHighlight: nil) + for _ in 0..<20 where rebuildCount == 0 { + await Task.yield() + } + + #expect(rebuildCount == 1) + #expect(controller.nativeHighlightDeferredMenuRebuilds[key] == nil) + #expect(!controller.menuNeedsRefresh(menu)) + } + + @Test + func `hosted submenu close resumes deferred explicit rebuild on fresh parent`() 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() } + controller.menuRefreshEnabledOverrideForTesting = true + + let parent = controller.makeMenu() + controller.populateMenu(parent, provider: .codex) + controller.markMenuFresh(parent) + let parentKey = ObjectIdentifier(parent) + controller.openMenus[parentKey] = parent + defer { controller.menuDidClose(parent) } + + let nativeItem = NSMenuItem(title: "Plan Usage", action: nil, keyEquivalent: "") + nativeItem.isEnabled = true + parent.addItem(nativeItem) + controller.menu(parent, willHighlight: nativeItem) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { menu in + if menu === parent { + rebuildCount += 1 + } + } + defer { controller._test_openMenuRebuildObserver = nil } + controller.scheduleOpenMenuRebuildIfStillVisible(parent, provider: .claude) + for _ in 0..<20 where controller.nativeHighlightDeferredMenuRebuilds[parentKey] == nil { + await Task.yield() + } + + #expect(rebuildCount == 0) + #expect(controller.nativeHighlightDeferredMenuRebuilds[parentKey]?.provider == .claude) + #expect(!controller.menuNeedsRefresh(parent)) + + let submenu = controller.makeHostedSubviewPlaceholderMenu( + chartID: StatusItemController.costHistoryChartID, + provider: .codex) + let submenuKey = ObjectIdentifier(submenu) + controller.openMenus[submenuKey] = submenu + controller.menu(parent, willHighlight: nil) + for _ in 0..<20 { + await Task.yield() + } + + #expect(rebuildCount == 0) + #expect(controller.nativeHighlightDeferredMenuRebuilds[parentKey]?.provider == .claude) + #expect(!controller.menuNeedsRefresh(parent)) + + controller.menuDidClose(submenu) + for _ in 0..<40 where rebuildCount == 0 { + await Task.yield() + } + + #expect(controller.openMenus[submenuKey] == nil) + #expect(rebuildCount == 1) + #expect(controller.nativeHighlightDeferredMenuRebuilds[parentKey] == nil) + #expect(!controller.menuNeedsRefresh(parent)) + } + + @Test + func `hosted submenu close keeps explicit rebuild ahead of dirty parent refresh`() 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() } + controller.menuRefreshEnabledOverrideForTesting = true + + let parent = controller.makeMenu() + controller.populateMenu(parent, provider: .codex) + controller.markMenuFresh(parent) + let parentKey = ObjectIdentifier(parent) + controller.openMenus[parentKey] = parent + defer { controller.menuDidClose(parent) } + + let nativeItem = NSMenuItem(title: "Plan Usage", action: nil, keyEquivalent: "") + nativeItem.isEnabled = true + parent.addItem(nativeItem) + controller.menu(parent, willHighlight: nativeItem) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { menu in + if menu === parent { + rebuildCount += 1 + } + } + defer { controller._test_openMenuRebuildObserver = nil } + controller.scheduleOpenMenuRebuildIfStillVisible(parent, provider: .claude) + for _ in 0..<20 where controller.nativeHighlightDeferredMenuRebuilds[parentKey] == nil { + await Task.yield() + } + + let submenu = controller.makeHostedSubviewPlaceholderMenu( + chartID: StatusItemController.costHistoryChartID, + provider: .codex) + controller.openMenus[ObjectIdentifier(submenu)] = submenu + controller.menuSession.invalidate(allowsStaleContent: false, requiresRebuild: true) + #expect(controller.menuNeedsRefresh(parent)) + + controller.menuDidClose(submenu) + for _ in 0..<40 { + await Task.yield() + } + + #expect(rebuildCount == 0) + #expect(controller.nativeHighlightDeferredMenuRebuilds[parentKey]?.provider == .claude) + #expect(controller.menuNeedsRefresh(parent)) + + controller.menu(parent, willHighlight: nil) + for _ in 0..<40 where rebuildCount == 0 { + await Task.yield() + } + + #expect(rebuildCount == 1) + #expect(controller.nativeHighlightDeferredMenuRebuilds[parentKey] == nil) + #expect(!controller.menuNeedsRefresh(parent)) + } + + @Test + func `hosted submenu close preserves pending parent baseline resync`() 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() } + controller.menuRefreshEnabledOverrideForTesting = true + + let parent = controller.makeMenu() + controller.populateMenu(parent, provider: .codex) + controller.markMenuFresh(parent) + let parentKey = ObjectIdentifier(parent) + controller.openMenus[parentKey] = parent + defer { controller.menuDidClose(parent) } + + let submenu = controller.makeHostedSubviewPlaceholderMenu( + chartID: StatusItemController.costHistoryChartID, + provider: .codex) + let submenuKey = ObjectIdentifier(submenu) + controller.openMenus[submenuKey] = submenu + + controller.lastMenuAdjunctReadinessSignature = "stale-baseline" + controller.menuSession.invalidate(allowsStaleContent: false, requiresRebuild: true) + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { menu in + if menu === parent { + rebuildCount += 1 + } + } + defer { controller._test_openMenuRebuildObserver = nil } + + controller.scheduleOpenMenuRebuildIfStillVisible( + parent, + provider: .codex, + resyncReadinessBaselineAfterRebuild: true) + for _ in 0..<20 where controller.openMenuRebuildTasks[parentKey] != nil { + await Task.yield() + } + + #expect(rebuildCount == 0) + #expect(controller.pendingMenuBaselineResyncs.contains(parentKey)) + #expect(controller.menuNeedsRefresh(parent)) + + controller.menuDidClose(submenu) + for _ in 0..<40 where rebuildCount == 0 { + await Task.yield() + } + + #expect(controller.openMenus[submenuKey] == nil) + #expect(rebuildCount == 1) + #expect(!controller.pendingMenuBaselineResyncs.contains(parentKey)) + #expect(!controller.menuNeedsRefresh(parent)) + #expect(controller.lastMenuAdjunctReadinessSignature == controller.menuAdjunctReadinessSignature()) + } + + @Test + func `menu close clears native highlight deferral and pending baseline resync`() 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() + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + let nativeItem = NSMenuItem(title: "Settings", action: nil, keyEquivalent: "") + nativeItem.isEnabled = true + menu.addItem(nativeItem) + controller.menu(menu, willHighlight: nativeItem) + controller.menuSession.invalidate(allowsStaleContent: false, requiresRebuild: true) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in rebuildCount += 1 } + defer { controller._test_openMenuRebuildObserver = nil } + controller.scheduleOpenMenuRebuildIfStillVisible( + menu, + provider: .codex, + resyncReadinessBaselineAfterRebuild: true) + for _ in 0..<20 where controller.nativeHighlightDeferredMenuRebuilds[key] == nil { + await Task.yield() + } + + #expect(controller.nativeHighlightDeferredMenuRebuilds[key] != nil) + #expect(controller.pendingMenuBaselineResyncs.contains(key)) + controller.menuDidClose(menu) + for _ in 0..<10 { + await Task.yield() + } + + #expect(rebuildCount == 0) + #expect(controller.openMenus[key] == nil) + #expect(controller.highlightedMenuItems[key] == nil) + #expect(controller.nativeHighlightDeferredMenuRebuilds[key] == nil) + #expect(!controller.pendingMenuBaselineResyncs.contains(key)) + #expect(controller.openMenuRebuildTasks[key] == nil) + #expect(controller.openMenuRebuildRequests.tokens[key] == nil) + } + + @Test + func `hosted native highlight defers signature changing refresh until pointer leaves`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = true + 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() } + controller.menuRefreshEnabledOverrideForTesting = true + + let submenu = NSMenu() + #expect(controller.appendStatusComponentsItem( + to: submenu, + provider: .codex, + width: StatusItemController.menuCardBaseWidth)) + let key = ObjectIdentifier(submenu) + controller.openMenus[key] = submenu + defer { controller.menuDidClose(submenu) } + let originalLink = try #require(submenu.items.last) + #expect(originalLink.title == L("Open Status Page")) + #expect(originalLink.view == nil) + #expect(originalLink.isEnabled) + controller.menu(submenu, willHighlight: originalLink) + + store.statusComponents[.codex] = [ + ProviderStatusComponent( + id: "api", + name: "API", + indicator: .none, + status: "operational"), + ] + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { menu in + if menu === submenu { + rebuildCount += 1 + } + } + defer { controller._test_openMenuRebuildObserver = nil } + + controller.refreshOpenMenusAllowingParentRebuild() + for _ in 0..<20 where controller.nativeHighlightDeferredMenuRebuilds[key] == nil { + await Task.yield() + } + + #expect(rebuildCount == 0) + #expect(controller.nativeHighlightDeferredMenuRebuilds[key] != nil) + #expect(submenu.items.count == 1) + #expect(submenu.items.first === originalLink) + + controller.menu(submenu, willHighlight: nil) + for _ in 0..<20 where rebuildCount == 0 { + await Task.yield() + } + + #expect(rebuildCount == 1) + #expect(controller.nativeHighlightDeferredMenuRebuilds[key] == nil) + #expect(submenu.items.count == 3) + #expect(submenu.items.last !== originalLink) + #expect(submenu.items.last?.title == L("Open Status Page")) + } + + @Test + func `custom highlight does not defer open menu rebuild`() { + 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) + defer { controller.menuDidClose(menu) } + let key = ObjectIdentifier(menu) + controller.cancelMenuWork(key) + controller.openMenus[key] = menu + let customItem = NSMenuItem() + customItem.view = HighlightProbeView() + customItem.isEnabled = true + menu.addItem(customItem) + controller.menu(menu, willHighlight: customItem) + controller.menuSession.invalidate(allowsStaleContent: false, requiresRebuild: true) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in rebuildCount += 1 } + defer { controller._test_openMenuRebuildObserver = nil } + controller.rebuildOpenMenuIfStillVisible(menu, provider: .codex) + + #expect(rebuildCount == 1) + #expect(controller.nativeHighlightDeferredMenuRebuilds[key] == nil) + #expect(!controller.menuNeedsRefresh(menu)) + } }