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 @@ -4,6 +4,7 @@

### Fixed
- Copilot: treat GitHub Copilot Business token-billing zero-entitlement quotas as unavailable instead of showing misleading 0% used usage (#1258, #1270). Thanks @devYRPauli!
- Menu bar: prepare closed menus after refresh and only reuse stale dropdown content for data-refresh invalidations so merged menu opens stay responsive without bypassing privacy or structure changes (#1261). Thanks @ProspectOre!
- OpenAI Web: stop reloading away from login and Cloudflare blocking states so the dashboard WebView does not loop on route corrections (#1259). Thanks @ProspectOre!

## 0.32.2 — 2026-06-01
Expand Down
8 changes: 3 additions & 5 deletions Sources/CodexBar/StatusItemController+Menu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ extension StatusItemController {
}

self.cancelDeferredMenuInteractionRefreshTask()
self.cancelClosedMenuRebuild(menu)

if self.isHostedSubviewMenu(menu) {
self.hydrateHostedSubviewMenuIfNeeded(menu)
Expand Down Expand Up @@ -122,11 +123,7 @@ extension StatusItemController {
self.deferOpenAIDashboardRefreshUntilMenuCloses(reason: "parent menu open")
}

if self.menuNeedsRefresh(menu) {
self.populateMenu(menu, provider: provider)
self.markMenuFresh(menu)
// Heights are already set during populateMenu, no need to remeasure
}
self.refreshMenuForOpenIfNeeded(menu, provider: provider)
if self.isMenuRefreshEnabled {
// Intentionally skip open-menu tracking when refresh is disabled (tests).
// If refresh is re-enabled while this menu stays open, it will not be backfilled until next open.
Expand All @@ -152,6 +149,7 @@ extension StatusItemController {
self.removeProviderSwitcherShortcutMonitor()
}

self.cancelClosedMenuRebuild(menu)
self.openMenus.removeValue(forKey: key)
self.menuRefreshTasks.removeValue(forKey: key)?.cancel()
self.openMenuRebuildTasks.removeValue(forKey: key)?.cancel()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ extension StatusItemController {
func menuLocalizationSignature() -> String {
[
codexBarLocalizationSignature(),
self.settings.hidePersonalInfo ? "hide-personal-info" : "show-personal-info",
L("Overview"),
L("Cost"),
].joined(separator: "|")
Expand Down
141 changes: 138 additions & 3 deletions Sources/CodexBar/StatusItemController+MenuTracking.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,39 @@ import AppKit
import CodexBarCore

extension StatusItemController {
private static let defaultClosedMenuPreparationDelay: Duration = .milliseconds(350)

#if DEBUG
private static var closedMenuPreparationDelayForTesting: Duration = defaultClosedMenuPreparationDelay
static func setClosedMenuPreparationDelayForTesting(_ delay: Duration) {
self.closedMenuPreparationDelayForTesting = delay
}

static func resetClosedMenuPreparationDelayForTesting() {
self.closedMenuPreparationDelayForTesting = self.defaultClosedMenuPreparationDelay
}
#endif

private static var closedMenuPreparationDelay: Duration {
#if DEBUG
closedMenuPreparationDelayForTesting
#else
defaultClosedMenuPreparationDelay
#endif
}

func invalidateMenus(
refreshOpenMenus: Bool = false,
deferOpenParentMenuRebuild: Bool = false)
deferOpenParentMenuRebuild: Bool = false,
allowStaleContentDuringDataRefresh: Bool = false)
{
#if DEBUG
guard !self.isReleasedForTesting else { return }
#endif
self.menuContentVersion &+= 1
if !allowStaleContentDuringDataRefresh {
self.latestRequiredMenuRebuildVersion = self.menuContentVersion
}
guard self.isMenuRefreshEnabled else { return }
if !self.openMenus.isEmpty {
guard refreshOpenMenus else { return }
Expand All @@ -19,6 +44,70 @@ extension StatusItemController {
deferParentRebuildDuringTracking: deferOpenParentMenuRebuild)
return
}
self.prepareAttachedClosedMenusIfNeeded()
}

func prepareAttachedClosedMenusIfNeeded() {
guard self.isMenuRefreshEnabled else { return }
guard self.openMenus.isEmpty else { return }
guard !self.isMenuDataRefreshInFlight else { return }
for menu in self.attachedMenusForClosedPreparation() {
self.rebuildClosedMenuIfNeeded(menu)
}
}

var isMenuDataRefreshInFlight: Bool {
self.store.isRefreshing ||
UsageProvider.allCases.contains { self.store.isTokenRefreshInFlight(for: $0) }
}

func refreshMenuForOpenIfNeeded(_ menu: NSMenu, provider: UsageProvider?) {
guard self.menuNeedsRefresh(menu) else { return }
if self.canPreserveStaleMenuContentDuringRefresh(menu) {
#if DEBUG
self.menuLogger.debug(
"menu open kept existing content during refresh",
metadata: [
"items": "\(menu.items.count)",
"provider": provider?.rawValue ?? "nil",
"storeRefreshing": self.store.isRefreshing ? "1" : "0",
])
#endif
self.deferMenuInteractionRefreshIfNeeded()
return
}
self.populateMenu(menu, provider: provider)
self.markMenuFresh(menu)
}

private func canPreserveStaleMenuContentDuringRefresh(_ menu: NSMenu) -> Bool {
guard self.isMenuDataRefreshInFlight, !menu.items.isEmpty else { return false }
let key = ObjectIdentifier(menu)
guard let menuVersion = self.menuVersions[key] else { return false }
return menuVersion >= self.latestRequiredMenuRebuildVersion
}

private func attachedMenusForClosedPreparation() -> [NSMenu] {
var menus: [NSMenu] = []
var seen = Set<ObjectIdentifier>()

func append(_ menu: NSMenu?) {
guard let menu else { return }
let key = ObjectIdentifier(menu)
guard seen.insert(key).inserted else { return }
menus.append(menu)
}

append(self.statusItem.menu)
append(self.mergedMenu)
append(self.fallbackMenu)
for item in self.statusItems.values {
append(item.menu)
}
for menu in self.providerMenus.values {
append(menu)
}
return menus
}

func renderedMenuWidth(for menu: NSMenu) -> CGFloat {
Expand All @@ -28,16 +117,62 @@ extension StatusItemController {

func rebuildClosedMenuIfNeeded(_ menu: NSMenu) {
guard !self.hasPreparedForAppShutdown else { return }
guard !self.isMenuDataRefreshInFlight else { return }
let key = ObjectIdentifier(menu)
let provider = self.menuProvider(for: menu)
Task { @MainActor [weak self, weak menu] in
self.closedMenuRebuildTokenCounter &+= 1
let rebuildToken = self.closedMenuRebuildTokenCounter
self.closedMenuRebuildTokens[key] = rebuildToken
self.closedMenuRebuildTasks[key]?.cancel()
self.closedMenuRebuildTasks[key] = Task { @MainActor [weak self, weak menu] in
let delay = Self.closedMenuPreparationDelay
if delay > .zero {
try? await Task.sleep(for: delay)
}
guard !Task.isCancelled else { return }
await Task.yield()
guard let self, let menu else { return }
guard !Task.isCancelled else { return }
guard let self else { return }
defer {
if self.closedMenuRebuildTokens[key] == rebuildToken {
self.closedMenuRebuildTasks.removeValue(forKey: key)
self.closedMenuRebuildTokens.removeValue(forKey: key)
}
}
guard let menu else { return }
guard self.closedMenuRebuildTokens[key] == rebuildToken else { return }
guard !self.hasPreparedForAppShutdown else { return }
guard !self.isMenuDataRefreshInFlight else { return }
guard self.openMenus[ObjectIdentifier(menu)] == nil else { return }
guard self.menuNeedsRefresh(menu) else { return }
self.populateMenu(menu, provider: provider)
self.markMenuFresh(menu)
#if DEBUG
if self.lastLoggedClosedMenuRebuildVersion != self.menuContentVersion {
self.lastLoggedClosedMenuRebuildVersion = self.menuContentVersion
self.menuLogger.debug(
"closed menu rebuild completed",
metadata: [
"items": "\(menu.items.count)",
"provider": provider?.rawValue ?? "nil",
])
}
#endif
}
}

func cancelClosedMenuRebuild(_ menu: NSMenu) {
let key = ObjectIdentifier(menu)
self.closedMenuRebuildTasks.removeValue(forKey: key)?.cancel()
self.closedMenuRebuildTokens.removeValue(forKey: key)
}

func cancelAllClosedMenuRebuilds() {
for task in self.closedMenuRebuildTasks.values {
task.cancel()
}
self.closedMenuRebuildTasks.removeAll(keepingCapacity: false)
self.closedMenuRebuildTokens.removeAll(keepingCapacity: false)
}

func menuNeedsRefresh(_ menu: NSMenu) -> Bool {
Expand Down
3 changes: 3 additions & 0 deletions Sources/CodexBar/StatusItemController+Shutdown.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ extension StatusItemController {
for task in self.menuRefreshTasks.values {
task.cancel()
}
self.cancelAllClosedMenuRebuilds()
for task in self.openMenuRebuildTasks.values {
task.cancel()
}
Expand All @@ -56,6 +57,8 @@ extension StatusItemController {
private func clearShutdownMenuState() {
self.removeProviderSwitcherShortcutMonitor()
self.menuRefreshTasks.removeAll(keepingCapacity: false)
self.closedMenuRebuildTasks.removeAll(keepingCapacity: false)
self.closedMenuRebuildTokens.removeAll(keepingCapacity: false)
self.openMenuRebuildTasks.removeAll(keepingCapacity: false)
self.openMenuRebuildTokens.removeAll(keepingCapacity: false)
self.openMenuRebuildsClosingHostedSubviewMenus.removeAll(keepingCapacity: false)
Expand Down
32 changes: 21 additions & 11 deletions Sources/CodexBar/StatusItemController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,6 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin
}
}

#if DEBUG
static func setMenuRefreshEnabledForTesting(_ enabled: Bool) {
self.menuRefreshEnabled = enabled
}

static func resetMenuRefreshEnabledForTesting() {
self.menuRefreshEnabled = self.defaultMenuRefreshEnabled
}
#endif

#if DEBUG
var menuRefreshEnabledOverrideForTesting: Bool?
#endif
Expand Down Expand Up @@ -125,13 +115,17 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin
var lastMenuProvider: UsageProvider?
var menuProviders: [ObjectIdentifier: UsageProvider] = [:]
var menuContentVersion: Int = 0
var latestRequiredMenuRebuildVersion: Int = 0
var menuVersions: [ObjectIdentifier: Int] = [:]
var lastMenuAdjunctReadinessSignature = ""
var mergedMenu: NSMenu?
var providerMenus: [UsageProvider: NSMenu] = [:]
var fallbackMenu: NSMenu?
var openMenus: [ObjectIdentifier: NSMenu] = [:]
var menuRefreshTasks: [ObjectIdentifier: Task<Void, Never>] = [:]
var closedMenuRebuildTasks: [ObjectIdentifier: Task<Void, Never>] = [:]
var closedMenuRebuildTokens: [ObjectIdentifier: Int] = [:]
var closedMenuRebuildTokenCounter = 0
var openMenuRebuildTasks: [ObjectIdentifier: Task<Void, Never>] = [:]
var openMenuRebuildTokens: [ObjectIdentifier: Int] = [:]
var openMenuRebuildTokenCounter = 0
Expand All @@ -150,6 +144,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin
var onDeferredMenuInteractionRefreshForTesting: (() -> Void)?
var onOpenMenuInvalidationRetryForTesting: (() -> Void)?
var isReleasedForTesting = false
var lastLoggedClosedMenuRebuildVersion: Int?
var _test_openMenuRefreshYieldOverride: (@MainActor () async -> Void)?
var _test_openMenuRebuildObserver: (@MainActor (NSMenu) -> Void)?
var _test_codexAmbientLoginRunnerOverride:
Expand Down Expand Up @@ -448,7 +443,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin
self.observeStoreChanges()
self.invalidateMenus(
refreshOpenMenus: self.didMenuAdjunctReadinessChange(),
deferOpenParentMenuRebuild: true)
deferOpenParentMenuRebuild: true,
allowStaleContentDuringDataRefresh: true)
}
}
}
Expand Down Expand Up @@ -797,6 +793,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin
if self.statusItem.menu !== self.mergedMenu {
self.statusItem.menu = self.mergedMenu
}
self.prepareAttachedClosedMenusIfNeeded()
}

private func attachMenus(fallback: UsageProvider? = nil) {
Expand Down Expand Up @@ -827,6 +824,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin
item.menu = nil
}
}
self.prepareAttachedClosedMenusIfNeeded()
}

private func rebuildProviderStatusItems() {
Expand Down Expand Up @@ -902,6 +900,18 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin
}
}

#if DEBUG
extension StatusItemController {
static func setMenuRefreshEnabledForTesting(_ enabled: Bool) {
self.menuRefreshEnabled = enabled
}

static func resetMenuRefreshEnabledForTesting() {
self.menuRefreshEnabled = self.defaultMenuRefreshEnabled
}
}
#endif

extension StatusItemController {
private func legacyDefaultItemIndex(forNewProvider provider: UsageProvider) -> Int? {
let visibleProviders = self.settings.orderedProviders().filter { self.isVisible($0) }
Expand Down
Loading