Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down
62 changes: 61 additions & 1 deletion Sources/CodexBar/StatusItemController+Animation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
4 changes: 4 additions & 0 deletions Sources/CodexBar/StatusItemController+Menu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -185,6 +186,9 @@ extension StatusItemController {
}
self.parentMenuRebuildsDeferredDuringTracking.remove(key)
self.scheduleDeferredMenuInteractionRefreshIfNeeded()
if wasMergedMenu {
self.applyDeferredMergedIconRenderAfterTrackingIfNeeded()
}
}

func menu(_ menu: NSMenu, willHighlight item: NSMenuItem?) {
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/StatusItemController+Shutdown.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ extension StatusItemController {
self.openMenuInvalidationRetryTask = nil
self.providerSelectionUIRefreshTask?.cancel()
self.providerSelectionUIRefreshTask = nil
self.deferredMergedIconRenderAfterTracking = false
self.providerSwitcherPointerInteractionMenuID = nil
self.pendingProviderSwitcherPointerRebuild = nil
}
Expand Down
24 changes: 3 additions & 21 deletions Sources/CodexBar/StatusItemController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>?
var deferredMergedIconRenderAfterTracking = false
var lastAppliedMergedIconRenderSignature: String?
var lastAppliedProviderIconRenderSignatures: [UsageProvider: String] = [:]
var lastObservedStoreIconWorkSignature: String?
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -670,7 +651,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin
}
}

private func updateIcons() {
func updateIcons() {
#if DEBUG
guard !self.isReleasedForTesting else { return }
#endif
Expand All @@ -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
{
Expand Down
125 changes: 125 additions & 0 deletions Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down