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 @@ -14,6 +14,7 @@
- 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 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!
- Development: disable Keychain access for unbundled executables to avoid repeated password prompts while preserving packaged app behavior (#1271). Thanks @Yuxin-Qiao!
- Antigravity: exclude model quotas without a remaining fraction from family summaries so they no longer mask tracked usage in the automatic menu-bar metric (#1369). Thanks @Martin-Hausleitner!
Expand Down
2 changes: 1 addition & 1 deletion Sources/CodexBar/MenuCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1047,7 +1047,7 @@ extension UsageMenuCardView.Model {
return (lastError.trimmingCharacters(in: .whitespacesAndNewlines), .error)
}

if isRefreshing, snapshot == nil {
if isRefreshing {
return ("\(L("Refreshing"))…", .loading)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ extension UsageStore {
self.kiloEnabledScopes.count > 1
}

func refreshKiloScopes() async {
func refreshKiloScopes(generation: UInt64? = nil) async {
let scopes = self.kiloEnabledScopes
guard scopes.count > 1 else {
await MainActor.run { self.kiloScopeSnapshots = [] }
Expand Down Expand Up @@ -102,6 +102,7 @@ extension UsageStore {
let ordered = scopes.compactMap { resultByID[$0.scopeIdentifier] }

await MainActor.run {
guard self.isCurrentProviderRefreshGeneration(.kilo, generation: generation) else { return }
self.kiloScopeSnapshots = ordered
}
}
Expand Down
3 changes: 1 addition & 2 deletions Sources/CodexBar/StatusItemController+Actions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,7 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate {
func refreshOpenMenusAfterExplicitStoreAction() {
self.invalidateMenus(
refreshOpenMenus: true,
deferOpenParentMenuRebuild: true,
allowStaleContentDuringDataRefresh: true)
deferOpenParentMenuRebuild: true)
}

@objc func refreshNow() {
Expand Down
55 changes: 46 additions & 9 deletions Sources/CodexBar/StatusItemController+Menu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1135,8 +1135,11 @@ extension StatusItemController {
// provider fetch failed and needs a retry; periodic freshness is handled by the refresh timer.
// AppKit menu tracking is modal, so starting provider refreshes while it is active can make the menu
// feel frozen and can block keyboard focus from returning.
if self.menuNeedsDelayedRefreshRetry(for: menu) {
self.deferMenuInteractionRefreshIfNeeded()
let providersNeedingRetry = self.delayedRefreshRetryProviders(for: menu).filter {
self.store.isStale(provider: $0) || self.store.snapshot(for: $0) == nil
}
if !providersNeedingRetry.isEmpty {
self.deferMenuInteractionRefreshIfNeeded(providers: providersNeedingRetry)
}
let key = ObjectIdentifier(menu)
self.menuRefreshTasks[key]?.cancel()
Expand All @@ -1149,13 +1152,43 @@ extension StatusItemController {
self.onDelayedMenuRefreshAttemptForTesting?()
#endif
guard self.openMenus[ObjectIdentifier(menu)] != nil else { return }
guard !self.store.isRefreshing else { return }
let retryProviders = self.delayedRefreshRetryProviders(for: menu)
let retryStaleProviderCount = retryProviders.count { self.store.isStale(provider: $0) }
let retryMissingSnapshotCount = retryProviders.count { self.store.snapshot(for: $0) == nil }
let willRetryRefresh = retryStaleProviderCount > 0 || retryMissingSnapshotCount > 0
guard willRetryRefresh else { return }
self.deferMenuInteractionRefreshIfNeeded()
let availableProviders = Set(self.store.enabledProvidersForBackgroundWork())
let retryProviders = self.delayedRefreshRetryProviders(for: menu).filter {
availableProviders.contains($0) &&
(self.store.refreshingProviders.contains($0) ||
self.store.isStale(provider: $0) ||
self.store.snapshot(for: $0) == nil)
}
guard !retryProviders.isEmpty else {
self.clearSatisfiedDeferredMenuInteractionRefreshes(
for: self.delayedRefreshRetryProviders(for: menu))
if self.menuNeedsRefresh(menu) {
self.scheduleOpenMenuRebuildIfStillVisible(
menu,
provider: self.menuProvider(for: menu),
resyncReadinessBaselineAfterRebuild: self.openMenus.count == 1)
}
return
}
self.deferMenuInteractionRefreshIfNeeded(providers: retryProviders)
await ProviderInteractionContext.$current.withValue(.background) {
for provider in retryProviders {
guard !Task.isCancelled else { return }
await self.store.refreshProvider(provider, coalesceIfRefreshing: true)
}
}
let stillNeedsRetry = retryProviders.contains {
self.store.isStale(provider: $0) || self.store.snapshot(for: $0) == nil
}
if !stillNeedsRetry {
self.clearSatisfiedDeferredMenuInteractionRefreshes(for: retryProviders)
Comment on lines +1180 to +1184

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve deferred retry after failed coalesced refresh

When the menu opens while a full store refresh is already in progress, the call to deferMenuInteractionRefreshIfNeeded above is a no-op because that helper returns while store.isRefreshing is true. If the coalesced provider refresh then finishes with an error or still has no snapshot, stillNeedsRetry is true here, but this branch only clears on success and never records the pending retry after the in-flight refresh has ended. In that scenario, closing the menu no longer schedules the retry that the stale/missing card needs, leaving the user stuck until the next timer/manual refresh.

Useful? React with 👍 / 👎.

}
guard !Task.isCancelled else { return }
guard self.openMenus[ObjectIdentifier(menu)] != nil else { return }
self.invalidateMenus(
refreshOpenMenus: true,
deferOpenParentMenuRebuild: false,
allowStaleContentDuringDataRefresh: true)
}
}

Expand All @@ -1168,6 +1201,10 @@ extension StatusItemController {
}

private func delayedRefreshRetryProviders(for menu: NSMenu) -> [UsageProvider] {
self.renderedProviders(for: menu)
}

func renderedProviders(for menu: NSMenu) -> [UsageProvider] {
let enabledProviders = self.store.enabledProvidersForDisplay()
guard !enabledProviders.isEmpty else { return [] }
let includesOverview = self.includesOverviewTab(enabledProviders: enabledProviders)
Expand Down
2 changes: 1 addition & 1 deletion Sources/CodexBar/StatusItemController+MenuCardModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ extension StatusItemController {
tokenSnapshot: tokenSnapshot,
tokenError: tokenError,
account: fallbackAccount,
isRefreshing: self.store.shouldShowRefreshingMenuCard(for: target),
isRefreshing: self.store.shouldShowRefreshingMenuCardIndicator(for: target),
lastError: errorOverride
?? codexProjection?.userFacingErrors.usage
?? self.store.userFacingError(for: target),
Expand Down
14 changes: 11 additions & 3 deletions Sources/CodexBar/StatusItemController+MenuInteractionRefresh.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,17 @@ extension StatusItemController {
])
}

func deferMenuInteractionRefreshIfNeeded() {
func deferMenuInteractionRefreshIfNeeded(providers: [UsageProvider]) {
guard !self.store.isRefreshing else { return }
self.deferredMenuInteractionRefreshPending = true
self.deferredMenuInteractionRefreshProviders.formUnion(providers)
}

func clearSatisfiedDeferredMenuInteractionRefreshes(for providers: [UsageProvider]) {
for provider in providers
where !self.store.isStale(provider: provider) && self.store.snapshot(for: provider) != nil
{
self.deferredMenuInteractionRefreshProviders.remove(provider)
}
}

func deferOpenAIDashboardRefreshUntilMenuCloses(reason: String) {
Expand Down Expand Up @@ -110,7 +118,7 @@ extension StatusItemController {
return
}
self.deferredMenuInteractionRefreshTask = nil
self.deferredMenuInteractionRefreshPending = false
self.deferredMenuInteractionRefreshProviders.removeAll()
self.deferredOpenAIDashboardRefreshReason = nil
#if DEBUG
self.onDeferredMenuInteractionRefreshForTesting?()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ extension StatusItemController {
[
provider.rawValue,
"token=\(tokenSignature)",
"refreshing=\(self.store.shouldShowRefreshingMenuCardIndicator(for: provider) ? "1" : "0")",
"usageHistory=\(usageHistoryVisible ? "1" : "0")",
].joined(separator: ":"))
}
Expand Down Expand Up @@ -214,6 +215,7 @@ extension StatusItemController {
_ menu: NSMenu,
provider: UsageProvider?,
closeHostedSubviewMenusBeforeRebuild: Bool = false,
resyncReadinessBaselineAfterRebuild: Bool = false,
debounceNanoseconds: UInt64 = 0,
beforeRebuild: (@MainActor () -> Bool)? = nil)
{
Expand Down Expand Up @@ -255,6 +257,9 @@ extension StatusItemController {
self.closeHostedSubviewMenusForParentSwitch()
}
self.rebuildOpenMenuIfStillVisible(menu, provider: provider)
if resyncReadinessBaselineAfterRebuild, !self.menuNeedsRefresh(menu) {
self.resyncMenuAdjunctReadinessBaseline()
}
}
}

Expand Down
125 changes: 111 additions & 14 deletions Sources/CodexBar/StatusItemController+MenuTracking.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ extension StatusItemController {
self.clearMergedSwitcherContentCaches()
}
self.pruneVersionScopedMenuCardHeightCache()
if allowStaleContentDuringDataRefresh {
self.latestDataOnlyMenuContentVersion = self.menuContentVersion
} else {
self.latestStructuralMenuContentVersion = self.menuContentVersion
}
if !allowStaleContentDuringDataRefresh, !preservesMergedSwitcherContentCaches {
self.latestRequiredMenuRebuildVersion = self.menuContentVersion
}
Expand All @@ -49,31 +54,56 @@ extension StatusItemController {
deferParentRebuildDuringTracking: deferOpenParentMenuRebuild)
return
}
if allowStaleContentDuringDataRefresh {
if !self.cancelNonRequiredClosedMenuPreparation() {
self.prepareAttachedClosedMenusIfNeeded()
}
return
}
self.prepareAttachedClosedMenusIfNeeded()
}

@discardableResult
private func cancelNonRequiredClosedMenuPreparation() -> Bool {
let menus = self.attachedMenusForClosedPreparation()
let hasRequiredClosedMenu = self.latestRequiredMenuRebuildVersion > 0 && menus.contains { menu in
let key = ObjectIdentifier(menu)
return (self.menuVersions[key] ?? -1) < self.latestRequiredMenuRebuildVersion
}
guard !hasRequiredClosedMenu else { return false }
self.cancelAllClosedMenuRebuilds()
for menu in menus {
self.closedMenusDeferredUntilNextOpen.remove(ObjectIdentifier(menu))
}
return true
}

func prepareAttachedClosedMenusIfNeeded() {
guard self.isMenuRefreshEnabled else { return }
guard self.openMenus.isEmpty else { return }
guard !self.isMenuDataRefreshInFlight else { return }
let menus = self.attachedMenusForClosedPreparation()
let requiredClosedPreparationVersion: Int?
if self.menuContentVersion > self.latestRequiredMenuRebuildVersion {
guard self.latestRequiredMenuRebuildVersion > 0 else { return }
let hasRequiredClosedMenu = menus.contains { menu in
let key = ObjectIdentifier(menu)
return (self.menuVersions[key] ?? -1) < self.latestRequiredMenuRebuildVersion
}
guard hasRequiredClosedMenu else { return }
if self.latestRequiredMenuRebuildVersion > 0,
menus.contains(where: { menu in
let key = ObjectIdentifier(menu)
return (self.menuVersions[key] ?? -1) < self.latestRequiredMenuRebuildVersion
})
{
requiredClosedPreparationVersion = self.latestRequiredMenuRebuildVersion
} else if self.menuContentVersion > self.latestRequiredMenuRebuildVersion {
guard self.latestRequiredMenuRebuildVersion > 0 else { return }
return
} else {
requiredClosedPreparationVersion = nil
}
for menu in menus {
let key = ObjectIdentifier(menu)
guard !self.closedMenusDeferredUntilNextOpen.contains(key) else { continue }
if let requiredClosedPreparationVersion {
self.closedMenusDeferredUntilNextOpen.remove(key)
guard (self.menuVersions[key] ?? -1) < requiredClosedPreparationVersion else { continue }
} else {
guard !self.closedMenusDeferredUntilNextOpen.contains(key) else { continue }
}
// Pre-warming the merged menu while it is closed runs a full main-thread populateMenu
// (incl. SwiftUI hosting-view layout) that menuWillOpen redoes synchronously on display
Expand All @@ -96,6 +126,7 @@ extension StatusItemController {
self.menuProviders.removeValue(forKey: key)
self.menuVersions.removeValue(forKey: key)
self.menuReadinessSignatures.removeValue(forKey: key)
self.menuIdentitySignatures.removeValue(forKey: key)
self.closedMenusDeferredUntilNextOpen.remove(key)
}

Expand All @@ -112,28 +143,36 @@ extension StatusItemController {
func refreshMenuForOpenIfNeeded(_ menu: NSMenu, provider: UsageProvider?) {
self.closedMenusDeferredUntilNextOpen.remove(ObjectIdentifier(menu))
guard self.menuNeedsRefresh(menu) else { return }
if self.canPreserveStaleMenuContentDuringRefresh(menu) {
if self.canPreserveStaleMenuContentForInstantOpen(menu) {
#if DEBUG
self.menuLogger.debug(
"menu open kept existing content during refresh",
"menu open kept existing content for instant render",
metadata: [
"items": "\(menu.items.count)",
"provider": provider?.rawValue ?? "nil",
"storeRefreshing": self.store.isRefreshing ? "1" : "0",
])
#endif
self.deferMenuInteractionRefreshIfNeeded()
if self.isMenuRefreshEnabled, !self.isMenuDataRefreshInFlight {
self.scheduleOpenMenuRebuildIfStillVisible(
menu,
provider: provider,
resyncReadinessBaselineAfterRebuild: self.openMenus.isEmpty)
}
return
}
self.populateMenu(menu, provider: provider)
self.markMenuFresh(menu)
}

private func canPreserveStaleMenuContentDuringRefresh(_ menu: NSMenu) -> Bool {
guard self.isMenuDataRefreshInFlight, !menu.items.isEmpty else { return false }
private func canPreserveStaleMenuContentForInstantOpen(_ menu: NSMenu) -> Bool {
guard !menu.items.isEmpty else { return false }
let key = ObjectIdentifier(menu)
guard let menuVersion = self.menuVersions[key] else { return false }
return menuVersion >= self.latestRequiredMenuRebuildVersion
return self.menuContentVersion == self.latestDataOnlyMenuContentVersion &&
menuVersion >= self.latestStructuralMenuContentVersion &&
self.menuIdentitySignatures[key] == self.menuIdentitySignature(
for: self.renderedProviders(for: menu))
}

private func attachedMenusForClosedPreparation() -> [NSMenu] {
Expand Down Expand Up @@ -233,6 +272,64 @@ extension StatusItemController {
let key = ObjectIdentifier(menu)
self.menuVersions[key] = self.menuContentVersion
self.menuReadinessSignatures[key] = self.menuAdjunctReadinessSignature()
self.menuIdentitySignatures[key] = self.menuIdentitySignature(
for: self.renderedProviders(for: menu))
}

private func menuIdentitySignature(for providers: [UsageProvider]) -> String {
var parts: [String] = []
for target in providers {
parts.append(target.rawValue)
parts.append(self.providerIdentitySignature(self.store.snapshot(for: target)?.identity(for: target)))

if self.store.metadata(for: target).usesAccountFallback {
let account = self.store.accountInfo(for: target)
parts.append(Self.menuIdentityField(account.email))
parts.append(Self.menuIdentityField(account.plan))
}

for accountSnapshot in self.store.accountSnapshots[target] ?? [] {
parts.append(accountSnapshot.account.id.uuidString)
parts.append(Self.menuIdentityField(accountSnapshot.account.label))
parts.append(self.providerIdentitySignature(accountSnapshot.snapshot?.identity(for: target)))
}

if target == .codex {
for account in self.settings.codexVisibleAccountProjection.visibleAccounts {
parts.append(Self.menuIdentityField(account.id))
parts.append(Self.menuIdentityField(account.email))
parts.append(Self.menuIdentityField(account.workspaceLabel))
parts.append(account.isActive ? "active" : "inactive")
parts.append(account.isLive ? "live" : "stored")
}
for accountSnapshot in self.store.codexAccountSnapshots {
parts.append(Self.menuIdentityField(accountSnapshot.id))
parts.append(self.providerIdentitySignature(accountSnapshot.snapshot?.identity(for: target)))
}
}

if target == .kilo {
for scopeSnapshot in self.store.kiloScopeSnapshots {
parts.append(Self.menuIdentityField(scopeSnapshot.id))
parts.append(self.providerIdentitySignature(scopeSnapshot.snapshot?.identity(for: target)))
}
}
}
return parts.joined(separator: "|")
}

private func providerIdentitySignature(_ identity: ProviderIdentitySnapshot?) -> String {
[
identity?.providerID?.rawValue ?? "",
Self.menuIdentityField(identity?.accountEmail),
Self.menuIdentityField(identity?.accountOrganization),
Self.menuIdentityField(identity?.loginMethod),
].joined(separator: ":")
}

private static func menuIdentityField(_ value: String?) -> String {
let value = value ?? ""
return "\(value.utf8.count):\(value)"
}

func hasOpenHostedSubviewMenu() -> Bool {
Expand Down
Loading