Files
steipete--codexbar/Sources/CodexBar/StatusItemController+CompactAccountMenu.swift
sf-jin-ku d2ef26296a fix(codex): show Business monthly credit on stacked cards (#3112)
* fix(codex): show Business monthly credit on stacked cards

Stacked account fetches skipped the spend-controls credits path, so
Enterprise/Business cards showed Limits not available instead of used vs cap.

* docs: credit #3112 changelog entry

* fix(codex): keep cached monthly limits after spend-controls failures

Transient enrichment errors were persisted as missing credits, so stacked Business cards fell back to “Limits not available.” Restore the unreleased changelog heading as well.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(codex): clear published credits when the monthly cap is gone

Successful spend-controls absence skipped publication and left the old cap on the selected account. Retarget gatekeeper fingerprints after that cluster split.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(codex): keep monthly-only usage out of reset-credit rescue

A transient spend-controls failure still produced a success with no windows, and OAuth reset-credit rescue converted that into noRateLimitsFound before the cached monthly cap could be preserved.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(codex): treat unmappable monthly limits as enrichment failures

A nested spend-controls limit that is present but not numeric is no longer treated as confirmed absence, so a cached Business cap is preserved.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(codex): honor disabled caps and unmappable monthly usage

Inactive enforcement still confirms absence even if limit JSON is malformed, and a non-numeric current_month_usage is treated as enrichment failure instead of zero used.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(codex): let an absent monthly cap win over malformed usage

Malformed current_month_usage only counts as enrichment failure when an active numeric limit actually needs that used amount.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(codex): hide monthly credit when optional credits are off

Override-derived monthly lanes now follow Show credits + extra usage, so stacked Business cards no longer keep a credit meter the user asked to hide.

* fix(codex): include monthly credits in compact account planning

Inactive compact rows now see per-account monthly caps so an exhausted Business limit cannot hide behind a window-less usage snapshot.

* fix(codex): preserve only the monthly cap after enrichment failure

When spend-controls fails and usage confirmed there is no generic credit balance, keep the cached monthly limit without republishing stale remaining/events.

* fix(codex): fail monthly enrichment when account id or enforcement is unknown

A gated spend-controls fetch without an account id, or a non-string enforcement_mode, is now inconclusive so a cached Business cap is not cleared or published as active.

* fix(codex): hide menu-bar monthly credit when optional credits are off

Menu-bar projections now omit the optional monthly cap when the credits toggle is off, and a mappable nonpositive limit confirms cap removal even if enforcement is unreadable.

* fix(codex): reject non-finite monthly credit values

NaN and Infinity usage or limit strings are treated as mapping failures so they cannot zero out cached usage or persist invalid credit snapshots.

* fix(codex): keep monthly cap through standalone credits refresh

When spend-controls enrichment fails, the follow-up credits refresh now merges the prior monthly limit instead of publishing a generic-only snapshot.

* fix(codex): clear generic credits when monthly enrichment fails

A standalone credits refresh that confirms the generic balance is gone now publishes nil instead of throwing, so stale credits are not restored while a monthly cap is still preserved when one exists.

* fix(codex): retarget snapshot-store architecture suppressions

The monthly-cap helper shifted the Codex identity anchors, so the provider-architecture allowlist now points at the new lines.

* fix(codex): fetch monthly credits when plan type is unknown

Spend-control accounts with a missing or unmapped plan type now query the monthly endpoint, so a cached Business cap is not treated as confirmed removal.

* fix(codex): hydrate cached monthly credits and clear stale generic balance

Offline startup left the menu-bar projection without the disk-cached cap, and enrichment failure kept a generic balance when the monthly cap was already absent.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(codex): persist standalone credits onto account snapshots

Usage refresh stored the selected account snapshot before the credits pass, so a later cap add or removal never reached disk and offline hydration replayed the stale value.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(codex): persist credits using workspace identity

Same-email workspace accounts made the snapshot write a no-op; match the published cap with the scoped refresh identity so only the selected workspace row is stored.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore: retrigger CI

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-08-21 16:18:15 -07:00

337 lines
15 KiB
Swift

import AppKit
import CodexBarCore
/// Shared renderer for the compact multi-account menu layout: full cards for the
/// active and explicitly expanded accounts, one-line rows for the rest, and a
/// summary row standing in for the collapsed healthy tail. Used by every
/// multi-account presentation (claude-swap, token accounts, Codex accounts).
extension StatusItemController {
func compactAccountPlan(
for provider: UsageProvider,
accounts: [ProviderAccountUsageSnapshot]) -> AccountMenuLayoutPlanner.Plan
{
AccountMenuLayoutPlanner.plan(
accounts: accounts,
expandedAccountIDs: self.compactAccountExpandedIDs,
healthyTailExpanded: self.compactAccountExpandedHealthyTailProviders.contains(provider.instanceID))
}
struct CompactAccountMenuRendering {
let plan: AccountMenuLayoutPlanner.Plan
let accounts: [ProviderAccountUsageSnapshot]
let idPrefix: String
let cardModel: (ProviderAccountUsageSnapshot) -> UsageMenuCardView.Model?
var planAction: ((ProviderAccountUsageSnapshot) -> (() -> Void)?)?
}
/// Renders the token-account list with the compact plan when it applies.
/// Returns false when the caller should fall back to the stacked cards.
func addCompactTokenAccountMenuIfPlanned(
display: TokenAccountMenuDisplay,
to menu: NSMenu,
captureMenu: NSMenu,
context: MenuCardContext) -> Bool
{
let provider = context.currentProvider
let projected = Self.projectedTokenAccounts(
provider: provider,
snapshots: display.snapshots,
selectedAccountID: self.settings.effectiveSelectedTokenAccount(for: provider)?.id)
let plan = self.compactAccountPlan(for: provider, accounts: projected)
guard plan.usesCompactLayout else { return false }
let snapshotsByID = Dictionary(
uniqueKeysWithValues: display.snapshots.map { ($0.account.id.uuidString, $0) })
self.addCompactAccountMenuRows(
CompactAccountMenuRendering(
plan: plan,
accounts: projected,
idPrefix: "tokenAccount",
cardModel: { [weak self] projectedAccount in
guard let self,
let accountSnapshot = snapshotsByID[projectedAccount.id.opaqueID] else { return nil }
return self.tokenAccountMenuCardModel(for: provider, accountSnapshot: accountSnapshot)
},
planAction: nil),
to: menu,
captureMenu: captureMenu,
context: context)
return true
}
/// Renders the Codex account list with the compact plan when it applies.
/// Workspace-grouped lists keep their sectioned stacked layout; the flat
/// compact plan would lose the grouping headers.
func addCompactCodexAccountMenuIfPlanned(
display: CodexAccountMenuDisplay,
to menu: NSMenu,
captureMenu: NSMenu,
context: MenuCardContext) -> Bool
{
guard !display.showsWorkspaceGroups else { return false }
let projected = Self.projectedCodexAccounts(
display: display,
includeOptionalCredits: self.settings.showOptionalCreditsAndExtraUsage)
let plan = self.compactAccountPlan(for: .codex, accounts: projected)
guard plan.usesCompactLayout else { return false }
let snapshotsByAccountID = Dictionary(
uniqueKeysWithValues: display.snapshots.map { ($0.account.id, $0) })
let accountsByID = Dictionary(
uniqueKeysWithValues: display.accounts.map { ($0.id, $0) })
self.addCompactAccountMenuRows(
CompactAccountMenuRendering(
plan: plan,
accounts: projected,
idPrefix: "codexAccount",
cardModel: { [weak self] projectedAccount in
guard let self,
let account = accountsByID[projectedAccount.id.opaqueID] else { return nil }
let accountSnapshot = snapshotsByAccountID[account.id]
let health = CodexAccountHealth.status(for: account, error: accountSnapshot?.error)
return self.menuCardModel(
for: .codex,
snapshotOverride: accountSnapshot?.snapshot,
errorOverride: health.label,
forceOverrideCard: accountSnapshot == nil,
accountOverride: self.accountInfo(for: account),
historySelectionOverride: self.store.codexPlanUtilizationHistorySelection(
forVisibleAccount: account),
creditsOverride: accountSnapshot?.credits)
},
planAction: nil),
to: menu,
captureMenu: captureMenu,
context: context)
return true
}
func addCompactAccountMenuRows(
_ rendering: CompactAccountMenuRendering,
to menu: NSMenu,
captureMenu: NSMenu,
context: MenuCardContext)
{
let plan = rendering.plan
let idPrefix = rendering.idPrefix
let cardModel = rendering.cardModel
let planAction = rendering.planAction
let provider = context.currentProvider
let accountsByID = Dictionary(uniqueKeysWithValues: rendering.accounts.map { ($0.id, $0) })
let progressColor = UsageMenuCardView.Model.progressColor(for: provider)
var previousRowWasCard = false
for (index, row) in plan.rows.enumerated() {
switch row {
case let .card(accountID):
guard let account = accountsByID[accountID],
let model = cardModel(account) else { continue }
if index > 0 {
menu.addItem(.separator())
}
let collapseClick: (() -> Void)? = account.isActive ? nil : { [weak self, weak captureMenu] in
self?.toggleCompactAccountExpansion(accountID, menu: captureMenu)
}
menu.addItem(self.makeMenuCardItem(
UsageMenuCardView(
model: model,
width: context.menuWidth,
planAction: planAction?(account)),
id: "\(idPrefix)Card-\(accountID.opaqueID)",
width: context.menuWidth,
heightCacheScope: "\(idPrefix)-card-\(accountID.opaqueID)",
heightCacheFingerprint: model.heightFingerprint(section: "card"),
containsInteractiveControls: true,
onClick: collapseClick))
previousRowWasCard = true
case let .compact(compactRow):
if previousRowWasCard {
menu.addItem(.separator())
}
let rowModel = MenuCardCompactAccountRowView.Model(
label: PersonalInfoRedactor.redactEmail(
compactRow.label,
isEnabled: self.settings.hidePersonalInfo),
headroomPercent: compactRow.headroomPercent,
severity: compactRow.severity,
constraintDetail: compactRow.constraintDetail,
hasError: compactRow.hasError,
showsBestBadge: compactRow.isBestCandidate)
let accountID = compactRow.accountID
menu.addItem(self.makeMenuCardItem(
MenuCardCompactAccountRowView(
model: rowModel,
progressColor: progressColor,
width: context.menuWidth),
id: "\(idPrefix)Compact-\(accountID.opaqueID)",
width: context.menuWidth,
heightCacheScope: "\(idPrefix)-compact-\(accountID.opaqueID)",
heightCacheFingerprint: rowModel.heightFingerprint,
onClick: { [weak self, weak captureMenu] in
self?.toggleCompactAccountExpansion(accountID, menu: captureMenu)
}))
previousRowWasCard = false
case let .collapsedHealthy(count):
let view = MenuCardCollapsedAccountsRowView(count: count, width: context.menuWidth)
menu.addItem(self.makeMenuCardItem(
view,
id: "\(idPrefix)Collapsed",
width: context.menuWidth,
heightCacheScope: "\(idPrefix)-collapsed",
heightCacheFingerprint: "collapsed-\(count)",
onClick: { [weak self, weak captureMenu] in
self?.expandCompactAccountHealthyTail(for: provider, menu: captureMenu)
}))
previousRowWasCard = false
}
}
if !plan.rows.isEmpty {
menu.addItem(.separator())
}
if self.addStorageMenuCardSection(to: menu, provider: provider, width: context.menuWidth) {
menu.addItem(.separator())
}
}
/// Classic stacked layout: one full card per account. Shared fallback for
/// every multi-account list below the compact-layout threshold.
func addStackedMenuCards(
_ cards: [UsageMenuCardView.Model],
to menu: NSMenu,
context: MenuCardContext,
planAction: ((Int) -> (() -> Void)?)? = nil)
{
if cards.isEmpty, let model = self.menuCardModel(for: context.selectedProvider) {
let renderedModel = self.menuCardRefreshMonitor.model(for: model.provider, fallback: model)
menu.addItem(self.makeMenuCardItem(
UsageMenuCardView(model: model, layoutModel: renderedModel, width: context.menuWidth),
id: "menuCard",
width: context.menuWidth,
heightCacheScope: context.currentProvider.rawValue,
heightCacheFingerprint: renderedModel.heightFingerprint(section: "card"),
containsInteractiveControls: true))
menu.addItem(.separator())
} else {
for (index, model) in cards.enumerated() {
menu.addItem(self.makeMenuCardItem(
UsageMenuCardView(
model: model,
width: context.menuWidth,
planAction: planAction?(index)),
id: "menuCard-\(index)",
width: context.menuWidth,
heightCacheScope: "\(context.currentProvider.rawValue)-\(index)",
heightCacheFingerprint: model.heightFingerprint(section: "card"),
containsInteractiveControls: true))
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())
}
}
// MARK: - Projections
static func projectedTokenAccounts(
provider: UsageProvider,
snapshots: [TokenAccountUsageSnapshot],
selectedAccountID: UUID?) -> [ProviderAccountUsageSnapshot]
{
snapshots.map { accountSnapshot in
let isActive = accountSnapshot.account.id == selectedAccountID
return ProviderAccountUsageSnapshot(
id: ProviderAccountIdentity(
source: "token-account",
opaqueID: accountSnapshot.account.id.uuidString),
provider: provider,
displayLabel: accountSnapshot.account.displayName,
isActive: isActive,
canActivate: !isActive,
snapshot: accountSnapshot.snapshot,
error: accountSnapshot.error,
sourceLabel: accountSnapshot.sourceLabel)
}
}
static func projectedCodexAccounts(
display: CodexAccountMenuDisplay,
includeOptionalCredits: Bool = true) -> [ProviderAccountUsageSnapshot]
{
let snapshotsByAccountID = Dictionary(uniqueKeysWithValues: display.snapshots.map { ($0.account.id, $0) })
return display.accounts.map { account in
let accountSnapshot = snapshotsByAccountID[account.id]
let health = CodexAccountHealth.status(for: account, error: accountSnapshot?.error)
let isActive = account.id == display.activeVisibleAccountID || account.isActive
let credits = includeOptionalCredits ? accountSnapshot?.credits : nil
return ProviderAccountUsageSnapshot(
id: ProviderAccountIdentity(source: "codex-account", opaqueID: account.id),
provider: .codex,
displayLabel: account.menuDisplayName,
isActive: isActive,
canActivate: !isActive,
snapshot: Self.snapshotIncludingMonthlyCredit(
snapshot: accountSnapshot?.snapshot,
credits: credits),
error: health.label,
sourceLabel: accountSnapshot?.sourceLabel)
}
}
static func snapshotIncludingMonthlyCredit(
snapshot: UsageSnapshot?,
credits: CreditsSnapshot?) -> UsageSnapshot?
{
guard let limit = credits?.codexCreditLimit else { return snapshot }
let monthly = RateWindow(
usedPercent: limit.usedPercent,
windowMinutes: nil,
resetsAt: limit.resetsAt,
resetDescription: nil)
guard let snapshot else {
return UsageSnapshot(
primary: nil,
secondary: nil,
tertiary: monthly,
updatedAt: limit.updatedAt)
}
if snapshot.tertiary == nil {
return snapshot.with(tertiary: monthly)
}
let extras = (snapshot.extraRateWindows ?? []) + [
NamedRateWindow(
id: "codex-monthly-credit",
title: limit.title,
window: monthly),
]
return snapshot.with(extraRateWindows: extras)
}
// MARK: - Expansion state
private func toggleCompactAccountExpansion(_ accountID: ProviderAccountIdentity, menu: NSMenu?) {
self.advanceMenuInteraction(for: menu)
if self.compactAccountExpandedIDs.contains(accountID) {
self.compactAccountExpandedIDs.remove(accountID)
} else {
self.compactAccountExpandedIDs.insert(accountID)
}
self.invalidateMenus(refreshOpenMenus: true)
}
private func expandCompactAccountHealthyTail(for provider: UsageProvider, menu: NSMenu?) {
self.advanceMenuInteraction(for: menu)
self.compactAccountExpandedHealthyTailProviders.insert(provider.instanceID)
self.invalidateMenus(refreshOpenMenus: true)
}
/// Compact-layout expansion is per-open transient UI state; reset when the last menu closes.
func resetCompactAccountMenuExpansionStateIfIdle() {
guard self.openMenus.isEmpty else { return }
self.compactAccountExpandedIDs.removeAll()
self.compactAccountExpandedHealthyTailProviders.removeAll()
}
}