From 9bc8125a8cf6515ba662e461ece1ac1f6041bf86 Mon Sep 17 00:00:00 2001 From: Alasdair McCall Date: Fri, 8 May 2026 09:00:07 +0100 Subject: [PATCH 1/3] Add stacked multi-account menu layout --- Sources/CodexBar/PreferencesDisplayPane.swift | 22 +- .../Codex/CodexConsumerProjection.swift | 6 +- .../Codex/UsageStore+CodexAccountState.swift | 1 + Sources/CodexBar/SettingsStore+Defaults.swift | 13 +- .../SettingsStore+MenuObservation.swift | 2 +- Sources/CodexBar/SettingsStore.swift | 23 +- Sources/CodexBar/SettingsStoreState.swift | 2 +- .../CodexBar/StatusItemController+Menu.swift | 91 ++++++-- .../StatusItemController+MenuTypes.swift | 45 +++- Sources/CodexBar/UsageStore+OpenAIWeb.swift | 1 + Sources/CodexBar/UsageStore+Refresh.swift | 10 + .../CodexBar/UsageStore+TokenAccounts.swift | 201 +++++++++++++++++- Sources/CodexBar/UsageStore.swift | 4 +- .../PreferencesPaneSmokeTests.swift | 2 +- .../SettingsStoreCoverageTests.swift | 33 +++ .../StatusMenuCodexSwitcherTests.swift | 184 ++++++++++++++++ .../StatusMenuTokenAccountSwitcherTests.swift | 76 ++++++- 17 files changed, 678 insertions(+), 38 deletions(-) diff --git a/Sources/CodexBar/PreferencesDisplayPane.swift b/Sources/CodexBar/PreferencesDisplayPane.swift index 04050b3bb..726565ad2 100644 --- a/Sources/CodexBar/PreferencesDisplayPane.swift +++ b/Sources/CodexBar/PreferencesDisplayPane.swift @@ -78,10 +78,24 @@ struct DisplayPane: View { title: "Show credits + extra usage", subtitle: "Show Codex Credits and Claude Extra usage sections in the menu.", binding: self.$settings.showOptionalCreditsAndExtraUsage) - PreferenceToggleRow( - title: "Show all token accounts", - subtitle: "Stack token accounts in the menu (otherwise show an account switcher bar).", - binding: self.$settings.showAllTokenAccountsInMenu) + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text("Multi-account layout") + .font(.body) + Text("Choose segmented account switching or stacked account cards.") + .font(.footnote) + .foregroundStyle(.tertiary) + } + Spacer() + Picker("Multi-account layout", selection: self.$settings.multiAccountMenuLayout) { + ForEach(MultiAccountMenuLayout.allCases) { layout in + Text(layout.label).tag(layout) + } + } + .labelsHidden() + .pickerStyle(.menu) + .frame(maxWidth: 200) + } self.overviewProviderSelector } } diff --git a/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift b/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift index 9e49fb8b9..4b0f346e7 100644 --- a/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift +++ b/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift @@ -380,9 +380,11 @@ extension UsageStore { errorOverride: String? = nil, now: Date = Date()) -> CodexConsumerProjection { + let snapshot = surface == .overrideCard ? snapshotOverride : snapshotOverride ?? self.snapshots[.codex] + let rawUsageError = surface == .overrideCard ? errorOverride : errorOverride ?? self.errors[.codex] let context = CodexConsumerProjection.Context( - snapshot: snapshotOverride ?? self.snapshots[.codex], - rawUsageError: errorOverride ?? self.errors[.codex], + snapshot: snapshot, + rawUsageError: rawUsageError, liveCredits: self.credits, rawCreditsError: self.lastCreditsError, liveDashboard: self.openAIDashboard, diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift index 03e42f23d..0952a47ae 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift @@ -68,6 +68,7 @@ extension UsageStore { self.lastSourceLabels.removeValue(forKey: .codex) self.lastFetchAttempts.removeValue(forKey: .codex) self.accountSnapshots.removeValue(forKey: .codex) + self.codexAccountSnapshots = [] self.failureGates[.codex]?.reset() self.lastKnownSessionRemaining.removeValue(forKey: .codex) self.lastKnownSessionWindowSource.removeValue(forKey: .codex) diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 90581c1ce..9259e0711 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -144,14 +144,19 @@ extension SettingsStore { set { self.menuBarDisplayModeRaw = newValue.rawValue } } - var showAllTokenAccountsInMenu: Bool { - get { self.defaultsState.showAllTokenAccountsInMenu } + var multiAccountMenuLayout: MultiAccountMenuLayout { + get { MultiAccountMenuLayout(rawValue: self.defaultsState.multiAccountMenuLayoutRaw) ?? .segmented } set { - self.defaultsState.showAllTokenAccountsInMenu = newValue - self.userDefaults.set(newValue, forKey: "showAllTokenAccountsInMenu") + self.defaultsState.multiAccountMenuLayoutRaw = newValue.rawValue + self.userDefaults.set(newValue.rawValue, forKey: "multiAccountMenuLayout") } } + var showAllTokenAccountsInMenu: Bool { + get { self.multiAccountMenuLayout == .stacked } + set { self.multiAccountMenuLayout = newValue ? .stacked : .segmented } + } + var historicalTrackingEnabled: Bool { get { self.defaultsState.historicalTrackingEnabled } set { diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index 682117cab..7dfe59ce4 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -17,7 +17,7 @@ extension SettingsStore { _ = self.menuBarShowsHighestUsage _ = self.menuBarDisplayMode _ = self.historicalTrackingEnabled - _ = self.showAllTokenAccountsInMenu + _ = self.multiAccountMenuLayout _ = self.menuBarMetricPreferencesRaw _ = self.costUsageEnabled _ = self.hidePersonalInfo diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index aeb772a92..e486cefb7 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -62,6 +62,22 @@ enum MenuBarMetricPreference: String, CaseIterable, Identifiable { } } +enum MultiAccountMenuLayout: String, CaseIterable, Identifiable { + case segmented + case stacked + + var id: String { + self.rawValue + } + + var label: String { + switch self { + case .segmented: "Segmented" + case .stacked: "Stacked" + } + } +} + @MainActor @Observable final class SettingsStore { @@ -244,7 +260,10 @@ extension SettingsStore { let menuBarDisplayModeRaw = userDefaults.string(forKey: "menuBarDisplayMode") ?? MenuBarDisplayMode.percent.rawValue let historicalTrackingEnabled = userDefaults.object(forKey: "historicalTrackingEnabled") as? Bool ?? false - let showAllTokenAccountsInMenu = userDefaults.object(forKey: "showAllTokenAccountsInMenu") as? Bool ?? false + let multiAccountMenuLayoutRaw = userDefaults.string(forKey: "multiAccountMenuLayout") ?? { + let legacyShowAll = userDefaults.object(forKey: "showAllTokenAccountsInMenu") as? Bool ?? false + return legacyShowAll ? MultiAccountMenuLayout.stacked.rawValue : MultiAccountMenuLayout.segmented.rawValue + }() let storedPreferences = userDefaults.dictionary(forKey: "menuBarMetricPreferences") as? [String: String] ?? [:] var resolvedPreferences = storedPreferences if resolvedPreferences.isEmpty, @@ -304,7 +323,7 @@ extension SettingsStore { menuBarShowsBrandIconWithPercent: menuBarShowsBrandIconWithPercent, menuBarDisplayModeRaw: menuBarDisplayModeRaw, historicalTrackingEnabled: historicalTrackingEnabled, - showAllTokenAccountsInMenu: showAllTokenAccountsInMenu, + multiAccountMenuLayoutRaw: multiAccountMenuLayoutRaw, menuBarMetricPreferencesRaw: resolvedPreferences, costUsageEnabled: costUsageEnabled, hidePersonalInfo: hidePersonalInfo, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 8db7d29b7..4ca33ffd2 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -16,7 +16,7 @@ struct SettingsDefaultsState { var menuBarShowsBrandIconWithPercent: Bool var menuBarDisplayModeRaw: String? var historicalTrackingEnabled: Bool - var showAllTokenAccountsInMenu: Bool + var multiAccountMenuLayoutRaw: String var menuBarMetricPreferencesRaw: [String: String] var costUsageEnabled: Bool var hidePersonalInfo: Bool diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 5a0ddd5ae..5dd256695 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -178,10 +178,10 @@ extension StatusItemController { let currentProvider = selectedProvider ?? enabledProviders.first ?? .codex let codexAccountDisplay = isOverviewSelected ? nil : self.codexAccountMenuDisplay(for: currentProvider) let tokenAccountDisplay = isOverviewSelected ? nil : self.tokenAccountMenuDisplay(for: currentProvider) - let showAllTokenAccounts = tokenAccountDisplay?.showAll ?? false + let showAllAccounts = (tokenAccountDisplay?.showAll ?? false) || (codexAccountDisplay?.showAll ?? false) let openAIContext = self.openAIWebContext( currentProvider: currentProvider, - showAllTokenAccounts: showAllTokenAccounts) + showAllAccounts: showAllAccounts) let descriptor = MenuDescriptor.build( provider: selectedProvider, store: self.store, @@ -201,7 +201,8 @@ extension StatusItemController { let switcherOverviewAvailabilityMatches = includesOverview == self.lastSwitcherIncludesOverview let tokenSwitcherCompatible = tokenAccountDisplay == nil && !hasTokenSwitcher let codexSwitcherCompatible = codexAccountDisplay == self.lastCodexAccountMenuDisplay && - ((codexAccountDisplay == nil && !hasCodexSwitcher) || (codexAccountDisplay != nil && hasCodexSwitcher)) + ((codexAccountDisplay?.showSwitcher == true && hasCodexSwitcher) || + (codexAccountDisplay?.showSwitcher != true && !hasCodexSwitcher)) let reusableRowWidthsMatch = self.reusableFixedWidthRows(in: menu).allSatisfy { item in guard let view = item.view else { return false } return abs(view.frame.width - menuWidth) <= 0.5 @@ -250,6 +251,7 @@ extension StatusItemController { currentProvider: currentProvider, selectedProvider: selectedProvider, menuWidth: menuWidth, + codexAccountDisplay: codexAccountDisplay, tokenAccountDisplay: tokenAccountDisplay, openAIContext: openAIContext) self.store.refreshStorageFootprintsForOverview() @@ -345,6 +347,7 @@ extension StatusItemController { currentProvider: currentProvider, selectedProvider: provider, menuWidth: menuWidth, + codexAccountDisplay: nil, tokenAccountDisplay: nil, openAIContext: openAIContext) let addedOpenAIWebItems = self.addMenuCards(to: menu, context: menuContext) @@ -371,13 +374,14 @@ extension StatusItemController { let currentProvider: UsageProvider let selectedProvider: UsageProvider? let menuWidth: CGFloat + let codexAccountDisplay: CodexAccountMenuDisplay? let tokenAccountDisplay: TokenAccountMenuDisplay? let openAIContext: OpenAIWebContext } private func openAIWebContext( currentProvider: UsageProvider, - showAllTokenAccounts: Bool) -> OpenAIWebContext + showAllAccounts: Bool) -> OpenAIWebContext { let codexProjection = self.store.codexConsumerProjectionIfNeeded( for: currentProvider, @@ -388,7 +392,7 @@ extension StatusItemController { (self.store.tokenSnapshot(for: currentProvider)?.daily.isEmpty == false) let canShowBuyCredits = self.settings.showOptionalCreditsAndExtraUsage && codexProjection?.canShowBuyCredits == true - let hasOpenAIWebMenuItems = !showAllTokenAccounts && + let hasOpenAIWebMenuItems = !showAllAccounts && (hasCreditsHistory || hasUsageBreakdown || hasCostHistory) return OpenAIWebContext( hasUsageBreakdown: hasUsageBreakdown, @@ -424,7 +428,7 @@ extension StatusItemController { } private func addCodexAccountSwitcherIfNeeded(to menu: NSMenu, display: CodexAccountMenuDisplay?, width: CGFloat) { - guard let display else { return } + guard let display, display.showSwitcher else { return } let switcherItem = self.makeCodexAccountSwitcherItem(display: display, menu: menu, width: width) menu.addItem(switcherItem) menu.addItem(.separator()) @@ -483,6 +487,49 @@ extension StatusItemController { } private func addMenuCards(to menu: NSMenu, context: MenuCardContext) -> Bool { + if let codexAccountDisplay = context.codexAccountDisplay, codexAccountDisplay.showAll { + let snapshotsByAccountID = Dictionary(uniqueKeysWithValues: codexAccountDisplay.snapshots.map { + ($0.account.id, $0) + }) + let cards = codexAccountDisplay.accounts.compactMap { account in + if let accountSnapshot = snapshotsByAccountID[account.id] { + return self.menuCardModel( + for: .codex, + snapshotOverride: accountSnapshot.snapshot, + errorOverride: accountSnapshot.error, + accountOverride: self.accountInfo(for: account)) + } + return self.menuCardModel( + for: .codex, + forceOverrideCard: true, + accountOverride: self.accountInfo(for: account)) + } + if cards.isEmpty, let model = self.menuCardModel(for: context.selectedProvider) { + menu.addItem(self.makeMenuCardItem( + UsageMenuCardView(model: model, width: context.menuWidth), + id: "menuCard", + width: context.menuWidth)) + menu.addItem(.separator()) + } else { + for (index, model) in cards.enumerated() { + menu.addItem(self.makeMenuCardItem( + UsageMenuCardView(model: model, width: context.menuWidth), + id: "menuCard-\(index)", + width: context.menuWidth)) + if index < cards.count - 1 { + menu.addItem(.separator()) + } + } + if !cards.isEmpty { + menu.addItem(.separator()) + } + } + if self.addStorageMenuCardSection(to: menu, provider: context.currentProvider, width: context.menuWidth) { + menu.addItem(.separator()) + } + return false + } + if let tokenAccountDisplay = context.tokenAccountDisplay, tokenAccountDisplay.showAll { let accountSnapshots = tokenAccountDisplay.snapshots let cards = accountSnapshots.isEmpty @@ -862,26 +909,31 @@ extension StatusItemController { let accounts = self.settings.tokenAccounts(for: provider) guard accounts.count > 1 else { return nil } let activeIndex = self.settings.tokenAccountsData(for: provider)?.clampedActiveIndex() ?? 0 - let canShowAllCopilotAccounts = provider == .copilot && - accounts.count <= UsageStore.tokenAccountMenuSnapshotLimit - let showAll = canShowAllCopilotAccounts || self.settings.showAllTokenAccountsInMenu + let showAll = self.settings.multiAccountMenuLayout == .stacked let snapshots = showAll ? (self.store.accountSnapshots[provider] ?? []) : [] return TokenAccountMenuDisplay( provider: provider, accounts: accounts, snapshots: snapshots, activeIndex: activeIndex, - showAll: showAll, - showSwitcher: !showAll) + layout: showAll ? .stacked : .segmented) } private func codexAccountMenuDisplay(for provider: UsageProvider) -> CodexAccountMenuDisplay? { guard provider == .codex else { return nil } let projection = self.settings.codexVisibleAccountProjection guard projection.visibleAccounts.count > 1 else { return nil } + let showAll = self.settings.multiAccountMenuLayout == .stacked + let accounts = showAll + ? self.store.limitedCodexVisibleAccounts( + projection.visibleAccounts, + activeVisibleAccountID: projection.activeVisibleAccountID) + : projection.visibleAccounts return CodexAccountMenuDisplay( - accounts: projection.visibleAccounts, - activeVisibleAccountID: projection.activeVisibleAccountID) + accounts: accounts, + snapshots: showAll ? self.store.codexAccountSnapshots : [], + activeVisibleAccountID: projection.activeVisibleAccountID, + layout: showAll ? .stacked : .segmented) } private func menuNeedsRefresh(_ menu: NSMenu) -> Bool { @@ -1438,12 +1490,15 @@ extension StatusItemController { func menuCardModel( for provider: UsageProvider?, snapshotOverride: UsageSnapshot? = nil, - errorOverride: String? = nil) -> UsageMenuCardView.Model? + errorOverride: String? = nil, + forceOverrideCard: Bool = false, + accountOverride: AccountInfo? = nil) -> UsageMenuCardView.Model? { let target = provider ?? self.store.enabledProvidersForDisplay().first ?? .codex let metadata = self.store.metadata(for: target) - let surface: CodexConsumerProjection.Surface = if snapshotOverride != nil || errorOverride != nil { + let usesOverrideCard = forceOverrideCard || snapshotOverride != nil || errorOverride != nil + let surface: CodexConsumerProjection.Surface = if usesOverrideCard { .overrideCard } else { .liveCard @@ -1522,7 +1577,7 @@ extension StatusItemController { dashboardError: dashboardError, tokenSnapshot: tokenSnapshot, tokenError: tokenError, - account: self.store.accountInfo(for: target), + account: accountOverride ?? self.store.accountInfo(for: target), isRefreshing: self.store.shouldShowRefreshingMenuCard(for: target), lastError: errorOverride ?? codexProjection?.userFacingErrors.usage @@ -1540,6 +1595,10 @@ extension StatusItemController { return UsageMenuCardView.Model.make(input) } + private func accountInfo(for account: CodexVisibleAccount) -> AccountInfo { + AccountInfo(email: account.email, plan: account.workspaceLabel) + } + @objc private func menuCardNoOp(_ sender: NSMenuItem) { _ = sender } diff --git a/Sources/CodexBar/StatusItemController+MenuTypes.swift b/Sources/CodexBar/StatusItemController+MenuTypes.swift index 114ee0d27..b3f648d62 100644 --- a/Sources/CodexBar/StatusItemController+MenuTypes.swift +++ b/Sources/CodexBar/StatusItemController+MenuTypes.swift @@ -69,11 +69,52 @@ struct TokenAccountMenuDisplay { let accounts: [ProviderTokenAccount] let snapshots: [TokenAccountUsageSnapshot] let activeIndex: Int - let showAll: Bool - let showSwitcher: Bool + let layout: MultiAccountMenuLayout + + var showAll: Bool { + self.layout == .stacked + } + + var showSwitcher: Bool { + self.layout == .segmented + } } struct CodexAccountMenuDisplay: Equatable { let accounts: [CodexVisibleAccount] + let snapshots: [CodexAccountUsageSnapshot] let activeVisibleAccountID: String? + let layout: MultiAccountMenuLayout + + var showAll: Bool { + self.layout == .stacked + } + + var showSwitcher: Bool { + self.layout == .segmented + } + + static func == (lhs: CodexAccountMenuDisplay, rhs: CodexAccountMenuDisplay) -> Bool { + lhs.accounts == rhs.accounts && + lhs.activeVisibleAccountID == rhs.activeVisibleAccountID && + lhs.layout == rhs.layout && + lhs.snapshotIdentity == rhs.snapshotIdentity + } + + private var snapshotIdentity: [SnapshotIdentity] { + self.snapshots.map { snapshot in + SnapshotIdentity( + id: snapshot.id, + hasSnapshot: snapshot.snapshot != nil, + error: snapshot.error, + sourceLabel: snapshot.sourceLabel) + } + } + + private struct SnapshotIdentity: Equatable { + let id: String + let hasSnapshot: Bool + let error: String? + let sourceLabel: String? + } } diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index c3f475b8f..c02e06c52 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -285,6 +285,7 @@ extension UsageStore { self.lastSourceLabels.removeValue(forKey: .codex) self.lastFetchAttempts.removeValue(forKey: .codex) self.accountSnapshots.removeValue(forKey: .codex) + self.codexAccountSnapshots = [] self.failureGates[.codex]?.reset() self.lastKnownSessionRemaining.removeValue(forKey: .codex) self.lastKnownSessionWindowSource.removeValue(forKey: .codex) diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index ad30cc3ab..d2987fdd7 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -26,6 +26,9 @@ extension UsageStore { self.lastSourceLabels.removeValue(forKey: provider) self.lastFetchAttempts.removeValue(forKey: provider) self.accountSnapshots.removeValue(forKey: provider) + if provider == .codex { + self.codexAccountSnapshots = [] + } self.tokenSnapshots.removeValue(forKey: provider) self.tokenErrors[provider] = nil self.failureGates[provider]?.reset() @@ -41,6 +44,13 @@ extension UsageStore { self.refreshingProviders.insert(provider) defer { self.refreshingProviders.remove(provider) } + if provider == .codex, self.shouldFetchAllCodexVisibleAccounts() { + await self.refreshCodexVisibleAccountsForMenu() + return + } else if provider == .codex { + self.codexAccountSnapshots = [] + } + let tokenAccounts = self.tokenAccounts(for: provider) if self.shouldFetchAllTokenAccounts(provider: provider, accounts: tokenAccounts) { await self.refreshTokenAccounts(provider: provider, accounts: tokenAccounts) diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 4c46c0339..fe2d5f5ec 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -17,6 +17,22 @@ struct TokenAccountUsageSnapshot: Identifiable { } } +struct CodexAccountUsageSnapshot: Identifiable { + let id: String + let account: CodexVisibleAccount + let snapshot: UsageSnapshot? + let error: String? + let sourceLabel: String? + + init(account: CodexVisibleAccount, snapshot: UsageSnapshot?, error: String?, sourceLabel: String?) { + self.id = account.id + self.account = account + self.snapshot = snapshot + self.error = error + self.sourceLabel = sourceLabel + } +} + extension UsageStore { static let tokenAccountMenuSnapshotLimit = 6 @@ -27,10 +43,80 @@ extension UsageStore { func shouldFetchAllTokenAccounts(provider: UsageProvider, accounts: [ProviderTokenAccount]) -> Bool { guard TokenAccountSupportCatalog.support(for: provider) != nil else { return false } - if provider == .copilot { - return accounts.count > 1 + return self.settings.multiAccountMenuLayout == .stacked && accounts.count > 1 + } + + func shouldFetchAllCodexVisibleAccounts() -> Bool { + self.settings.multiAccountMenuLayout == .stacked && + self.settings.codexVisibleAccountProjection.visibleAccounts.count > 1 + } + + func refreshCodexVisibleAccountsForMenu() async { + let projection = self.settings.codexVisibleAccountProjection + let accounts = self.limitedCodexVisibleAccounts( + projection.visibleAccounts, + activeVisibleAccountID: projection.activeVisibleAccountID) + guard accounts.count > 1 else { + self.codexAccountSnapshots = [] + return + } + + let originalSource = self.settings.codexActiveSource + let originalVisibleAccountID = projection.activeVisibleAccountID + let priorByAccountID = Dictionary(uniqueKeysWithValues: self.codexAccountSnapshots.map { ($0.id, $0) }) + var snapshots: [CodexAccountUsageSnapshot] = [] + var selectedOutcome: ProviderFetchOutcome? + var selectedSnapshot: UsageSnapshot? + var selectedSourceLabel: String? + var sawAnyNonCancellationOutcome = false + + let restoreOriginalSelection = { + var restoredSelection = false + if let originalVisibleAccountID, + self.settings.selectCodexVisibleAccount(id: originalVisibleAccountID) + { + restoredSelection = true + } + if !restoredSelection { + self.settings.codexActiveSource = originalSource + } + } + defer { restoreOriginalSelection() } + + for account in accounts { + guard self.settings.selectCodexVisibleAccount(id: account.id) else { continue } + let outcome = await self.fetchOutcome(provider: .codex, override: nil) + let isCancellation = Self.outcomeIsCancellation(outcome) + if !isCancellation { + sawAnyNonCancellationOutcome = true + } + let resolved = self.resolveCodexAccountOutcome( + outcome, + account: account, + priorSnapshot: priorByAccountID[account.id]) + if let snapshot = resolved.snapshot { + snapshots.append(snapshot) + } + if account.id == originalVisibleAccountID { + selectedOutcome = outcome + selectedSnapshot = resolved.usage + selectedSourceLabel = resolved.sourceLabel + } + } + + let shouldPreservePriorState = !sawAnyNonCancellationOutcome && + snapshots.allSatisfy { $0.snapshot == nil } + if !shouldPreservePriorState { + self.codexAccountSnapshots = snapshots + } + + restoreOriginalSelection() + if let selectedOutcome { + await self.applySelectedCodexVisibleAccountOutcome( + selectedOutcome, + snapshot: selectedSnapshot, + sourceLabel: selectedSourceLabel) } - return self.settings.showAllTokenAccountsInMenu && accounts.count > 1 } func refreshTokenAccounts(provider: UsageProvider, accounts: [ProviderTokenAccount]) async { @@ -121,6 +207,23 @@ extension UsageStore { return limited } + func limitedCodexVisibleAccounts( + _ accounts: [CodexVisibleAccount], + activeVisibleAccountID: String?) -> [CodexVisibleAccount] + { + let limit = Self.tokenAccountMenuSnapshotLimit + if accounts.count <= limit { return accounts } + var limited = Array(accounts.prefix(limit)) + if let activeVisibleAccountID, + let active = accounts.first(where: { $0.id == activeVisibleAccountID }), + !limited.contains(where: { $0.id == activeVisibleAccountID }) + { + limited.removeLast() + limited.append(active) + } + return limited + } + func fetchOutcome( provider: UsageProvider, override: TokenAccountOverride?) async -> ProviderFetchOutcome @@ -168,6 +271,12 @@ extension UsageStore { let usage: UsageSnapshot? } + private struct ResolvedCodexAccountOutcome { + let snapshot: CodexAccountUsageSnapshot? + let usage: UsageSnapshot? + let sourceLabel: String? + } + func tokenAccountErrorMessage(_ error: any Error) -> String? { guard !(error is CancellationError) else { return nil } let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) @@ -240,6 +349,79 @@ extension UsageStore { } } + private func resolveCodexAccountOutcome( + _ outcome: ProviderFetchOutcome, + account: CodexVisibleAccount, + priorSnapshot: CodexAccountUsageSnapshot? = nil) -> ResolvedCodexAccountOutcome + { + switch outcome.result { + case let .success(result): + let scoped = result.usage.scoped(to: .codex) + let labeled = self.applyCodexVisibleAccountLabel(scoped, account: account) + let snapshot = CodexAccountUsageSnapshot( + account: account, + snapshot: labeled, + error: nil, + sourceLabel: result.sourceLabel) + return ResolvedCodexAccountOutcome( + snapshot: snapshot, + usage: labeled, + sourceLabel: result.sourceLabel) + case let .failure(error): + if error is CancellationError { + if let priorSnapshot, priorSnapshot.snapshot != nil { + return ResolvedCodexAccountOutcome( + snapshot: priorSnapshot, + usage: priorSnapshot.snapshot, + sourceLabel: priorSnapshot.sourceLabel) + } + return ResolvedCodexAccountOutcome(snapshot: nil, usage: nil, sourceLabel: nil) + } + let snapshot = CodexAccountUsageSnapshot( + account: account, + snapshot: nil, + error: self.tokenAccountSnapshotErrorMessage(error), + sourceLabel: nil) + return ResolvedCodexAccountOutcome(snapshot: snapshot, usage: nil, sourceLabel: nil) + } + } + + func applySelectedCodexVisibleAccountOutcome( + _ outcome: ProviderFetchOutcome, + snapshot: UsageSnapshot?, + sourceLabel: String?) async + { + self.lastFetchAttempts[.codex] = outcome.attempts + switch outcome.result { + case .success: + guard let snapshot else { return } + let backfilled = snapshot.backfillingResetTimes(from: self.lastKnownResetSnapshots[.codex]) + self.handleSessionQuotaTransition(provider: .codex, snapshot: backfilled) + self.lastKnownResetSnapshots[.codex] = backfilled + self.snapshots[.codex] = backfilled + if let sourceLabel { + self.lastSourceLabels[.codex] = sourceLabel + } + self.errors[.codex] = nil + self.failureGates[.codex]?.recordSuccess() + self.rememberLiveSystemCodexEmailIfNeeded(backfilled.accountEmail(for: .codex)) + self.seedCodexAccountScopedRefreshGuard(accountEmail: backfilled.accountEmail(for: .codex)) + await self.recordPlanUtilizationHistorySample(provider: .codex, snapshot: backfilled) + self.recordCodexHistoricalSampleIfNeeded(snapshot: backfilled) + case let .failure(error): + let hadPriorData = self.snapshots[.codex] != nil + let shouldSurface = + self.failureGates[.codex]? + .shouldSurfaceError(onFailureWithPriorData: hadPriorData) ?? true + if shouldSurface { + self.errors[.codex] = error.localizedDescription + self.snapshots.removeValue(forKey: .codex) + } else { + self.errors[.codex] = nil + } + } + } + func applySelectedOutcome( _ outcome: ProviderFetchOutcome, provider: UsageProvider, @@ -307,4 +489,17 @@ extension UsageStore { loginMethod: existing?.loginMethod) return snapshot.withIdentity(identity) } + + func applyCodexVisibleAccountLabel(_ snapshot: UsageSnapshot, account: CodexVisibleAccount) -> UsageSnapshot { + let existing = snapshot.identity(for: .codex) + let email = existing?.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedEmail = (email?.isEmpty ?? true) ? account.email : email + let loginMethod = existing?.loginMethod ?? account.workspaceLabel + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: resolvedEmail, + accountOrganization: existing?.accountOrganization, + loginMethod: loginMethod) + return snapshot.withIdentity(identity) + } } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 00ac59400..4f886bd91 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -14,6 +14,7 @@ extension UsageStore { _ = self.lastSourceLabels _ = self.lastFetchAttempts _ = self.accountSnapshots + _ = self.codexAccountSnapshots _ = self.tokenSnapshots _ = self.tokenErrors _ = self.tokenRefreshInFlight @@ -60,7 +61,7 @@ extension UsageStore { for implementation in ProviderCatalog.all { implementation.observeSettings(self.settings) } - _ = self.settings.showAllTokenAccountsInMenu + _ = self.settings.multiAccountMenuLayout _ = self.settings.tokenAccountsByProvider _ = self.settings.mergeIcons _ = self.settings.selectedMenuProvider @@ -126,6 +127,7 @@ final class UsageStore { var lastSourceLabels: [UsageProvider: String] = [:] var lastFetchAttempts: [UsageProvider: [ProviderFetchAttempt]] = [:] var accountSnapshots: [UsageProvider: [TokenAccountUsageSnapshot]] = [:] + var codexAccountSnapshots: [CodexAccountUsageSnapshot] = [] var tokenSnapshots: [UsageProvider: CostUsageTokenSnapshot] = [:] var tokenErrors: [UsageProvider: String] = [:] var tokenRefreshInFlight: Set = [] diff --git a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift index b71448ef1..17ef6cf01 100644 --- a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift +++ b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift @@ -25,7 +25,7 @@ struct PreferencesPaneSmokeTests { let settings = Self.makeSettingsStore(suite: "PreferencesPaneSmokeTests-toggled") settings.menuBarShowsBrandIconWithPercent = true settings.menuBarShowsHighestUsage = true - settings.showAllTokenAccountsInMenu = true + settings.multiAccountMenuLayout = .stacked settings.hidePersonalInfo = true settings.resetTimesShowAbsolute = true settings.debugDisableKeychainAccess = true diff --git a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift index 70032e07e..1c0266d61 100644 --- a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift +++ b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift @@ -74,6 +74,39 @@ struct SettingsStoreCoverageTests { #expect(settings.resetTimeDisplayStyle == .absolute) } + @Test + func `multi account menu layout persists and bridges legacy show all token accounts`() throws { + let suite = "SettingsStoreCoverageTests-multi-account-layout" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let initial = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(initial.multiAccountMenuLayout == .segmented) + + initial.multiAccountMenuLayout = .stacked + #expect(defaults.string(forKey: "multiAccountMenuLayout") == MultiAccountMenuLayout.stacked.rawValue) + #expect(initial.showAllTokenAccountsInMenu) + + let reloaded = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(reloaded.multiAccountMenuLayout == .stacked) + reloaded.showAllTokenAccountsInMenu = false + #expect(reloaded.multiAccountMenuLayout == .segmented) + } + + @Test + func `legacy show all token accounts migrates to stacked layout`() throws { + let suite = "SettingsStoreCoverageTests-legacy-token-account-layout" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set(true, forKey: "showAllTokenAccountsInMenu") + let configStore = testConfigStore(suiteName: suite) + + let settings = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + + #expect(settings.multiAccountMenuLayout == .stacked) + } + @Test func `token account mutations apply side effects`() { let settings = Self.makeSettingsStore() diff --git a/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift b/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift index dbcc3d2d4..22e4db82d 100644 --- a/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift +++ b/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift @@ -1,3 +1,4 @@ +import AppKit import CodexBarCore import Foundation import Testing @@ -23,6 +24,10 @@ struct StatusMenuCodexSwitcherTests { syntheticTokenStore: NoopSyntheticTokenStore()) } + private func makeStatusBarForTesting() -> NSStatusBar { + .system + } + private func enableOnlyCodex(_ settings: SettingsStore) { let registry = ProviderRegistry.shared for provider in UsageProvider.allCases { @@ -47,6 +52,26 @@ struct StatusMenuCodexSwitcherTests { } } + private func representedIDs(in menu: NSMenu) -> [String] { + menu.items.compactMap { $0.representedObject as? String } + } + + private func snapshot(email: String, percent: Double = 12) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: percent, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(300), + resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: email, + accountOrganization: nil, + loginMethod: "Plus")) + } + private func selectCodexVisibleAccountForStatusMenu( id: String, settings: SettingsStore, @@ -163,6 +188,165 @@ struct StatusMenuCodexSwitcherTests { #expect(self.actionLabels(in: descriptor).contains("Add Account...")) } + @Test + func `codex segmented multi account layout shows account switcher`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .segmented + self.enableOnlyCodex(settings) + + let managedAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed@example.com", + managedHomePath: "/tmp/managed-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + } + + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings.codexActiveSource = .liveSystem + + 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() } + + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + + #expect(menu.items.compactMap { $0.view as? CodexAccountSwitcherView }.first != nil) + #expect(self.representedIDs(in: menu).filter { $0.hasPrefix("menuCard") } == ["menuCard"]) + } + + @Test + func `codex stacked multi account layout shows account cards`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .stacked + self.enableOnlyCodex(settings) + + let managedAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed@example.com", + managedHomePath: "/tmp/managed-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + } + + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings.codexActiveSource = .liveSystem + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let projection = settings.codexVisibleAccountProjection + store.codexAccountSnapshots = projection.visibleAccounts.enumerated().map { index, account in + CodexAccountUsageSnapshot( + account: account, + snapshot: self.snapshot(email: account.email, percent: Double(10 + index)), + error: nil, + sourceLabel: "test") + } + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + + #expect(menu.items.compactMap { $0.view as? CodexAccountSwitcherView }.first == nil) + #expect(self.representedIDs(in: menu).filter { $0.hasPrefix("menuCard") } == ["menuCard-0", "menuCard-1"]) + } + + @Test + func `codex stacked multi account layout shows account cards before per account snapshots load`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .stacked + self.enableOnlyCodex(settings) + + let managedAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed@example.com", + managedHomePath: "/tmp/managed-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + } + + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings.codexActiveSource = .liveSystem + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setSnapshotForTesting(self.snapshot(email: "live@example.com"), provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + + #expect(menu.items.compactMap { $0.view as? CodexAccountSwitcherView }.first == nil) + #expect(self.representedIDs(in: menu).filter { $0.hasPrefix("menuCard") } == ["menuCard-0", "menuCard-1"]) + } + @Test func `codex switcher suppresses personal labels while preserving team workspace tooltips`() { let accounts = [ diff --git a/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift b/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift index 8e3152ef1..841cdcef3 100644 --- a/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift +++ b/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift @@ -33,13 +33,21 @@ final class StatusMenuTokenAccountSwitcherTests: XCTestCase { } private func enableOnlyClaude(_ settings: SettingsStore) { + self.enableOnly(.claude, settings) + } + + private func enableOnly(_ enabledProvider: UsageProvider, _ settings: SettingsStore) { 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 == .claude) + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == enabledProvider) } } + private func representedIDs(in menu: NSMenu) -> [String] { + menu.items.compactMap { $0.representedObject as? String } + } + private func installBlockingClaudeProvider(on store: UsageStore, blocker: BlockingTokenAccountFetchStrategy) { let baseSpec = store.providerSpecs[.claude]! store.providerSpecs[.claude] = Self.makeClaudeProviderSpec(baseSpec: baseSpec) { @@ -130,6 +138,72 @@ final class StatusMenuTokenAccountSwitcherTests: XCTestCase { let startedCallCount = await blocker.startedCallCount() XCTAssertGreaterThanOrEqual(startedCallCount, 2) } + + func test_multiAccountSegmentedLayoutShowsCopilotSwitcher() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .segmented + self.enableOnly(.copilot, settings) + settings.addTokenAccount(provider: .copilot, label: "Primary", token: "gh_primary") + settings.addTokenAccount(provider: .copilot, label: "Secondary", token: "gh_secondary") + + 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() } + + let menu = controller.makeMenu(for: .copilot) + controller.menuWillOpen(menu) + + XCTAssertNotNil(menu.items.compactMap { $0.view as? TokenAccountSwitcherView }.first) + XCTAssertEqual(self.representedIDs(in: menu).filter { $0.hasPrefix("menuCard") }, ["menuCard"]) + } + + func test_multiAccountStackedLayoutShowsCopilotCards() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .stacked + self.enableOnly(.copilot, settings) + settings.addTokenAccount(provider: .copilot, label: "Primary", token: "gh_primary") + settings.addTokenAccount(provider: .copilot, label: "Secondary", token: "gh_secondary") + let accounts = settings.tokenAccounts(for: .copilot) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store.accountSnapshots[.copilot] = accounts.enumerated().map { index, account in + TokenAccountUsageSnapshot( + account: account, + snapshot: self.snapshot(percent: Double(10 + index)), + error: nil, + sourceLabel: "test") + } + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu(for: .copilot) + controller.menuWillOpen(menu) + + XCTAssertNil(menu.items.compactMap { $0.view as? TokenAccountSwitcherView }.first) + XCTAssertEqual(self.representedIDs(in: menu).filter { $0.hasPrefix("menuCard") }, ["menuCard-0", "menuCard-1"]) + } } private struct StatusMenuTokenAccountFetchStrategy: ProviderFetchStrategy { From 12d131aad2326be07bcfd95d808ebc0f35d76fdc Mon Sep 17 00:00:00 2001 From: Alasdair McCall Date: Fri, 8 May 2026 09:00:08 +0100 Subject: [PATCH 2/3] Suppress cancelled multi-account refresh errors --- .../CodexBar/UsageStore+TokenAccounts.swift | 39 +++++++++++++------ .../CopilotMultiAccountTests.swift | 16 +++++--- 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index fe2d5f5ec..0664e40d3 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -190,9 +190,27 @@ extension UsageStore { if case let .failure(error) = outcome.result, error is CancellationError { return true } + if case let .failure(error) = outcome.result { + return self.errorIsCancellation(error) + } return false } + private static func errorIsCancellation(_ error: any Error) -> Bool { + if error is CancellationError { + return true + } + if let urlError = error as? URLError, urlError.code == .cancelled { + return true + } + let message = error.localizedDescription + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + return message == "cancelled" || + message.contains("cancellationerror") || + message.contains("cancelled") + } + func limitedTokenAccounts( _ accounts: [ProviderTokenAccount], selected: ProviderTokenAccount?) -> [ProviderTokenAccount] @@ -278,19 +296,14 @@ extension UsageStore { } func tokenAccountErrorMessage(_ error: any Error) -> String? { - guard !(error is CancellationError) else { return nil } + guard !Self.errorIsCancellation(error) else { return nil } let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) return message.isEmpty ? nil : message } - /// Per-account snapshot error text. Unlike ``tokenAccountErrorMessage``, - /// cancellations are preserved as a non-empty marker so the menu does not - /// silently fall back to the live (selected-account) snapshot when an - /// individual account refresh is cancelled. + /// Per-account snapshot error text. Cancellation is handled before this path so + /// transient menu refresh cancellation does not render as a user-facing error. func tokenAccountSnapshotErrorMessage(_ error: any Error) -> String { - if error is CancellationError { - return "Refresh cancelled" - } let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) return message.isEmpty ? "Refresh failed" : message } @@ -330,7 +343,7 @@ extension UsageStore { // Preserve the last-good snapshot when the refresh was cancelled (e.g. the // user switched menu tabs mid-flight). Without this the per-account list // would briefly render error chips for accounts that already had data. - if error is CancellationError { + if Self.errorIsCancellation(error) { if let priorSnapshot, priorSnapshot.snapshot != nil { return ResolvedAccountOutcome(snapshot: priorSnapshot, usage: priorSnapshot.snapshot) } @@ -368,7 +381,7 @@ extension UsageStore { usage: labeled, sourceLabel: result.sourceLabel) case let .failure(error): - if error is CancellationError { + if Self.errorIsCancellation(error) { if let priorSnapshot, priorSnapshot.snapshot != nil { return ResolvedCodexAccountOutcome( snapshot: priorSnapshot, @@ -409,12 +422,16 @@ extension UsageStore { await self.recordPlanUtilizationHistorySample(provider: .codex, snapshot: backfilled) self.recordCodexHistoricalSampleIfNeeded(snapshot: backfilled) case let .failure(error): + guard let message = self.tokenAccountErrorMessage(error) else { + self.errors[.codex] = nil + return + } let hadPriorData = self.snapshots[.codex] != nil let shouldSurface = self.failureGates[.codex]? .shouldSurfaceError(onFailureWithPriorData: hadPriorData) ?? true if shouldSurface { - self.errors[.codex] = error.localizedDescription + self.errors[.codex] = message self.snapshots.removeValue(forKey: .codex) } else { self.errors[.codex] = nil diff --git a/Tests/CodexBarTests/CopilotMultiAccountTests.swift b/Tests/CodexBarTests/CopilotMultiAccountTests.swift index e472a8219..0dc6ae673 100644 --- a/Tests/CodexBarTests/CopilotMultiAccountTests.swift +++ b/Tests/CodexBarTests/CopilotMultiAccountTests.swift @@ -354,17 +354,21 @@ struct CopilotExternalIdentifierTests { @MainActor struct TokenAccountSnapshotErrorMessageTests { @Test - func `cancellation produces non-empty marker for per-account snapshot`() { + func `cancellation is suppressed for global error path`() { let store = Self.makeUsageStore() - let message = store.tokenAccountSnapshotErrorMessage(CancellationError()) - #expect(!message.isEmpty) - #expect(message.lowercased().contains("cancel")) + #expect(store.tokenAccountErrorMessage(CancellationError()) == nil) + #expect(store.tokenAccountErrorMessage(URLError(.cancelled)) == nil) } @Test - func `cancellation is suppressed for global error path`() { + func `cancellation-like localized errors are suppressed`() { let store = Self.makeUsageStore() - #expect(store.tokenAccountErrorMessage(CancellationError()) == nil) + struct Cancelled: LocalizedError { + var errorDescription: String? { + "cancelled" + } + } + #expect(store.tokenAccountErrorMessage(Cancelled()) == nil) } @Test From 96ef6b48d8704736488a4809a5175eaa99103f37 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 10 May 2026 16:22:48 +0100 Subject: [PATCH 3/3] ci: serialize status menu tests --- .github/workflows/ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9029ba7d..2213efde2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,10 +53,14 @@ jobs: --skip CodexUsageFetcherFallbackTests \ --skip CodexWebDashboardStrategyAuthorityTests \ --skip OpenAIDashboardNavigationDelegateTests \ + --skip StatusMenuCodexSwitcherTests \ + --skip StatusMenuHostedSubmenuRefreshTests \ + --skip StatusMenuSwitcherClickTests \ + --skip StatusMenuTests \ --skip StatusMenuTokenAccountSwitcherTests \ --skip SubprocessRunnerTests swift test --no-parallel \ - --filter 'ClaudeOAuthCredentialsStoreSecurityCLITests|CLIOpenAIDashboardCacheTests|CodexAccountScopedRefreshDashboardCleanupTests|CodexAccountScopedRefreshTests|CodexBaselineCharacterizationTests|CodexDashboardWorkedExampleParityTests|CodexUsageFetcherFallbackTests|CodexWebDashboardStrategyAuthorityTests|OpenAIDashboardNavigationDelegateTests|SubprocessRunnerTests' + --filter 'ClaudeOAuthCredentialsStoreSecurityCLITests|CLIOpenAIDashboardCacheTests|CodexAccountScopedRefreshDashboardCleanupTests|CodexAccountScopedRefreshTests|CodexBaselineCharacterizationTests|CodexDashboardWorkedExampleParityTests|CodexUsageFetcherFallbackTests|CodexWebDashboardStrategyAuthorityTests|OpenAIDashboardNavigationDelegateTests|StatusMenuCodexSwitcherTests|StatusMenuHostedSubmenuRefreshTests|StatusMenuSwitcherClickTests|StatusMenuTests|SubprocessRunnerTests' build-linux-cli: timeout-minutes: 20