diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ec2c03e0f..342d7b8238 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Codex: keep local token and cost history visible when remote quota data is unavailable (#1390). Thanks @vaibhavarora14! - Doubao: confirm zero-remaining HTTP 200 request limits before falling back, preserving genuine exhaustion and avoiding false 100% usage (#1383). Thanks @LeoLin990405 and @foobra! - Menu bar: defer pasteboard writes and copy feedback outside the `NSMenu` tracking callback so in-menu copy buttons no longer beachball on macOS 26 (#1388). Thanks @LeoLin990405! +- Menu bar: defer merged status-icon redraws until the tracked menu closes while preserving animation lifecycle and quota-warning timing, reducing WindowServer churn during long menu sessions (#1409, fixes #1399). Thanks @kiranmagic7! - Menu bar: defer Overview-row provider transitions out of AppKit's click callback so opening provider detail no longer performs a full synchronous menu rebuild (#1325). - Menu bar: open cached menus immediately after data-only invalidations, then refresh missing or stale provider data asynchronously without queuing redundant work on close (#1398). Thanks @joshuavial! - Menu bar: recycle SwiftUI card hosting views across data refreshes and provider switches, and reconcile matching menu rows in place instead of removing and reinserting every row, cutting open-click, switch, and idle rebuild cost (#1394). Thanks @bcssewl! diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index bbb42c4429..3b545e6dc4 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -232,8 +232,13 @@ extension StatusItemController { } @discardableResult - func applyIcon(phase: Double?) -> Bool { + func applyIcon( + phase: Double?, + bypassMergedMenuTrackingDeferral: Bool = false) -> Bool + { guard let button = self.statusItem.button else { return false } + if !bypassMergedMenuTrackingDeferral, + self.deferMergedIconRenderDuringMenuTrackingIfNeeded() { return true } let style = self.store.iconStyle let showUsed = self.settings.usageBarsShowUsed @@ -395,6 +400,25 @@ extension StatusItemController { return false } + private func deferMergedIconRenderDuringMenuTrackingIfNeeded() -> Bool { + guard self.shouldMergeIcons, self.isMergedMenuOpen else { return false } + self.deferredMergedIconRenderAfterTracking = true + self.noteIconPerfRender(skipped: true) + return true + } + + func applyDeferredMergedIconRenderAfterTrackingIfNeeded() { + guard self.deferredMergedIconRenderAfterTracking else { return } + guard self.shouldMergeIcons else { + self.deferredMergedIconRenderAfterTracking = false + return + } + guard !self.isMergedMenuOpen else { return } + self.deferredMergedIconRenderAfterTracking = false + let phase: Double? = self.animationDriver == nil ? nil : self.animationPhase + self.applyIcon(phase: phase) + } + private func shouldSkipMergedIconRender(_ signature: String) -> Bool { guard self.shouldMergeIcons else { self.lastAppliedMergedIconRenderSignature = signature @@ -595,6 +619,42 @@ extension StatusItemController { return false } + func startQuotaWarningFlash(provider: UsageProvider, postedAt: Date = Date()) { + let until = postedAt.addingTimeInterval(Self.quotaWarningFlashDuration) + self.quotaWarningFlashUntil[provider] = until + self.quotaWarningFlashTasks[provider]?.cancel() + self.updateIcons() + self.applyQuotaWarningIconDuringMergedMenuTrackingIfNeeded() + self.quotaWarningFlashTasks[provider] = Task { [weak self] in + try? await Task.sleep(for: .seconds(Self.quotaWarningFlashDuration)) + await MainActor.run { [weak self] in + self?.clearExpiredQuotaWarningFlash(provider: provider) + } + } + } + + func clearExpiredQuotaWarningFlash(provider: UsageProvider, now: Date = Date()) { + guard let currentUntil = self.quotaWarningFlashUntil[provider], + currentUntil <= now + else { + return + } + self.quotaWarningFlashUntil.removeValue(forKey: provider) + self.quotaWarningFlashTasks.removeValue(forKey: provider) + self.updateIcons() + self.applyQuotaWarningIconDuringMergedMenuTrackingIfNeeded() + } + + private func applyQuotaWarningIconDuringMergedMenuTrackingIfNeeded() { + guard self.shouldMergeIcons, + self.isMergedMenuOpen + else { + return + } + let phase: Double? = self.animationDriver == nil ? nil : self.animationPhase + self.applyIcon(phase: phase, bypassMergedMenuTrackingDeferral: true) + } + static func quotaWarningFlashImage(base: NSImage) -> NSImage { let image = NSImage(size: base.size) image.lockFocus() diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 9dfebcf3b9..17fd31bfd9 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -159,6 +159,7 @@ extension StatusItemController { func forgetClosedMenu(_ menu: NSMenu) { let key = ObjectIdentifier(menu) + let wasMergedMenu = menu === self.mergedMenu if key == self.providerSwitcherShortcutMenuID { self.removeProviderSwitcherShortcutMonitor() @@ -185,6 +186,9 @@ extension StatusItemController { } self.parentMenuRebuildsDeferredDuringTracking.remove(key) self.scheduleDeferredMenuInteractionRefreshIfNeeded() + if wasMergedMenu { + self.applyDeferredMergedIconRenderAfterTrackingIfNeeded() + } } func menu(_ menu: NSMenu, willHighlight item: NSMenuItem?) { diff --git a/Sources/CodexBar/StatusItemController+Shutdown.swift b/Sources/CodexBar/StatusItemController+Shutdown.swift index 6e44db4e03..5ca3f397bf 100644 --- a/Sources/CodexBar/StatusItemController+Shutdown.swift +++ b/Sources/CodexBar/StatusItemController+Shutdown.swift @@ -54,6 +54,7 @@ extension StatusItemController { self.openMenuInvalidationRetryTask = nil self.providerSelectionUIRefreshTask?.cancel() self.providerSelectionUIRefreshTask = nil + self.deferredMergedIconRenderAfterTracking = false self.providerSwitcherPointerInteractionMenuID = nil self.pendingProviderSwitcherPointerRebuild = nil } diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index b1e57e6ea3..ca719199ad 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -249,6 +249,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin /// Monotonic token used to ignore stale deferred provider-switcher menu rebuilds. var providerSwitcherUpdateToken = 0 var providerSelectionUIRefreshTask: Task? + var deferredMergedIconRenderAfterTracking = false var lastAppliedMergedIconRenderSignature: String? var lastAppliedProviderIconRenderSignatures: [UsageProvider: String] = [:] var lastObservedStoreIconWorkSignature: String? @@ -568,26 +569,6 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.startQuotaWarningFlash(provider: event.provider, postedAt: event.postedAt) } - func startQuotaWarningFlash(provider: UsageProvider, postedAt: Date = Date()) { - let until = postedAt.addingTimeInterval(Self.quotaWarningFlashDuration) - self.quotaWarningFlashUntil[provider] = until - self.quotaWarningFlashTasks[provider]?.cancel() - self.updateIcons() - self.quotaWarningFlashTasks[provider] = Task { [weak self] in - try? await Task.sleep(for: .seconds(Self.quotaWarningFlashDuration)) - await MainActor.run { [weak self] in - guard let self else { return } - if let currentUntil = self.quotaWarningFlashUntil[provider], - currentUntil <= Date() - { - self.quotaWarningFlashUntil.removeValue(forKey: provider) - self.quotaWarningFlashTasks.removeValue(forKey: provider) - self.updateIcons() - } - } - } - } - private func observeUpdaterChanges() { withObservationTracking { _ = self.updater.updateStatus.isUpdateReady @@ -670,7 +651,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } } - private func updateIcons() { + func updateIcons() { #if DEBUG guard !self.isReleasedForTesting else { return } #endif @@ -683,6 +664,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin if self.shouldMergeIcons { let skippedMergedRender = self.applyIcon(phase: phase) if skippedMergedRender, + !self.deferredMergedIconRenderAfterTracking, let mergedMenu = self.mergedMenu, self.statusItem.menu === mergedMenu { diff --git a/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift index dfef3960d7..94e3800145 100644 --- a/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift +++ b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift @@ -138,6 +138,131 @@ struct StatusItemAnimationSignatureTests { #expect(button.imagePosition == .imageLeft) } + @Test + func `merged icon render defers while merged menu is tracking`() async throws { + let suite = "StatusItemAnimationSignatureTests-merged-icon-defers-during-tracking" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.menuBarShowsBrandIconWithPercent = false + settings.syntheticAPIToken = "synthetic-test-token" + + 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 == .synthetic) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + func snapshot(usedPercent: Double) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date()) + } + + store._setSnapshotForTesting(snapshot(usedPercent: 20), provider: .codex) + for _ in 0..<10 where controller.animationDriver != nil { + await Task.yield() + } + #expect(controller.animationDriver == nil) + controller.applyIcon(phase: nil) + let initialSignature = try #require(controller.lastAppliedMergedIconRenderSignature) + + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + controller.menuWillOpen(menu) + #expect(controller.isMergedMenuOpen) + + store._setSnapshotForTesting(nil, provider: .codex) + for _ in 0..<10 where controller.animationDriver == nil { + await Task.yield() + } + #expect(controller.animationDriver != nil) + #expect(controller.deferredMergedIconRenderAfterTracking) + + store._setSnapshotForTesting(snapshot(usedPercent: 80), provider: .codex) + for _ in 0..<10 where controller.animationDriver != nil { + await Task.yield() + } + #expect(controller.animationDriver == nil) + #expect(controller.deferredMergedIconRenderAfterTracking) + #expect(controller.lastAppliedMergedIconRenderSignature == initialSignature) + + controller.startQuotaWarningFlash(provider: .codex) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("warningFlash=1") == true) + + let quotaWarningTask = controller.quotaWarningFlashTasks[.codex] + controller.clearExpiredQuotaWarningFlash(provider: .codex, now: .distantFuture) + quotaWarningTask?.cancel() + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("warningFlash=0") == true) + + controller.menuDidClose(menu) + + #expect(!controller.deferredMergedIconRenderAfterTracking) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("warningFlash=0") == true) + + controller.menuWillOpen(menu) + settings.selectedMenuProvider = .synthetic + #expect(controller.primaryProviderForUnifiedIcon() == .synthetic) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=codex") == true) + + controller.startQuotaWarningFlash(provider: .codex) + let switchedProviderWarningTask = controller.quotaWarningFlashTasks[.codex] + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=synthetic") == true) + controller.clearExpiredQuotaWarningFlash(provider: .codex, now: .distantFuture) + switchedProviderWarningTask?.cancel() + controller.menuDidClose(menu) + + settings.selectedMenuProvider = .codex + for _ in 0..<10 where controller.primaryProviderForUnifiedIcon() != .codex { + await Task.yield() + } + + controller.menuWillOpen(menu) + store._setSnapshotForTesting(nil, provider: .codex) + controller.updateAnimationState() + controller.applyIcon(phase: controller.animationPhase) + #expect(controller.animationDriver != nil) + #expect(controller.deferredMergedIconRenderAfterTracking) + + controller.animationDriver?.stop() + controller.animationDriver = nil + controller.animationPhase = 0 + controller.menuDidClose(menu) + + #expect(controller.animationDriver == nil) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("primary=nil") == true) + } + @Test func `merged fallback provider follows enabled provider order`() throws { let suite = "StatusItemAnimationSignatureTests-merged-provider-order"