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 @@ -16,6 +16,7 @@
- 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!
- Provider status: decode status feeds on the concurrent executor and reuse ISO8601 formatters, removing a measured main-thread stall during refreshes (#1406). Thanks @ProspectOre!
- Menu bar: keep one stable width across merged provider tabs and resize every hosted card row to AppKit's final menu width so provider switching no longer leaves a widened menu with inset submenu arrows (#1410).
- Menu bar: keep Codex `auth.json` reads, JWT parsing, and fingerprint hashing off the menu-build path by rendering a cached account snapshot and revalidating it asynchronously (#1401). Thanks @ProspectOre!
- 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
111 changes: 98 additions & 13 deletions Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ extension SettingsStore {
}
set {
self.invalidateCodexAccountReconciliationSnapshotCache()
self.cachedCodexAccountMenuProjection = nil
self.updateProviderConfig(provider: .codex) { entry in
entry.codexActiveSource = newValue
}
Expand All @@ -166,6 +167,7 @@ extension SettingsStore {
@discardableResult
func refreshCodexAccountReconciliationAfterManagedAccountsDidChange() -> Bool {
self.invalidateCodexAccountReconciliationSnapshotCache()
self.cachedCodexAccountMenuProjection = nil
return self.persistResolvedCodexActiveSourceCorrectionIfNeeded()
}

Expand Down Expand Up @@ -210,6 +212,7 @@ extension SettingsStore {

func invalidateCodexAccountReconciliationSnapshotCache() {
self.cachedCodexAccountReconciliationSnapshot = nil
self.codexAccountReconciliationGeneration &+= 1
}

var codexAccountReconciliationSnapshot: CodexAccountReconciliationSnapshot {
Expand All @@ -230,16 +233,84 @@ extension SettingsStore {
return cached.snapshot
}

let snapshot = self.codexAccountReconciler(activeSource: activeSource).loadSnapshot()
let snapshot = self.codexAccountSnapshotLoader(activeSource: activeSource)()
let loadedAt = Date()
if cacheInterval > 0 {
self.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot(
activeSource: activeSource,
loadedAt: now,
loadedAt: loadedAt,
snapshot: snapshot)
}
if activeSource == self.codexPersistedActiveSource {
self.cachedCodexAccountMenuProjection = CachedCodexAccountMenuProjection(
activeSource: activeSource,
loadedAt: loadedAt,
projection: CodexVisibleAccountProjection.make(from: snapshot))
}
return snapshot
}

/// Menu rendering must stay side-effect free: no `auth.json` reads, JWT parsing, or fingerprint hashing.
var codexVisibleAccountProjectionForMenuDisplay: CodexVisibleAccountProjection? {
let activeSource = self.codexPersistedActiveSource
guard let cached = self.cachedCodexAccountMenuProjection,
cached.activeSource == activeSource
else {
return nil
}
return cached.projection
}

var codexAccountMenuProjectionNeedsRevalidation: Bool {
let activeSource = self.codexPersistedActiveSource
guard let cached = self.cachedCodexAccountMenuProjection,
cached.activeSource == activeSource
else {
return true
}
return Date().timeIntervalSince(cached.loadedAt) >= Self.codexAccountReconciliationSnapshotCacheInterval
}

func revalidateCodexAccountMenuProjection() async -> CodexAccountMenuProjectionRevalidationResult {
guard self.codexAccountMenuProjectionNeedsRevalidation else { return .skipped }

let activeSource = self.codexPersistedActiveSource
let generation = self.codexAccountReconciliationGeneration
let loader = self.codexAccountSnapshotLoader(activeSource: activeSource)
let snapshot = await Self.loadCodexAccountSnapshot(loader)

guard generation == self.codexAccountReconciliationGeneration,
activeSource == self.codexPersistedActiveSource
else {
return .discarded
}

let now = Date()
let projection = CodexVisibleAccountProjection.make(from: snapshot)
let previousProjection = self.cachedCodexAccountMenuProjection.flatMap { cached in
cached.activeSource == activeSource ? cached.projection : nil
}
self.cachedCodexAccountMenuProjection = CachedCodexAccountMenuProjection(
activeSource: activeSource,
loadedAt: now,
projection: projection)
if Self.codexAccountReconciliationSnapshotCacheInterval > 0 {
self.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot(
activeSource: activeSource,
loadedAt: now,
snapshot: snapshot)
}
return previousProjection == projection ? .unchanged : .updated
}

@concurrent
private nonisolated static func loadCodexAccountSnapshot(
_ loader: @escaping @Sendable () -> CodexAccountReconciliationSnapshot)
async -> CodexAccountReconciliationSnapshot
{
loader()
}

var codexVisibleAccountProjection: CodexVisibleAccountProjection {
CodexVisibleAccountProjection.make(from: self.codexAccountReconciliationSnapshot)
}
Expand All @@ -257,11 +328,8 @@ extension SettingsStore {
}

func selectDisplayedCodexVisibleAccount(_ account: CodexVisibleAccount) {
if self.selectCodexVisibleAccount(id: account.id) {
return
}
// An open menu can preserve a previously rendered account row while the live projection is briefly incomplete.
self.invalidateCodexAccountReconciliationSnapshotCache()
// The row already carries the exact source it represented. Re-resolving its ID would synchronously
// reload auth state from the menu click callback and can also fail after a stale snapshot is rendered.
self.codexActiveSource = account.selectionSource
}

Expand All @@ -283,6 +351,18 @@ extension SettingsStore {
self.codexVisibleAccountProjection.source(forVisibleAccountID: id)
}

private func codexAccountSnapshotLoader(
activeSource: CodexActiveSource) -> @Sendable () -> CodexAccountReconciliationSnapshot
{
#if DEBUG
if let loader = self._test_codexAccountSnapshotLoader {
return { loader(activeSource) }
}
#endif
let reconciler = self.codexAccountReconciler(activeSource: activeSource)
return { reconciler.loadSnapshot() }
}

private func codexAccountReconciler(activeSource: CodexActiveSource) -> DefaultCodexAccountReconciler {
let baseEnvironment = self.codexReconciliationEnvironment()
#if DEBUG
Expand Down Expand Up @@ -518,50 +598,55 @@ private struct CodexManagedRemoteHomeTestingSystemObserver: CodexSystemAccountOb
}

extension SettingsStore {
private func invalidateCodexAccountReconciliationCachesForTesting() {
self.invalidateCodexAccountReconciliationSnapshotCache()
self.cachedCodexAccountMenuProjection = nil
}

var _test_activeManagedCodexRemoteHomePath: String? {
get { CodexManagedRemoteHomeTestingOverride.homePath(for: self) }
set {
self.invalidateCodexAccountReconciliationSnapshotCache()
self.invalidateCodexAccountReconciliationCachesForTesting()
CodexManagedRemoteHomeTestingOverride.setHomePath(newValue, for: self)
}
}

var _test_activeManagedCodexAccount: ManagedCodexAccount? {
get { CodexManagedRemoteHomeTestingOverride.account(for: self) }
set {
self.invalidateCodexAccountReconciliationSnapshotCache()
self.invalidateCodexAccountReconciliationCachesForTesting()
CodexManagedRemoteHomeTestingOverride.setAccount(newValue, for: self)
}
}

var _test_unreadableManagedCodexAccountStore: Bool {
get { CodexManagedRemoteHomeTestingOverride.isUnreadable(for: self) }
set {
self.invalidateCodexAccountReconciliationSnapshotCache()
self.invalidateCodexAccountReconciliationCachesForTesting()
CodexManagedRemoteHomeTestingOverride.setUnreadable(newValue, for: self)
}
}

var _test_managedCodexAccountStoreURL: URL? {
get { CodexManagedRemoteHomeTestingOverride.managedStoreURL(for: self) }
set {
self.invalidateCodexAccountReconciliationSnapshotCache()
self.invalidateCodexAccountReconciliationCachesForTesting()
CodexManagedRemoteHomeTestingOverride.setManagedStoreURL(newValue, for: self)
}
}

var _test_liveSystemCodexAccount: ObservedSystemCodexAccount? {
get { CodexManagedRemoteHomeTestingOverride.liveSystemAccount(for: self) }
set {
self.invalidateCodexAccountReconciliationSnapshotCache()
self.invalidateCodexAccountReconciliationCachesForTesting()
CodexManagedRemoteHomeTestingOverride.setLiveSystemAccount(newValue, for: self)
}
}

var _test_codexReconciliationEnvironment: [String: String]? {
get { CodexManagedRemoteHomeTestingOverride.reconciliationEnvironment(for: self) }
set {
self.invalidateCodexAccountReconciliationSnapshotCache()
self.invalidateCodexAccountReconciliationCachesForTesting()
CodexManagedRemoteHomeTestingOverride.setReconciliationEnvironment(newValue, for: self)
}
}
Expand Down
19 changes: 19 additions & 0 deletions Sources/CodexBar/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,19 @@ struct CachedCodexAccountReconciliationSnapshot {
let snapshot: CodexAccountReconciliationSnapshot
}

struct CachedCodexAccountMenuProjection: Equatable {
let activeSource: CodexActiveSource
let loadedAt: Date
let projection: CodexVisibleAccountProjection
}

enum CodexAccountMenuProjectionRevalidationResult: Equatable {
case skipped
case discarded
case unchanged
case updated
}

@MainActor
@Observable
final class SettingsStore {
Expand All @@ -140,6 +153,12 @@ final class SettingsStore {
@ObservationIgnored var tokenAccountsLoaded = false
@ObservationIgnored var cachedCodexAccountReconciliationSnapshot:
CachedCodexAccountReconciliationSnapshot?
@ObservationIgnored var cachedCodexAccountMenuProjection: CachedCodexAccountMenuProjection?
@ObservationIgnored var codexAccountReconciliationGeneration: UInt = 0
#if DEBUG
@ObservationIgnored var _test_codexAccountSnapshotLoader:
(@Sendable (CodexActiveSource) -> CodexAccountReconciliationSnapshot)?
#endif
@ObservationIgnored var mergedMenuLastSelectedWasOverviewStorage = false
@ObservationIgnored var selectedMenuProviderRawStorage: String?
var defaultsState: SettingsDefaultsState
Expand Down
51 changes: 50 additions & 1 deletion Sources/CodexBar/StatusItemController+AccountMenuDisplay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,30 @@ import AppKit
import CodexBarCore

extension StatusItemController {
private static let defaultCodexAccountMenuProjectionRevalidationEnabled = !SettingsStore.isRunningTests

#if DEBUG
private static var codexAccountMenuProjectionRevalidationEnabledForTesting =
defaultCodexAccountMenuProjectionRevalidationEnabled

static func setCodexAccountMenuProjectionRevalidationEnabledForTesting(_ enabled: Bool) {
self.codexAccountMenuProjectionRevalidationEnabledForTesting = enabled
}

static func resetCodexAccountMenuProjectionRevalidationEnabledForTesting() {
self.codexAccountMenuProjectionRevalidationEnabledForTesting =
self.defaultCodexAccountMenuProjectionRevalidationEnabled
}
#endif

private static var codexAccountMenuProjectionRevalidationEnabled: Bool {
#if DEBUG
self.codexAccountMenuProjectionRevalidationEnabledForTesting
#else
self.defaultCodexAccountMenuProjectionRevalidationEnabled
#endif
}

func tokenAccountMenuDisplay(for provider: UsageProvider) -> TokenAccountMenuDisplay? {
guard TokenAccountSupportCatalog.support(for: provider) != nil else { return nil }
let accounts = self.settings.tokenAccounts(for: provider)
Expand Down Expand Up @@ -35,7 +59,7 @@ extension StatusItemController {

func codexAccountMenuDisplay(for provider: UsageProvider) -> CodexAccountMenuDisplay? {
guard provider == .codex else { return nil }
let projection = self.settings.codexVisibleAccountProjection
guard let projection = self.settings.codexVisibleAccountProjectionForMenuDisplay else { return nil }
guard projection.visibleAccounts.count > 1 else { return nil }
let showAll = self.settings.multiAccountMenuLayout == .stacked
let accounts = showAll
Expand All @@ -52,6 +76,31 @@ extension StatusItemController {
layout: showAll ? .stacked : .segmented)
}

func scheduleCodexAccountMenuProjectionRevalidationIfNeeded(for providers: [UsageProvider]) {
guard Self.codexAccountMenuProjectionRevalidationEnabled else { return }
guard providers.contains(.codex) else { return }
guard self.settings.codexAccountMenuProjectionNeedsRevalidation else { return }
guard self.codexAccountMenuProjectionRevalidationTask == nil else { return }

self.codexAccountMenuProjectionRevalidationTask = Task { @MainActor [weak self] in
guard let settings = self?.settings else { return }
let result = await settings.revalidateCodexAccountMenuProjection()
guard let self else { return }
guard !Task.isCancelled else {
self.codexAccountMenuProjectionRevalidationTask = nil
return
}
self.codexAccountMenuProjectionRevalidationTask = nil

switch result {
case .updated:
self.invalidateMenus(refreshOpenMenus: false)
case .discarded, .skipped, .unchanged:
break
}
}
}

private func codexAccountSnapshots(matching accounts: [CodexVisibleAccount]) -> [CodexAccountUsageSnapshot] {
var snapshotsByID: [String: CodexAccountUsageSnapshot] = [:]
for snapshot in self.store.codexAccountSnapshots {
Expand Down
2 changes: 2 additions & 0 deletions Sources/CodexBar/StatusItemController+Menu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ extension StatusItemController {

let menuWasFreshBeforeOpen = !self.menuNeedsRefresh(menu)
self.refreshMenuForOpenIfNeeded(menu, provider: provider)
self.scheduleCodexAccountMenuProjectionRevalidationIfNeeded(
for: self.renderedProviders(for: menu))
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 Down
6 changes: 4 additions & 2 deletions Sources/CodexBar/StatusItemController+MenuTracking.swift
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ extension StatusItemController {
parts.append(target.rawValue)
parts.append(self.providerIdentitySignature(self.store.snapshot(for: target)?.identity(for: target)))

if self.store.metadata(for: target).usesAccountFallback {
if target != .codex, 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))
Expand All @@ -316,7 +316,9 @@ extension StatusItemController {
}

if target == .codex {
for account in self.settings.codexVisibleAccountProjection.visibleAccounts {
parts.append(Self.menuIdentityField(self.account.email))
parts.append(Self.menuIdentityField(self.account.plan))
for account in self.settings.codexVisibleAccountProjectionForMenuDisplay?.visibleAccounts ?? [] {
parts.append(Self.menuIdentityField(account.id))
parts.append(Self.menuIdentityField(account.email))
parts.append(Self.menuIdentityField(account.workspaceLabel))
Expand Down
2 changes: 2 additions & 0 deletions Sources/CodexBar/StatusItemController+Shutdown.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ extension StatusItemController {
}
self.openMenuInvalidationRetryTask?.cancel()
self.openMenuInvalidationRetryTask = nil
self.codexAccountMenuProjectionRevalidationTask?.cancel()
self.codexAccountMenuProjectionRevalidationTask = nil
self.providerSelectionUIRefreshTask?.cancel()
self.providerSelectionUIRefreshTask = nil
self.deferredMergedIconRenderAfterTracking = false
Expand Down
3 changes: 3 additions & 0 deletions Sources/CodexBar/StatusItemController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin
var openMenuRebuildTokens: [ObjectIdentifier: Int] = [:]
var openMenuRebuildTokenCounter = 0
var menuIdentitySignatures: [ObjectIdentifier: String] = [:]
var codexAccountMenuProjectionRevalidationTask: Task<Void, Never>?
var openMenuRebuildsClosingHostedSubviewMenus: Set<ObjectIdentifier> = []
var parentMenuRebuildsDeferredDuringTracking: Set<ObjectIdentifier> = []
var deferredMenuInteractionRefreshProviders: Set<UsageProvider> = []
Expand Down Expand Up @@ -415,6 +416,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin
self.wireBindings()
self.updateVisibility()
self.updateIcons()
self.scheduleCodexAccountMenuProjectionRevalidationIfNeeded(
for: self.store.enabledProvidersForDisplay())
self.scheduleStartupStatusItemVisibilityCheck()
NotificationCenter.default.addObserver(
self,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ public struct CodexAccountReconciliationSnapshot: Equatable, Sendable {
}
}

public struct DefaultCodexAccountReconciler {
public struct DefaultCodexAccountReconciler: Sendable {
public let storeLoader: @Sendable () throws -> ManagedCodexAccountSet
public let systemObserver: any CodexSystemAccountObserving
public let activeSource: CodexActiveSource
Expand Down
Loading