diff --git a/CHANGELOG.md b/CHANGELOG.md index 92f03c6f5d..90baa53fc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Menu: move each usage window's used percentage and reset time into its title row, with all pace detail on one line (#2182). Thanks @jack24254029! ### Fixed +- Usage & Spend: keep validated Codex totals visible while the local scanner catches up, with refresh indicators in the dashboard and menu cost rows (#2397). Thanks @hhh2210! - ZoomMate: preserve browser cookie scope so parent-domain sessions reach both API hosts without leaking host-only cookies (fixes #2507). Thanks @weddle! - Sync: propagate provider configuration edits made by the CLI or directly in `config.json` to the iCloud fleet without echoing remotely applied writes. diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index 33921e7856..0376883153 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -127,7 +127,9 @@ extension UsageMenuCardView.Model { preferredCurrencyCode: String = "auto") -> String? { guard metadata.supportsCredits else { return nil } - if metadata.id == .codex, credits == nil, error == nil { return nil } + if metadata.id == .codex, credits == nil, error == nil { + return nil + } if metadata.id == .amp, let ampUsage = snapshot?.ampUsage, let ampCredits = self.ampCreditsLine(ampUsage, preferredCurrencyCode: preferredCurrencyCode) @@ -187,6 +189,7 @@ extension UsageMenuCardView.Model { static func tokenUsageSection( provider: UsageProvider, enabled: Bool, + isRefreshing: Bool = false, comparisonPeriodsEnabled: Bool, snapshot: CostUsageTokenSnapshot?, error: String?, @@ -253,6 +256,7 @@ extension UsageMenuCardView.Model { } let err = (error?.isEmpty ?? true) ? nil : error return TokenUsageSection( + isRefreshing: isRefreshing, sessionLine: sessionLine, monthLine: monthLine, meteredLine: meteredLine, @@ -338,13 +342,19 @@ extension UsageMenuCardView.Model { return (entry, dayKey) } .max { lhs, rhs in - if lhs.dayKey != rhs.dayKey { return lhs.dayKey < rhs.dayKey } + if lhs.dayKey != rhs.dayKey { + return lhs.dayKey < rhs.dayKey + } let lCost = lhs.entry.costUSD ?? -1 let rCost = rhs.entry.costUSD ?? -1 - if lCost != rCost { return lCost < rCost } + if lCost != rCost { + return lCost < rCost + } let lTokens = lhs.entry.totalTokens ?? -1 let rTokens = rhs.entry.totalTokens ?? -1 - if lTokens != rTokens { return lTokens < rTokens } + if lTokens != rTokens { + return lTokens < rTokens + } return lhs.entry.date < rhs.entry.date }?.entry } @@ -396,8 +406,12 @@ extension UsageMenuCardView.Model { private static func daysInBedrockBillingMonth(_ month: Int, year: Int) -> Int { switch month { case 2: - if year.isMultiple(of: 400) { return 29 } - if year.isMultiple(of: 100) { return 28 } + if year.isMultiple(of: 400) { + return 29 + } + if year.isMultiple(of: 100) { + return 28 + } return year.isMultiple(of: 4) ? 29 : 28 case 4, 6, 9, 11: return 30 diff --git a/Sources/CodexBar/MenuCardView+ModelInput.swift b/Sources/CodexBar/MenuCardView+ModelInput.swift index 485ab98374..ae00259dc8 100644 --- a/Sources/CodexBar/MenuCardView+ModelInput.swift +++ b/Sources/CodexBar/MenuCardView+ModelInput.swift @@ -22,6 +22,7 @@ extension UsageMenuCardView.Model { let usageBarsShowUsed: Bool let resetTimeDisplayStyle: ResetTimeDisplayStyle let tokenCostUsageEnabled: Bool + let tokenCostIsRefreshing: Bool let codexLocalSessionCostLedgerEnabled: Bool let tokenCostInlineDashboardEnabled: Bool let tokenCostMenuSectionEnabled: Bool @@ -62,6 +63,7 @@ extension UsageMenuCardView.Model { usageBarsShowUsed: Bool, resetTimeDisplayStyle: ResetTimeDisplayStyle, tokenCostUsageEnabled: Bool, + tokenCostIsRefreshing: Bool = false, codexLocalSessionCostLedgerEnabled: Bool = false, tokenCostInlineDashboardEnabled: Bool? = nil, tokenCostMenuSectionEnabled: Bool? = nil, @@ -101,6 +103,7 @@ extension UsageMenuCardView.Model { self.usageBarsShowUsed = usageBarsShowUsed self.resetTimeDisplayStyle = resetTimeDisplayStyle self.tokenCostUsageEnabled = tokenCostUsageEnabled + self.tokenCostIsRefreshing = tokenCostIsRefreshing self.codexLocalSessionCostLedgerEnabled = codexLocalSessionCostLedgerEnabled self.tokenCostInlineDashboardEnabled = tokenCostInlineDashboardEnabled ?? tokenCostUsageEnabled self.tokenCostMenuSectionEnabled = tokenCostMenuSectionEnabled ?? tokenCostUsageEnabled diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 33223a4a99..6d0f575f6d 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -112,6 +112,7 @@ struct UsageMenuCardView: View { } struct TokenUsageSection { + let isRefreshing: Bool let sessionLine: String let monthLine: String let meteredLine: String? @@ -123,6 +124,7 @@ struct UsageMenuCardView: View { /// Explicit initializer so `meteredLine`/`comparisonLines` default to empty: callers /// that predate them (and providers that never report them) keep their call sites. init( + isRefreshing: Bool = false, sessionLine: String, monthLine: String, meteredLine: String? = nil, @@ -131,6 +133,7 @@ struct UsageMenuCardView: View { errorLine: String?, errorCopyText: String?) { + self.isRefreshing = isRefreshing self.sessionLine = sessionLine self.monthLine = monthLine self.meteredLine = meteredLine @@ -457,9 +460,16 @@ private struct TokenUsageSectionContent: View { var body: some View { VStack(alignment: .leading, spacing: 6) { - Text(UsageMenuCardView.Model.tokenUsageHeader(provider: self.provider)) - .font(.body) - .fontWeight(.medium) + HStack(spacing: 6) { + Text(UsageMenuCardView.Model.tokenUsageHeader(provider: self.provider)) + .font(.body) + .fontWeight(.medium) + if self.tokenUsage.isRefreshing { + ProgressView() + .controlSize(.mini) + .accessibilityLabel(L("Refreshing")) + } + } Text(self.tokenUsage.sessionLine) .font(self.lineFont) .lineLimit(1) @@ -947,6 +957,7 @@ extension UsageMenuCardView.Model { let tokenUsage = Self.tokenUsageSection( provider: input.provider, enabled: input.tokenCostMenuSectionEnabled, + isRefreshing: input.tokenCostIsRefreshing, comparisonPeriodsEnabled: input.costComparisonPeriodsEnabled, snapshot: tokenUsageSnapshot, error: input.tokenError, diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index e55aa37bb4..44f1fa42bc 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -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) })) } @@ -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) } @@ -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 diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index f56a80d75d..3adfdea811 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -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 @@ -255,6 +258,70 @@ enum SpendDashboardSource { }) } + 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 + await CostUsageFetcher(cacheRoot: context.cacheRoot) + .loadCachedCodexTokenSnapshotForScopedHome( + now: context.now, + codexHomePath: context.account.homePath, + historyDays: context.historyDays, + includePiSessions: false, + includeProjectAndSessionBreakdowns: false) + }) + } + + 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)) + } + return SpendDashboardLoadResult(inputs: inputs, failedSourceIDs: request.unavailableSourceIDs) + } + static func load( _ request: SpendDashboardLoadRequest, codexSnapshotLoader: CodexSnapshotLoader) async -> SpendDashboardLoadResult @@ -498,7 +565,11 @@ enum SpendDashboardSource { } static func codexCacheRoot(for request: CodexSpendScanRequest) -> URL { - UsageStore.costUsageCacheDirectory() + let costUsageDirectory = UsageStore.costUsageCacheDirectory() + if request.source == .liveSystem { + return costUsageDirectory.deletingLastPathComponent() + } + return costUsageDirectory .appendingPathComponent("accounts", isDirectory: true) .appendingPathComponent(request.cacheIdentity, isDirectory: true) } @@ -577,6 +648,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 @@ -674,6 +746,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? @@ -685,11 +758,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)) @@ -736,6 +811,12 @@ final class SpendDashboardController { self.failedSourceCount = 0 self.rebuildModel() } + let shouldPrimeCachedCodex: Bool = if case .ordinary = phase { + self.cachedLoader != nil && !Set(Self.codexOwnershipByID(configuration.codexAccountIdentities).keys) + .isSubset(of: Set(self.loadedInputs.map(\.id))) + } else { + false + } guard configuration.costUsageEnabled, !configuration.providerIDs.isEmpty else { self.loadedInputs = [] @@ -751,6 +832,18 @@ final class SpendDashboardController { self.isRefreshing = true self.loadTask = Task { [weak self] in guard let self else { return } + if shouldPrimeCachedCodex, let cachedLoader = self.cachedLoader { + let cachedRequest = await self.requestBuilder(.captureOnly) + guard !Task.isCancelled, + generation == self.generation + else { return } + let cachedResult = await cachedLoader(cachedRequest) + guard !Task.isCancelled, + generation == self.generation, + cachedRequest.configuration == self.configuration + else { return } + self.applyCached(request: cachedRequest, result: cachedResult) + } let request = await self.requestBuilder(phase.buildMode) guard !Task.isCancelled, generation == self.generation @@ -764,6 +857,19 @@ final class SpendDashboardController { } } + private func applyCached( + request: SpendDashboardLoadRequest, + result: SpendDashboardLoadResult) + { + let cachedIDs = Set(result.inputs.map(\.id)) + self.loadedInputs.removeAll { cachedIDs.contains($0.id) } + self.loadedInputs.append(contentsOf: result.inputs) + self.loadedAt = request.now + self.failedSourceCount = result.failedSourceCount + self.refreshRetainedCodexDisplayNames(request.configuration.codexAccountDisplayNames) + self.rebuildModel() + } + private func handleBuiltRequest( _ request: SpendDashboardLoadRequest, startedWith startConfiguration: SpendDashboardConfiguration, diff --git a/Sources/CodexBar/StatusItemController+MenuCardModel.swift b/Sources/CodexBar/StatusItemController+MenuCardModel.swift index ea8ac4bff5..2dbdf01e9f 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardModel.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardModel.swift @@ -124,6 +124,7 @@ extension StatusItemController { usageBarsShowUsed: self.settings.usageBarsShowUsed, resetTimeDisplayStyle: self.settings.resetTimeDisplayStyle, tokenCostUsageEnabled: self.settings.isCostUsageEffectivelyEnabled(for: target), + tokenCostIsRefreshing: self.store.tokenCostRefreshIsActive(for: target), codexLocalSessionCostLedgerEnabled: self.settings.codexLocalSessionCostLedgerEnabled, tokenCostInlineDashboardEnabled: self.settings.costSummaryShowsInlineDashboard(for: target), // openai/mistral's cost history always surfaces via the inline dashboard or a diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index b6a202c1d8..d616fc6606 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -268,6 +268,13 @@ extension UsageStore { self.tokenRefreshInFlight.contains(provider) } + func tokenCostRefreshIsActive(for provider: UsageProvider) -> Bool { + if self.tokenRefreshInFlight.contains(provider) { + return true + } + return provider == .codex && self.codexCostCatchUpActivity?.phase == .indexing + } + func tokenCostScope(for provider: UsageProvider) -> (codexHomePath: String?, signature: String) { if provider == .vertexai { return (nil, "vertexai:allow-claude-fallback=\(!self.isEnabled(.claude))") diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index e15afb6d77..0662e18f7d 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -98,6 +98,23 @@ public struct CostUsageFetcher: Sendable { scannerOptions: self.scannerOptionsOverride()) } + package func loadCachedCodexTokenSnapshotForScopedHome( + now: Date = Date(), + codexHomePath: String, + historyDays: Int = 30, + includePiSessions: Bool = false, + includeProjectAndSessionBreakdowns: Bool = false) async -> CostUsageTokenSnapshot? + { + await Self.loadCachedCodexTokenSnapshot( + now: now, + codexHomePath: codexHomePath, + historyDays: historyDays, + allowScopedCodexHome: true, + includePiSessions: includePiSessions, + includeProjectAndSessionBreakdowns: includeProjectAndSessionBreakdowns, + scannerOptions: self.scannerOptionsOverride()) + } + public func loadCachedCodexLocalProjectUsageSnapshot( now: Date = Date(), codexHomePath: String? = nil, @@ -654,12 +671,18 @@ public struct CostUsageFetcher: Sendable { now: Date = Date(), codexHomePath: String? = nil, historyDays: Int = 30, + allowScopedCodexHome: Bool = false, + includePiSessions: Bool = true, + includeProjectAndSessionBreakdowns: Bool = true, scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil) async -> CostUsageTokenSnapshot? { await self.loadCachedCodexTokenSnapshotResult( now: now, codexHomePath: codexHomePath, historyDays: historyDays, + allowScopedCodexHome: allowScopedCodexHome, + includePiSessions: includePiSessions, + includeProjectAndSessionBreakdowns: includeProjectAndSessionBreakdowns, scannerOptions: overrideScannerOptions)?.snapshot } @@ -667,12 +690,14 @@ public struct CostUsageFetcher: Sendable { now: Date = Date(), codexHomePath: String? = nil, historyDays: Int = 30, + allowScopedCodexHome: Bool = false, + includePiSessions: Bool = true, + includeProjectAndSessionBreakdowns: Bool = true, scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil) async -> CachedCodexTokenSnapshotResult? { - if let codexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines), - !codexHomePath.isEmpty - { + let scopedCodexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines) + if scopedCodexHomePath?.isEmpty == false, !allowScopedCodexHome { return nil } @@ -680,7 +705,10 @@ public struct CostUsageFetcher: Sendable { // cooperative pool alongside the scans themselves. let cachedSnapshot: CachedCodexTokenSnapshotResult?? = try? await CostUsageScanExecutor.run { _ in let clampedHistoryDays = max(1, min(365, historyDays)) - let options = overrideScannerOptions ?? CostUsageScanner.Options() + let options = Self.resolvedScannerOptions( + overrideScannerOptions, + provider: .codex, + codexHomePath: codexHomePath) let until = now let since = options.calendar.date( byAdding: .day, @@ -690,6 +718,7 @@ public struct CostUsageFetcher: Sendable { since: since, until: until, calendar: options.calendar) + let shouldMergePiUsage = scopedCodexHomePath?.isEmpty != false let roots = CostUsageScanner.codexSessionsRoots(options: options) let rootsFingerprint = CostUsageScanner.codexRootsFingerprint(options: options) let loadedCache = CostUsageCacheIO.loadCodexForMigration( @@ -734,16 +763,18 @@ public struct CostUsageFetcher: Sendable { nativeScanAt = scanAt scanTimes.append(scanAt) } - sessions = CostUsageScanner.buildCodexSessionBreakdownsFromCache( - cache: cache, - range: range, - modelsDevCacheRoot: options.cacheRoot, - sessionRoots: roots) - if cache.codexProjectMetadataVersion == CostUsageScanner.codexProjectMetadataVersion { - projects.append(contentsOf: CostUsageScanner.buildCodexProjectBreakdownsFromCache( + if includeProjectAndSessionBreakdowns { + sessions = CostUsageScanner.buildCodexSessionBreakdownsFromCache( cache: cache, range: range, - modelsDevCacheRoot: options.cacheRoot)) + modelsDevCacheRoot: options.cacheRoot, + sessionRoots: roots) + if cache.codexProjectMetadataVersion == CostUsageScanner.codexProjectMetadataVersion { + projects.append(contentsOf: CostUsageScanner.buildCodexProjectBreakdownsFromCache( + cache: cache, + range: range, + modelsDevCacheRoot: options.cacheRoot)) + } } } } else if let incompatibleCache = loadedCache.incompatibleCache, @@ -767,13 +798,15 @@ public struct CostUsageFetcher: Sendable { } } - if let piResult = PiSessionCostScanner.loadCachedDailyReportResult( - provider: .codex, - since: since, - until: until, - now: now, - cacheRoot: options.cacheRoot, - calendar: options.calendar) + if includePiSessions, + shouldMergePiUsage, + let piResult = PiSessionCostScanner.loadCachedDailyReportResult( + provider: .codex, + since: since, + until: until, + now: now, + cacheRoot: options.cacheRoot, + calendar: options.calendar) { reports.append(piResult.report) piMerged = true diff --git a/Tests/CodexBarTests/MenuCardCostComparisonTests.swift b/Tests/CodexBarTests/MenuCardCostComparisonTests.swift index 48ce6c996d..7c0d4790b0 100644 --- a/Tests/CodexBarTests/MenuCardCostComparisonTests.swift +++ b/Tests/CodexBarTests/MenuCardCostComparisonTests.swift @@ -52,6 +52,30 @@ struct MenuCardCostComparisonTests { #expect(section.comparisonLines.isEmpty) } + @Test + func `retained cost rows keep totals while showing refresh activity`() throws { + let snapshot = CostUsageTokenSnapshot( + sessionTokens: 100, + sessionCostUSD: 1, + last30DaysTokens: 900, + last30DaysCostUSD: 9, + historyDays: 30, + daily: [], + updatedAt: Date()) + + let section = try #require(UsageMenuCardView.Model.tokenUsageSection( + provider: .codex, + enabled: true, + isRefreshing: true, + comparisonPeriodsEnabled: false, + snapshot: snapshot, + error: nil)) + + #expect(section.isRefreshing) + #expect(section.sessionLine.contains("$1.00")) + #expect(section.monthLine.contains("$9.00")) + } + @Test func `inline dashboard shows enabled comparison periods`() throws { let now = Date(timeIntervalSince1970: 1_783_123_200) diff --git a/Tests/CodexBarTests/MenuLayoutScreenshotRenderTests.swift b/Tests/CodexBarTests/MenuLayoutScreenshotRenderTests.swift index 95a0e0c6a5..894fe2526d 100644 --- a/Tests/CodexBarTests/MenuLayoutScreenshotRenderTests.swift +++ b/Tests/CodexBarTests/MenuLayoutScreenshotRenderTests.swift @@ -35,6 +35,34 @@ final class MenuLayoutScreenshotRenderTests: XCTestCase { } } + func test_renderCachedCostRefreshScreenshots() throws { + guard let dir = ProcessInfo.processInfo.environment["CODEXBAR_COST_SCREENSHOT_DIR"] else { + throw XCTSkip("Set CODEXBAR_COST_SCREENSHOT_DIR to render cached cost screenshots.") + } + let directory = URL(fileURLWithPath: dir, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + for isRefreshing in [false, true] { + let tokenUsage = UsageMenuCardView.Model.TokenUsageSection( + isRefreshing: isRefreshing, + sessionLine: "Today: $1.24 · 18.4K tokens", + monthLine: "Last 30 days: $38.62 · 612K tokens", + hintLine: "Costs are estimated from local usage.", + errorLine: nil, + errorCopyText: nil) + let view = AnyView(UsageMenuCardCostSectionView( + model: Self.costModel(tokenUsage: tokenUsage), + topPadding: 12, + bottomPadding: 12, + width: Self.width)) + let suffix = isRefreshing ? "refreshing" : "idle" + let data = try XCTUnwrap(Self.pngData(for: view), "render failed for cached cost \(suffix)") + let url = directory.appendingPathComponent("usage-spend-cached-menu-\(suffix).png") + try data.write(to: url, options: .atomic) + print("Wrote \(url.path)") + } + } + // MARK: - Fixture private static func screenshotAccounts() -> [ProviderAccountUsageSnapshot] { @@ -98,6 +126,32 @@ final class MenuLayoutScreenshotRenderTests: XCTestCase { now: self.now)) } + private static func costModel( + tokenUsage: UsageMenuCardView.Model.TokenUsageSection) -> UsageMenuCardView.Model + { + UsageMenuCardView.Model( + provider: .codex, + providerName: "Codex", + email: "", + subtitleText: "Updated now", + subtitleStyle: .info, + planText: nil, + metrics: [], + usageNotes: [], + openAIAPIUsage: nil, + inlineUsageDashboard: nil, + creditsText: nil, + creditsRemaining: nil, + creditsProgressPercent: nil, + creditsScaleText: nil, + creditsHintText: nil, + creditsHintCopyText: nil, + providerCost: nil, + tokenUsage: tokenUsage, + placeholder: nil, + progressColor: .blue) + } + // MARK: - Preview composition private static func stackedPreview(accounts: [ProviderAccountUsageSnapshot]) -> some View { diff --git a/Tests/CodexBarTests/SpendDashboardCachedPresentationTests.swift b/Tests/CodexBarTests/SpendDashboardCachedPresentationTests.swift new file mode 100644 index 0000000000..e801e93ceb --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardCachedPresentationTests.swift @@ -0,0 +1,272 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct SpendDashboardCachedPresentationTests { + @Test + func `empty dashboard distinguishes active refresh from converged empty history`() { + #expect(SpendDashboardEmptyState.make(isRefreshing: true).title == L("Refreshing")) + #expect(SpendDashboardEmptyState.make(isRefreshing: false).title == L("No local cost history yet")) + } + + @Test + func `production loader reads a validated scoped account report`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 15) + _ = try env.writeCodexSessionFile( + day: day, + filename: "dashboard-cached.jsonl", + contents: env.jsonl([ + [ + "type": "turn_context", + "timestamp": env.isoString(for: day), + "payload": ["model": "gpt-5.2"], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: day.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "last_token_usage": [ + "input_tokens": 42, + "cached_input_tokens": 0, + "output_tokens": 0, + ], + "model": "gpt-5.2", + ], + ], + ], + ])) + _ = try await CostUsageFetcher(cacheRoot: env.cacheRoot).loadTokenSnapshot( + provider: .codex, + now: day, + codexHomePath: env.codexHomeRoot.path, + historyDays: SpendDashboardSource.scanDays, + includePiSessions: false) + let account = CodexSpendScanRequest( + id: "profile", + displayName: "Codex profile", + source: .profileHome(path: env.codexHomeRoot.path), + homePath: env.codexHomeRoot.path, + authFingerprint: nil, + authFileWasReadable: false, + cacheIdentity: "profile-cache") + let request = SpendDashboardLoadRequest( + configuration: SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue], + codexAccountIdentities: ["profile|profile-cache"]), + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [account], + now: day, + force: false) + let cacheRoot = env.cacheRoot + + let result = await SpendDashboardSource.loadCached(request, cacheRootResolver: { _ in cacheRoot }) + + #expect(result.inputs.count == 1) + #expect(result.inputs.first?.id == "codex:profile") + #expect(result.inputs.first?.snapshot.sessionTokens == 42) + #expect(result.inputs.first?.snapshot.projects.isEmpty == true) + #expect(result.inputs.first?.snapshot.sessions.isEmpty == true) + } + + @Test + func `retained Codex totals render during refresh and clear only after convergence`() async { + let gate = SpendDashboardCachedLoaderGate() + let configuration = Self.configuration(account: "account|cache") + let controller = SpendDashboardController( + requestBuilder: { mode in + Self.request(configuration: configuration, force: mode.forcesLoader) + }, + cachedLoader: { _ in + SpendDashboardLoadResult( + inputs: [Self.input(id: "codex:account", cost: 3)], + failedSourceIDs: []) + }, + loader: { request in await gate.load(request) }) + + controller.update(configuration: configuration) + await Self.waitForPendingCount(1, gate: gate) + + #expect(controller.isRefreshing) + #expect(controller.model.groups.first?.totalCost == 3) + #expect(Set(controller.model.groups.flatMap(\.providers).map(\.id)) == ["codex:account"]) + + await gate.resume(at: 0, result: .init(inputs: [], failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.model.groups.isEmpty) + } + + @Test + func `cached Codex totals stay bound to their account cache`() async { + let first = Self.scanRequest(id: "first", cacheIdentity: "first-cache") + let second = Self.scanRequest(id: "second", cacheIdentity: "second-cache") + let request = SpendDashboardLoadRequest( + configuration: SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue], + codexAccountIdentities: ["first|first-cache", "second|second-cache"]), + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [first, second], + now: Self.fixtureNow, + force: false) + + let result = await SpendDashboardSource.loadCached(request, cachedCodexSnapshotLoader: { context in + switch context.account.id { + case "first": Self.input(cost: 2).snapshot + case "second": Self.input(cost: 5).snapshot + default: nil + } + }) + + #expect(Dictionary(uniqueKeysWithValues: result.inputs.map { ($0.id, $0.snapshot.last30DaysCostUSD) }) == [ + "codex:first": 2, + "codex:second": 5, + ]) + #expect(SpendDashboardSource.codexCacheRoot(for: first).lastPathComponent == "first-cache") + #expect(SpendDashboardSource.codexCacheRoot(for: second).lastPathComponent == "second-cache") + } + + @Test + func `cached Codex totals reject an account rotation during hydration`() async throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("SpendDashboardCachedAuth-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: home) } + let authURL = CodexAuthFingerprint.authFileURL(homePath: home.path) + let originalAuth = Data("{\"profile\":\"owner-one\"}".utf8) + try originalAuth.write(to: authURL, options: .atomic) + let account = CodexSpendScanRequest( + id: "account", + displayName: "Codex", + source: .profileHome(path: home.path), + homePath: home.path, + authFingerprint: CodexAuthFingerprint.fingerprint(data: originalAuth), + authFileWasReadable: true, + cacheIdentity: "cached-auth") + let request = SpendDashboardLoadRequest( + configuration: Self.configuration(account: "account|cached-auth"), + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [account], + now: Self.fixtureNow, + force: false) + + let result = await SpendDashboardSource.loadCached(request, cachedCodexSnapshotLoader: { _ in + try? Data("{\"profile\":\"owner-two\"}".utf8).write(to: authURL, options: .atomic) + return Self.input(cost: 9).snapshot + }) + + #expect(result.inputs.isEmpty) + } + + private nonisolated static let fixtureNow = Date(timeIntervalSince1970: 1_784_179_200) + + private static func configuration(account: String) -> SpendDashboardConfiguration { + SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue], + codexAccountIdentities: [account]) + } + + private static func request( + configuration: SpendDashboardConfiguration, + force: Bool) -> SpendDashboardLoadRequest + { + SpendDashboardLoadRequest( + configuration: configuration, + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [], + now: self.fixtureNow, + force: force) + } + + private nonisolated static func scanRequest( + id: String, + cacheIdentity: String) -> CodexSpendScanRequest + { + let homePath = "/synthetic/\(id)" + return CodexSpendScanRequest( + id: id, + displayName: "Codex \(id)", + source: .profileHome(path: homePath), + homePath: homePath, + authFingerprint: nil, + authFileWasReadable: false, + cacheIdentity: cacheIdentity) + } + + private nonisolated static func input( + id: String? = nil, + cost: Double) -> SpendDashboardModel.ProviderInput + { + let entry = CostUsageDailyReport.Entry( + date: "2026-07-15", + inputTokens: nil, + outputTokens: nil, + totalTokens: 10, + costUSD: cost, + modelsUsed: nil, + modelBreakdowns: nil) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 10, + last30DaysCostUSD: cost, + daily: [entry], + updatedAt: Self.fixtureNow) + return SpendDashboardModel.ProviderInput( + id: id, + provider: .codex, + displayName: "Codex", + snapshot: snapshot) + } + + private static func waitForPendingCount(_ count: Int, gate: SpendDashboardCachedLoaderGate) async { + for _ in 0..<1000 { + if await gate.pendingCount == count { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for \(count) pending dashboard loads") + } + + private static func waitUntil(_ condition: @MainActor () -> Bool) async { + for _ in 0..<1000 { + if condition() { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for controller state") + } +} + +private actor SpendDashboardCachedLoaderGate { + private var continuations: [CheckedContinuation] = [] + + var pendingCount: Int { + self.continuations.count + } + + func load(_ request: SpendDashboardLoadRequest) async -> SpendDashboardLoadResult { + _ = request + return await withCheckedContinuation { continuation in + self.continuations.append(continuation) + } + } + + func resume(at index: Int, result: SpendDashboardLoadResult) { + self.continuations.remove(at: index).resume(returning: result) + } +} diff --git a/docs/screenshots/usage-spend-cached-menu-idle.png b/docs/screenshots/usage-spend-cached-menu-idle.png new file mode 100644 index 0000000000..4bec781b22 Binary files /dev/null and b/docs/screenshots/usage-spend-cached-menu-idle.png differ diff --git a/docs/screenshots/usage-spend-cached-menu-refreshing.png b/docs/screenshots/usage-spend-cached-menu-refreshing.png new file mode 100644 index 0000000000..72a2acb3f1 Binary files /dev/null and b/docs/screenshots/usage-spend-cached-menu-refreshing.png differ