From 4a82582a8f407bf8552528e798fd06d49bb537d8 Mon Sep 17 00:00:00 2001 From: Trim Date: Fri, 10 Jul 2026 08:25:49 -0500 Subject: [PATCH 1/5] Fix native menu hover highlight alignment --- CHANGELOG.md | 1 + .../CodexBar/StatusItemController+Menu.swift | 5 + .../StatusItemController+MenuTracking.swift | 20 ++++ .../StatusItemController+Shutdown.swift | 1 + Sources/CodexBar/StatusItemController.swift | 2 + ...tusItemControllerSplitLifecycleTests.swift | 2 + .../StatusMenuHighlightTests.swift | 101 ++++++++++++++++++ 7 files changed, 132 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30d632a8a8..9b461ad0ef 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. - 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+MenuTracking.swift b/Sources/CodexBar/StatusItemController+MenuTracking.swift index 43118d1e5a..16c9a48f20 100644 --- a/Sources/CodexBar/StatusItemController+MenuTracking.swift +++ b/Sources/CodexBar/StatusItemController+MenuTracking.swift @@ -136,6 +136,7 @@ extension StatusItemController { if let highlightedView = self.highlightedMenuItems.removeValue(forKey: key)?.view { (highlightedView as? MenuCardHighlighting)?.setHighlighted(false) } + self.nativeHighlightDeferredMenuRebuilds.remove(key) } func removeMenuLifecycleState(_ key: ObjectIdentifier) { @@ -395,6 +396,11 @@ extension StatusItemController { let key = ObjectIdentifier(menu) guard self.openMenus[key] != nil else { return } guard self.isHostedSubviewMenu(menu) || !self.hasOpenHostedSubviewMenu() else { return } + guard !self.isNativeMenuItemHighlighted(in: menu) else { + self.nativeHighlightDeferredMenuRebuilds.insert(key) + return + } + self.nativeHighlightDeferredMenuRebuilds.remove(key) self.populateMenu(menu, provider: provider) self.markMenuFresh(menu) self.menuSession.clearParentRebuildDeferral(key) @@ -404,6 +410,20 @@ extension StatusItemController { #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 self.nativeHighlightDeferredMenuRebuilds.remove(key) != nil else { return } + guard self.openMenus[key] === menu, self.menuNeedsRefresh(menu) else { return } + guard !self.hasOpenHostedSubviewMenu() else { return } + self.scheduleOpenMenuRebuildIfStillVisible(menu, provider: self.menuProvider(for: menu)) + } + func refreshOpenMenusIfNeeded() { guard self.isMenuRefreshEnabled else { return } guard !self.openMenus.isEmpty else { return } diff --git a/Sources/CodexBar/StatusItemController+Shutdown.swift b/Sources/CodexBar/StatusItemController+Shutdown.swift index a5a6ce8827..0c70ad429c 100644 --- a/Sources/CodexBar/StatusItemController+Shutdown.swift +++ b/Sources/CodexBar/StatusItemController+Shutdown.swift @@ -80,6 +80,7 @@ extension StatusItemController { self.menuSession.clearMenuTracking() self.openMenus.removeAll(keepingCapacity: false) self.highlightedMenuItems.removeAll(keepingCapacity: false) + self.nativeHighlightDeferredMenuRebuilds.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..8340a09900 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -168,6 +168,8 @@ 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: Set = [] var providerSwitcherShortcutEventMonitor: ProviderSwitcherShortcutEventMonitor? var providerSwitcherShortcutMenuID: ObjectIdentifier? var providerSwitcherPointerInteractionMenuID: ObjectIdentifier? diff --git a/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift b/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift index d95f2d130f..cd9d01894a 100644 --- a/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift +++ b/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift @@ -148,6 +148,7 @@ struct StatusItemControllerSplitLifecycleTests { _ = controller.openMenuRebuildRequests.replaceRequest(for: key) controller.openMenuRebuildsClosingHostedSubviewMenus.insert(key) controller.highlightedMenuItems[key] = NSMenuItem(title: "Highlighted", action: nil, keyEquivalent: "") + controller.nativeHighlightDeferredMenuRebuilds.insert(key) } settings.mergeIcons = true @@ -168,6 +169,7 @@ struct StatusItemControllerSplitLifecycleTests { #expect(controller.openMenuRebuildRequests.tokens[key] == nil) #expect(!controller.openMenuRebuildsClosingHostedSubviewMenus.contains(key)) #expect(controller.highlightedMenuItems[key] == nil) + #expect(!controller.nativeHighlightDeferredMenuRebuilds.contains(key)) } } diff --git a/Tests/CodexBarTests/StatusMenuHighlightTests.swift b/Tests/CodexBarTests/StatusMenuHighlightTests.swift index 96fccbb561..f56ed21917 100644 --- a/Tests/CodexBarTests/StatusMenuHighlightTests.swift +++ b/Tests/CodexBarTests/StatusMenuHighlightTests.swift @@ -52,4 +52,105 @@ extension StatusMenuTests { #expect(secondView.states == [true]) #expect(thirdView.states.isEmpty) } + + @Test + func `native highlight defers open menu rebuild 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.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 == 0) + #expect(controller.nativeHighlightDeferredMenuRebuilds.contains(key)) + #expect(controller.menuNeedsRefresh(menu)) + + controller.menu(menu, willHighlight: cost) + for _ in 0..<20 { + await Task.yield() + } + #expect(rebuildCount == 0) + #expect(controller.nativeHighlightDeferredMenuRebuilds.contains(key)) + + controller.menu(menu, willHighlight: nil) + for _ in 0..<20 where rebuildCount == 0 { + await Task.yield() + } + + #expect(rebuildCount == 1) + #expect(!controller.nativeHighlightDeferredMenuRebuilds.contains(key)) + #expect(!controller.menuNeedsRefresh(menu)) + } + + @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.contains(key)) + #expect(!controller.menuNeedsRefresh(menu)) + } } From 78950745b46ab690e4961b887d6d16b3cccd2a5d Mon Sep 17 00:00:00 2001 From: Trim Date: Fri, 10 Jul 2026 09:06:13 -0500 Subject: [PATCH 2/5] Preserve deferred menu readiness resync --- ...ItemController+MenuRefreshScheduling.swift | 10 ++++++-- .../StatusItemController+MenuTracking.swift | 24 +++++++++++++++---- .../StatusItemController+Shutdown.swift | 1 + Sources/CodexBar/StatusItemController.swift | 1 + ...tusItemControllerSplitLifecycleTests.swift | 2 ++ .../StatusMenuHighlightTests.swift | 12 +++++++++- 6 files changed, 43 insertions(+), 7 deletions(-) diff --git a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift index c4764814cd..e7bd4f3a71 100644 --- a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift +++ b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift @@ -344,8 +344,14 @@ extension StatusItemController { if request.shouldCloseHostedSubviewMenus { self.closeHostedSubviewMenusForParentSwitch() } - self.rebuildOpenMenuIfStillVisible(menu, provider: request.provider) - if request.resyncReadinessBaselineAfterRebuild, !self.menuNeedsRefresh(menu) { + self.rebuildOpenMenuIfStillVisible( + menu, + provider: request.provider, + resyncReadinessBaselineAfterRebuild: request.resyncReadinessBaselineAfterRebuild) + let shouldResyncReadinessBaseline = request.resyncReadinessBaselineAfterRebuild || + self.nativeHighlightDeferredMenuBaselineResyncs.contains(key) + if shouldResyncReadinessBaseline, !self.menuNeedsRefresh(menu) { + self.nativeHighlightDeferredMenuBaselineResyncs.remove(key) self.resyncMenuAdjunctReadinessBaseline() } } diff --git a/Sources/CodexBar/StatusItemController+MenuTracking.swift b/Sources/CodexBar/StatusItemController+MenuTracking.swift index 16c9a48f20..2d49ea3991 100644 --- a/Sources/CodexBar/StatusItemController+MenuTracking.swift +++ b/Sources/CodexBar/StatusItemController+MenuTracking.swift @@ -137,6 +137,7 @@ extension StatusItemController { (highlightedView as? MenuCardHighlighting)?.setHighlighted(false) } self.nativeHighlightDeferredMenuRebuilds.remove(key) + self.nativeHighlightDeferredMenuBaselineResyncs.remove(key) } func removeMenuLifecycleState(_ key: ObjectIdentifier) { @@ -392,12 +393,19 @@ extension StatusItemController { allowStaleContentDuringDataRefresh: true) } - func rebuildOpenMenuIfStillVisible(_ menu: NSMenu, provider: UsageProvider?) { + func rebuildOpenMenuIfStillVisible( + _ menu: NSMenu, + provider: UsageProvider?, + resyncReadinessBaselineAfterRebuild: Bool = false) + { let key = ObjectIdentifier(menu) guard self.openMenus[key] != nil else { return } guard self.isHostedSubviewMenu(menu) || !self.hasOpenHostedSubviewMenu() else { return } guard !self.isNativeMenuItemHighlighted(in: menu) else { self.nativeHighlightDeferredMenuRebuilds.insert(key) + if resyncReadinessBaselineAfterRebuild { + self.nativeHighlightDeferredMenuBaselineResyncs.insert(key) + } return } self.nativeHighlightDeferredMenuRebuilds.remove(key) @@ -418,10 +426,18 @@ extension StatusItemController { func resumeMenuRebuildDeferredForNativeHighlightIfNeeded(_ menu: NSMenu) { let key = ObjectIdentifier(menu) - guard self.nativeHighlightDeferredMenuRebuilds.remove(key) != nil else { return } - guard self.openMenus[key] === menu, self.menuNeedsRefresh(menu) else { return } + guard self.nativeHighlightDeferredMenuRebuilds.contains(key) else { return } + guard self.openMenus[key] === menu, self.menuNeedsRefresh(menu) else { + self.nativeHighlightDeferredMenuRebuilds.remove(key) + self.nativeHighlightDeferredMenuBaselineResyncs.remove(key) + return + } guard !self.hasOpenHostedSubviewMenu() else { return } - self.scheduleOpenMenuRebuildIfStillVisible(menu, provider: self.menuProvider(for: menu)) + self.nativeHighlightDeferredMenuRebuilds.remove(key) + self.scheduleOpenMenuRebuildIfStillVisible( + menu, + provider: self.menuProvider(for: menu), + resyncReadinessBaselineAfterRebuild: self.nativeHighlightDeferredMenuBaselineResyncs.contains(key)) } func refreshOpenMenusIfNeeded() { diff --git a/Sources/CodexBar/StatusItemController+Shutdown.swift b/Sources/CodexBar/StatusItemController+Shutdown.swift index 0c70ad429c..58b01d5e15 100644 --- a/Sources/CodexBar/StatusItemController+Shutdown.swift +++ b/Sources/CodexBar/StatusItemController+Shutdown.swift @@ -81,6 +81,7 @@ extension StatusItemController { self.openMenus.removeAll(keepingCapacity: false) self.highlightedMenuItems.removeAll(keepingCapacity: false) self.nativeHighlightDeferredMenuRebuilds.removeAll(keepingCapacity: false) + self.nativeHighlightDeferredMenuBaselineResyncs.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 8340a09900..3a44598184 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -170,6 +170,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var highlightedMenuItems: [ObjectIdentifier: NSMenuItem] = [:] /// Open-menu rebuilds paused so AppKit's native selection background cannot retain stale geometry. var nativeHighlightDeferredMenuRebuilds: Set = [] + var nativeHighlightDeferredMenuBaselineResyncs: Set = [] var providerSwitcherShortcutEventMonitor: ProviderSwitcherShortcutEventMonitor? var providerSwitcherShortcutMenuID: ObjectIdentifier? var providerSwitcherPointerInteractionMenuID: ObjectIdentifier? diff --git a/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift b/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift index cd9d01894a..1447f50e24 100644 --- a/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift +++ b/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift @@ -149,6 +149,7 @@ struct StatusItemControllerSplitLifecycleTests { controller.openMenuRebuildsClosingHostedSubviewMenus.insert(key) controller.highlightedMenuItems[key] = NSMenuItem(title: "Highlighted", action: nil, keyEquivalent: "") controller.nativeHighlightDeferredMenuRebuilds.insert(key) + controller.nativeHighlightDeferredMenuBaselineResyncs.insert(key) } settings.mergeIcons = true @@ -170,6 +171,7 @@ struct StatusItemControllerSplitLifecycleTests { #expect(!controller.openMenuRebuildsClosingHostedSubviewMenus.contains(key)) #expect(controller.highlightedMenuItems[key] == nil) #expect(!controller.nativeHighlightDeferredMenuRebuilds.contains(key)) + #expect(!controller.nativeHighlightDeferredMenuBaselineResyncs.contains(key)) } } diff --git a/Tests/CodexBarTests/StatusMenuHighlightTests.swift b/Tests/CodexBarTests/StatusMenuHighlightTests.swift index f56ed21917..3f6457c667 100644 --- a/Tests/CodexBarTests/StatusMenuHighlightTests.swift +++ b/Tests/CodexBarTests/StatusMenuHighlightTests.swift @@ -86,15 +86,23 @@ extension StatusMenuTests { 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.rebuildOpenMenuIfStillVisible(menu, provider: .codex) + controller.scheduleOpenMenuRebuildIfStillVisible( + menu, + provider: .codex, + resyncReadinessBaselineAfterRebuild: true) + for _ in 0..<20 where !controller.nativeHighlightDeferredMenuRebuilds.contains(key) { + await Task.yield() + } #expect(rebuildCount == 0) #expect(controller.nativeHighlightDeferredMenuRebuilds.contains(key)) + #expect(controller.nativeHighlightDeferredMenuBaselineResyncs.contains(key)) #expect(controller.menuNeedsRefresh(menu)) controller.menu(menu, willHighlight: cost) @@ -111,7 +119,9 @@ extension StatusMenuTests { #expect(rebuildCount == 1) #expect(!controller.nativeHighlightDeferredMenuRebuilds.contains(key)) + #expect(!controller.nativeHighlightDeferredMenuBaselineResyncs.contains(key)) #expect(!controller.menuNeedsRefresh(menu)) + #expect(controller.lastMenuAdjunctReadinessSignature == controller.menuAdjunctReadinessSignature()) } @Test From 49418c2657354079d0f0f0771ca2679953df3b93 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 10 Jul 2026 08:03:11 -0700 Subject: [PATCH 3/5] fix: preserve deferred menu rebuild state Co-authored-by: Trim --- CHANGELOG.md | 2 +- ...ItemController+MenuRefreshScheduling.swift | 17 +- .../StatusItemController+MenuTracking.swift | 45 +++-- .../StatusItemController+Shutdown.swift | 2 +- Sources/CodexBar/StatusItemController.swift | 3 +- .../StatusItemControllerShutdownTests.swift | 4 + ...tusItemControllerSplitLifecycleTests.swift | 4 +- .../StatusMenuHighlightTests.swift | 190 +++++++++++++++++- 8 files changed, 228 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b461ad0ef..bf51835e7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +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. +- 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+MenuRefreshScheduling.swift b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift index e7bd4f3a71..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)) } } @@ -344,14 +344,9 @@ extension StatusItemController { if request.shouldCloseHostedSubviewMenus { self.closeHostedSubviewMenusForParentSwitch() } - self.rebuildOpenMenuIfStillVisible( - menu, - provider: request.provider, - resyncReadinessBaselineAfterRebuild: request.resyncReadinessBaselineAfterRebuild) - let shouldResyncReadinessBaseline = request.resyncReadinessBaselineAfterRebuild || - self.nativeHighlightDeferredMenuBaselineResyncs.contains(key) - if shouldResyncReadinessBaseline, !self.menuNeedsRefresh(menu) { - self.nativeHighlightDeferredMenuBaselineResyncs.remove(key) + self.rebuildOpenMenuIfStillVisible(menu, provider: request.provider) + 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 2d49ea3991..278816f6cf 100644 --- a/Sources/CodexBar/StatusItemController+MenuTracking.swift +++ b/Sources/CodexBar/StatusItemController+MenuTracking.swift @@ -130,6 +130,7 @@ 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) { @@ -137,7 +138,6 @@ extension StatusItemController { (highlightedView as? MenuCardHighlighting)?.setHighlighted(false) } self.nativeHighlightDeferredMenuRebuilds.remove(key) - self.nativeHighlightDeferredMenuBaselineResyncs.remove(key) } func removeMenuLifecycleState(_ key: ObjectIdentifier) { @@ -393,26 +393,24 @@ extension StatusItemController { allowStaleContentDuringDataRefresh: true) } - func rebuildOpenMenuIfStillVisible( - _ menu: NSMenu, - provider: UsageProvider?, - resyncReadinessBaselineAfterRebuild: Bool = false) - { + 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 } + let isHostedSubviewMenu = self.isHostedSubviewMenu(menu) + guard isHostedSubviewMenu || !self.hasOpenHostedSubviewMenu() else { return } guard !self.isNativeMenuItemHighlighted(in: menu) else { self.nativeHighlightDeferredMenuRebuilds.insert(key) - if resyncReadinessBaselineAfterRebuild { - self.nativeHighlightDeferredMenuBaselineResyncs.insert(key) - } return } self.nativeHighlightDeferredMenuRebuilds.remove(key) - self.populateMenu(menu, provider: provider) - self.markMenuFresh(menu) - self.menuSession.clearParentRebuildDeferral(key) - self.applyIcon(phase: nil) + 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 @@ -427,17 +425,24 @@ extension StatusItemController { func resumeMenuRebuildDeferredForNativeHighlightIfNeeded(_ menu: NSMenu) { let key = ObjectIdentifier(menu) guard self.nativeHighlightDeferredMenuRebuilds.contains(key) else { return } - guard self.openMenus[key] === menu, self.menuNeedsRefresh(menu) else { + guard self.openMenus[key] === menu else { self.nativeHighlightDeferredMenuRebuilds.remove(key) - self.nativeHighlightDeferredMenuBaselineResyncs.remove(key) + self.pendingMenuBaselineResyncs.remove(key) return } - guard !self.hasOpenHostedSubviewMenu() else { return } + let isHostedSubviewMenu = self.isHostedSubviewMenu(menu) + guard isHostedSubviewMenu || self.menuNeedsRefresh(menu) else { + self.nativeHighlightDeferredMenuRebuilds.remove(key) + if self.pendingMenuBaselineResyncs.remove(key) != nil { + self.resyncMenuAdjunctReadinessBaseline() + } + return + } + guard isHostedSubviewMenu || !self.hasOpenHostedSubviewMenu() else { return } self.nativeHighlightDeferredMenuRebuilds.remove(key) self.scheduleOpenMenuRebuildIfStillVisible( menu, - provider: self.menuProvider(for: menu), - resyncReadinessBaselineAfterRebuild: self.nativeHighlightDeferredMenuBaselineResyncs.contains(key)) + provider: self.menuProvider(for: menu)) } func refreshOpenMenusIfNeeded() { @@ -521,7 +526,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 58b01d5e15..1ca78b57a9 100644 --- a/Sources/CodexBar/StatusItemController+Shutdown.swift +++ b/Sources/CodexBar/StatusItemController+Shutdown.swift @@ -81,7 +81,7 @@ extension StatusItemController { self.openMenus.removeAll(keepingCapacity: false) self.highlightedMenuItems.removeAll(keepingCapacity: false) self.nativeHighlightDeferredMenuRebuilds.removeAll(keepingCapacity: false) - self.nativeHighlightDeferredMenuBaselineResyncs.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 3a44598184..5d9ef28f73 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -170,7 +170,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var highlightedMenuItems: [ObjectIdentifier: NSMenuItem] = [:] /// Open-menu rebuilds paused so AppKit's native selection background cannot retain stale geometry. var nativeHighlightDeferredMenuRebuilds: Set = [] - var nativeHighlightDeferredMenuBaselineResyncs: Set = [] + /// 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..4603f433d8 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.insert(key) + 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 1447f50e24..e0be27d83a 100644 --- a/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift +++ b/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift @@ -149,7 +149,7 @@ struct StatusItemControllerSplitLifecycleTests { controller.openMenuRebuildsClosingHostedSubviewMenus.insert(key) controller.highlightedMenuItems[key] = NSMenuItem(title: "Highlighted", action: nil, keyEquivalent: "") controller.nativeHighlightDeferredMenuRebuilds.insert(key) - controller.nativeHighlightDeferredMenuBaselineResyncs.insert(key) + controller.pendingMenuBaselineResyncs.insert(key) } settings.mergeIcons = true @@ -171,7 +171,7 @@ struct StatusItemControllerSplitLifecycleTests { #expect(!controller.openMenuRebuildsClosingHostedSubviewMenus.contains(key)) #expect(controller.highlightedMenuItems[key] == nil) #expect(!controller.nativeHighlightDeferredMenuRebuilds.contains(key)) - #expect(!controller.nativeHighlightDeferredMenuBaselineResyncs.contains(key)) + #expect(!controller.pendingMenuBaselineResyncs.contains(key)) } } diff --git a/Tests/CodexBarTests/StatusMenuHighlightTests.swift b/Tests/CodexBarTests/StatusMenuHighlightTests.swift index 3f6457c667..5e97749602 100644 --- a/Tests/CodexBarTests/StatusMenuHighlightTests.swift +++ b/Tests/CodexBarTests/StatusMenuHighlightTests.swift @@ -54,7 +54,7 @@ extension StatusMenuTests { } @Test - func `native highlight defers open menu rebuild until pointer leaves native rows`() async { + func `native highlight preserves coalesced baseline resync until pointer leaves native rows`() async { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -96,13 +96,14 @@ extension StatusMenuTests { menu, provider: .codex, resyncReadinessBaselineAfterRebuild: true) + controller.scheduleOpenMenuRebuildIfStillVisible(menu, provider: .codex) for _ in 0..<20 where !controller.nativeHighlightDeferredMenuRebuilds.contains(key) { await Task.yield() } #expect(rebuildCount == 0) #expect(controller.nativeHighlightDeferredMenuRebuilds.contains(key)) - #expect(controller.nativeHighlightDeferredMenuBaselineResyncs.contains(key)) + #expect(controller.pendingMenuBaselineResyncs.contains(key)) #expect(controller.menuNeedsRefresh(menu)) controller.menu(menu, willHighlight: cost) @@ -119,11 +120,194 @@ extension StatusMenuTests { #expect(rebuildCount == 1) #expect(!controller.nativeHighlightDeferredMenuRebuilds.contains(key)) - #expect(!controller.nativeHighlightDeferredMenuBaselineResyncs.contains(key)) + #expect(!controller.pendingMenuBaselineResyncs.contains(key)) #expect(!controller.menuNeedsRefresh(menu)) #expect(controller.lastMenuAdjunctReadinessSignature == controller.menuAdjunctReadinessSignature()) } + @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.contains(key) { + await Task.yield() + } + + #expect(controller.nativeHighlightDeferredMenuRebuilds.contains(key)) + #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.contains(key)) + #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.contains(key) { + await Task.yield() + } + + #expect(rebuildCount == 0) + #expect(controller.nativeHighlightDeferredMenuRebuilds.contains(key)) + #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.contains(key)) + #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() From 44e4f90bd2c6573f683021f34e4cdf768f4ad965 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 10 Jul 2026 08:59:50 -0700 Subject: [PATCH 4/5] fix: preserve explicit highlighted menu rebuilds Co-authored-by: Trim --- .../StatusItemController+MenuTracking.swift | 21 ++---- Sources/CodexBar/StatusItemController.swift | 6 +- .../StatusItemControllerShutdownTests.swift | 2 +- ...tusItemControllerSplitLifecycleTests.swift | 4 +- .../StatusMenuHighlightTests.swift | 74 ++++++++++++++++--- 5 files changed, 78 insertions(+), 29 deletions(-) diff --git a/Sources/CodexBar/StatusItemController+MenuTracking.swift b/Sources/CodexBar/StatusItemController+MenuTracking.swift index 278816f6cf..ae44e76c2e 100644 --- a/Sources/CodexBar/StatusItemController+MenuTracking.swift +++ b/Sources/CodexBar/StatusItemController+MenuTracking.swift @@ -137,7 +137,7 @@ extension StatusItemController { if let highlightedView = self.highlightedMenuItems.removeValue(forKey: key)?.view { (highlightedView as? MenuCardHighlighting)?.setHighlighted(false) } - self.nativeHighlightDeferredMenuRebuilds.remove(key) + self.nativeHighlightDeferredMenuRebuilds.removeValue(forKey: key) } func removeMenuLifecycleState(_ key: ObjectIdentifier) { @@ -399,10 +399,10 @@ extension StatusItemController { let isHostedSubviewMenu = self.isHostedSubviewMenu(menu) guard isHostedSubviewMenu || !self.hasOpenHostedSubviewMenu() else { return } guard !self.isNativeMenuItemHighlighted(in: menu) else { - self.nativeHighlightDeferredMenuRebuilds.insert(key) + self.nativeHighlightDeferredMenuRebuilds[key] = NativeHighlightDeferredMenuRebuild(provider: provider) return } - self.nativeHighlightDeferredMenuRebuilds.remove(key) + self.nativeHighlightDeferredMenuRebuilds.removeValue(forKey: key) if isHostedSubviewMenu { self.refreshHostedSubviewMenu(menu) } else { @@ -424,25 +424,18 @@ extension StatusItemController { func resumeMenuRebuildDeferredForNativeHighlightIfNeeded(_ menu: NSMenu) { let key = ObjectIdentifier(menu) - guard self.nativeHighlightDeferredMenuRebuilds.contains(key) else { return } + guard let deferredRebuild = self.nativeHighlightDeferredMenuRebuilds[key] else { return } guard self.openMenus[key] === menu else { - self.nativeHighlightDeferredMenuRebuilds.remove(key) + self.nativeHighlightDeferredMenuRebuilds.removeValue(forKey: key) self.pendingMenuBaselineResyncs.remove(key) return } let isHostedSubviewMenu = self.isHostedSubviewMenu(menu) - guard isHostedSubviewMenu || self.menuNeedsRefresh(menu) else { - self.nativeHighlightDeferredMenuRebuilds.remove(key) - if self.pendingMenuBaselineResyncs.remove(key) != nil { - self.resyncMenuAdjunctReadinessBaseline() - } - return - } guard isHostedSubviewMenu || !self.hasOpenHostedSubviewMenu() else { return } - self.nativeHighlightDeferredMenuRebuilds.remove(key) + self.nativeHighlightDeferredMenuRebuilds.removeValue(forKey: key) self.scheduleOpenMenuRebuildIfStillVisible( menu, - provider: self.menuProvider(for: menu)) + provider: deferredRebuild.provider) } func refreshOpenMenusIfNeeded() { diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 5d9ef28f73..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. @@ -169,7 +173,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var deferredMenuInteractionRefreshTask: Task? var highlightedMenuItems: [ObjectIdentifier: NSMenuItem] = [:] /// Open-menu rebuilds paused so AppKit's native selection background cannot retain stale geometry. - var nativeHighlightDeferredMenuRebuilds: Set = [] + var nativeHighlightDeferredMenuRebuilds: [ObjectIdentifier: NativeHighlightDeferredMenuRebuild] = [:] /// Baseline resync intent survives rebuild coalescing and any native-row or hosted-submenu deferral. var pendingMenuBaselineResyncs: Set = [] var providerSwitcherShortcutEventMonitor: ProviderSwitcherShortcutEventMonitor? diff --git a/Tests/CodexBarTests/StatusItemControllerShutdownTests.swift b/Tests/CodexBarTests/StatusItemControllerShutdownTests.swift index 4603f433d8..69e185a9df 100644 --- a/Tests/CodexBarTests/StatusItemControllerShutdownTests.swift +++ b/Tests/CodexBarTests/StatusItemControllerShutdownTests.swift @@ -43,7 +43,7 @@ struct StatusItemControllerShutdownTests { controller.menuRefreshTasks[key] = Task { try? await Task.sleep(for: .seconds(30)) } controller.menuReadinessSignatures[key] = "readiness" controller.menuIdentitySignatures[key] = "identity" - controller.nativeHighlightDeferredMenuRebuilds.insert(key) + controller.nativeHighlightDeferredMenuRebuilds[key] = .init(provider: .codex) controller.pendingMenuBaselineResyncs.insert(key) #expect(controller.openMenus[key] === menu) diff --git a/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift b/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift index e0be27d83a..3d64fffa01 100644 --- a/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift +++ b/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift @@ -148,7 +148,7 @@ struct StatusItemControllerSplitLifecycleTests { _ = controller.openMenuRebuildRequests.replaceRequest(for: key) controller.openMenuRebuildsClosingHostedSubviewMenus.insert(key) controller.highlightedMenuItems[key] = NSMenuItem(title: "Highlighted", action: nil, keyEquivalent: "") - controller.nativeHighlightDeferredMenuRebuilds.insert(key) + controller.nativeHighlightDeferredMenuRebuilds[key] = .init(provider: .codex) controller.pendingMenuBaselineResyncs.insert(key) } @@ -170,7 +170,7 @@ struct StatusItemControllerSplitLifecycleTests { #expect(controller.openMenuRebuildRequests.tokens[key] == nil) #expect(!controller.openMenuRebuildsClosingHostedSubviewMenus.contains(key)) #expect(controller.highlightedMenuItems[key] == nil) - #expect(!controller.nativeHighlightDeferredMenuRebuilds.contains(key)) + #expect(controller.nativeHighlightDeferredMenuRebuilds[key] == nil) #expect(!controller.pendingMenuBaselineResyncs.contains(key)) } } diff --git a/Tests/CodexBarTests/StatusMenuHighlightTests.swift b/Tests/CodexBarTests/StatusMenuHighlightTests.swift index 5e97749602..aa31e95a4b 100644 --- a/Tests/CodexBarTests/StatusMenuHighlightTests.swift +++ b/Tests/CodexBarTests/StatusMenuHighlightTests.swift @@ -97,12 +97,12 @@ extension StatusMenuTests { provider: .codex, resyncReadinessBaselineAfterRebuild: true) controller.scheduleOpenMenuRebuildIfStillVisible(menu, provider: .codex) - for _ in 0..<20 where !controller.nativeHighlightDeferredMenuRebuilds.contains(key) { + for _ in 0..<20 where controller.nativeHighlightDeferredMenuRebuilds[key] == nil { await Task.yield() } #expect(rebuildCount == 0) - #expect(controller.nativeHighlightDeferredMenuRebuilds.contains(key)) + #expect(controller.nativeHighlightDeferredMenuRebuilds[key] != nil) #expect(controller.pendingMenuBaselineResyncs.contains(key)) #expect(controller.menuNeedsRefresh(menu)) @@ -111,7 +111,7 @@ extension StatusMenuTests { await Task.yield() } #expect(rebuildCount == 0) - #expect(controller.nativeHighlightDeferredMenuRebuilds.contains(key)) + #expect(controller.nativeHighlightDeferredMenuRebuilds[key] != nil) controller.menu(menu, willHighlight: nil) for _ in 0..<20 where rebuildCount == 0 { @@ -119,12 +119,64 @@ extension StatusMenuTests { } #expect(rebuildCount == 1) - #expect(!controller.nativeHighlightDeferredMenuRebuilds.contains(key)) + #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 preserves pending parent baseline resync`() async { self.disableMenuCardsForTesting() @@ -221,11 +273,11 @@ extension StatusMenuTests { menu, provider: .codex, resyncReadinessBaselineAfterRebuild: true) - for _ in 0..<20 where !controller.nativeHighlightDeferredMenuRebuilds.contains(key) { + for _ in 0..<20 where controller.nativeHighlightDeferredMenuRebuilds[key] == nil { await Task.yield() } - #expect(controller.nativeHighlightDeferredMenuRebuilds.contains(key)) + #expect(controller.nativeHighlightDeferredMenuRebuilds[key] != nil) #expect(controller.pendingMenuBaselineResyncs.contains(key)) controller.menuDidClose(menu) for _ in 0..<10 { @@ -235,7 +287,7 @@ extension StatusMenuTests { #expect(rebuildCount == 0) #expect(controller.openMenus[key] == nil) #expect(controller.highlightedMenuItems[key] == nil) - #expect(!controller.nativeHighlightDeferredMenuRebuilds.contains(key)) + #expect(controller.nativeHighlightDeferredMenuRebuilds[key] == nil) #expect(!controller.pendingMenuBaselineResyncs.contains(key)) #expect(controller.openMenuRebuildTasks[key] == nil) #expect(controller.openMenuRebuildRequests.tokens[key] == nil) @@ -287,12 +339,12 @@ extension StatusMenuTests { defer { controller._test_openMenuRebuildObserver = nil } controller.refreshOpenMenusAllowingParentRebuild() - for _ in 0..<20 where !controller.nativeHighlightDeferredMenuRebuilds.contains(key) { + for _ in 0..<20 where controller.nativeHighlightDeferredMenuRebuilds[key] == nil { await Task.yield() } #expect(rebuildCount == 0) - #expect(controller.nativeHighlightDeferredMenuRebuilds.contains(key)) + #expect(controller.nativeHighlightDeferredMenuRebuilds[key] != nil) #expect(submenu.items.count == 1) #expect(submenu.items.first === originalLink) @@ -302,7 +354,7 @@ extension StatusMenuTests { } #expect(rebuildCount == 1) - #expect(!controller.nativeHighlightDeferredMenuRebuilds.contains(key)) + #expect(controller.nativeHighlightDeferredMenuRebuilds[key] == nil) #expect(submenu.items.count == 3) #expect(submenu.items.last !== originalLink) #expect(submenu.items.last?.title == L("Open Status Page")) @@ -344,7 +396,7 @@ extension StatusMenuTests { controller.rebuildOpenMenuIfStillVisible(menu, provider: .codex) #expect(rebuildCount == 1) - #expect(!controller.nativeHighlightDeferredMenuRebuilds.contains(key)) + #expect(controller.nativeHighlightDeferredMenuRebuilds[key] == nil) #expect(!controller.menuNeedsRefresh(menu)) } } From 94ccb89a96f426444e76d79d790c674ea1a9df7b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 10 Jul 2026 11:22:00 -0700 Subject: [PATCH 5/5] fix: resume highlighted parents after submenu close Co-authored-by: Trim --- .../StatusItemController+MenuTracking.swift | 15 ++ .../StatusMenuHighlightTests.swift | 147 +++++++++++++++++- 2 files changed, 160 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBar/StatusItemController+MenuTracking.swift b/Sources/CodexBar/StatusItemController+MenuTracking.swift index ae44e76c2e..9b032175bc 100644 --- a/Sources/CodexBar/StatusItemController+MenuTracking.swift +++ b/Sources/CodexBar/StatusItemController+MenuTracking.swift @@ -457,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() { diff --git a/Tests/CodexBarTests/StatusMenuHighlightTests.swift b/Tests/CodexBarTests/StatusMenuHighlightTests.swift index aa31e95a4b..e69ca422e7 100644 --- a/Tests/CodexBarTests/StatusMenuHighlightTests.swift +++ b/Tests/CodexBarTests/StatusMenuHighlightTests.swift @@ -177,6 +177,145 @@ extension StatusMenuTests { #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() @@ -212,7 +351,9 @@ extension StatusMenuTests { controller.menuSession.invalidate(allowsStaleContent: false, requiresRebuild: true) var rebuildCount = 0 controller._test_openMenuRebuildObserver = { menu in - if menu === parent { rebuildCount += 1 } + if menu === parent { + rebuildCount += 1 + } } defer { controller._test_openMenuRebuildObserver = nil } @@ -334,7 +475,9 @@ extension StatusMenuTests { ] var rebuildCount = 0 controller._test_openMenuRebuildObserver = { menu in - if menu === submenu { rebuildCount += 1 } + if menu === submenu { + rebuildCount += 1 + } } defer { controller._test_openMenuRebuildObserver = nil }