Skip to content

Commit 4029705

Browse files
Share spend sources between Overview and dashboard (#3067)
* ci: retry timed-out Linux runner * feat: share spend sources across overview and dashboard * refactor: split OpenCodex spend source * fix: refresh shared Codex spend ownership * test: realign provider architecture anchors * fix: notify shared spend on Codex publication * Publish adopted spend configuration immediately * Parse Codex account identities at the final delimiter codexDisplayNamesByID and orderedSourceIDs split serialized account identities at the first pipe, but profile-home account IDs can themselves contain pipe characters. This truncated the ID and created phantom unavailable sources, inflating the coverage denominator. Use lastIndex(of:) consistently with codexOwnershipByID and add a regression for a pipe-containing profile-home path. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Realign provider architecture gatekeeper anchors after delimiter fix The regression test added in 546bd90 shifted four SpendDashboardController.swift gatekeeper anchors by one line. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Alec Gutman <chipagosfinest@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9654802 commit 4029705

18 files changed

Lines changed: 1926 additions & 186 deletions

Sources/CodexBar/PreferencesSpendDashboardPane.swift

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -142,17 +142,11 @@ func spendDashboardModelHistoryPresentation(
142142
struct SpendDashboardPane: View {
143143
@Bindable var settings: SettingsStore
144144
@Bindable var store: UsageStore
145-
@State private var controller: SpendDashboardController
146145
@State private var isVisible = false
147146

148147
init(settings: SettingsStore, store: UsageStore) {
149148
self.settings = settings
150149
self.store = store
151-
self._controller = State(initialValue: SpendDashboardController(requestBuilder: { mode in
152-
await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode)
153-
}, cachedLoader: { request in
154-
await SpendDashboardSource.loadCached(request)
155-
}))
156150
}
157151

158152
var body: some View {
@@ -194,7 +188,6 @@ struct SpendDashboardPane: View {
194188
}
195189
.onDisappear {
196190
self.isVisible = false
197-
self.controller.stop()
198191
}
199192
.onReceive(NotificationCenter.default.publisher(for: .NSCalendarDayChanged)) { _ in
200193
self.controller.refreshDateWindow()
@@ -211,6 +204,10 @@ struct SpendDashboardPane: View {
211204
SpendDashboardSource.configuration(settings: self.settings, store: self.store)
212205
}
213206

207+
private var controller: SpendDashboardController {
208+
self.store.sharedSpendDashboardController()
209+
}
210+
214211
private var header: some View {
215212
HStack(alignment: .top, spacing: 16) {
216213
VStack(alignment: .leading, spacing: 4) {

Sources/CodexBar/SpendDashboardController.swift

Lines changed: 269 additions & 132 deletions
Large diffs are not rendered by default.

Sources/CodexBar/SpendDashboardModel.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -347,9 +347,13 @@ struct SpendDashboardModel: Equatable, Sendable {
347347
hideNativeCodexWhenOpenCodexPresent: Bool) -> [ProviderInput]
348348
{
349349
var filtered = inputs.filter { !hiddenSourceIDs.contains($0.id) }
350-
let hasOpenCodex = filtered.contains { $0.sourceKind == .openCodex }
350+
// Provider-specific by design: only a canonical OpenCodex Codex row may replace native Codex rows.
351+
let hasOpenCodex = filtered.contains {
352+
$0.id == Self.openCodexSourceID &&
353+
$0.provider == .codex &&
354+
$0.sourceKind == .openCodex
355+
}
351356
if hideNativeCodexWhenOpenCodexPresent, hasOpenCodex {
352-
// Provider-specific by design: the OpenCodex source can explicitly replace native Codex rows.
353357
filtered.removeAll { $0.sourceKind == .native && $0.provider == .codex }
354358
}
355359
return filtered
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import CodexBarCore
2+
import Foundation
3+
4+
struct SpendSourcePublication: Sendable, Equatable {
5+
enum Role: Sendable, Equatable {
6+
case subscription
7+
case enrichment
8+
}
9+
10+
enum State: Sendable, Equatable {
11+
case loading
12+
case available
13+
case confirmedEmpty
14+
case unavailable
15+
case staleLastKnown
16+
}
17+
18+
let id: String
19+
let provider: UsageProvider?
20+
let displayName: String
21+
let role: Role
22+
let state: State
23+
}
24+
25+
struct SpendDashboardPublication: Sendable {
26+
let revision: UInt64
27+
let generation: UInt64
28+
let configuration: SpendDashboardConfiguration?
29+
let loadedAt: Date
30+
let isRefreshing: Bool
31+
let inputs: [SpendDashboardModel.ProviderInput]
32+
let sources: [SpendSourcePublication]
33+
34+
static let empty = SpendDashboardPublication(
35+
revision: 0,
36+
generation: 0,
37+
configuration: nil,
38+
loadedAt: .distantPast,
39+
isRefreshing: false,
40+
inputs: [],
41+
sources: [])
42+
43+
func model(
44+
requestedDays: Int,
45+
now: Date,
46+
calendar: Calendar,
47+
preferredCurrencyCode: String,
48+
hiddenSourceIDs: Set<String> = [],
49+
hideNativeCodexWhenOpenCodexPresent: Bool = false,
50+
selectedDay: Date? = nil,
51+
providerScope: Set<UsageProvider>? = nil) -> SpendDashboardModel
52+
{
53+
let staleSourceIDs = Set(self.sources.compactMap { source in
54+
source.state == .staleLastKnown ? source.id : nil
55+
})
56+
let inputs = self.inputs.filter { input in
57+
(providerScope?.contains(input.provider) ?? true) && !staleSourceIDs.contains(input.id)
58+
}
59+
return SpendDashboardModel.build(
60+
inputs: inputs,
61+
requestedDays: requestedDays,
62+
now: now,
63+
calendar: calendar,
64+
preferredCurrencyCode: preferredCurrencyCode,
65+
hiddenSourceIDs: hiddenSourceIDs,
66+
hideNativeCodexWhenOpenCodexPresent: hideNativeCodexWhenOpenCodexPresent,
67+
selectedDay: selectedDay)
68+
}
69+
70+
func subscriptionCount(
71+
providerScope: Set<UsageProvider>,
72+
hiddenSourceIDs: Set<String> = [],
73+
hideNativeCodexWhenOpenCodexPresent: Bool = false) -> Int
74+
{
75+
providerScope.reduce(into: 0) { count, provider in
76+
let rosterSources = self.subscriptionRosterSources(for: provider)
77+
let coverageSources = self.coverageSources(
78+
for: provider,
79+
hiddenSourceIDs: hiddenSourceIDs,
80+
hideNativeCodexWhenOpenCodexPresent: hideNativeCodexWhenOpenCodexPresent)
81+
if rosterSources.isEmpty, coverageSources.isEmpty {
82+
count += hiddenSourceIDs.contains(provider.rawValue) ? 0 : 1
83+
} else {
84+
count += coverageSources.count
85+
}
86+
}
87+
}
88+
89+
func knownCostSubscriptionCount(
90+
model: SpendDashboardModel,
91+
providerScope: Set<UsageProvider>,
92+
hiddenSourceIDs: Set<String> = [],
93+
hideNativeCodexWhenOpenCodexPresent: Bool = false) -> Int
94+
{
95+
let knownInputIDs = Set(model.groups.flatMap(\.providers).compactMap { row in
96+
row.totalCost == nil ? nil : row.id
97+
})
98+
return self.knownSubscriptionCount(
99+
knownInputIDs: knownInputIDs,
100+
providerScope: providerScope,
101+
hiddenSourceIDs: hiddenSourceIDs,
102+
hideNativeCodexWhenOpenCodexPresent: hideNativeCodexWhenOpenCodexPresent)
103+
}
104+
105+
func knownTokenSubscriptionCount(
106+
model: SpendDashboardModel,
107+
providerScope: Set<UsageProvider>,
108+
hiddenSourceIDs: Set<String> = [],
109+
hideNativeCodexWhenOpenCodexPresent: Bool = false) -> Int
110+
{
111+
let knownInputIDs = Set(model.groups.flatMap(\.providers).compactMap { row in
112+
row.totalTokens == nil ? nil : row.id
113+
})
114+
return self.knownSubscriptionCount(
115+
knownInputIDs: knownInputIDs,
116+
providerScope: providerScope,
117+
hiddenSourceIDs: hiddenSourceIDs,
118+
hideNativeCodexWhenOpenCodexPresent: hideNativeCodexWhenOpenCodexPresent)
119+
}
120+
121+
private func knownSubscriptionCount(
122+
knownInputIDs: Set<String>,
123+
providerScope: Set<UsageProvider>,
124+
hiddenSourceIDs: Set<String>,
125+
hideNativeCodexWhenOpenCodexPresent: Bool) -> Int
126+
{
127+
providerScope.reduce(into: 0) { count, provider in
128+
count += self.coverageSources(
129+
for: provider,
130+
hiddenSourceIDs: hiddenSourceIDs,
131+
hideNativeCodexWhenOpenCodexPresent: hideNativeCodexWhenOpenCodexPresent)
132+
.count { source in
133+
source.state == .confirmedEmpty ||
134+
(source.state == .available && knownInputIDs.contains(source.id))
135+
}
136+
}
137+
}
138+
139+
private func subscriptionRosterSources(for provider: UsageProvider) -> [SpendSourcePublication] {
140+
self.sources.filter { $0.provider == provider && $0.role == .subscription }
141+
}
142+
143+
private func coverageSources(
144+
for provider: UsageProvider,
145+
hiddenSourceIDs: Set<String>,
146+
hideNativeCodexWhenOpenCodexPresent: Bool) -> [SpendSourcePublication]
147+
{
148+
let rosterSources = self.subscriptionRosterSources(for: provider)
149+
.filter { !hiddenSourceIDs.contains($0.id) }
150+
// Provider-specific by design: OpenCodex replaces Codex coverage only with a canonical Codex payload.
151+
guard provider == .codex else { return rosterSources }
152+
let visibleOpenCodexInputIDs: Set<String> = Set(self.inputs.compactMap { input -> String? in
153+
guard input.provider == .codex,
154+
input.sourceKind == .openCodex,
155+
!hiddenSourceIDs.contains(input.id)
156+
else { return nil }
157+
return input.id
158+
})
159+
let inputBackedEnrichmentSources = self.sources.filter {
160+
$0.provider == .codex &&
161+
$0.role == .enrichment &&
162+
visibleOpenCodexInputIDs.contains($0.id)
163+
}
164+
let canonicalReplacement = inputBackedEnrichmentSources.first {
165+
$0.id == SpendDashboardModel.openCodexSourceID
166+
}
167+
if hideNativeCodexWhenOpenCodexPresent,
168+
let canonicalReplacement,
169+
canonicalReplacement.state == SpendSourcePublication.State.available
170+
{
171+
return [canonicalReplacement]
172+
}
173+
if rosterSources.isEmpty, !inputBackedEnrichmentSources.isEmpty {
174+
return inputBackedEnrichmentSources
175+
}
176+
// Provider-specific by design: only canonical Codex enrichment can replace Codex subscription coverage.
177+
guard self.subscriptionRosterSources(for: provider).isEmpty,
178+
!hiddenSourceIDs.contains(SpendDashboardModel.openCodexSourceID),
179+
let openCodexObservation = self.sources.first(where: {
180+
$0.id == SpendDashboardModel.openCodexSourceID &&
181+
$0.provider == .codex &&
182+
$0.role == .enrichment
183+
})
184+
else { return rosterSources }
185+
let hasCodexReplacementInput = self.inputs.contains {
186+
$0.id == SpendDashboardModel.openCodexSourceID &&
187+
$0.provider == .codex &&
188+
$0.sourceKind == .openCodex
189+
}
190+
return hasCodexReplacementInput || openCodexObservation.state == .confirmedEmpty
191+
? [openCodexObservation]
192+
: rosterSources
193+
}
194+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import CodexBarCore
2+
import Foundation
3+
4+
extension SpendDashboardSource {
5+
static func mergingOpenCodexInputs(
6+
_ inputs: [SpendDashboardModel.ProviderInput],
7+
request: SpendDashboardLoadRequest) -> [SpendDashboardModel.ProviderInput]
8+
{
9+
self.mergingOpenCodexInputsWithObservation(inputs, request: request).inputs
10+
}
11+
12+
static func mergingOpenCodexInputsWithObservation(
13+
_ inputs: [SpendDashboardModel.ProviderInput],
14+
request: SpendDashboardLoadRequest,
15+
environment: [String: String] = ProcessInfo.processInfo.environment,
16+
entryLoader: ((URL) throws -> [OpenCodexUsageEntry])? = nil) -> (
17+
inputs: [SpendDashboardModel.ProviderInput],
18+
observation: SpendDashboardLoadResult.OpenCodexObservation)
19+
{
20+
guard request.configuration.openCodexUsageLogsEnabled,
21+
!request.configuration.hiddenSourceIDs.contains(SpendDashboardModel.openCodexSourceID)
22+
else {
23+
return (
24+
inputs.filter { $0.id != SpendDashboardModel.openCodexSourceID },
25+
.disabled)
26+
}
27+
guard let logURL = OpenCodexUsageLog.usageLogURL(environment: environment) else {
28+
return (inputs.filter { $0.id != SpendDashboardModel.openCodexSourceID }, .unavailable)
29+
}
30+
let store = OpenCodexUsageStore(cacheRoot: OpenCodexUsageLog.cacheRoot())
31+
let entries: [OpenCodexUsageEntry]
32+
do {
33+
entries = try entryLoader?(logURL) ?? store.loadEntries(logURL: logURL)
34+
} catch {
35+
return (inputs.filter { $0.id != SpendDashboardModel.openCodexSourceID }, .unavailable)
36+
}
37+
guard !entries.isEmpty else {
38+
return (inputs.filter { $0.id != SpendDashboardModel.openCodexSourceID }, .confirmedEmpty)
39+
}
40+
41+
let snapshots = OpenCodexUsageFanOut.snapshotsBySubscription(
42+
entries: entries,
43+
now: request.now,
44+
historyDays: Self.scanDays,
45+
calendar: request.configuration.bucketCalendar)
46+
var merged = inputs.filter { $0.id != SpendDashboardModel.openCodexSourceID }
47+
var published = false
48+
49+
for (provider, supplement) in snapshots {
50+
guard Self.shouldPublishOpenCodexSnapshot(supplement) else { continue }
51+
published = true
52+
// Provider-specific by design: hide-native keeps OpenCodex on its own Codex row
53+
// so visibleInputs can drop overlapping native Codex snapshots.
54+
if provider == .codex,
55+
request.configuration.hideNativeCodexCostWhenOpenCodexPresent
56+
{
57+
merged.append(SpendDashboardModel.ProviderInput(
58+
id: SpendDashboardModel.openCodexSourceID,
59+
provider: provider,
60+
displayName: ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName,
61+
snapshot: supplement,
62+
sourceKind: .openCodex))
63+
continue
64+
}
65+
if let index = Self.preferredMergeIndex(for: provider, in: merged) {
66+
merged[index] = Self.mergeProviderInput(
67+
merged[index],
68+
supplement: supplement,
69+
request: request)
70+
} else {
71+
merged.append(SpendDashboardModel.ProviderInput(
72+
provider: provider,
73+
displayName: ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName,
74+
snapshot: supplement,
75+
sourceKind: .openCodex))
76+
}
77+
}
78+
return (merged, published ? .available : .confirmedEmpty)
79+
}
80+
81+
static func preferredMergeIndex(
82+
for provider: UsageProvider,
83+
in inputs: [SpendDashboardModel.ProviderInput]) -> Int?
84+
{
85+
// Provider-specific by design: OpenCodex fan-out merges into the native Codex subscription row when exactly one
86+
// exists.
87+
if provider == .codex {
88+
let codexIndices = inputs.indices.filter { inputs[$0].provider == .codex }
89+
guard codexIndices.count == 1 else { return nil }
90+
return codexIndices.first
91+
}
92+
let matching = inputs.indices.filter { inputs[$0].provider == provider }
93+
guard matching.count == 1 else {
94+
return inputs.firstIndex(where: { $0.provider == provider && $0.sourceKind == .native })
95+
}
96+
return matching.first
97+
}
98+
99+
private static func mergeProviderInput(
100+
_ input: SpendDashboardModel.ProviderInput,
101+
supplement: CostUsageTokenSnapshot,
102+
request: SpendDashboardLoadRequest) -> SpendDashboardModel.ProviderInput
103+
{
104+
SpendDashboardModel.ProviderInput(
105+
id: input.id,
106+
provider: input.provider,
107+
displayName: input.displayName,
108+
modelProviderName: input.modelProviderName,
109+
snapshot: OpenCodexUsageFanOut.mergeSnapshots(
110+
input.snapshot,
111+
supplement,
112+
now: request.now,
113+
historyDays: self.scanDays,
114+
calendar: request.configuration.bucketCalendar),
115+
tokenActivityCache: input.tokenActivityCache,
116+
sourceKind: input.sourceKind)
117+
}
118+
119+
static func shouldPublishOpenCodexSnapshot(_ snapshot: CostUsageTokenSnapshot) -> Bool {
120+
!snapshot.daily.isEmpty || !snapshot.sessions.isEmpty
121+
}
122+
}

Sources/CodexBar/StatusItemController+Menu.swift

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -577,11 +577,18 @@ extension StatusItemController {
577577
let t0 = CACurrentMediaTime()
578578
defer { self.logChartRenderDurationIfSlow("addOverviewRows(\(rows.count))", startedAt: t0) }
579579

580-
let spendModel = self.overviewSpendDashboardModel(providers: providerScopes.spend)
581-
if !spendModel.groups.isEmpty {
580+
let spendProviders = providerScopes.spend
581+
let spendModel = self.overviewSpendDashboardModel(providers: spendProviders)
582+
let spendProviderCount = self.overviewSpendSubscriptionCount(providers: spendProviders)
583+
if spendProviderCount > 0 {
584+
let knownCounts = self.overviewSpendKnownSubscriptionCounts(
585+
providers: spendProviders,
586+
model: spendModel)
582587
let spendSummary = OverviewSpendSummary(
583588
model: spendModel,
584-
providerCount: providerScopes.spend.count)
589+
providerCount: spendProviderCount,
590+
knownCostProviderCount: knownCounts.cost,
591+
knownTokenProviderCount: knownCounts.tokens)
585592
let summaryItem = self.makeMenuCardItem(
586593
OverviewSpendSummaryCardView(
587594
summary: spendSummary,

0 commit comments

Comments
 (0)