Skip to content
Closed
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
23 changes: 21 additions & 2 deletions Sources/CodexBar/PreferencesSpendDashboardPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ struct SpendDashboardPane: View {
self.store = store
self._controller = State(initialValue: SpendDashboardController(requestBuilder: { mode in
await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode)
}, cachedLoader: { request in
await SpendDashboardSource.loadCached(request)
}))
}

Expand Down Expand Up @@ -293,11 +295,12 @@ struct SpendDashboardPane: View {
.frame(maxWidth: .infinity, minHeight: 220)
}
} else if self.controller.model.groups.isEmpty {
let emptyState = SpendDashboardEmptyState.make(isRefreshing: self.controller.isRefreshing)
SpendDashboardPanel {
ContentUnavailableView {
Label(L("No local cost history yet"), systemImage: "chart.bar.xaxis")
Label(emptyState.title, systemImage: "chart.bar.xaxis")
} description: {
Text(L("Turn on cost tracking or refresh after using a supported provider."))
Text(emptyState.message)
}
.frame(maxWidth: .infinity, minHeight: 220)
}
Expand Down Expand Up @@ -383,6 +386,22 @@ struct SpendDashboardPane: View {
}
}

struct SpendDashboardEmptyState: Equatable {
let title: String
let message: String

static func make(isRefreshing: Bool) -> Self {
if isRefreshing {
return Self(
title: L("Refreshing"),
message: L("Local estimated cost history across supported providers."))
}
return Self(
title: L("No local cost history yet"),
message: L("Turn on cost tracking or refresh after using a supported provider."))
}
}

private struct SpendCurrencySection: View {
let group: SpendDashboardModel.CurrencyGroup
let requestedDays: Int
Expand Down
142 changes: 137 additions & 5 deletions Sources/CodexBar/SpendDashboardController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ struct CodexSpendSnapshotLoadContext: Sendable {
enum SpendDashboardSource {
typealias CodexSnapshotLoader = @Sendable (CodexSpendSnapshotLoadContext) async throws
-> CostUsageTokenSnapshot
typealias CachedCodexSnapshotLoader = @Sendable (CodexSpendSnapshotLoadContext) async
-> CostUsageTokenSnapshot?
typealias CodexCacheRootResolver = @Sendable (CodexSpendScanRequest) -> URL

static let scanDays = 30

Expand Down Expand Up @@ -255,6 +258,86 @@ enum SpendDashboardSource {
})
}

/// Dashboard priming may only publish current admitted caches. Stale catch-up placeholders
/// (previous report or incompatible producer) stay hidden until live validation finishes.
static func admittedCachedCodexSnapshot(
from result: CostUsageFetcher.CachedCodexTokenSnapshotResult?) -> CostUsageTokenSnapshot?
{
guard let result, result.staleSnapshotUpdatedAt == nil else { return nil }
return result.snapshot
}

static func loadCached(_ request: SpendDashboardLoadRequest) async -> SpendDashboardLoadResult {
await self.loadCached(request, cacheRootResolver: { self.codexCacheRoot(for: $0) })
}

static func loadCached(
_ request: SpendDashboardLoadRequest,
cacheRootResolver: @escaping CodexCacheRootResolver) async -> SpendDashboardLoadResult
{
await self.loadCached(
request,
cacheRootResolver: cacheRootResolver,
cachedCodexSnapshotLoader: { context in
// Only prime from an admitted current cache. Previous-report / incompatible-producer
// placeholders carry staleSnapshotUpdatedAt while catch-up or producer upgrade is
// still pending; showing those as "validated" spend would reintroduce the upgrade
// risk that #2525's catch-up path is designed to retire.
let cached = await CostUsageFetcher(cacheRoot: context.cacheRoot)
.loadCachedCodexTokenSnapshotResultForScopedHome(
now: context.now,
codexHomePath: context.account.homePath,
historyDays: context.historyDays,
includePiSessions: false,
includeProjectAndSessionBreakdowns: false)
return Self.admittedCachedCodexSnapshot(from: cached)
})
}

static func loadCached(
_ request: SpendDashboardLoadRequest,
cachedCodexSnapshotLoader: CachedCodexSnapshotLoader) async -> SpendDashboardLoadResult
{
await self.loadCached(
request,
cacheRootResolver: { self.codexCacheRoot(for: $0) },
cachedCodexSnapshotLoader: cachedCodexSnapshotLoader)
}

private static func loadCached(
_ request: SpendDashboardLoadRequest,
cacheRootResolver: CodexCacheRootResolver,
cachedCodexSnapshotLoader: CachedCodexSnapshotLoader) async -> SpendDashboardLoadResult
{
var inputs = request.capturedInputs
for account in request.codexRequests {
guard !Task.isCancelled,
self.currentAuthFingerprint(for: account) == account.authFingerprint
else { continue }
let snapshot = await cachedCodexSnapshotLoader(CodexSpendSnapshotLoadContext(
account: account,
cacheRoot: cacheRootResolver(account),
now: request.now,
force: false,
historyDays: Self.scanDays,
refreshPricingInBackground: false,
includePiSessions: false))
guard !Task.isCancelled,
let snapshot,
self.currentAuthFingerprint(for: account) == account.authFingerprint
else { continue }
inputs.append(SpendDashboardModel.ProviderInput(
id: "codex:\(account.id)",
provider: .codex,
displayName: account.displayName,
modelProviderName: ProviderDescriptorRegistry.descriptor(for: .codex).metadata.displayName,
snapshot: snapshot))
}
// A cache miss is still pending fresh validation, not a new provider failure. Preserve
// failures already captured in the request so priming cannot briefly clear the warning.
return SpendDashboardLoadResult(inputs: inputs, failedSourceIDs: request.unavailableSourceIDs)
}

static func load(
_ request: SpendDashboardLoadRequest,
codexSnapshotLoader: CodexSnapshotLoader) async -> SpendDashboardLoadResult
Expand Down Expand Up @@ -498,7 +581,13 @@ enum SpendDashboardSource {
}

static func codexCacheRoot(for request: CodexSpendScanRequest) -> URL {
UsageStore.costUsageCacheDirectory()
let costUsageDirectory = UsageStore.costUsageCacheDirectory()
if request.source == .liveSystem {
// The live account reads the same local-home telemetry as UsageStore's ambient scanner.
// Reuse that cache instead of indexing the identical session corpus a second time.
return costUsageDirectory.deletingLastPathComponent()
}
return costUsageDirectory
.appendingPathComponent("accounts", isDirectory: true)
.appendingPathComponent(request.cacheIdentity, isDirectory: true)
}
Expand Down Expand Up @@ -577,6 +666,7 @@ final class SpendDashboardController {
typealias RequestBuilder = @MainActor @Sendable (SpendDashboardRequestBuildMode) async
-> SpendDashboardLoadRequest
typealias Loader = @Sendable (SpendDashboardLoadRequest) async -> SpendDashboardLoadResult
typealias CachedLoader = @Sendable (SpendDashboardLoadRequest) async -> SpendDashboardLoadResult

private enum ReconciliationObservation: Sendable {
case confirmedEmpty
Expand Down Expand Up @@ -644,12 +734,14 @@ final class SpendDashboardController {
}

private enum LoadPhase: Sendable {
case priming
case ordinary
case forcing
case reconciling(ForcedOutcome)

var buildMode: SpendDashboardRequestBuildMode {
switch self {
case .priming: .captureOnly
case .ordinary: .refreshMissing
case .forcing: .forceRefresh
case .reconciling: .captureOnly
Expand All @@ -658,7 +750,7 @@ final class SpendDashboardController {

var manualRefreshOutstanding: Bool {
switch self {
case .ordinary: false
case .priming, .ordinary: false
case .forcing, .reconciling: true
}
}
Expand All @@ -674,6 +766,7 @@ final class SpendDashboardController {
private static let daysDefaultsKey = "settingsSpendDashboardDays"
private let userDefaults: UserDefaults
private let requestBuilder: RequestBuilder
private let cachedLoader: CachedLoader?
private let loader: Loader
private let nowProvider: @Sendable () -> Date
private var loadTask: Task<Void, Never>?
Expand All @@ -685,11 +778,13 @@ final class SpendDashboardController {
init(
userDefaults: UserDefaults = .standard,
requestBuilder: @escaping RequestBuilder,
cachedLoader: CachedLoader? = nil,
loader: @escaping Loader = SpendDashboardSource.load,
nowProvider: @escaping @Sendable () -> Date = { Date() })
{
self.userDefaults = userDefaults
self.requestBuilder = requestBuilder
self.cachedLoader = cachedLoader
self.loader = loader
self.nowProvider = nowProvider
self.selectedDays = Self.normalizedDays(userDefaults.integer(forKey: Self.daysDefaultsKey))
Expand All @@ -711,7 +806,18 @@ final class SpendDashboardController {
{
return
}
let nextPhase: LoadPhase = self.phase.manualRefreshOutstanding ? .forcing : .ordinary
let ownershipChanged = previousConfiguration.map {
!Self.sameSourceOwnership($0, configuration)
} ?? false
let shouldPrime = self.cachedLoader != nil &&
(self.lastSuccessfulConfiguration == nil || ownershipChanged)
let nextPhase: LoadPhase = if self.phase.manualRefreshOutstanding {
.forcing
} else if shouldPrime {
.priming
} else {
.ordinary
}
self.startLoad(configuration: configuration, phase: nextPhase)
}

Expand All @@ -724,7 +830,7 @@ final class SpendDashboardController {
self.loadTask?.cancel()
let invalidatedSourceIDs = switch phase {
case let .reconciling(outcome): outcome.invalidatedSourceIDs
case .ordinary, .forcing:
case .priming, .ordinary, .forcing:
Self.invalidatedSourceIDs(
previous: self.lastSuccessfulConfiguration,
current: configuration)
Expand Down Expand Up @@ -816,6 +922,27 @@ final class SpendDashboardController {
}

switch phase {
case .priming:
guard let cachedLoader = self.cachedLoader else {
self.startLoad(configuration: request.configuration, phase: .ordinary)
return
}
let result = await cachedLoader(request)
guard !Task.isCancelled,
generation == self.generation,
let latestConfiguration = self.configuration
else { return }
guard request.configuration == latestConfiguration else {
self.startLoad(configuration: latestConfiguration, phase: .ordinary)
return
}
self.apply(
request: request,
result: result,
invalidatedSourceIDs: invalidatedSourceIDs,
confirmedEmptySourceIDs: [])
self.startLoad(configuration: request.configuration, phase: .ordinary)

case .ordinary:
let result = await self.loader(request)
guard !Task.isCancelled,
Expand Down Expand Up @@ -867,6 +994,7 @@ final class SpendDashboardController {
{
self.configuration = configuration
let nextPhase: LoadPhase = switch phase {
case .priming: .priming
case .ordinary: .ordinary
case .forcing: .forcing
case let .reconciling(outcome):
Expand Down Expand Up @@ -965,7 +1093,11 @@ final class SpendDashboardController {
self.loadedAt = now ?? self.nowProvider()
self.rebuildModel()
guard let configuration else { return }
let nextPhase: LoadPhase = self.phase.manualRefreshOutstanding ? .forcing : .ordinary
let nextPhase: LoadPhase = switch self.phase {
case .priming: .priming
case .ordinary: .ordinary
case .forcing, .reconciling: .forcing
}
self.startLoad(configuration: configuration, phase: nextPhase)
}

Expand Down
Loading