Fix menubar provider view showing $0.00 after idle and refresh race condition

CLI timeout increased from 20s to 45s to handle cold file-cache latency on
provider-specific queries. Loading overlay now appears when the all-provider
payload confirms a provider has spend but its dedicated data hasn't loaded yet.
Manual refresh (force: true) bypasses the in-flight guard so users can always
re-fetch. Tab strip prefers the provider-specific payload cost when available
so it stays in sync with the hero section.
This commit is contained in:
iamtoruk 2026-05-03 11:48:44 -07:00
parent 8cf68e7a16
commit 6702d55345
4 changed files with 20 additions and 10 deletions

View file

@ -99,14 +99,10 @@ final class AppStore {
private var inFlightKeys: Set<PayloadCacheKey> = []
/// Refresh the currently selected (period, provider) combination. Guards against concurrent
/// fetches for the same key so a slow initial request can't overwrite a newer one that
/// finished first (which would show stale numbers the user has already moved past).
/// When `force` is false (background timer), skips the CLI call if the cache is still fresh.
func refresh(includeOptimize: Bool, force: Bool = false) async {
let key = currentKey
if !force, cache[key]?.isFresh == true { return }
guard !inFlightKeys.contains(key) else { return }
if !force, inFlightKeys.contains(key) { return }
inFlightKeys.insert(key)
let showedLoading = cache[key] == nil
if showedLoading {

View file

@ -6,7 +6,7 @@ import Foundation
/// Pipe file descriptors pinned forever.
private let maxPayloadBytes = 20 * 1024 * 1024
private let maxStderrBytes = 256 * 1024
private let spawnTimeoutSeconds: UInt64 = 20
private let spawnTimeoutSeconds: UInt64 = 45
enum DataClientError: Error {
case spawn(String)

View file

@ -46,6 +46,9 @@ struct AgentTabStrip: View {
private func cost(for filter: ProviderFilter) -> Double? {
let data = periodAll
if filter == .all { return data.current.cost }
if filter == store.selectedProvider, store.hasCachedData {
return store.payload.current.cost
}
let providers = Dictionary(
data.current.providers.map { ($0.key.lowercased(), $0.value) },
uniquingKeysWith: +

View file

@ -41,7 +41,7 @@ struct MenuBarContent: View {
}
}
if store.isLoading {
if store.isLoading || (providerHasCostInAllPayload && !store.hasCachedData) {
BurnLoadingOverlay(periodLabel: store.selectedPeriod.rawValue)
.transition(.opacity)
}
@ -57,11 +57,22 @@ struct MenuBarContent: View {
}
}
/// True when a specific provider tab is selected and that provider has no spend in the
/// currently selected period. The .all tab is exempt -- it always shows aggregated data.
private var isFilteredEmpty: Bool {
guard store.selectedProvider != .all else { return false }
return store.payload.current.cost <= 0 && store.payload.current.calls == 0
if store.payload.current.cost > 0 || store.payload.current.calls > 0 { return false }
if providerHasCostInAllPayload { return false }
return true
}
private var providerHasCostInAllPayload: Bool {
guard let allPayload = store.periodAllPayload else { return false }
let providers = Dictionary(
allPayload.current.providers.map { ($0.key.lowercased(), $0.value) },
uniquingKeysWith: +
)
return store.selectedProvider.providerKeys.contains { key in
(providers[key] ?? 0) > 0
}
}
/// Show the tab row whenever the CLI detected at least one AI coding tool installed