From e9f6eb90f0e302397ef9d5bb0511d78bb99b2961 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Mon, 13 Apr 2026 17:01:44 +0530 Subject: [PATCH 1/7] Fix menu and Codex battery regressions --- Sources/CodexBar/ProviderRegistry.swift | 5 +- .../StatusItemController+HostedSubmenus.swift | 152 ++++++++++++++++++ .../CodexBar/StatusItemController+Menu.swift | 114 ++----------- ...tatusItemController+UsageHistoryMenu.swift | 11 +- Sources/CodexBar/UsageStore+OpenAIWeb.swift | 3 + .../OpenAIWeb/OpenAIDashboardFetcher.swift | 5 + 6 files changed, 185 insertions(+), 105 deletions(-) create mode 100644 Sources/CodexBar/StatusItemController+HostedSubmenus.swift diff --git a/Sources/CodexBar/ProviderRegistry.swift b/Sources/CodexBar/ProviderRegistry.swift index e3934d288..9f0613c33 100644 --- a/Sources/CodexBar/ProviderRegistry.swift +++ b/Sources/CodexBar/ProviderRegistry.swift @@ -115,7 +115,10 @@ struct ProviderRegistry { // Mac's Codex sessions, not as account-owned remote state. If we later want // account-scoped token history in the UI, that needs an explicit product decision and // presentation change so the two concepts are not conflated. - if provider == .codex, let managedHomePath = settings.activeManagedCodexRemoteHomePath { + if provider == .codex, + case .managedAccount = settings.codexActiveSource, + let managedHomePath = settings.activeManagedCodexRemoteHomePath + { env = CodexHomeScope.scopedEnvironment(base: env, codexHome: managedHomePath) } return env diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift new file mode 100644 index 000000000..2b6f12678 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -0,0 +1,152 @@ +import AppKit +import CodexBarCore +import SwiftUI + +extension StatusItemController { + private static let hostedSubviewWidth: CGFloat = 310 + + func makeHostedSubviewPlaceholderMenu(chartID: String, provider: UsageProvider? = nil) -> NSMenu { + let submenu = NSMenu() + submenu.delegate = self + let chartItem = NSMenuItem() + chartItem.isEnabled = false + chartItem.representedObject = chartID + chartItem.toolTip = provider?.rawValue + submenu.addItem(chartItem) + return submenu + } + + func hydrateHostedSubviewMenuIfNeeded(_ menu: NSMenu) { + guard let placeholder = menu.items.first, + menu.items.count == 1, + placeholder.view == nil, + let chartID = placeholder.representedObject as? String + else { + return + } + + let width = Self.hostedSubviewWidth + menu.removeAllItems() + + let didHydrate: Bool = switch chartID { + case Self.usageBreakdownChartID: + self.appendUsageBreakdownChartItem(to: menu, width: width) + case Self.creditsHistoryChartID: + self.appendCreditsHistoryChartItem(to: menu, width: width) + case Self.costHistoryChartID: + if let providerRawValue = placeholder.toolTip, + let provider = UsageProvider(rawValue: providerRawValue) + { + self.appendCostHistoryChartItem(to: menu, provider: provider, width: width) + } else { + false + } + case Self.usageHistoryChartID: + if let providerRawValue = placeholder.toolTip, + let provider = UsageProvider(rawValue: providerRawValue) + { + self.appendUsageHistoryChartItem(to: menu, provider: provider, width: width) + } else { + false + } + default: + false + } + + guard !didHydrate else { return } + + let unavailableItem = NSMenuItem(title: "No data available", action: nil, keyEquivalent: "") + unavailableItem.isEnabled = false + unavailableItem.representedObject = chartID + menu.addItem(unavailableItem) + } + + @discardableResult + func appendUsageBreakdownChartItem(to submenu: NSMenu, width: CGFloat) -> Bool { + let breakdown = self.store.openAIDashboard?.usageBreakdown ?? [] + guard !breakdown.isEmpty else { return false } + + if !Self.menuCardRenderingEnabled { + let chartItem = NSMenuItem() + chartItem.isEnabled = false + chartItem.representedObject = Self.usageBreakdownChartID + submenu.addItem(chartItem) + return true + } + + let chartView = UsageBreakdownChartMenuView(breakdown: breakdown, width: width) + let hosting = MenuHostingView(rootView: chartView) + let controller = NSHostingController(rootView: chartView) + let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) + hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + + let chartItem = NSMenuItem() + chartItem.view = hosting + chartItem.isEnabled = false + chartItem.representedObject = Self.usageBreakdownChartID + submenu.addItem(chartItem) + return true + } + + @discardableResult + func appendCreditsHistoryChartItem(to submenu: NSMenu, width: CGFloat) -> Bool { + let breakdown = self.store.openAIDashboard?.dailyBreakdown ?? [] + guard !breakdown.isEmpty else { return false } + + if !Self.menuCardRenderingEnabled { + let chartItem = NSMenuItem() + chartItem.isEnabled = false + chartItem.representedObject = Self.creditsHistoryChartID + submenu.addItem(chartItem) + return true + } + + let chartView = CreditsHistoryChartMenuView(breakdown: breakdown, width: width) + let hosting = MenuHostingView(rootView: chartView) + let controller = NSHostingController(rootView: chartView) + let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) + hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + + let chartItem = NSMenuItem() + chartItem.view = hosting + chartItem.isEnabled = false + chartItem.representedObject = Self.creditsHistoryChartID + submenu.addItem(chartItem) + return true + } + + @discardableResult + func appendCostHistoryChartItem( + to submenu: NSMenu, + provider: UsageProvider, + width: CGFloat) -> Bool + { + guard let tokenSnapshot = self.store.tokenSnapshot(for: provider) else { return false } + guard !tokenSnapshot.daily.isEmpty else { return false } + + if !Self.menuCardRenderingEnabled { + let chartItem = NSMenuItem() + chartItem.isEnabled = false + chartItem.representedObject = Self.costHistoryChartID + submenu.addItem(chartItem) + return true + } + + let chartView = CostHistoryChartMenuView( + provider: provider, + daily: tokenSnapshot.daily, + totalCostUSD: tokenSnapshot.last30DaysCostUSD, + width: width) + let hosting = MenuHostingView(rootView: chartView) + let controller = NSHostingController(rootView: chartView) + let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) + hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + + let chartItem = NSMenuItem() + chartItem.view = hosting + chartItem.isEnabled = false + chartItem.representedObject = Self.costHistoryChartID + submenu.addItem(chartItem) + return true + } +} diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 332ba5ab4..661f3ccc7 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -11,6 +11,10 @@ extension StatusItemController { private static let maxOverviewProviders = SettingsStore.mergedOverviewProviderLimit private static let overviewRowIdentifierPrefix = "overviewRow-" private static let menuOpenRefreshDelay: Duration = .seconds(1.2) + static let usageBreakdownChartID = "usageBreakdownChart" + static let creditsHistoryChartID = "creditsHistoryChart" + static let costHistoryChartID = "costHistoryChart" + static let usageHistoryChartID = "usageHistoryChart" private func menuCardWidth(for providers: [UsageProvider], menu: NSMenu? = nil) -> CGFloat { _ = menu @@ -29,6 +33,7 @@ extension StatusItemController { func menuWillOpen(_ menu: NSMenu) { if self.isHostedSubviewMenu(menu) { + self.hydrateHostedSubviewMenuIfNeeded(menu) self.refreshHostedSubviewHeights(in: menu) if Self.menuRefreshEnabled, self.isOpenAIWebSubviewMenu(menu) { self.store.requestOpenAIDashboardRefreshIfStale(reason: "submenu open") @@ -1214,112 +1219,27 @@ extension StatusItemController { } private func makeUsageBreakdownSubmenu() -> NSMenu? { - let breakdown = self.store.openAIDashboard?.usageBreakdown ?? [] - let width = Self.menuCardBaseWidth - guard !breakdown.isEmpty else { return nil } - - if !Self.menuCardRenderingEnabled { - let submenu = NSMenu() - submenu.delegate = self - let chartItem = NSMenuItem() - chartItem.isEnabled = false - chartItem.representedObject = "usageBreakdownChart" - submenu.addItem(chartItem) - return submenu - } - - let submenu = NSMenu() - submenu.delegate = self - let chartView = UsageBreakdownChartMenuView(breakdown: breakdown, width: width) - let hosting = MenuHostingView(rootView: chartView) - // Use NSHostingController for efficient size calculation without multiple layout passes - let controller = NSHostingController(rootView: chartView) - let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) - hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) - - let chartItem = NSMenuItem() - chartItem.view = hosting - chartItem.isEnabled = false - chartItem.representedObject = "usageBreakdownChart" - submenu.addItem(chartItem) - return submenu + guard !(self.store.openAIDashboard?.usageBreakdown ?? []).isEmpty else { return nil } + return self.makeHostedSubviewPlaceholderMenu(chartID: Self.usageBreakdownChartID) } private func makeCreditsHistorySubmenu() -> NSMenu? { - let breakdown = self.store.openAIDashboard?.dailyBreakdown ?? [] - let width = Self.menuCardBaseWidth - guard !breakdown.isEmpty else { return nil } - - if !Self.menuCardRenderingEnabled { - let submenu = NSMenu() - submenu.delegate = self - let chartItem = NSMenuItem() - chartItem.isEnabled = false - chartItem.representedObject = "creditsHistoryChart" - submenu.addItem(chartItem) - return submenu - } - - let submenu = NSMenu() - submenu.delegate = self - let chartView = CreditsHistoryChartMenuView(breakdown: breakdown, width: width) - let hosting = MenuHostingView(rootView: chartView) - // Use NSHostingController for efficient size calculation without multiple layout passes - let controller = NSHostingController(rootView: chartView) - let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) - hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) - - let chartItem = NSMenuItem() - chartItem.view = hosting - chartItem.isEnabled = false - chartItem.representedObject = "creditsHistoryChart" - submenu.addItem(chartItem) - return submenu + guard !(self.store.openAIDashboard?.dailyBreakdown ?? []).isEmpty else { return nil } + return self.makeHostedSubviewPlaceholderMenu(chartID: Self.creditsHistoryChartID) } private func makeCostHistorySubmenu(provider: UsageProvider) -> NSMenu? { guard provider == .codex || provider == .claude || provider == .vertexai else { return nil } - let width = Self.menuCardBaseWidth - guard let tokenSnapshot = self.store.tokenSnapshot(for: provider) else { return nil } - guard !tokenSnapshot.daily.isEmpty else { return nil } - - if !Self.menuCardRenderingEnabled { - let submenu = NSMenu() - submenu.delegate = self - let chartItem = NSMenuItem() - chartItem.isEnabled = false - chartItem.representedObject = "costHistoryChart" - submenu.addItem(chartItem) - return submenu - } - - let submenu = NSMenu() - submenu.delegate = self - let chartView = CostHistoryChartMenuView( - provider: provider, - daily: tokenSnapshot.daily, - totalCostUSD: tokenSnapshot.last30DaysCostUSD, - width: width) - let hosting = MenuHostingView(rootView: chartView) - // Use NSHostingController for efficient size calculation without multiple layout passes - let controller = NSHostingController(rootView: chartView) - let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) - hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) - - let chartItem = NSMenuItem() - chartItem.view = hosting - chartItem.isEnabled = false - chartItem.representedObject = "costHistoryChart" - submenu.addItem(chartItem) - return submenu + guard self.store.tokenSnapshot(for: provider)?.daily.isEmpty == false else { return nil } + return self.makeHostedSubviewPlaceholderMenu(chartID: Self.costHistoryChartID, provider: provider) } private func isHostedSubviewMenu(_ menu: NSMenu) -> Bool { let ids: Set = [ - "usageBreakdownChart", - "creditsHistoryChart", - "costHistoryChart", - "usageHistoryChart", + Self.usageBreakdownChartID, + Self.creditsHistoryChartID, + Self.costHistoryChartID, + Self.usageHistoryChartID, ] return menu.items.contains { item in guard let id = item.representedObject as? String else { return false } @@ -1329,8 +1249,8 @@ extension StatusItemController { private func isOpenAIWebSubviewMenu(_ menu: NSMenu) -> Bool { let ids: Set = [ - "usageBreakdownChart", - "creditsHistoryChart", + Self.usageBreakdownChartID, + Self.creditsHistoryChartID, ] return menu.items.contains { item in guard let id = item.representedObject as? String else { return false } diff --git a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift index 3c4e7f4c8..a2d5b9600 100644 --- a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift +++ b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift @@ -35,13 +35,10 @@ extension StatusItemController { private func makeUsageHistorySubmenu(provider: UsageProvider) -> NSMenu? { guard self.store.supportsPlanUtilizationHistory(for: provider) else { return nil } guard !self.store.shouldHidePlanUtilizationMenuItem(for: provider) else { return nil } - let width: CGFloat = 310 - let submenu = NSMenu() - submenu.delegate = self - return self.appendUsageHistoryChartItem(to: submenu, provider: provider, width: width) ? submenu : nil + return self.makeHostedSubviewPlaceholderMenu(chartID: Self.usageHistoryChartID, provider: provider) } - private func appendUsageHistoryChartItem( + func appendUsageHistoryChartItem( to submenu: NSMenu, provider: UsageProvider, width: CGFloat) -> Bool @@ -52,7 +49,7 @@ extension StatusItemController { if !Self.menuCardRenderingEnabled { let chartItem = NSMenuItem() chartItem.isEnabled = false - chartItem.representedObject = "usageHistoryChart" + chartItem.representedObject = Self.usageHistoryChartID submenu.addItem(chartItem) return true } @@ -70,7 +67,7 @@ extension StatusItemController { let chartItem = NSMenuItem() chartItem.view = hosting chartItem.isEnabled = false - chartItem.representedObject = "usageHistoryChart" + chartItem.representedObject = Self.usageHistoryChartID submenu.addItem(chartItem) return true } diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index 08a15e4a6..d062d63a0 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -236,6 +236,8 @@ extension UsageStore { attachedAccountEmail: attachedAccountEmail) } + OpenAIDashboardFetcher.evictCachedWebView(accountEmail: attachedAccountEmail) + case .displayOnly: self.applyOpenAIDashboardCleanup(decision.cleanup, preserveVisibleDashboard: true) self.openAIDashboard = dashboard @@ -244,6 +246,7 @@ extension UsageStore { self.lastOpenAIDashboardAttachmentAuthorized = false self.lastOpenAIDashboardError = nil self.openAIDashboardRequiresLogin = false + OpenAIDashboardFetcher.evictCachedWebView(accountEmail: attachedAccountEmail) case .failClosed: self.applyOpenAIDashboardCleanup(decision.cleanup, preserveVisibleDashboard: false) diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift index 64ca88b8d..48af62a8e 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift @@ -313,6 +313,11 @@ public struct OpenAIDashboardFetcher { OpenAIDashboardWebViewCache.shared.evictAll() } + public static func evictCachedWebView(accountEmail: String?) { + let store = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: accountEmail) + OpenAIDashboardWebViewCache.shared.evict(websiteDataStore: store) + } + public func probeUsagePage( websiteDataStore: WKWebsiteDataStore, logger: ((String) -> Void)? = nil, From 024d8b0cffd385b11c020d4e6a6d4eaa93b4671d Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Mon, 13 Apr 2026 17:03:23 +0530 Subject: [PATCH 2/7] Add battery investigation instrumentation --- .../Providers/Codex/CodexSettingsStore.swift | 13 +- .../Codex/UsageStore+CodexAccountState.swift | 19 ++ Sources/CodexBar/SettingsStore.swift | 10 + .../StatusItemController+Actions.swift | 9 + .../StatusItemController+Animation.swift | 28 +++ .../StatusItemController+HostedSubmenus.swift | 31 +++ .../CodexBar/StatusItemController+Menu.swift | 55 +++++ .../StatusItemController+MenuDebug.swift | 104 ++++++++++ ...tatusItemController+UsageHistoryMenu.swift | 11 + Sources/CodexBar/StatusItemController.swift | 14 ++ Sources/CodexBar/UsageStore+OpenAIWeb.swift | 73 ++++++- Sources/CodexBar/UsageStore.swift | 49 +++++ .../Logging/AgentDebugLogger.swift | 47 +++++ .../OpenAIWeb/OpenAIDashboardFetcher.swift | 133 +++++++++++- .../OpenAIDashboardWebViewCache.swift | 109 +++++++++- .../CodexBarCore/PiSessionCostScanner.swift | 13 ++ .../Providers/Codex/CodexStatusProbe.swift | 51 ++++- Sources/CodexBarCore/UsageFetcher.swift | 195 +++++++++++++----- .../CostUsage/CostUsageScanner+Claude.swift | 13 ++ .../Vendored/CostUsage/CostUsageScanner.swift | 12 ++ 20 files changed, 910 insertions(+), 79 deletions(-) create mode 100644 Sources/CodexBar/StatusItemController+MenuDebug.swift create mode 100644 Sources/CodexBarCore/Logging/AgentDebugLogger.swift diff --git a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift index 91463ca4c..e4cedf866 100644 --- a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift +++ b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift @@ -182,7 +182,18 @@ extension SettingsStore { } var codexVisibleAccountProjection: CodexVisibleAccountProjection { - CodexVisibleAccountProjection.make(from: self.codexAccountReconciliationSnapshot) + let startedAt = Date() + let projection = CodexVisibleAccountProjection.make(from: self.codexAccountReconciliationSnapshot) + AgentDebugLogger.log( + "0.20 Codex visible account projection computed", + hypothesisId: "R", + location: "CodexSettingsStore.swift:codexVisibleAccountProjection", + data: [ + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + "mainThread": Thread.isMainThread ? "1" : "0", + "visibleAccounts": String(projection.visibleAccounts.count), + ]) + return projection } var codexVisibleAccounts: [CodexVisibleAccount] { diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift index 03e42f23d..1c94fce11 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift @@ -23,6 +23,15 @@ extension UsageStore { async { let refreshStartedAt = Date() + AgentDebugLogger.log( + "0.20 Codex account-scoped refresh started", + hypothesisId: "H", + location: "UsageStore+CodexAccountState.swift:refreshCodexAccountScopedState", + data: [ + "allowDisabled": allowDisabled ? "1" : "0", + "openAIWebEnabled": self.settings.codexCookieSource.isEnabled ? "1" : "0", + "resolvedSource": String(describing: self.settings.codexResolvedActiveSource), + ]) self.prepareRefreshState(for: .codex) if self.prepareCodexAccountScopedRefreshIfNeeded() { phaseDidChange?(.invalidated) @@ -51,6 +60,16 @@ extension UsageStore { self.persistWidgetSnapshot(reason: "codex-account-refresh") phaseDidChange?(.completed) + AgentDebugLogger.log( + "0.20 Codex account-scoped refresh completed", + hypothesisId: "H", + location: "UsageStore+CodexAccountState.swift:refreshCodexAccountScopedState", + data: [ + "durationMs": String(Int(Date().timeIntervalSince(refreshStartedAt) * 1000)), + "hasCredits": self.credits == nil ? "0" : "1", + "hasDashboard": self.openAIDashboard == nil ? "0" : "1", + "dashboardRequiresLogin": self.openAIDashboardRequiresLogin ? "1" : "0", + ]) } @discardableResult diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 68ba707aa..ff157a308 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -168,6 +168,16 @@ final class SettingsStore { config: config, hadExistingConfig: hadExistingConfig) } + AgentDebugLogger.log( + "0.20 startup OpenAI web access decision", + hypothesisId: "A", + location: "SettingsStore.swift:init", + data: [ + "codexCookieSource": self.codexCookieSource.rawValue, + "openAIWebAccessEnabled": self.openAIWebAccessEnabled ? "1" : "0", + "hasStoredPreference": hasStoredOpenAIWebAccessPreference ? "1" : "0", + "refreshFrequency": self.refreshFrequency.rawValue, + ]) if Self.shouldBridgeSharedDefaults(for: userDefaults) { Self.sharedDefaults?.set(self.debugDisableKeychainAccess, forKey: "debugDisableKeychainAccess") } diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index b470e049c..f002028a1 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -5,6 +5,15 @@ extension StatusItemController { // MARK: - Actions reachable from menus func refreshStore(forceTokenUsage: Bool) { + AgentDebugLogger.log( + "0.20 status item requested refresh", + hypothesisId: "L", + location: "StatusItemController+Actions.swift:refreshStore", + data: [ + "forceTokenUsage": forceTokenUsage ? "1" : "0", + "openMenus": String(self.openMenus.count), + "storeRefreshing": self.store.isRefreshing ? "1" : "0", + ]) Task { await ProviderInteractionContext.$current.withValue(.userInitiated) { await self.store.refresh(forceTokenUsage: forceTokenUsage) diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 7206412fd..2e985da99 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -595,6 +595,19 @@ extension StatusItemController { let needsAnimation = self.needsMenuBarIconAnimation() if needsAnimation { if self.animationDriver == nil { + let primaryProvider = self.primaryProviderForUnifiedIcon() + AgentDebugLogger.log( + "0.20 menu bar animation driver started", + hypothesisId: "O", + location: "StatusItemController+Animation.swift:updateAnimationState", + data: [ + "primaryProvider": primaryProvider.rawValue, + "selectedMenuProvider": self.selectedMenuProvider?.rawValue ?? "nil", + "snapshotKnown": self.store.snapshot(for: primaryProvider) == nil ? "0" : "1", + "mergedIcons": self.shouldMergeIcons ? "1" : "0", + "enabledProviders": String(self.store.enabledProvidersForDisplay().count), + "refreshingProviders": String(self.store.refreshingProviders.count), + ]) if let forced = self.settings.debugLoadingPattern { self.animationPattern = forced } else if !LoadingPattern.allCases.contains(self.animationPattern) { @@ -611,6 +624,21 @@ extension StatusItemController { self.animationPhase = 0 } } else { + if self.animationDriver != nil { + let primaryProvider = self.primaryProviderForUnifiedIcon() + AgentDebugLogger.log( + "0.20 menu bar animation driver stopped", + hypothesisId: "O", + location: "StatusItemController+Animation.swift:updateAnimationState", + data: [ + "primaryProvider": primaryProvider.rawValue, + "selectedMenuProvider": self.selectedMenuProvider?.rawValue ?? "nil", + "snapshotKnown": self.store.snapshot(for: primaryProvider) == nil ? "0" : "1", + "mergedIcons": self.shouldMergeIcons ? "1" : "0", + "enabledProviders": String(self.store.enabledProvidersForDisplay().count), + "refreshingProviders": String(self.store.refreshingProviders.count), + ]) + } self.animationDriver?.stop() self.animationDriver = nil self.animationPhase = 0 diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift index 2b6f12678..e9a8f693c 100644 --- a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -76,9 +76,19 @@ extension StatusItemController { let chartView = UsageBreakdownChartMenuView(breakdown: breakdown, width: width) let hosting = MenuHostingView(rootView: chartView) + let startedAt = Date() let controller = NSHostingController(rootView: chartView) let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + AgentDebugLogger.log( + "0.20 usage breakdown submenu rendered", + hypothesisId: "N", + location: "StatusItemController+HostedSubmenus.swift:appendUsageBreakdownChartItem", + data: [ + "days": String(breakdown.count), + "height": String(Int(size.height)), + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + ]) let chartItem = NSMenuItem() chartItem.view = hosting @@ -103,9 +113,19 @@ extension StatusItemController { let chartView = CreditsHistoryChartMenuView(breakdown: breakdown, width: width) let hosting = MenuHostingView(rootView: chartView) + let startedAt = Date() let controller = NSHostingController(rootView: chartView) let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + AgentDebugLogger.log( + "0.20 credits history submenu rendered", + hypothesisId: "N", + location: "StatusItemController+HostedSubmenus.swift:appendCreditsHistoryChartItem", + data: [ + "days": String(breakdown.count), + "height": String(Int(size.height)), + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + ]) let chartItem = NSMenuItem() chartItem.view = hosting @@ -138,9 +158,20 @@ extension StatusItemController { totalCostUSD: tokenSnapshot.last30DaysCostUSD, width: width) let hosting = MenuHostingView(rootView: chartView) + let startedAt = Date() let controller = NSHostingController(rootView: chartView) let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + AgentDebugLogger.log( + "0.20 cost history submenu rendered", + hypothesisId: "N", + location: "StatusItemController+HostedSubmenus.swift:appendCostHistoryChartItem", + data: [ + "provider": provider.rawValue, + "days": String(tokenSnapshot.daily.count), + "height": String(Int(size.height)), + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + ]) let chartItem = NSMenuItem() chartItem.view = hosting diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 661f3ccc7..1a21f3f35 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -36,6 +36,14 @@ extension StatusItemController { self.hydrateHostedSubviewMenuIfNeeded(menu) self.refreshHostedSubviewHeights(in: menu) if Self.menuRefreshEnabled, self.isOpenAIWebSubviewMenu(menu) { + AgentDebugLogger.log( + "0.20 OpenAI web submenu opened", + hypothesisId: "N", + location: "StatusItemController+Menu.swift:menuWillOpen", + data: [ + "menuItems": String(menu.items.count), + "storeRefreshing": self.store.isRefreshing ? "1" : "0", + ]) self.store.requestOpenAIDashboardRefreshIfStale(reason: "submenu open") } self.openMenus[ObjectIdentifier(menu)] = menu @@ -68,7 +76,19 @@ extension StatusItemController { self.markMenuFresh(menu) // Heights are already set during populateMenu, no need to remeasure } + AgentDebugLogger.log( + "0.20 top-level menu opened", + hypothesisId: "L", + location: "StatusItemController+Menu.swift:menuWillOpen", + data: [ + "provider": provider?.rawValue ?? "overview", + "didRefresh": didRefresh ? "1" : "0", + "menuItems": String(menu.items.count), + "storeRefreshing": self.store.isRefreshing ? "1" : "0", + "menuRefreshEnabled": Self.menuRefreshEnabled ? "1" : "0", + ]) self.openMenus[ObjectIdentifier(menu)] = menu + self.logOpenMenuStructure(menu, provider: provider) // Only schedule refresh after menu is registered as open - refreshNow is called async if Self.menuRefreshEnabled { self.scheduleOpenMenuRefresh(for: menu) @@ -101,6 +121,7 @@ extension StatusItemController { } private func populateMenu(_ menu: NSMenu, provider: UsageProvider?) { + let startedAt = Date() let enabledProviders = self.store.enabledProvidersForDisplay() let includesOverview = self.includesOverviewTab(enabledProviders: enabledProviders) let switcherSelection = self.shouldMergeIcons && enabledProviders.count > 1 @@ -150,6 +171,17 @@ extension StatusItemController { currentProvider: currentProvider, menuWidth: menuWidth, openAIContext: openAIContext) + AgentDebugLogger.log( + "0.20 menu populated using smart update", + hypothesisId: "P", + location: "StatusItemController+Menu.swift:populateMenu", + data: [ + "provider": currentProvider.rawValue, + "menuItems": String(menu.items.count), + "enabledProviders": String(enabledProviders.count), + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + "hasOpenAIWebItems": openAIContext.hasOpenAIWebMenuItems ? "1" : "0", + ]) return } @@ -208,6 +240,29 @@ extension StatusItemController { } } self.addActionableSections(descriptor.sections, to: menu, width: menuWidth) + let cardMode = if isOverviewSelected { + "overview" + } else if tokenAccountDisplay?.showAll == true { + "token-accounts" + } else if openAIContext.hasOpenAIWebMenuItems { + "segmented-openai" + } else { + "single-card" + } + AgentDebugLogger.log( + "0.20 menu populated", + hypothesisId: "P", + location: "StatusItemController+Menu.swift:populateMenu", + data: [ + "provider": currentProvider.rawValue, + "cardMode": cardMode, + "menuItems": String(menu.items.count), + "enabledProviders": String(enabledProviders.count), + "hasUsageBreakdown": openAIContext.hasUsageBreakdown ? "1" : "0", + "hasCreditsHistory": openAIContext.hasCreditsHistory ? "1" : "0", + "hasCostHistory": openAIContext.hasCostHistory ? "1" : "0", + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + ]) } /// Smart update: only rebuild content sections when switching providers (keep the switcher intact). diff --git a/Sources/CodexBar/StatusItemController+MenuDebug.swift b/Sources/CodexBar/StatusItemController+MenuDebug.swift new file mode 100644 index 000000000..4e3fb2ba8 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuDebug.swift @@ -0,0 +1,104 @@ +import AppKit +import CodexBarCore + +extension StatusItemController { + private struct MenuStructureSummary { + let itemCount: Int + var viewBackedItems = 0 + var menuCardItems = 0 + var switcherItems = 0 + var submenuItems = 0 + var chartSubviewMenus = 0 + var totalViews = 0 + var hostingViews = 0 + var buttonViews = 0 + var layerBackedViews = 0 + } + + func logOpenMenuStructure(_ menu: NSMenu, provider: UsageProvider?) { + Task { @MainActor [weak self, weak menu] in + guard let self, let menu else { return } + await Task.yield() + guard self.openMenus[ObjectIdentifier(menu)] != nil else { return } + let summary = self.menuStructureSummary(for: menu) + AgentDebugLogger.log( + "0.20 top-level menu structure", + hypothesisId: "Q", + location: "StatusItemController+MenuDebug.swift:logOpenMenuStructure", + data: [ + "provider": provider?.rawValue ?? "overview", + "itemCount": String(summary.itemCount), + "viewBackedItems": String(summary.viewBackedItems), + "menuCardItems": String(summary.menuCardItems), + "switcherItems": String(summary.switcherItems), + "submenuItems": String(summary.submenuItems), + "chartSubviewMenus": String(summary.chartSubviewMenus), + "totalViews": String(summary.totalViews), + "hostingViews": String(summary.hostingViews), + "buttonViews": String(summary.buttonViews), + "layerBackedViews": String(summary.layerBackedViews), + "storeRefreshing": self.store.isRefreshing ? "1" : "0", + ]) + } + } + + private func menuStructureSummary(for menu: NSMenu) -> MenuStructureSummary { + var summary = MenuStructureSummary(itemCount: menu.items.count) + for item in menu.items { + if let represented = item.representedObject as? String, + represented.hasPrefix("menuCard") + { + summary.menuCardItems += 1 + } + if item.view != nil { + summary.viewBackedItems += 1 + } + if item.view is ProviderSwitcherView || + item.view is TokenAccountSwitcherView || + item.view is CodexAccountSwitcherView + { + summary.switcherItems += 1 + } + if let submenu = item.submenu { + summary.submenuItems += 1 + if self.isChartSubviewMenu(submenu) { + summary.chartSubviewMenus += 1 + } + } + if let view = item.view { + self.accumulateMenuViewSummary(from: view, into: &summary) + } + } + return summary + } + + private func accumulateMenuViewSummary(from view: NSView, into summary: inout MenuStructureSummary) { + summary.totalViews += 1 + let typeName = String(describing: type(of: view)) + if typeName.contains("HostingView") { + summary.hostingViews += 1 + } + if view is NSButton { + summary.buttonViews += 1 + } + if view.wantsLayer || view.layer != nil { + summary.layerBackedViews += 1 + } + for subview in view.subviews { + self.accumulateMenuViewSummary(from: subview, into: &summary) + } + } + + private func isChartSubviewMenu(_ menu: NSMenu) -> Bool { + let ids: Set = [ + Self.usageBreakdownChartID, + Self.creditsHistoryChartID, + Self.costHistoryChartID, + Self.usageHistoryChartID, + ] + return menu.items.contains { item in + guard let id = item.representedObject as? String else { return false } + return ids.contains(id) + } + } +} diff --git a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift index a2d5b9600..f9de969ac 100644 --- a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift +++ b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift @@ -43,6 +43,7 @@ extension StatusItemController { provider: UsageProvider, width: CGFloat) -> Bool { + let startedAt = Date() let histories = self.store.planUtilizationHistory(for: provider) let snapshot = self.store.snapshot(for: provider) @@ -63,6 +64,16 @@ extension StatusItemController { let controller = NSHostingController(rootView: chartView) let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + AgentDebugLogger.log( + "0.20 usage history submenu rendered", + hypothesisId: "T", + location: "StatusItemController+UsageHistoryMenu.swift:appendUsageHistoryChartItem", + data: [ + "provider": provider.rawValue, + "seriesCount": String(histories.count), + "height": String(Int(size.height)), + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + ]) let chartItem = NSMenuItem() chartItem.view = hosting diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index c818e13cc..f0384b3a7 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -431,6 +431,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } private func updateIcons() { + let startedAt = Date() // Avoid flicker: when an animation driver is active, store updates can call `updateIcons()` and // briefly overwrite the animated frame with the static (phase=nil) icon. let phase: Double? = self.needsMenuBarIconAnimation() ? self.animationPhase : nil @@ -443,6 +444,19 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } self.updateAnimationState() self.updateBlinkingState() + if !self.openMenus.isEmpty || self.store.isRefreshing { + AgentDebugLogger.log( + "0.20 updateIcons completed during active menu/refresh", + hypothesisId: "S", + location: "StatusItemController.swift:updateIcons", + data: [ + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + "openMenus": String(self.openMenus.count), + "storeRefreshing": self.store.isRefreshing ? "1" : "0", + "shouldMergeIcons": self.shouldMergeIcons ? "1" : "0", + "needsAnimation": self.needsMenuBarIconAnimation() ? "1" : "0", + ]) + } } /// Lazily retrieves or creates a status item for the given provider diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index d062d63a0..fa436df14 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -67,6 +67,18 @@ extension UsageStore { "batterySaverEnabled": self.settings.openAIWebBatterySaverEnabled ? "1" : "0", "interaction": ProviderInteractionContext.current == .userInitiated ? "user" : "background", ]) + AgentDebugLogger.log( + "0.20 stale OpenAI web request evaluated", + hypothesisId: "C", + location: "UsageStore+OpenAIWeb.swift:requestOpenAIDashboardRefreshIfStale", + data: [ + "reason": reason, + "force": forceRefresh ? "1" : "0", + "openAIWebAccessEnabled": self.settings.openAIWebAccessEnabled ? "1" : "0", + "batterySaverEnabled": self.settings.openAIWebBatterySaverEnabled ? "1" : "0", + "refreshIntervalSeconds": String(Int(refreshInterval)), + "lastUpdatedAgeSeconds": lastUpdatedAt.map { String(Int(now.timeIntervalSince($0))) } ?? "none", + ]) let expectedGuard = self.currentCodexOpenAIWebRefreshGuard() Task { await self.refreshOpenAIDashboardIfNeeded(force: forceRefresh, expectedGuard: expectedGuard) } } @@ -99,6 +111,15 @@ extension UsageStore { authorityInput: authority.input, attachedAccountEmail: attachedAccountEmail, allowCodexUsageBackfill: allowCodexUsageBackfill) + OpenAIDashboardFetcher.evictCachedWebView(accountEmail: targetEmail) + AgentDebugLogger.log( + "0.20 evicts cached OpenAI webview after successful dashboard fetch", + hypothesisId: "K", + location: "UsageStore+OpenAIWeb.swift:applyOpenAIDashboard", + data: [ + "targetEmailKnown": targetEmail == nil ? "0" : "1", + "attachedEmailKnown": attachedAccountEmail == nil ? "0" : "1", + ]) } func applyOpenAIDashboardFailure( @@ -236,8 +257,6 @@ extension UsageStore { attachedAccountEmail: attachedAccountEmail) } - OpenAIDashboardFetcher.evictCachedWebView(accountEmail: attachedAccountEmail) - case .displayOnly: self.applyOpenAIDashboardCleanup(decision.cleanup, preserveVisibleDashboard: true) self.openAIDashboard = dashboard @@ -246,7 +265,6 @@ extension UsageStore { self.lastOpenAIDashboardAttachmentAuthorized = false self.lastOpenAIDashboardError = nil self.openAIDashboardRequiresLogin = false - OpenAIDashboardFetcher.evictCachedWebView(accountEmail: attachedAccountEmail) case .failClosed: self.applyOpenAIDashboardCleanup(decision.cleanup, preserveVisibleDashboard: false) @@ -376,6 +394,26 @@ extension UsageStore { let now = Date() let minInterval = self.openAIWebRefreshIntervalSeconds() + let snapshotAgeSeconds = self.lastOpenAIDashboardSnapshot.map { Int(now.timeIntervalSince($0.updatedAt)) } + let willSkipBecauseSnapshotIsFresh = !force && + !self.openAIWebAccountDidChange && + self.lastOpenAIDashboardError == nil && + snapshotAgeSeconds != nil && + TimeInterval(snapshotAgeSeconds ?? 0) < minInterval + AgentDebugLogger.log( + "0.20 OpenAI web refresh gate evaluated", + hypothesisId: "C", + location: "UsageStore+OpenAIWeb.swift:refreshOpenAIDashboardIfNeeded", + data: [ + "force": force ? "1" : "0", + "openAIWebAccessEnabled": self.settings.openAIWebAccessEnabled ? "1" : "0", + "accountDidChange": self.openAIWebAccountDidChange ? "1" : "0", + "hasLastError": self.lastOpenAIDashboardError == nil ? "0" : "1", + "snapshotAgeSeconds": snapshotAgeSeconds.map(String.init) ?? "none", + "refreshIntervalSeconds": String(Int(minInterval)), + "willSkipBecauseSnapshotIsFresh": willSkipBecauseSnapshotIsFresh ? "1" : "0", + "targetEmailKnown": targetEmail == nil ? "0" : "1", + ]) let refreshGate = OpenAIWebRefreshGateContext( force: force, accountDidChange: self.openAIWebAccountDidChange, @@ -385,6 +423,19 @@ extension UsageStore { now: now, refreshInterval: minInterval) if Self.shouldSkipOpenAIWebRefresh(refreshGate) { + if let lastAttemptAt = self.lastOpenAIDashboardAttemptAt, + now.timeIntervalSince(lastAttemptAt) < minInterval + { + AgentDebugLogger.log( + "0.20 OpenAI web refresh skipped because recent attempt is still within gate", + hypothesisId: "C", + location: "UsageStore+OpenAIWeb.swift:refreshOpenAIDashboardIfNeeded", + data: [ + "secondsSinceLastAttempt": String(Int(now.timeIntervalSince(lastAttemptAt))), + "refreshIntervalSeconds": String(Int(minInterval)), + "hasLastError": self.lastOpenAIDashboardError == nil ? "0" : "1", + ]) + } return } self.lastOpenAIDashboardAttemptAt = now @@ -515,6 +566,14 @@ extension UsageStore { latestCookieImportStatus: inout String?, logger: @escaping (String) -> Void) async { + AgentDebugLogger.log( + "0.20 OpenAI web refresh retried after missing dashboard data", + hypothesisId: "I", + location: "UsageStore+OpenAIWeb.swift:retryOpenAIDashboardAfterNoData", + data: [ + "bodyPresent": body.isEmpty ? "0" : "1", + "targetEmailKnown": context.targetEmail == nil ? "0" : "1", + ]) let targetEmail = self.currentCodexOpenAIWebTargetEmail( allowCurrentSnapshotFallback: context.allowCurrentSnapshotFallback, allowLastKnownLiveFallback: context.expectedGuard?.identity != .unresolved) @@ -574,6 +633,14 @@ extension UsageStore { latestCookieImportStatus: inout String?, logger: @escaping (String) -> Void) async { + AgentDebugLogger.log( + "0.20 OpenAI web refresh retried after login-required result", + hypothesisId: "I", + location: "UsageStore+OpenAIWeb.swift:retryOpenAIDashboardAfterLoginRequired", + data: [ + "targetEmailKnown": context.targetEmail == nil ? "0" : "1", + "cookieStatusKnown": latestCookieImportStatus == nil ? "0" : "1", + ]) let targetEmail = self.currentCodexOpenAIWebTargetEmail( allowCurrentSnapshotFallback: context.allowCurrentSnapshotFallback, allowLastKnownLiveFallback: context.expectedGuard?.identity != .unresolved) diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 0ecbc1820..3c49ebc90 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -441,6 +441,18 @@ final class UsageStore { let refreshStartedAt = Date() await ProviderRefreshContext.$current.withValue(refreshPhase) { + AgentDebugLogger.log( + "0.20 refresh loop scope", + hypothesisId: "D", + location: "UsageStore.swift:refresh", + data: [ + "phase": refreshPhase == .startup ? "startup" : "regular", + "enabledProviders": String(enabledProviders.count), + "allProviders": String(UsageProvider.allCases.count), + "statusChecksEnabled": self.settings.statusChecksEnabled ? "1" : "0", + "forceTokenUsage": forceTokenUsage ? "1" : "0", + "openAIWebAccessEnabled": self.settings.openAIWebAccessEnabled ? "1" : "0", + ]) self.isRefreshing = true defer { self.isRefreshing = false @@ -480,6 +492,17 @@ final class UsageStore { "interaction": ProviderInteractionContext.current == .userInitiated ? "user" : "background", "phase": refreshPhase == .startup ? "startup" : "regular", ]) + AgentDebugLogger.log( + "0.20 main OpenAI web refresh policy evaluated", + hypothesisId: "C", + location: "UsageStore.swift:refresh", + data: [ + "allowed": shouldRefreshOpenAIWeb ? "1" : "0", + "accessEnabled": refreshPolicy.accessEnabled ? "1" : "0", + "batterySaverEnabled": refreshPolicy.batterySaverEnabled ? "1" : "0", + "force": refreshPolicy.force ? "1" : "0", + "phase": refreshPhase == .startup ? "startup" : "regular", + ]) if shouldRefreshOpenAIWeb { let codexDashboardGuard = self.currentCodexOpenAIWebRefreshGuard() await self.refreshOpenAIDashboardIfNeeded( @@ -1184,6 +1207,14 @@ extension UsageStore { let providerText = provider.rawValue self.tokenCostLogger .debug("cost usage start provider=\(providerText) force=\(force)") + AgentDebugLogger.log( + "0.20 token usage refresh started", + hypothesisId: "G", + location: "UsageStore.swift:refreshTokenUsage", + data: [ + "provider": providerText, + "force": force ? "1" : "0", + ]) do { let fetcher = self.costUsageFetcher @@ -1230,6 +1261,15 @@ extension UsageStore { self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.recordSuccess() self.persistWidgetSnapshot(reason: "token-usage") + AgentDebugLogger.log( + "0.20 token usage refresh succeeded", + hypothesisId: "G", + location: "UsageStore.swift:refreshTokenUsage", + data: [ + "provider": providerText, + "durationMs": String(Int(duration * 1000)), + "dailyEntries": String(snapshot.daily.count), + ]) } catch { if error is CancellationError { return } let duration = Date().timeIntervalSince(startedAt) @@ -1246,6 +1286,15 @@ extension UsageStore { } else { self.tokenErrors[provider] = nil } + AgentDebugLogger.log( + "0.20 token usage refresh failed", + hypothesisId: "G", + location: "UsageStore.swift:refreshTokenUsage", + data: [ + "provider": providerText, + "durationMs": String(Int(duration * 1000)), + "error": String(describing: error), + ]) } } } diff --git a/Sources/CodexBarCore/Logging/AgentDebugLogger.swift b/Sources/CodexBarCore/Logging/AgentDebugLogger.swift new file mode 100644 index 000000000..e499d6e6d --- /dev/null +++ b/Sources/CodexBarCore/Logging/AgentDebugLogger.swift @@ -0,0 +1,47 @@ +import Foundation + +public enum AgentDebugLogger { + private static let lock = NSLock() + private static let logURL = URL( + fileURLWithPath: "/Users/ratulsarna/Developer/staipete/CodexBar/.cursor/debug-4f7ebf.log") + private static let sessionID = "4f7ebf" + + public static func log( + _ message: String, + hypothesisId: String, + location: String, + runId: String = "baseline", + data: [String: String] = [:]) + { + let payload: [String: Any] = [ + "sessionId": self.sessionID, + "runId": runId, + "hypothesisId": hypothesisId, + "location": location, + "message": message, + "data": data, + "timestamp": Int(Date().timeIntervalSince1970 * 1000), + ] + guard JSONSerialization.isValidJSONObject(payload), + let raw = try? JSONSerialization.data(withJSONObject: payload), + var line = String(data: raw, encoding: .utf8) + else { + return + } + line.append("\n") + guard let encoded = line.data(using: .utf8) else { return } + + self.lock.lock() + defer { self.lock.unlock() } + + if FileManager.default.fileExists(atPath: self.logURL.path), + let handle = try? FileHandle(forWritingTo: self.logURL) + { + defer { try? handle.close() } + _ = try? handle.seekToEnd() + try? handle.write(contentsOf: encoded) + } else { + try? encoded.write(to: self.logURL, options: .atomic) + } + } +} diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift index 48af62a8e..7972cab81 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift @@ -42,6 +42,81 @@ public struct OpenAIDashboardFetcher { 0.001 } + private nonisolated static func logDashboardEvent( + _ message: String, + data: [String: String]) + { + AgentDebugLogger.log( + message, + hypothesisId: "J", + location: "OpenAIDashboardFetcher.swift:loadLatestDashboard", + data: data) + } + + private struct DashboardFetchTrace { + let startedAt: Date + let timeout: TimeInterval + var scrapeIterations = 0 + var routeReloadCount = 0 + var workspaceWaitCount = 0 + var creditsScrollWaitCount = 0 + var creditsHydrationWaitCount = 0 + var breakdownHydrationWaitCount = 0 + } + + private struct DashboardSnapshotComponents { + let scrape: ScrapeResult + let codeReview: Double? + let codeReviewLimit: RateWindow? + let events: [CreditEvent] + let breakdown: [OpenAIDashboardDailyBreakdown] + let usageBreakdown: [OpenAIDashboardDailyBreakdown] + let rateLimits: (primary: RateWindow?, secondary: RateWindow?) + let creditsRemaining: Double? + let accountPlan: String? + } + + private nonisolated static func emitDashboardSummary( + message: String, + trace: DashboardFetchTrace, + anyDashboardSignalAt: Date?, + extra: [String: String] = [:]) + { + var data: [String: String] = [ + "durationMs": String(Int(Date().timeIntervalSince(trace.startedAt) * 1000)), + "timeoutSeconds": String(Int(trace.timeout)), + "iterations": String(trace.scrapeIterations), + "routeReloads": String(trace.routeReloadCount), + "workspaceWaits": String(trace.workspaceWaitCount), + "creditsScrollWaits": String(trace.creditsScrollWaitCount), + "creditsHydrationWaits": String(trace.creditsHydrationWaitCount), + "breakdownHydrationWaits": String(trace.breakdownHydrationWaitCount), + "hadDashboardSignal": anyDashboardSignalAt == nil ? "0" : "1", + ] + for (key, value) in extra { + data[key] = value + } + Self.logDashboardEvent(message, data: data) + } + + private nonisolated static func makeDashboardSnapshot(_ components: DashboardSnapshotComponents) + -> OpenAIDashboardSnapshot + { + OpenAIDashboardSnapshot( + signedInEmail: components.scrape.signedInEmail, + codeReviewRemainingPercent: components.codeReview, + codeReviewLimit: components.codeReviewLimit, + creditEvents: components.events, + dailyBreakdown: components.breakdown, + usageBreakdown: components.usageBreakdown, + creditsPurchaseURL: components.scrape.creditsPurchaseURL, + primaryLimit: components.rateLimits.primary, + secondaryLimit: components.rateLimits.secondary, + creditsRemaining: components.creditsRemaining, + accountPlan: components.accountPlan, + updatedAt: Date()) + } + public struct ProbeResult: Sendable { public let href: String? public let loginRequired: Bool @@ -81,12 +156,14 @@ public struct OpenAIDashboardFetcher { timeout: timeout) } + // swiftlint:disable function_body_length public func loadLatestDashboard( websiteDataStore: WKWebsiteDataStore, logger: ((String) -> Void)? = nil, debugDumpHTML: Bool = false, timeout: TimeInterval = 60) async throws -> OpenAIDashboardSnapshot { + var trace = DashboardFetchTrace(startedAt: Date(), timeout: timeout) let deadline = Self.deadline(startingAt: Date(), timeout: timeout) let lease = try await self.makeWebView( websiteDataStore: websiteDataStore, @@ -105,8 +182,8 @@ public struct OpenAIDashboardFetcher { var creditsHeaderVisibleAt: Date? var lastUsageBreakdownDebug: String? var lastCreditsPurchaseURL: String? - while Date() < deadline { + trace.scrapeIterations += 1 let scrape = try await self.scrape(webView: webView) lastBody = scrape.bodyText ?? lastBody lastHTML = scrape.bodyHTML ?? lastHTML @@ -125,12 +202,14 @@ public struct OpenAIDashboardFetcher { } if scrape.workspacePicker { + trace.workspaceWaitCount += 1 try? await Task.sleep(for: .milliseconds(500)) continue } // The page is a SPA and can land on ChatGPT UI or other routes; keep forcing the usage URL. if let href = scrape.href, !Self.isUsageRoute(href) { + trace.routeReloadCount += 1 _ = webView.load(URLRequest(url: self.usageURL)) try? await Task.sleep(for: .milliseconds(500)) continue @@ -140,6 +219,14 @@ public struct OpenAIDashboardFetcher { if debugDumpHTML, let html = scrape.bodyHTML { Self.writeDebugArtifacts(html: html, bodyText: scrape.bodyText, logger: log) } + Self.emitDashboardSummary( + message: "0.20 OpenAI dashboard fetch returned login-required", + trace: trace, + anyDashboardSignalAt: anyDashboardSignalAt, + extra: [ + "cloudflare": scrape.cloudflareInterstitial ? "1" : "0", + "workspacePicker": scrape.workspacePicker ? "1" : "0", + ]) throw FetchError.loginRequired } @@ -147,6 +234,10 @@ public struct OpenAIDashboardFetcher { if debugDumpHTML, let html = scrape.bodyHTML { Self.writeDebugArtifacts(html: html, bodyText: scrape.bodyText, logger: log) } + Self.emitDashboardSummary( + message: "0.20 OpenAI dashboard fetch hit Cloudflare interstitial", + trace: trace, + anyDashboardSignalAt: anyDashboardSignalAt) throw FetchError.noDashboardData(body: "Cloudflare challenge detected in WebView.") } @@ -187,6 +278,7 @@ public struct OpenAIDashboardFetcher { "inViewport=\(scrape.creditsHeaderInViewport) didScroll=\(scrape.didScrollToCredits) " + "rows=\(scrape.rows.count)") if scrape.didScrollToCredits { + trace.creditsScrollWaitCount += 1 log("scrollIntoView(Credits usage history) requested; waiting…") try? await Task.sleep(for: .milliseconds(600)) continue @@ -205,6 +297,7 @@ public struct OpenAIDashboardFetcher { creditsHeaderInViewport: scrape.creditsHeaderInViewport, didScrollToCredits: scrape.didScrollToCredits)) { + trace.creditsHydrationWaitCount += 1 try? await Task.sleep(for: .milliseconds(400)) continue } @@ -218,23 +311,31 @@ public struct OpenAIDashboardFetcher { if codeReview != nil, usageBreakdown.isEmpty { let elapsed = Date().timeIntervalSince(codeReviewFirstSeenAt ?? Date()) if elapsed < 6 { + trace.breakdownHydrationWaitCount += 1 try? await Task.sleep(for: .milliseconds(400)) continue } } - return OpenAIDashboardSnapshot( - signedInEmail: scrape.signedInEmail, - codeReviewRemainingPercent: codeReview, + Self.emitDashboardSummary( + message: "0.20 OpenAI dashboard fetch succeeded", + trace: trace, + anyDashboardSignalAt: anyDashboardSignalAt, + extra: [ + "creditRows": String(events.count), + "usageBreakdownDays": String(usageBreakdown.count), + "hasRateLimits": hasUsageLimits ? "1" : "0", + "hasCreditsRemaining": creditsRemaining == nil ? "0" : "1", + ]) + return Self.makeDashboardSnapshot(.init( + scrape: scrape, + codeReview: codeReview, codeReviewLimit: codeReviewLimit, - creditEvents: events, - dailyBreakdown: breakdown, + events: events, + breakdown: breakdown, usageBreakdown: usageBreakdown, - creditsPurchaseURL: scrape.creditsPurchaseURL, - primaryLimit: rateLimits.primary, - secondaryLimit: rateLimits.secondary, + rateLimits: rateLimits, creditsRemaining: creditsRemaining, - accountPlan: accountPlan, - updatedAt: Date()) + accountPlan: accountPlan)) } try? await Task.sleep(for: .milliseconds(500)) @@ -243,9 +344,19 @@ public struct OpenAIDashboardFetcher { if debugDumpHTML, let html = lastHTML { Self.writeDebugArtifacts(html: html, bodyText: lastBody, logger: log) } + Self.emitDashboardSummary( + message: "0.20 OpenAI dashboard fetch exhausted timeout without data", + trace: trace, + anyDashboardSignalAt: anyDashboardSignalAt, + extra: [ + "lastBodyPresent": lastBody == nil ? "0" : "1", + "lastHrefKnown": lastHref == nil ? "0" : "1", + ]) throw FetchError.noDashboardData(body: lastBody ?? "") } + // swiftlint:enable function_body_length + struct CreditsHistoryWaitContext { let now: Date let anyDashboardSignalAt: Date? diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift index e7c9291e1..a88d93973 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift @@ -34,6 +34,50 @@ final class OpenAIDashboardWebViewCache { private let idleTimeout: TimeInterval = 60 private let blankURL = URL(string: "about:blank")! + private func logCacheEvent( + _ message: String, + location: String, + data: [String: String]) + { + AgentDebugLogger.log( + message, + hypothesisId: "B", + location: location, + data: data) + } + + private func releaseCachedEntry(_ entry: Entry) { + entry.isBusy = false + entry.lastUsedAt = Date() + self.logCacheEvent( + "0.20 releases cached OpenAI webview to idle state", + location: "OpenAIDashboardWebViewCache.swift:releaseCached", + data: [ + "currentURLHost": entry.webView.url?.host ?? "none", + "currentURLPath": entry.webView.url?.path ?? "", + "idleTimeoutSeconds": String(Int(self.idleTimeout)), + "entriesAfterRelease": String(self.entries.count), + ]) + self.prepareCachedWebViewForIdle(entry.webView, host: entry.host) + self.prune(now: Date()) + } + + private func releaseNewEntry(_ entry: Entry, webView: WKWebView) { + entry.isBusy = false + entry.lastUsedAt = Date() + self.logCacheEvent( + "0.20 releases newly created OpenAI webview to idle state", + location: "OpenAIDashboardWebViewCache.swift:releaseNew", + data: [ + "currentURLHost": entry.webView.url?.host ?? "none", + "currentURLPath": entry.webView.url?.path ?? "", + "idleTimeoutSeconds": String(Int(self.idleTimeout)), + "entriesAfterRelease": String(self.entries.count), + ]) + self.prepareCachedWebViewForIdle(webView, host: entry.host) + self.prune(now: Date()) + } + // MARK: - Testing support #if DEBUG @@ -129,6 +173,13 @@ final class OpenAIDashboardWebViewCache { try await self.prepareWebView(webView, usageURL: usageURL, timeout: remainingTimeout) } catch { if allowTimeoutRetry, Self.isPrepareTimeout(error) { + self.logCacheEvent( + "0.20 temporary OpenAI webview timed out during prepare", + location: "OpenAIDashboardWebViewCache.swift:acquireBusyRetry", + data: [ + "existingEntries": String(self.entries.count), + "remainingTimeoutMs": String(Int(remainingTimeout * 1000)), + ]) host.close() log("Temporary OpenAI WebView timed out; retrying with a fresh WebView.") return try await self.acquireTemporaryWebView( @@ -148,11 +199,27 @@ final class OpenAIDashboardWebViewCache { entry.isBusy = true entry.lastUsedAt = now + self.logCacheEvent( + "0.20 reuses cached OpenAI webview", + location: "OpenAIDashboardWebViewCache.swift:acquire", + data: [ + "existingEntries": String(self.entries.count), + "idleTimeoutSeconds": String(Int(self.idleTimeout)), + "usageURLHost": usageURL.host ?? "none", + "usageURLPath": usageURL.path, + ]) entry.host.show() do { try await self.prepareWebView(entry.webView, usageURL: usageURL, timeout: remainingTimeout) } catch { if allowTimeoutRetry, Self.isPrepareTimeout(error) { + self.logCacheEvent( + "0.20 cached OpenAI webview timed out during prepare", + location: "OpenAIDashboardWebViewCache.swift:acquireCachedRetry", + data: [ + "existingEntries": String(self.entries.count), + "remainingTimeoutMs": String(Int(remainingTimeout * 1000)), + ]) entry.isBusy = false entry.lastUsedAt = Date() entry.host.close() @@ -178,22 +245,35 @@ final class OpenAIDashboardWebViewCache { log: log, release: { [weak self, weak entry] in guard let self, let entry else { return } - entry.isBusy = false - entry.lastUsedAt = Date() - self.prepareCachedWebViewForIdle(entry.webView, host: entry.host) - self.prune(now: Date()) + self.releaseCachedEntry(entry) }) } let (webView, host) = self.makeWebView(websiteDataStore: websiteDataStore) let entry = Entry(webView: webView, host: host, lastUsedAt: now, isBusy: true) self.entries[key] = entry + self.logCacheEvent( + "0.20 creates cached OpenAI webview", + location: "OpenAIDashboardWebViewCache.swift:createEntry", + data: [ + "existingEntries": String(self.entries.count), + "idleTimeoutSeconds": String(Int(self.idleTimeout)), + "usageURLHost": usageURL.host ?? "none", + "usageURLPath": usageURL.path, + ]) host.show() do { try await self.prepareWebView(webView, usageURL: usageURL, timeout: remainingTimeout) } catch { if allowTimeoutRetry, Self.isPrepareTimeout(error) { + self.logCacheEvent( + "0.20 newly created OpenAI webview timed out during prepare", + location: "OpenAIDashboardWebViewCache.swift:createEntryRetry", + data: [ + "existingEntries": String(self.entries.count), + "remainingTimeoutMs": String(Int(remainingTimeout * 1000)), + ]) self.entries.removeValue(forKey: key) host.close() log("OpenAI WebView timed out during prepare; retrying once.") @@ -215,10 +295,7 @@ final class OpenAIDashboardWebViewCache { log: log, release: { [weak self, weak entry] in guard let self, let entry else { return } - entry.isBusy = false - entry.lastUsedAt = Date() - self.prepareCachedWebViewForIdle(webView, host: entry.host) - self.prune(now: Date()) + self.releaseNewEntry(entry, webView: webView) }) } @@ -232,6 +309,15 @@ final class OpenAIDashboardWebViewCache { func evictAll() { let existing = self.entries self.entries.removeAll() + if !existing.isEmpty { + self.logCacheEvent( + "0.20 evicts cached OpenAI webviews after refresh failure or reset", + location: "OpenAIDashboardWebViewCache.swift:evictAll", + data: [ + "evictedEntries": String(existing.count), + "idleTimeoutSeconds": String(Int(self.idleTimeout)), + ]) + } for (_, entry) in existing { entry.host.close() } @@ -255,6 +341,13 @@ final class OpenAIDashboardWebViewCache { !entry.isBusy && now.timeIntervalSince(entry.lastUsedAt) > self.idleTimeout } for (key, entry) in expired { + self.logCacheEvent( + "0.20 prunes cached OpenAI webview after shorter idle timeout", + location: "OpenAIDashboardWebViewCache.swift:prune", + data: [ + "idleTimeoutSeconds": String(Int(self.idleTimeout)), + "entriesBeforePrune": String(self.entries.count), + ]) entry.host.close() self.entries.removeValue(forKey: key) Self.log.debug("OpenAI webview pruned") diff --git a/Sources/CodexBarCore/PiSessionCostScanner.swift b/Sources/CodexBarCore/PiSessionCostScanner.swift index 49c9e6998..635a1ef2c 100644 --- a/Sources/CodexBarCore/PiSessionCostScanner.swift +++ b/Sources/CodexBarCore/PiSessionCostScanner.swift @@ -58,6 +58,7 @@ enum PiSessionCostScanner { || nowMs - cache.lastScanUnixMs > refreshMs if shouldRefresh { + let startedAt = Date() let root = self.defaultPiSessionsRoot(options: options) let startCutoff = self.dateFromDayKey(range.scanSinceKey) ?? since let files = self.listPiSessionFiles(root: root, startCutoffLocal: startCutoff) @@ -85,6 +86,18 @@ enum PiSessionCostScanner { cache.scanUntilKey = range.scanUntilKey cache.lastScanUnixMs = nowMs PiSessionCostCacheIO.save(cache: cache, cacheRoot: options.cacheRoot) + AgentDebugLogger.log( + "0.20 PI session cost scanner refreshed cache", + hypothesisId: "G", + location: "PiSessionCostScanner.swift:loadDailyReport", + data: [ + "provider": provider.rawValue, + "fileCount": String(files.count), + "cacheFiles": String(cache.files.count), + "forceRescan": options.forceRescan ? "1" : "0", + "windowExpanded": windowExpanded ? "1" : "0", + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + ]) } return self.buildReport(provider: provider, cache: cache, range: range) diff --git a/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift b/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift index 3226674b3..1baf368c0 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift @@ -76,6 +76,7 @@ public struct CodexStatusProbe { } public func fetch() async throws -> CodexStatusSnapshot { + let startedAt = Date() let env = self.environment let resolved = BinaryLocator.resolveCodexBinary(env: env, loginPATH: LoginShellPathCache.shared.current) ?? self.codexBinary @@ -83,19 +84,65 @@ public struct CodexStatusProbe { throw CodexStatusProbeError.codexNotInstalled } do { - return try await self.runAndParse(binary: resolved, rows: 60, cols: 200, timeout: self.timeout) + let snapshot = try await self.runAndParse(binary: resolved, rows: 60, cols: 200, timeout: self.timeout) + AgentDebugLogger.log( + "0.20 Codex status probe completed on first attempt", + hypothesisId: "F", + location: "CodexStatusProbe.swift:fetch", + data: [ + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + "keepSessionAlive": self.keepCLISessionsAlive ? "1" : "0", + ]) + return snapshot } catch let error as CodexStatusProbeError { // Retry only parser-level flakes with a short second attempt. switch error { case .parseFailed: - return try await self.runAndParse( + AgentDebugLogger.log( + "0.20 Codex status probe retried after parser failure", + hypothesisId: "F", + location: "CodexStatusProbe.swift:fetch", + data: [ + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + "keepSessionAlive": self.keepCLISessionsAlive ? "1" : "0", + ]) + let snapshot = try await self.runAndParse( binary: resolved, rows: 70, cols: 220, timeout: Self.parseRetryTimeoutSeconds) + AgentDebugLogger.log( + "0.20 Codex status probe completed after parser retry", + hypothesisId: "F", + location: "CodexStatusProbe.swift:fetch", + data: [ + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + "keepSessionAlive": self.keepCLISessionsAlive ? "1" : "0", + ]) + return snapshot default: + AgentDebugLogger.log( + "0.20 Codex status probe failed", + hypothesisId: "F", + location: "CodexStatusProbe.swift:fetch", + data: [ + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + "keepSessionAlive": self.keepCLISessionsAlive ? "1" : "0", + "error": String(describing: error), + ]) throw error } + } catch { + AgentDebugLogger.log( + "0.20 Codex status probe failed", + hypothesisId: "F", + location: "CodexStatusProbe.swift:fetch", + data: [ + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + "keepSessionAlive": self.keepCLISessionsAlive ? "1" : "0", + "error": String(describing: error), + ]) + throw error } } diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index b560f005e..c1c83355a 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -566,56 +566,100 @@ public struct UsageFetcher: Sendable { } private func loadRPCUsage() async throws -> UsageSnapshot { + let startedAt = Date() let rpc = try CodexRPCClient(environment: self.environment) defer { rpc.shutdown() } - - try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4") - // The app-server answers on a single stdout stream, so keep requests - // serialized to avoid starving one reader when multiple awaiters race - // for the same pipe. - let limits = try await rpc.fetchRateLimits().rateLimits - let account = try? await rpc.fetchAccount() - - let identity = ProviderIdentitySnapshot( - providerID: .codex, - accountEmail: account?.account.flatMap { details in - if case let .chatgpt(email, _) = details { email } else { nil } - }, - accountOrganization: nil, - loginMethod: account?.account.flatMap { details in - if case let .chatgpt(_, plan) = details { plan } else { nil } - }) - guard let state = CodexReconciledState.fromCLI( - primary: Self.makeWindow(from: limits.primary), - secondary: Self.makeWindow(from: limits.secondary), - identity: identity) - else { - throw UsageError.noRateLimitsFound + do { + try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4") + // The app-server answers on a single stdout stream, so keep requests + // serialized to avoid starving one reader when multiple awaiters race + // for the same pipe. + let limits = try await rpc.fetchRateLimits().rateLimits + let account = try? await rpc.fetchAccount() + + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: account?.account.flatMap { details in + if case let .chatgpt(email, _) = details { email } else { nil } + }, + accountOrganization: nil, + loginMethod: account?.account.flatMap { details in + if case let .chatgpt(_, plan) = details { plan } else { nil } + }) + guard let state = CodexReconciledState.fromCLI( + primary: Self.makeWindow(from: limits.primary), + secondary: Self.makeWindow(from: limits.secondary), + identity: identity) + else { + throw UsageError.noRateLimitsFound + } + AgentDebugLogger.log( + "0.20 Codex RPC usage fetch succeeded", + hypothesisId: "E", + location: "UsageFetcher.swift:loadRPCUsage", + data: [ + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + "accountEmailKnown": identity.accountEmail == nil ? "0" : "1", + ]) + return state.toUsageSnapshot() + } catch { + AgentDebugLogger.log( + "0.20 Codex RPC usage fetch failed", + hypothesisId: "E", + location: "UsageFetcher.swift:loadRPCUsage", + data: [ + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + "error": String(describing: error), + ]) + throw error } - return state.toUsageSnapshot() } private func loadTTYUsage(keepCLISessionsAlive: Bool) async throws -> UsageSnapshot { - let status = try await CodexStatusProbe( - keepCLISessionsAlive: keepCLISessionsAlive, - environment: self.environment) - .fetch() - guard let state = CodexReconciledState.fromCLI( - primary: Self.makeTTYWindow( - percentLeft: status.fiveHourPercentLeft, - windowMinutes: 300, - resetsAt: status.fiveHourResetsAt, - resetDescription: status.fiveHourResetDescription), - secondary: Self.makeTTYWindow( - percentLeft: status.weeklyPercentLeft, - windowMinutes: 10080, - resetsAt: status.weeklyResetsAt, - resetDescription: status.weeklyResetDescription), - identity: nil) - else { - throw UsageError.noRateLimitsFound + let startedAt = Date() + do { + let status = try await CodexStatusProbe( + keepCLISessionsAlive: keepCLISessionsAlive, + environment: self.environment) + .fetch() + guard let state = CodexReconciledState.fromCLI( + primary: Self.makeTTYWindow( + percentLeft: status.fiveHourPercentLeft, + windowMinutes: 300, + resetsAt: status.fiveHourResetsAt, + resetDescription: status.fiveHourResetDescription), + secondary: Self.makeTTYWindow( + percentLeft: status.weeklyPercentLeft, + windowMinutes: 10080, + resetsAt: status.weeklyResetsAt, + resetDescription: status.weeklyResetDescription), + identity: nil) + else { + throw UsageError.noRateLimitsFound + } + AgentDebugLogger.log( + "0.20 Codex TTY usage fetch succeeded", + hypothesisId: "F", + location: "UsageFetcher.swift:loadTTYUsage", + data: [ + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + "keepSessionAlive": keepCLISessionsAlive ? "1" : "0", + "hasFiveHour": status.fiveHourPercentLeft == nil ? "0" : "1", + "hasWeekly": status.weeklyPercentLeft == nil ? "0" : "1", + ]) + return state.toUsageSnapshot() + } catch { + AgentDebugLogger.log( + "0.20 Codex TTY usage fetch failed", + hypothesisId: "F", + location: "UsageFetcher.swift:loadTTYUsage", + data: [ + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + "keepSessionAlive": keepCLISessionsAlive ? "1" : "0", + "error": String(describing: error), + ]) + throw error } - return state.toUsageSnapshot() } public func loadLatestCredits(keepCLISessionsAlive: Bool = false) async throws -> CreditsSnapshot { @@ -625,22 +669,65 @@ public struct UsageFetcher: Sendable { } private func loadRPCCredits() async throws -> CreditsSnapshot { + let startedAt = Date() let rpc = try CodexRPCClient(environment: self.environment) defer { rpc.shutdown() } - try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4") - let limits = try await rpc.fetchRateLimits().rateLimits - guard let credits = limits.credits else { throw UsageError.noRateLimitsFound } - let remaining = Self.parseCredits(credits.balance) - return CreditsSnapshot(remaining: remaining, events: [], updatedAt: Date()) + do { + try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4") + let limits = try await rpc.fetchRateLimits().rateLimits + guard let credits = limits.credits else { throw UsageError.noRateLimitsFound } + let remaining = Self.parseCredits(credits.balance) + AgentDebugLogger.log( + "0.20 Codex RPC credits fetch succeeded", + hypothesisId: "E", + location: "UsageFetcher.swift:loadRPCCredits", + data: [ + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + "creditsKnown": "1", + ]) + return CreditsSnapshot(remaining: remaining, events: [], updatedAt: Date()) + } catch { + AgentDebugLogger.log( + "0.20 Codex RPC credits fetch failed", + hypothesisId: "E", + location: "UsageFetcher.swift:loadRPCCredits", + data: [ + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + "error": String(describing: error), + ]) + throw error + } } private func loadTTYCredits(keepCLISessionsAlive: Bool) async throws -> CreditsSnapshot { - let status = try await CodexStatusProbe( - keepCLISessionsAlive: keepCLISessionsAlive, - environment: self.environment) - .fetch() - guard let credits = status.credits else { throw UsageError.noRateLimitsFound } - return CreditsSnapshot(remaining: credits, events: [], updatedAt: Date()) + let startedAt = Date() + do { + let status = try await CodexStatusProbe( + keepCLISessionsAlive: keepCLISessionsAlive, + environment: self.environment) + .fetch() + guard let credits = status.credits else { throw UsageError.noRateLimitsFound } + AgentDebugLogger.log( + "0.20 Codex TTY credits fetch succeeded", + hypothesisId: "F", + location: "UsageFetcher.swift:loadTTYCredits", + data: [ + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + "keepSessionAlive": keepCLISessionsAlive ? "1" : "0", + ]) + return CreditsSnapshot(remaining: credits, events: [], updatedAt: Date()) + } catch { + AgentDebugLogger.log( + "0.20 Codex TTY credits fetch failed", + hypothesisId: "F", + location: "UsageFetcher.swift:loadTTYCredits", + data: [ + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + "keepSessionAlive": keepCLISessionsAlive ? "1" : "0", + "error": String(describing: error), + ]) + throw error + } } private func withFallback( diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index c1ea28ed4..2e3f1c1a9 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -542,6 +542,7 @@ extension CostUsageScanner { var touched: Set = [] if shouldRefresh { + let startedAt = Date() if options.forceRescan { cache = CostUsageCache() } @@ -565,6 +566,18 @@ extension CostUsageScanner { Self.pruneDays(cache: &cache, sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) cache.lastScanUnixMs = nowMs CostUsageCacheIO.save(provider: provider, cache: cache, cacheRoot: options.cacheRoot) + AgentDebugLogger.log( + "0.20 Claude local cost scanner refreshed cache", + hypothesisId: "G", + location: "CostUsageScanner+Claude.swift:loadClaudeDaily", + data: [ + "provider": provider.rawValue, + "rootCount": String(roots.count), + "touchedFiles": String(touched.count), + "cacheFiles": String(cache.files.count), + "forceRescan": options.forceRescan ? "1" : "0", + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + ]) } return Self.buildClaudeReportFromCache(cache: cache, range: range) diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 91de4e1ca..c344fa688 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -990,6 +990,7 @@ enum CostUsageScanner { || nowMs - cache.lastScanUnixMs > refreshMs if shouldRefresh { + let startedAt = Date() if options.forceRescan { cache = CostUsageCache() } @@ -1066,6 +1067,17 @@ enum CostUsageScanner { cache.roots = rootsFingerprint cache.lastScanUnixMs = nowMs CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: options.cacheRoot) + AgentDebugLogger.log( + "0.20 Codex local cost scanner refreshed cache", + hypothesisId: "G", + location: "CostUsageScanner.swift:loadCodexDaily", + data: [ + "fileCount": String(files.count), + "rootCount": String(roots.count), + "cacheFiles": String(cache.files.count), + "forceRescan": options.forceRescan ? "1" : "0", + "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), + ]) } return Self.buildCodexReportFromCache(cache: cache, range: range) From 25c9203fa973d5e46b1ff5746d01905ea39e470c Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Mon, 13 Apr 2026 17:13:28 +0530 Subject: [PATCH 3/7] Limit background work to runnable providers --- Sources/CodexBar/UsageStore.swift | 17 +++++++++---- .../UsageStoreCoverageTests.swift | 25 +++++++++++++++++++ 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 3c49ebc90..1722ccb41 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -331,6 +331,11 @@ final class UsageStore { self.settings.enabledProvidersOrdered(metadataByProvider: self.providerMetadata) } + /// Providers that should actually participate in background refresh/status/token work. + func enabledProvidersForBackgroundWork() -> [UsageProvider] { + self.enabledProviders() + } + var statusChecksEnabled: Bool { self.settings.statusChecksEnabled } @@ -436,8 +441,9 @@ final class UsageStore { guard !self.isRefreshing else { return } self.prepareRefreshState() let refreshPhase: ProviderRefreshPhase = self.hasCompletedInitialRefresh ? .regular : .startup - let enabledProviders = self.enabledProvidersForDisplay() - let enabledProviderSet = Set(enabledProviders) + let displayEnabledProviders = self.enabledProvidersForDisplay() + let enabledProviderSet = Set(displayEnabledProviders) + let refreshProviders = self.enabledProvidersForBackgroundWork() let refreshStartedAt = Date() await ProviderRefreshContext.$current.withValue(refreshPhase) { @@ -447,7 +453,8 @@ final class UsageStore { location: "UsageStore.swift:refresh", data: [ "phase": refreshPhase == .startup ? "startup" : "regular", - "enabledProviders": String(enabledProviders.count), + "enabledProviders": String(refreshProviders.count), + "displayEnabledProviders": String(displayEnabledProviders.count), "allProviders": String(UsageProvider.allCases.count), "statusChecksEnabled": self.settings.statusChecksEnabled ? "1" : "0", "forceTokenUsage": forceTokenUsage ? "1" : "0", @@ -462,7 +469,7 @@ final class UsageStore { self.clearDisabledProviderState(enabledProviders: enabledProviderSet) await withTaskGroup(of: Void.self) { group in - for provider in enabledProviders { + for provider in refreshProviders { group.addTask { await self.refreshProvider(provider) } group.addTask { await self.refreshStatus(provider) } } @@ -571,7 +578,7 @@ final class UsageStore { return } - let providers = self.enabledProvidersForDisplay() + let providers = self.enabledProvidersForBackgroundWork() self.tokenRefreshSequenceTask = Task(priority: .utility) { [weak self] in guard let self else { return } defer { diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index 49dd6e681..75213dfdc 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -203,6 +203,31 @@ struct UsageStoreCoverageTests { #expect(store.statuses[.synthetic]?.indicator == .major) } + @Test + func backgroundWorkExcludesEnabledButUnavailableProviders() throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-background-unavailable") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + + let metadata = ProviderRegistry.shared.metadata + for provider in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(metadata[provider]), + enabled: false) + } + try settings.setProviderEnabled( + provider: .synthetic, + metadata: #require(metadata[.synthetic]), + enabled: true) + + let store = Self.makeUsageStore(settings: settings) + + #expect(store.enabledProvidersForDisplay() == [.synthetic]) + #expect(store.enabledProviders().isEmpty) + #expect(store.enabledProvidersForBackgroundWork().isEmpty) + } + @Test func statusIndicatorsAndFailureGate() { #expect(!ProviderStatusIndicator.none.hasIssue) From 50e9e2875b9f65a0c5d92294c96f9bb983420c6e Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Mon, 13 Apr 2026 19:31:16 +0530 Subject: [PATCH 4/7] Refactor StatusItemController and SettingsStore by removing debug logging and enhancing icon rendering logic - Removed various debug logging statements from StatusItemController, SettingsStore, and UsageStore to streamline the code and reduce log clutter. - Introduced a mechanism to skip redundant icon rendering in StatusItemController, improving performance during icon updates. - Added a new property to track the last applied merged icon render signature for better management of icon updates. - Updated CodexAccountMenuDisplay to conform to Equatable for improved comparison capabilities. --- .../Providers/Codex/CodexSettingsStore.swift | 13 +-- .../Codex/UsageStore+CodexAccountState.swift | 19 ---- Sources/CodexBar/SettingsStore.swift | 10 -- .../StatusItemController+Actions.swift | 9 -- .../StatusItemController+Animation.swift | 107 ++++++++++++------ .../StatusItemController+HostedSubmenus.swift | 31 ----- .../CodexBar/StatusItemController+Menu.swift | 80 +++---------- .../StatusItemController+MenuDebug.swift | 104 ----------------- .../StatusItemController+MenuTypes.swift | 2 +- .../StatusItemController+SwitcherViews.swift | 4 +- ...tatusItemController+UsageHistoryMenu.swift | 11 -- Sources/CodexBar/StatusItemController.swift | 38 ++++--- Sources/CodexBar/UsageStore+OpenAIWeb.swift | 69 ----------- Sources/CodexBar/UsageStore.swift | 67 +++-------- .../Logging/AgentDebugLogger.swift | 47 -------- .../OpenAIWeb/OpenAIDashboardFetcher.swift | 85 -------------- .../OpenAIDashboardWebViewCache.swift | 85 -------------- .../CodexBarCore/PiSessionCostScanner.swift | 13 --- .../Providers/Codex/CodexStatusProbe.swift | 49 +------- Sources/CodexBarCore/UsageFetcher.swift | 72 ------------ .../CostUsage/CostUsageScanner+Claude.swift | 13 --- .../Vendored/CostUsage/CostUsageScanner.swift | 12 -- 22 files changed, 136 insertions(+), 804 deletions(-) delete mode 100644 Sources/CodexBar/StatusItemController+MenuDebug.swift delete mode 100644 Sources/CodexBarCore/Logging/AgentDebugLogger.swift diff --git a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift index e4cedf866..91463ca4c 100644 --- a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift +++ b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift @@ -182,18 +182,7 @@ extension SettingsStore { } var codexVisibleAccountProjection: CodexVisibleAccountProjection { - let startedAt = Date() - let projection = CodexVisibleAccountProjection.make(from: self.codexAccountReconciliationSnapshot) - AgentDebugLogger.log( - "0.20 Codex visible account projection computed", - hypothesisId: "R", - location: "CodexSettingsStore.swift:codexVisibleAccountProjection", - data: [ - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - "mainThread": Thread.isMainThread ? "1" : "0", - "visibleAccounts": String(projection.visibleAccounts.count), - ]) - return projection + CodexVisibleAccountProjection.make(from: self.codexAccountReconciliationSnapshot) } var codexVisibleAccounts: [CodexVisibleAccount] { diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift index 1c94fce11..03e42f23d 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift @@ -23,15 +23,6 @@ extension UsageStore { async { let refreshStartedAt = Date() - AgentDebugLogger.log( - "0.20 Codex account-scoped refresh started", - hypothesisId: "H", - location: "UsageStore+CodexAccountState.swift:refreshCodexAccountScopedState", - data: [ - "allowDisabled": allowDisabled ? "1" : "0", - "openAIWebEnabled": self.settings.codexCookieSource.isEnabled ? "1" : "0", - "resolvedSource": String(describing: self.settings.codexResolvedActiveSource), - ]) self.prepareRefreshState(for: .codex) if self.prepareCodexAccountScopedRefreshIfNeeded() { phaseDidChange?(.invalidated) @@ -60,16 +51,6 @@ extension UsageStore { self.persistWidgetSnapshot(reason: "codex-account-refresh") phaseDidChange?(.completed) - AgentDebugLogger.log( - "0.20 Codex account-scoped refresh completed", - hypothesisId: "H", - location: "UsageStore+CodexAccountState.swift:refreshCodexAccountScopedState", - data: [ - "durationMs": String(Int(Date().timeIntervalSince(refreshStartedAt) * 1000)), - "hasCredits": self.credits == nil ? "0" : "1", - "hasDashboard": self.openAIDashboard == nil ? "0" : "1", - "dashboardRequiresLogin": self.openAIDashboardRequiresLogin ? "1" : "0", - ]) } @discardableResult diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index ff157a308..68ba707aa 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -168,16 +168,6 @@ final class SettingsStore { config: config, hadExistingConfig: hadExistingConfig) } - AgentDebugLogger.log( - "0.20 startup OpenAI web access decision", - hypothesisId: "A", - location: "SettingsStore.swift:init", - data: [ - "codexCookieSource": self.codexCookieSource.rawValue, - "openAIWebAccessEnabled": self.openAIWebAccessEnabled ? "1" : "0", - "hasStoredPreference": hasStoredOpenAIWebAccessPreference ? "1" : "0", - "refreshFrequency": self.refreshFrequency.rawValue, - ]) if Self.shouldBridgeSharedDefaults(for: userDefaults) { Self.sharedDefaults?.set(self.debugDisableKeychainAccess, forKey: "debugDisableKeychainAccess") } diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index f002028a1..b470e049c 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -5,15 +5,6 @@ extension StatusItemController { // MARK: - Actions reachable from menus func refreshStore(forceTokenUsage: Bool) { - AgentDebugLogger.log( - "0.20 status item requested refresh", - hypothesisId: "L", - location: "StatusItemController+Actions.swift:refreshStore", - data: [ - "forceTokenUsage": forceTokenUsage ? "1" : "0", - "openMenus": String(self.openMenus.count), - "storeRefreshing": self.store.isRefreshing ? "1" : "0", - ]) Task { await ProviderInteractionContext.$current.withValue(.userInitiated) { await self.store.refresh(forceTokenUsage: forceTokenUsage) diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 2e985da99..29c592a25 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -217,8 +217,10 @@ extension StatusItemController { return false } - func applyIcon(phase: Double?) { - guard let button = self.statusItem.button else { return } + // swiftlint:disable function_body_length + @discardableResult + func applyIcon(phase: Double?) -> Bool { + guard let button = self.statusItem.button else { return false } let style = self.store.iconStyle let showUsed = self.settings.usageBarsShowUsed @@ -299,31 +301,88 @@ extension StatusItemController { } return .none }() + let debugDouble: (Double?) -> String = { value in + guard let value else { return "nil" } + return String(format: "%.3f", value) + } if showBrandPercent, let brand = ProviderBrandIcon.image(for: primaryProvider) { let displayText = self.menuBarDisplayText(for: primaryProvider, snapshot: snapshot) + let signature = [ + "mode=brandPercent", + "provider=\(primaryProvider.rawValue)", + "primary=\(debugDouble(primary))", + "weekly=\(debugDouble(weekly))", + "credits=\(debugDouble(credits))", + "stale=\(stale ? "1" : "0")", + "status=\(statusIndicator.rawValue)", + "text=\(displayText ?? "nil")", + "anim=\(needsAnimation ? "1" : "0")", + ].joined(separator: "|") + if self.shouldSkipMergedIconRender(signature) { + return true + } self.setButtonImage(brand, for: button) self.setButtonTitle(displayText, for: button) - return + return false } if Self.shouldUseOpenRouterBrandFallback(provider: primaryProvider, snapshot: snapshot), let brand = ProviderBrandIcon.image(for: primaryProvider) { + let signature = [ + "mode=openRouterFallback", + "provider=\(primaryProvider.rawValue)", + "primary=\(debugDouble(primary))", + "weekly=\(debugDouble(weekly))", + "credits=\(debugDouble(credits))", + "stale=\(stale ? "1" : "0")", + "status=\(statusIndicator.rawValue)", + "anim=\(needsAnimation ? "1" : "0")", + ].joined(separator: "|") + if self.shouldSkipMergedIconRender(signature) { + return true + } self.setButtonTitle(nil, for: button) self.setButtonImage( Self.brandImageWithStatusOverlay(brand: brand, statusIndicator: statusIndicator), for: button) - return + return false } self.setButtonTitle(nil, for: button) if let morphProgress { + let signature = [ + "mode=morph", + "provider=\(primaryProvider.rawValue)", + "morph=\(debugDouble(morphProgress))", + "status=\(statusIndicator.rawValue)", + "anim=\(needsAnimation ? "1" : "0")", + ].joined(separator: "|") + if self.shouldSkipMergedIconRender(signature) { + return true + } let image = IconRenderer.makeMorphIcon(progress: morphProgress, style: style) self.setButtonImage(image, for: button) } else { + let signature = [ + "mode=icon", + "provider=\(primaryProvider.rawValue)", + "primary=\(debugDouble(primary))", + "weekly=\(debugDouble(weekly))", + "credits=\(debugDouble(credits))", + "stale=\(stale ? "1" : "0")", + "status=\(statusIndicator.rawValue)", + "blink=\(debugDouble(Double(blink)))", + "wiggle=\(debugDouble(Double(wiggle)))", + "tilt=\(debugDouble(Double(tilt)))", + "anim=\(needsAnimation ? "1" : "0")", + ].joined(separator: "|") + if self.shouldSkipMergedIconRender(signature) { + return true + } let image = IconRenderer.makeIcon( primaryRemaining: primary, weeklyRemaining: weekly, @@ -336,6 +395,18 @@ extension StatusItemController { statusIndicator: statusIndicator) self.setButtonImage(image, for: button) } + return false + } + + // swiftlint:enable function_body_length + + private func shouldSkipMergedIconRender(_ signature: String) -> Bool { + guard self.shouldMergeIcons else { return false } + if self.lastAppliedMergedIconRenderSignature == signature { + return true + } + self.lastAppliedMergedIconRenderSignature = signature + return false } func applyIcon(for provider: UsageProvider, phase: Double?) { @@ -595,19 +666,6 @@ extension StatusItemController { let needsAnimation = self.needsMenuBarIconAnimation() if needsAnimation { if self.animationDriver == nil { - let primaryProvider = self.primaryProviderForUnifiedIcon() - AgentDebugLogger.log( - "0.20 menu bar animation driver started", - hypothesisId: "O", - location: "StatusItemController+Animation.swift:updateAnimationState", - data: [ - "primaryProvider": primaryProvider.rawValue, - "selectedMenuProvider": self.selectedMenuProvider?.rawValue ?? "nil", - "snapshotKnown": self.store.snapshot(for: primaryProvider) == nil ? "0" : "1", - "mergedIcons": self.shouldMergeIcons ? "1" : "0", - "enabledProviders": String(self.store.enabledProvidersForDisplay().count), - "refreshingProviders": String(self.store.refreshingProviders.count), - ]) if let forced = self.settings.debugLoadingPattern { self.animationPattern = forced } else if !LoadingPattern.allCases.contains(self.animationPattern) { @@ -624,21 +682,6 @@ extension StatusItemController { self.animationPhase = 0 } } else { - if self.animationDriver != nil { - let primaryProvider = self.primaryProviderForUnifiedIcon() - AgentDebugLogger.log( - "0.20 menu bar animation driver stopped", - hypothesisId: "O", - location: "StatusItemController+Animation.swift:updateAnimationState", - data: [ - "primaryProvider": primaryProvider.rawValue, - "selectedMenuProvider": self.selectedMenuProvider?.rawValue ?? "nil", - "snapshotKnown": self.store.snapshot(for: primaryProvider) == nil ? "0" : "1", - "mergedIcons": self.shouldMergeIcons ? "1" : "0", - "enabledProviders": String(self.store.enabledProvidersForDisplay().count), - "refreshingProviders": String(self.store.refreshingProviders.count), - ]) - } self.animationDriver?.stop() self.animationDriver = nil self.animationPhase = 0 diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift index e9a8f693c..2b6f12678 100644 --- a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -76,19 +76,9 @@ extension StatusItemController { let chartView = UsageBreakdownChartMenuView(breakdown: breakdown, width: width) let hosting = MenuHostingView(rootView: chartView) - let startedAt = Date() let controller = NSHostingController(rootView: chartView) let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) - AgentDebugLogger.log( - "0.20 usage breakdown submenu rendered", - hypothesisId: "N", - location: "StatusItemController+HostedSubmenus.swift:appendUsageBreakdownChartItem", - data: [ - "days": String(breakdown.count), - "height": String(Int(size.height)), - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - ]) let chartItem = NSMenuItem() chartItem.view = hosting @@ -113,19 +103,9 @@ extension StatusItemController { let chartView = CreditsHistoryChartMenuView(breakdown: breakdown, width: width) let hosting = MenuHostingView(rootView: chartView) - let startedAt = Date() let controller = NSHostingController(rootView: chartView) let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) - AgentDebugLogger.log( - "0.20 credits history submenu rendered", - hypothesisId: "N", - location: "StatusItemController+HostedSubmenus.swift:appendCreditsHistoryChartItem", - data: [ - "days": String(breakdown.count), - "height": String(Int(size.height)), - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - ]) let chartItem = NSMenuItem() chartItem.view = hosting @@ -158,20 +138,9 @@ extension StatusItemController { totalCostUSD: tokenSnapshot.last30DaysCostUSD, width: width) let hosting = MenuHostingView(rootView: chartView) - let startedAt = Date() let controller = NSHostingController(rootView: chartView) let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) - AgentDebugLogger.log( - "0.20 cost history submenu rendered", - hypothesisId: "N", - location: "StatusItemController+HostedSubmenus.swift:appendCostHistoryChartItem", - data: [ - "provider": provider.rawValue, - "days": String(tokenSnapshot.daily.count), - "height": String(Int(size.height)), - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - ]) let chartItem = NSMenuItem() chartItem.view = hosting diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 1a21f3f35..88055ad39 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -36,14 +36,6 @@ extension StatusItemController { self.hydrateHostedSubviewMenuIfNeeded(menu) self.refreshHostedSubviewHeights(in: menu) if Self.menuRefreshEnabled, self.isOpenAIWebSubviewMenu(menu) { - AgentDebugLogger.log( - "0.20 OpenAI web submenu opened", - hypothesisId: "N", - location: "StatusItemController+Menu.swift:menuWillOpen", - data: [ - "menuItems": String(menu.items.count), - "storeRefreshing": self.store.isRefreshing ? "1" : "0", - ]) self.store.requestOpenAIDashboardRefreshIfStale(reason: "submenu open") } self.openMenus[ObjectIdentifier(menu)] = menu @@ -76,19 +68,7 @@ extension StatusItemController { self.markMenuFresh(menu) // Heights are already set during populateMenu, no need to remeasure } - AgentDebugLogger.log( - "0.20 top-level menu opened", - hypothesisId: "L", - location: "StatusItemController+Menu.swift:menuWillOpen", - data: [ - "provider": provider?.rawValue ?? "overview", - "didRefresh": didRefresh ? "1" : "0", - "menuItems": String(menu.items.count), - "storeRefreshing": self.store.isRefreshing ? "1" : "0", - "menuRefreshEnabled": Self.menuRefreshEnabled ? "1" : "0", - ]) self.openMenus[ObjectIdentifier(menu)] = menu - self.logOpenMenuStructure(menu, provider: provider) // Only schedule refresh after menu is registered as open - refreshNow is called async if Self.menuRefreshEnabled { self.scheduleOpenMenuRefresh(for: menu) @@ -121,7 +101,6 @@ extension StatusItemController { } private func populateMenu(_ menu: NSMenu, provider: UsageProvider?) { - let startedAt = Date() let enabledProviders = self.store.enabledProvidersForDisplay() let includesOverview = self.includesOverviewTab(enabledProviders: enabledProviders) let switcherSelection = self.shouldMergeIcons && enabledProviders.count > 1 @@ -144,13 +123,15 @@ extension StatusItemController { currentProvider: currentProvider, showAllTokenAccounts: showAllTokenAccounts) - let hasAuxiliarySwitcher = menu.items.contains { - $0.view is TokenAccountSwitcherView || $0.view is CodexAccountSwitcherView - } + let hasTokenSwitcher = menu.items.contains { $0.view is TokenAccountSwitcherView } + let hasCodexSwitcher = menu.items.contains { $0.view is CodexAccountSwitcherView } let switcherProvidersMatch = enabledProviders == self.lastSwitcherProviders let switcherUsageBarsShowUsedMatch = self.settings.usageBarsShowUsed == self.lastSwitcherUsageBarsShowUsed let switcherSelectionMatches = switcherSelection == self.lastMergedSwitcherSelection let switcherOverviewAvailabilityMatches = includesOverview == self.lastSwitcherIncludesOverview + let tokenSwitcherCompatible = tokenAccountDisplay == nil && !hasTokenSwitcher + let codexSwitcherCompatible = codexAccountDisplay == self.lastCodexAccountMenuDisplay && + ((codexAccountDisplay == nil && !hasCodexSwitcher) || (codexAccountDisplay != nil && hasCodexSwitcher)) let canSmartUpdate = self.shouldMergeIcons && enabledProviders.count > 1 && !isOverviewSelected && @@ -158,9 +139,8 @@ extension StatusItemController { switcherUsageBarsShowUsedMatch && switcherSelectionMatches && switcherOverviewAvailabilityMatches && - codexAccountDisplay == nil && - tokenAccountDisplay == nil && - !hasAuxiliarySwitcher && + tokenSwitcherCompatible && + codexSwitcherCompatible && !menu.items.isEmpty && menu.items.first?.view is ProviderSwitcherView @@ -171,17 +151,6 @@ extension StatusItemController { currentProvider: currentProvider, menuWidth: menuWidth, openAIContext: openAIContext) - AgentDebugLogger.log( - "0.20 menu populated using smart update", - hypothesisId: "P", - location: "StatusItemController+Menu.swift:populateMenu", - data: [ - "provider": currentProvider.rawValue, - "menuItems": String(menu.items.count), - "enabledProviders": String(enabledProviders.count), - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - "hasOpenAIWebItems": openAIContext.hasOpenAIWebMenuItems ? "1" : "0", - ]) return } @@ -210,6 +179,7 @@ extension StatusItemController { self.lastSwitcherIncludesOverview = includesOverview } self.addCodexAccountSwitcherIfNeeded(to: menu, display: codexAccountDisplay) + self.lastCodexAccountMenuDisplay = codexAccountDisplay self.addTokenAccountSwitcherIfNeeded(to: menu, display: tokenAccountDisplay) let menuContext = MenuCardContext( currentProvider: currentProvider, @@ -240,29 +210,6 @@ extension StatusItemController { } } self.addActionableSections(descriptor.sections, to: menu, width: menuWidth) - let cardMode = if isOverviewSelected { - "overview" - } else if tokenAccountDisplay?.showAll == true { - "token-accounts" - } else if openAIContext.hasOpenAIWebMenuItems { - "segmented-openai" - } else { - "single-card" - } - AgentDebugLogger.log( - "0.20 menu populated", - hypothesisId: "P", - location: "StatusItemController+Menu.swift:populateMenu", - data: [ - "provider": currentProvider.rawValue, - "cardMode": cardMode, - "menuItems": String(menu.items.count), - "enabledProviders": String(enabledProviders.count), - "hasUsageBreakdown": openAIContext.hasUsageBreakdown ? "1" : "0", - "hasCreditsHistory": openAIContext.hasCreditsHistory ? "1" : "0", - "hasCostHistory": openAIContext.hasCostHistory ? "1" : "0", - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - ]) } /// Smart update: only rebuild content sections when switching providers (keep the switcher intact). @@ -282,6 +229,11 @@ extension StatusItemController { if menu.items.first?.view is ProviderSwitcherView { contentStartIndex = 2 } + if menu.items.count > contentStartIndex, + menu.items[contentStartIndex].view is CodexAccountSwitcherView + { + contentStartIndex += 2 + } if menu.items.count > contentStartIndex, menu.items[contentStartIndex].view is TokenAccountSwitcherView { @@ -910,7 +862,11 @@ extension StatusItemController { guard !Task.isCancelled else { return } guard self.openMenus[ObjectIdentifier(menu)] != nil else { return } guard !self.store.isRefreshing else { return } - guard self.menuNeedsDelayedRefreshRetry(for: menu) else { return } + let retryProviders = self.delayedRefreshRetryProviders(for: menu) + let retryStaleProviderCount = retryProviders.count { self.store.isStale(provider: $0) } + let retryMissingSnapshotCount = retryProviders.count { self.store.snapshot(for: $0) == nil } + let willRetryRefresh = retryStaleProviderCount > 0 || retryMissingSnapshotCount > 0 + guard willRetryRefresh else { return } self.refreshStore(forceTokenUsage: false) } } diff --git a/Sources/CodexBar/StatusItemController+MenuDebug.swift b/Sources/CodexBar/StatusItemController+MenuDebug.swift deleted file mode 100644 index 4e3fb2ba8..000000000 --- a/Sources/CodexBar/StatusItemController+MenuDebug.swift +++ /dev/null @@ -1,104 +0,0 @@ -import AppKit -import CodexBarCore - -extension StatusItemController { - private struct MenuStructureSummary { - let itemCount: Int - var viewBackedItems = 0 - var menuCardItems = 0 - var switcherItems = 0 - var submenuItems = 0 - var chartSubviewMenus = 0 - var totalViews = 0 - var hostingViews = 0 - var buttonViews = 0 - var layerBackedViews = 0 - } - - func logOpenMenuStructure(_ menu: NSMenu, provider: UsageProvider?) { - Task { @MainActor [weak self, weak menu] in - guard let self, let menu else { return } - await Task.yield() - guard self.openMenus[ObjectIdentifier(menu)] != nil else { return } - let summary = self.menuStructureSummary(for: menu) - AgentDebugLogger.log( - "0.20 top-level menu structure", - hypothesisId: "Q", - location: "StatusItemController+MenuDebug.swift:logOpenMenuStructure", - data: [ - "provider": provider?.rawValue ?? "overview", - "itemCount": String(summary.itemCount), - "viewBackedItems": String(summary.viewBackedItems), - "menuCardItems": String(summary.menuCardItems), - "switcherItems": String(summary.switcherItems), - "submenuItems": String(summary.submenuItems), - "chartSubviewMenus": String(summary.chartSubviewMenus), - "totalViews": String(summary.totalViews), - "hostingViews": String(summary.hostingViews), - "buttonViews": String(summary.buttonViews), - "layerBackedViews": String(summary.layerBackedViews), - "storeRefreshing": self.store.isRefreshing ? "1" : "0", - ]) - } - } - - private func menuStructureSummary(for menu: NSMenu) -> MenuStructureSummary { - var summary = MenuStructureSummary(itemCount: menu.items.count) - for item in menu.items { - if let represented = item.representedObject as? String, - represented.hasPrefix("menuCard") - { - summary.menuCardItems += 1 - } - if item.view != nil { - summary.viewBackedItems += 1 - } - if item.view is ProviderSwitcherView || - item.view is TokenAccountSwitcherView || - item.view is CodexAccountSwitcherView - { - summary.switcherItems += 1 - } - if let submenu = item.submenu { - summary.submenuItems += 1 - if self.isChartSubviewMenu(submenu) { - summary.chartSubviewMenus += 1 - } - } - if let view = item.view { - self.accumulateMenuViewSummary(from: view, into: &summary) - } - } - return summary - } - - private func accumulateMenuViewSummary(from view: NSView, into summary: inout MenuStructureSummary) { - summary.totalViews += 1 - let typeName = String(describing: type(of: view)) - if typeName.contains("HostingView") { - summary.hostingViews += 1 - } - if view is NSButton { - summary.buttonViews += 1 - } - if view.wantsLayer || view.layer != nil { - summary.layerBackedViews += 1 - } - for subview in view.subviews { - self.accumulateMenuViewSummary(from: subview, into: &summary) - } - } - - private func isChartSubviewMenu(_ menu: NSMenu) -> Bool { - let ids: Set = [ - Self.usageBreakdownChartID, - Self.creditsHistoryChartID, - Self.costHistoryChartID, - Self.usageHistoryChartID, - ] - return menu.items.contains { item in - guard let id = item.representedObject as? String else { return false } - return ids.contains(id) - } - } -} diff --git a/Sources/CodexBar/StatusItemController+MenuTypes.swift b/Sources/CodexBar/StatusItemController+MenuTypes.swift index 18243f3c1..3e8c6c3cc 100644 --- a/Sources/CodexBar/StatusItemController+MenuTypes.swift +++ b/Sources/CodexBar/StatusItemController+MenuTypes.swift @@ -55,7 +55,7 @@ struct TokenAccountMenuDisplay { let showSwitcher: Bool } -struct CodexAccountMenuDisplay { +struct CodexAccountMenuDisplay: Equatable { let accounts: [CodexVisibleAccount] let activeVisibleAccountID: String? } diff --git a/Sources/CodexBar/StatusItemController+SwitcherViews.swift b/Sources/CodexBar/StatusItemController+SwitcherViews.swift index 7bd0ea512..74bf8fae7 100644 --- a/Sources/CodexBar/StatusItemController+SwitcherViews.swift +++ b/Sources/CodexBar/StatusItemController+SwitcherViews.swift @@ -942,7 +942,6 @@ final class CodexAccountSwitcherView: NSView { let second = Array(self.accounts.dropFirst(perRow)) return [first, second] }() - let stack = NSStackView() stack.orientation = .vertical stack.alignment = .centerX @@ -959,8 +958,9 @@ final class CodexAccountSwitcherView: NSView { let buttonWidth = self.buttonWidth(for: rowAccounts.count) for account in rowAccounts { + let title = self.compactButtonTitle(for: account, buttonWidth: buttonWidth) let button = PaddedToggleButton( - title: self.compactButtonTitle(for: account, buttonWidth: buttonWidth), + title: title, target: self, action: #selector(self.handleSelect)) button.identifier = NSUserInterfaceItemIdentifier(account.id) diff --git a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift index f9de969ac..a2d5b9600 100644 --- a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift +++ b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift @@ -43,7 +43,6 @@ extension StatusItemController { provider: UsageProvider, width: CGFloat) -> Bool { - let startedAt = Date() let histories = self.store.planUtilizationHistory(for: provider) let snapshot = self.store.snapshot(for: provider) @@ -64,16 +63,6 @@ extension StatusItemController { let controller = NSHostingController(rootView: chartView) let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) - AgentDebugLogger.log( - "0.20 usage history submenu rendered", - hypothesisId: "T", - location: "StatusItemController+UsageHistoryMenu.swift:appendUsageHistoryChartItem", - data: [ - "provider": provider.rawValue, - "seriesCount": String(histories.count), - "height": String(Int(size.height)), - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - ]) let chartItem = NSMenuItem() chartItem.view = hosting diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index f0384b3a7..b77865f96 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -120,6 +120,9 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var lastSwitcherProviders: [UsageProvider] = [] /// Tracks which switcher tab state was used for the current merged-menu switcher instance. var lastMergedSwitcherSelection: ProviderSwitcherSelection? + /// Tracks the visible Codex account switcher contents for merged-menu smart updates. + var lastCodexAccountMenuDisplay: CodexAccountMenuDisplay? + var lastAppliedMergedIconRenderSignature: String? let loginLogger = CodexBarLog.logger(LogCategories.login) var selectedMenuProvider: UsageProvider? { get { self.settings.selectedMenuProvider } @@ -279,6 +282,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin private func wireBindings() { self.observeStoreChanges() + self.observeStoreIconChanges() self.observeDebugForceAnimation() self.observeSettingsChanges() self.observeUpdaterChanges() @@ -293,8 +297,18 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin guard let self else { return } self.observeStoreChanges() self.invalidateMenus() + } + } + } + + private func observeStoreIconChanges() { + withObservationTracking { + _ = self.store.iconObservationToken + } onChange: { [weak self] in + Task { @MainActor [weak self] in + guard let self else { return } + self.observeStoreIconChanges() self.updateIcons() - self.updateBlinkingState() } } } @@ -431,12 +445,17 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } private func updateIcons() { - let startedAt = Date() // Avoid flicker: when an animation driver is active, store updates can call `updateIcons()` and // briefly overwrite the animated frame with the static (phase=nil) icon. let phase: Double? = self.needsMenuBarIconAnimation() ? self.animationPhase : nil if self.shouldMergeIcons { - self.applyIcon(phase: phase) + let skippedMergedRender = self.applyIcon(phase: phase) + if skippedMergedRender, + let mergedMenu = self.mergedMenu, + self.statusItem.menu === mergedMenu + { + return + } self.attachMenus() } else { UsageProvider.allCases.forEach { self.applyIcon(for: $0, phase: phase) } @@ -444,19 +463,6 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } self.updateAnimationState() self.updateBlinkingState() - if !self.openMenus.isEmpty || self.store.isRefreshing { - AgentDebugLogger.log( - "0.20 updateIcons completed during active menu/refresh", - hypothesisId: "S", - location: "StatusItemController.swift:updateIcons", - data: [ - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - "openMenus": String(self.openMenus.count), - "storeRefreshing": self.store.isRefreshing ? "1" : "0", - "shouldMergeIcons": self.shouldMergeIcons ? "1" : "0", - "needsAnimation": self.needsMenuBarIconAnimation() ? "1" : "0", - ]) - } } /// Lazily retrieves or creates a status item for the given provider diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index fa436df14..88851717e 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -67,18 +67,6 @@ extension UsageStore { "batterySaverEnabled": self.settings.openAIWebBatterySaverEnabled ? "1" : "0", "interaction": ProviderInteractionContext.current == .userInitiated ? "user" : "background", ]) - AgentDebugLogger.log( - "0.20 stale OpenAI web request evaluated", - hypothesisId: "C", - location: "UsageStore+OpenAIWeb.swift:requestOpenAIDashboardRefreshIfStale", - data: [ - "reason": reason, - "force": forceRefresh ? "1" : "0", - "openAIWebAccessEnabled": self.settings.openAIWebAccessEnabled ? "1" : "0", - "batterySaverEnabled": self.settings.openAIWebBatterySaverEnabled ? "1" : "0", - "refreshIntervalSeconds": String(Int(refreshInterval)), - "lastUpdatedAgeSeconds": lastUpdatedAt.map { String(Int(now.timeIntervalSince($0))) } ?? "none", - ]) let expectedGuard = self.currentCodexOpenAIWebRefreshGuard() Task { await self.refreshOpenAIDashboardIfNeeded(force: forceRefresh, expectedGuard: expectedGuard) } } @@ -112,14 +100,6 @@ extension UsageStore { attachedAccountEmail: attachedAccountEmail, allowCodexUsageBackfill: allowCodexUsageBackfill) OpenAIDashboardFetcher.evictCachedWebView(accountEmail: targetEmail) - AgentDebugLogger.log( - "0.20 evicts cached OpenAI webview after successful dashboard fetch", - hypothesisId: "K", - location: "UsageStore+OpenAIWeb.swift:applyOpenAIDashboard", - data: [ - "targetEmailKnown": targetEmail == nil ? "0" : "1", - "attachedEmailKnown": attachedAccountEmail == nil ? "0" : "1", - ]) } func applyOpenAIDashboardFailure( @@ -394,26 +374,6 @@ extension UsageStore { let now = Date() let minInterval = self.openAIWebRefreshIntervalSeconds() - let snapshotAgeSeconds = self.lastOpenAIDashboardSnapshot.map { Int(now.timeIntervalSince($0.updatedAt)) } - let willSkipBecauseSnapshotIsFresh = !force && - !self.openAIWebAccountDidChange && - self.lastOpenAIDashboardError == nil && - snapshotAgeSeconds != nil && - TimeInterval(snapshotAgeSeconds ?? 0) < minInterval - AgentDebugLogger.log( - "0.20 OpenAI web refresh gate evaluated", - hypothesisId: "C", - location: "UsageStore+OpenAIWeb.swift:refreshOpenAIDashboardIfNeeded", - data: [ - "force": force ? "1" : "0", - "openAIWebAccessEnabled": self.settings.openAIWebAccessEnabled ? "1" : "0", - "accountDidChange": self.openAIWebAccountDidChange ? "1" : "0", - "hasLastError": self.lastOpenAIDashboardError == nil ? "0" : "1", - "snapshotAgeSeconds": snapshotAgeSeconds.map(String.init) ?? "none", - "refreshIntervalSeconds": String(Int(minInterval)), - "willSkipBecauseSnapshotIsFresh": willSkipBecauseSnapshotIsFresh ? "1" : "0", - "targetEmailKnown": targetEmail == nil ? "0" : "1", - ]) let refreshGate = OpenAIWebRefreshGateContext( force: force, accountDidChange: self.openAIWebAccountDidChange, @@ -423,19 +383,6 @@ extension UsageStore { now: now, refreshInterval: minInterval) if Self.shouldSkipOpenAIWebRefresh(refreshGate) { - if let lastAttemptAt = self.lastOpenAIDashboardAttemptAt, - now.timeIntervalSince(lastAttemptAt) < minInterval - { - AgentDebugLogger.log( - "0.20 OpenAI web refresh skipped because recent attempt is still within gate", - hypothesisId: "C", - location: "UsageStore+OpenAIWeb.swift:refreshOpenAIDashboardIfNeeded", - data: [ - "secondsSinceLastAttempt": String(Int(now.timeIntervalSince(lastAttemptAt))), - "refreshIntervalSeconds": String(Int(minInterval)), - "hasLastError": self.lastOpenAIDashboardError == nil ? "0" : "1", - ]) - } return } self.lastOpenAIDashboardAttemptAt = now @@ -566,14 +513,6 @@ extension UsageStore { latestCookieImportStatus: inout String?, logger: @escaping (String) -> Void) async { - AgentDebugLogger.log( - "0.20 OpenAI web refresh retried after missing dashboard data", - hypothesisId: "I", - location: "UsageStore+OpenAIWeb.swift:retryOpenAIDashboardAfterNoData", - data: [ - "bodyPresent": body.isEmpty ? "0" : "1", - "targetEmailKnown": context.targetEmail == nil ? "0" : "1", - ]) let targetEmail = self.currentCodexOpenAIWebTargetEmail( allowCurrentSnapshotFallback: context.allowCurrentSnapshotFallback, allowLastKnownLiveFallback: context.expectedGuard?.identity != .unresolved) @@ -633,14 +572,6 @@ extension UsageStore { latestCookieImportStatus: inout String?, logger: @escaping (String) -> Void) async { - AgentDebugLogger.log( - "0.20 OpenAI web refresh retried after login-required result", - hypothesisId: "I", - location: "UsageStore+OpenAIWeb.swift:retryOpenAIDashboardAfterLoginRequired", - data: [ - "targetEmailKnown": context.targetEmail == nil ? "0" : "1", - "cookieStatusKnown": latestCookieImportStatus == nil ? "0" : "1", - ]) let targetEmail = self.currentCodexOpenAIWebTargetEmail( allowCurrentSnapshotFallback: context.allowCurrentSnapshotFallback, allowLastKnownLiveFallback: context.expectedGuard?.identity != .unresolved) diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 1722ccb41..a679ebc17 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -22,8 +22,6 @@ extension UsageStore { _ = self.openAIDashboard _ = self.lastOpenAIDashboardError _ = self.openAIDashboardRequiresLogin - _ = self.openAIDashboardCookieImportStatus - _ = self.openAIDashboardCookieImportDebugLog _ = self.versions _ = self.isRefreshing _ = self.refreshingProviders @@ -34,6 +32,21 @@ extension UsageStore { return 0 } + var iconObservationToken: Int { + _ = self.snapshots + _ = self.errors + _ = self.credits + _ = self.lastCreditsError + _ = self.openAIDashboard + _ = self.lastOpenAIDashboardError + _ = self.openAIDashboardRequiresLogin + _ = self.isRefreshing + _ = self.refreshingProviders + _ = self.statuses + _ = self.historicalPaceRevision + return 0 + } + func observeSettingsChanges() { withObservationTracking { _ = self.settings.refreshFrequency @@ -447,19 +460,6 @@ final class UsageStore { let refreshStartedAt = Date() await ProviderRefreshContext.$current.withValue(refreshPhase) { - AgentDebugLogger.log( - "0.20 refresh loop scope", - hypothesisId: "D", - location: "UsageStore.swift:refresh", - data: [ - "phase": refreshPhase == .startup ? "startup" : "regular", - "enabledProviders": String(refreshProviders.count), - "displayEnabledProviders": String(displayEnabledProviders.count), - "allProviders": String(UsageProvider.allCases.count), - "statusChecksEnabled": self.settings.statusChecksEnabled ? "1" : "0", - "forceTokenUsage": forceTokenUsage ? "1" : "0", - "openAIWebAccessEnabled": self.settings.openAIWebAccessEnabled ? "1" : "0", - ]) self.isRefreshing = true defer { self.isRefreshing = false @@ -499,17 +499,6 @@ final class UsageStore { "interaction": ProviderInteractionContext.current == .userInitiated ? "user" : "background", "phase": refreshPhase == .startup ? "startup" : "regular", ]) - AgentDebugLogger.log( - "0.20 main OpenAI web refresh policy evaluated", - hypothesisId: "C", - location: "UsageStore.swift:refresh", - data: [ - "allowed": shouldRefreshOpenAIWeb ? "1" : "0", - "accessEnabled": refreshPolicy.accessEnabled ? "1" : "0", - "batterySaverEnabled": refreshPolicy.batterySaverEnabled ? "1" : "0", - "force": refreshPolicy.force ? "1" : "0", - "phase": refreshPhase == .startup ? "startup" : "regular", - ]) if shouldRefreshOpenAIWeb { let codexDashboardGuard = self.currentCodexOpenAIWebRefreshGuard() await self.refreshOpenAIDashboardIfNeeded( @@ -1214,14 +1203,6 @@ extension UsageStore { let providerText = provider.rawValue self.tokenCostLogger .debug("cost usage start provider=\(providerText) force=\(force)") - AgentDebugLogger.log( - "0.20 token usage refresh started", - hypothesisId: "G", - location: "UsageStore.swift:refreshTokenUsage", - data: [ - "provider": providerText, - "force": force ? "1" : "0", - ]) do { let fetcher = self.costUsageFetcher @@ -1268,15 +1249,6 @@ extension UsageStore { self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.recordSuccess() self.persistWidgetSnapshot(reason: "token-usage") - AgentDebugLogger.log( - "0.20 token usage refresh succeeded", - hypothesisId: "G", - location: "UsageStore.swift:refreshTokenUsage", - data: [ - "provider": providerText, - "durationMs": String(Int(duration * 1000)), - "dailyEntries": String(snapshot.daily.count), - ]) } catch { if error is CancellationError { return } let duration = Date().timeIntervalSince(startedAt) @@ -1293,15 +1265,6 @@ extension UsageStore { } else { self.tokenErrors[provider] = nil } - AgentDebugLogger.log( - "0.20 token usage refresh failed", - hypothesisId: "G", - location: "UsageStore.swift:refreshTokenUsage", - data: [ - "provider": providerText, - "durationMs": String(Int(duration * 1000)), - "error": String(describing: error), - ]) } } } diff --git a/Sources/CodexBarCore/Logging/AgentDebugLogger.swift b/Sources/CodexBarCore/Logging/AgentDebugLogger.swift deleted file mode 100644 index e499d6e6d..000000000 --- a/Sources/CodexBarCore/Logging/AgentDebugLogger.swift +++ /dev/null @@ -1,47 +0,0 @@ -import Foundation - -public enum AgentDebugLogger { - private static let lock = NSLock() - private static let logURL = URL( - fileURLWithPath: "/Users/ratulsarna/Developer/staipete/CodexBar/.cursor/debug-4f7ebf.log") - private static let sessionID = "4f7ebf" - - public static func log( - _ message: String, - hypothesisId: String, - location: String, - runId: String = "baseline", - data: [String: String] = [:]) - { - let payload: [String: Any] = [ - "sessionId": self.sessionID, - "runId": runId, - "hypothesisId": hypothesisId, - "location": location, - "message": message, - "data": data, - "timestamp": Int(Date().timeIntervalSince1970 * 1000), - ] - guard JSONSerialization.isValidJSONObject(payload), - let raw = try? JSONSerialization.data(withJSONObject: payload), - var line = String(data: raw, encoding: .utf8) - else { - return - } - line.append("\n") - guard let encoded = line.data(using: .utf8) else { return } - - self.lock.lock() - defer { self.lock.unlock() } - - if FileManager.default.fileExists(atPath: self.logURL.path), - let handle = try? FileHandle(forWritingTo: self.logURL) - { - defer { try? handle.close() } - _ = try? handle.seekToEnd() - try? handle.write(contentsOf: encoded) - } else { - try? encoded.write(to: self.logURL, options: .atomic) - } - } -} diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift index 7972cab81..d83ba7a72 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift @@ -42,28 +42,6 @@ public struct OpenAIDashboardFetcher { 0.001 } - private nonisolated static func logDashboardEvent( - _ message: String, - data: [String: String]) - { - AgentDebugLogger.log( - message, - hypothesisId: "J", - location: "OpenAIDashboardFetcher.swift:loadLatestDashboard", - data: data) - } - - private struct DashboardFetchTrace { - let startedAt: Date - let timeout: TimeInterval - var scrapeIterations = 0 - var routeReloadCount = 0 - var workspaceWaitCount = 0 - var creditsScrollWaitCount = 0 - var creditsHydrationWaitCount = 0 - var breakdownHydrationWaitCount = 0 - } - private struct DashboardSnapshotComponents { let scrape: ScrapeResult let codeReview: Double? @@ -76,29 +54,6 @@ public struct OpenAIDashboardFetcher { let accountPlan: String? } - private nonisolated static func emitDashboardSummary( - message: String, - trace: DashboardFetchTrace, - anyDashboardSignalAt: Date?, - extra: [String: String] = [:]) - { - var data: [String: String] = [ - "durationMs": String(Int(Date().timeIntervalSince(trace.startedAt) * 1000)), - "timeoutSeconds": String(Int(trace.timeout)), - "iterations": String(trace.scrapeIterations), - "routeReloads": String(trace.routeReloadCount), - "workspaceWaits": String(trace.workspaceWaitCount), - "creditsScrollWaits": String(trace.creditsScrollWaitCount), - "creditsHydrationWaits": String(trace.creditsHydrationWaitCount), - "breakdownHydrationWaits": String(trace.breakdownHydrationWaitCount), - "hadDashboardSignal": anyDashboardSignalAt == nil ? "0" : "1", - ] - for (key, value) in extra { - data[key] = value - } - Self.logDashboardEvent(message, data: data) - } - private nonisolated static func makeDashboardSnapshot(_ components: DashboardSnapshotComponents) -> OpenAIDashboardSnapshot { @@ -156,14 +111,12 @@ public struct OpenAIDashboardFetcher { timeout: timeout) } - // swiftlint:disable function_body_length public func loadLatestDashboard( websiteDataStore: WKWebsiteDataStore, logger: ((String) -> Void)? = nil, debugDumpHTML: Bool = false, timeout: TimeInterval = 60) async throws -> OpenAIDashboardSnapshot { - var trace = DashboardFetchTrace(startedAt: Date(), timeout: timeout) let deadline = Self.deadline(startingAt: Date(), timeout: timeout) let lease = try await self.makeWebView( websiteDataStore: websiteDataStore, @@ -183,7 +136,6 @@ public struct OpenAIDashboardFetcher { var lastUsageBreakdownDebug: String? var lastCreditsPurchaseURL: String? while Date() < deadline { - trace.scrapeIterations += 1 let scrape = try await self.scrape(webView: webView) lastBody = scrape.bodyText ?? lastBody lastHTML = scrape.bodyHTML ?? lastHTML @@ -202,14 +154,12 @@ public struct OpenAIDashboardFetcher { } if scrape.workspacePicker { - trace.workspaceWaitCount += 1 try? await Task.sleep(for: .milliseconds(500)) continue } // The page is a SPA and can land on ChatGPT UI or other routes; keep forcing the usage URL. if let href = scrape.href, !Self.isUsageRoute(href) { - trace.routeReloadCount += 1 _ = webView.load(URLRequest(url: self.usageURL)) try? await Task.sleep(for: .milliseconds(500)) continue @@ -219,14 +169,6 @@ public struct OpenAIDashboardFetcher { if debugDumpHTML, let html = scrape.bodyHTML { Self.writeDebugArtifacts(html: html, bodyText: scrape.bodyText, logger: log) } - Self.emitDashboardSummary( - message: "0.20 OpenAI dashboard fetch returned login-required", - trace: trace, - anyDashboardSignalAt: anyDashboardSignalAt, - extra: [ - "cloudflare": scrape.cloudflareInterstitial ? "1" : "0", - "workspacePicker": scrape.workspacePicker ? "1" : "0", - ]) throw FetchError.loginRequired } @@ -234,10 +176,6 @@ public struct OpenAIDashboardFetcher { if debugDumpHTML, let html = scrape.bodyHTML { Self.writeDebugArtifacts(html: html, bodyText: scrape.bodyText, logger: log) } - Self.emitDashboardSummary( - message: "0.20 OpenAI dashboard fetch hit Cloudflare interstitial", - trace: trace, - anyDashboardSignalAt: anyDashboardSignalAt) throw FetchError.noDashboardData(body: "Cloudflare challenge detected in WebView.") } @@ -278,7 +216,6 @@ public struct OpenAIDashboardFetcher { "inViewport=\(scrape.creditsHeaderInViewport) didScroll=\(scrape.didScrollToCredits) " + "rows=\(scrape.rows.count)") if scrape.didScrollToCredits { - trace.creditsScrollWaitCount += 1 log("scrollIntoView(Credits usage history) requested; waiting…") try? await Task.sleep(for: .milliseconds(600)) continue @@ -297,7 +234,6 @@ public struct OpenAIDashboardFetcher { creditsHeaderInViewport: scrape.creditsHeaderInViewport, didScrollToCredits: scrape.didScrollToCredits)) { - trace.creditsHydrationWaitCount += 1 try? await Task.sleep(for: .milliseconds(400)) continue } @@ -311,21 +247,10 @@ public struct OpenAIDashboardFetcher { if codeReview != nil, usageBreakdown.isEmpty { let elapsed = Date().timeIntervalSince(codeReviewFirstSeenAt ?? Date()) if elapsed < 6 { - trace.breakdownHydrationWaitCount += 1 try? await Task.sleep(for: .milliseconds(400)) continue } } - Self.emitDashboardSummary( - message: "0.20 OpenAI dashboard fetch succeeded", - trace: trace, - anyDashboardSignalAt: anyDashboardSignalAt, - extra: [ - "creditRows": String(events.count), - "usageBreakdownDays": String(usageBreakdown.count), - "hasRateLimits": hasUsageLimits ? "1" : "0", - "hasCreditsRemaining": creditsRemaining == nil ? "0" : "1", - ]) return Self.makeDashboardSnapshot(.init( scrape: scrape, codeReview: codeReview, @@ -344,19 +269,9 @@ public struct OpenAIDashboardFetcher { if debugDumpHTML, let html = lastHTML { Self.writeDebugArtifacts(html: html, bodyText: lastBody, logger: log) } - Self.emitDashboardSummary( - message: "0.20 OpenAI dashboard fetch exhausted timeout without data", - trace: trace, - anyDashboardSignalAt: anyDashboardSignalAt, - extra: [ - "lastBodyPresent": lastBody == nil ? "0" : "1", - "lastHrefKnown": lastHref == nil ? "0" : "1", - ]) throw FetchError.noDashboardData(body: lastBody ?? "") } - // swiftlint:enable function_body_length - struct CreditsHistoryWaitContext { let now: Date let anyDashboardSignalAt: Date? diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift index a88d93973..397e41138 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift @@ -34,30 +34,9 @@ final class OpenAIDashboardWebViewCache { private let idleTimeout: TimeInterval = 60 private let blankURL = URL(string: "about:blank")! - private func logCacheEvent( - _ message: String, - location: String, - data: [String: String]) - { - AgentDebugLogger.log( - message, - hypothesisId: "B", - location: location, - data: data) - } - private func releaseCachedEntry(_ entry: Entry) { entry.isBusy = false entry.lastUsedAt = Date() - self.logCacheEvent( - "0.20 releases cached OpenAI webview to idle state", - location: "OpenAIDashboardWebViewCache.swift:releaseCached", - data: [ - "currentURLHost": entry.webView.url?.host ?? "none", - "currentURLPath": entry.webView.url?.path ?? "", - "idleTimeoutSeconds": String(Int(self.idleTimeout)), - "entriesAfterRelease": String(self.entries.count), - ]) self.prepareCachedWebViewForIdle(entry.webView, host: entry.host) self.prune(now: Date()) } @@ -65,15 +44,6 @@ final class OpenAIDashboardWebViewCache { private func releaseNewEntry(_ entry: Entry, webView: WKWebView) { entry.isBusy = false entry.lastUsedAt = Date() - self.logCacheEvent( - "0.20 releases newly created OpenAI webview to idle state", - location: "OpenAIDashboardWebViewCache.swift:releaseNew", - data: [ - "currentURLHost": entry.webView.url?.host ?? "none", - "currentURLPath": entry.webView.url?.path ?? "", - "idleTimeoutSeconds": String(Int(self.idleTimeout)), - "entriesAfterRelease": String(self.entries.count), - ]) self.prepareCachedWebViewForIdle(webView, host: entry.host) self.prune(now: Date()) } @@ -173,13 +143,6 @@ final class OpenAIDashboardWebViewCache { try await self.prepareWebView(webView, usageURL: usageURL, timeout: remainingTimeout) } catch { if allowTimeoutRetry, Self.isPrepareTimeout(error) { - self.logCacheEvent( - "0.20 temporary OpenAI webview timed out during prepare", - location: "OpenAIDashboardWebViewCache.swift:acquireBusyRetry", - data: [ - "existingEntries": String(self.entries.count), - "remainingTimeoutMs": String(Int(remainingTimeout * 1000)), - ]) host.close() log("Temporary OpenAI WebView timed out; retrying with a fresh WebView.") return try await self.acquireTemporaryWebView( @@ -199,27 +162,11 @@ final class OpenAIDashboardWebViewCache { entry.isBusy = true entry.lastUsedAt = now - self.logCacheEvent( - "0.20 reuses cached OpenAI webview", - location: "OpenAIDashboardWebViewCache.swift:acquire", - data: [ - "existingEntries": String(self.entries.count), - "idleTimeoutSeconds": String(Int(self.idleTimeout)), - "usageURLHost": usageURL.host ?? "none", - "usageURLPath": usageURL.path, - ]) entry.host.show() do { try await self.prepareWebView(entry.webView, usageURL: usageURL, timeout: remainingTimeout) } catch { if allowTimeoutRetry, Self.isPrepareTimeout(error) { - self.logCacheEvent( - "0.20 cached OpenAI webview timed out during prepare", - location: "OpenAIDashboardWebViewCache.swift:acquireCachedRetry", - data: [ - "existingEntries": String(self.entries.count), - "remainingTimeoutMs": String(Int(remainingTimeout * 1000)), - ]) entry.isBusy = false entry.lastUsedAt = Date() entry.host.close() @@ -252,28 +199,12 @@ final class OpenAIDashboardWebViewCache { let (webView, host) = self.makeWebView(websiteDataStore: websiteDataStore) let entry = Entry(webView: webView, host: host, lastUsedAt: now, isBusy: true) self.entries[key] = entry - self.logCacheEvent( - "0.20 creates cached OpenAI webview", - location: "OpenAIDashboardWebViewCache.swift:createEntry", - data: [ - "existingEntries": String(self.entries.count), - "idleTimeoutSeconds": String(Int(self.idleTimeout)), - "usageURLHost": usageURL.host ?? "none", - "usageURLPath": usageURL.path, - ]) host.show() do { try await self.prepareWebView(webView, usageURL: usageURL, timeout: remainingTimeout) } catch { if allowTimeoutRetry, Self.isPrepareTimeout(error) { - self.logCacheEvent( - "0.20 newly created OpenAI webview timed out during prepare", - location: "OpenAIDashboardWebViewCache.swift:createEntryRetry", - data: [ - "existingEntries": String(self.entries.count), - "remainingTimeoutMs": String(Int(remainingTimeout * 1000)), - ]) self.entries.removeValue(forKey: key) host.close() log("OpenAI WebView timed out during prepare; retrying once.") @@ -309,15 +240,6 @@ final class OpenAIDashboardWebViewCache { func evictAll() { let existing = self.entries self.entries.removeAll() - if !existing.isEmpty { - self.logCacheEvent( - "0.20 evicts cached OpenAI webviews after refresh failure or reset", - location: "OpenAIDashboardWebViewCache.swift:evictAll", - data: [ - "evictedEntries": String(existing.count), - "idleTimeoutSeconds": String(Int(self.idleTimeout)), - ]) - } for (_, entry) in existing { entry.host.close() } @@ -341,13 +263,6 @@ final class OpenAIDashboardWebViewCache { !entry.isBusy && now.timeIntervalSince(entry.lastUsedAt) > self.idleTimeout } for (key, entry) in expired { - self.logCacheEvent( - "0.20 prunes cached OpenAI webview after shorter idle timeout", - location: "OpenAIDashboardWebViewCache.swift:prune", - data: [ - "idleTimeoutSeconds": String(Int(self.idleTimeout)), - "entriesBeforePrune": String(self.entries.count), - ]) entry.host.close() self.entries.removeValue(forKey: key) Self.log.debug("OpenAI webview pruned") diff --git a/Sources/CodexBarCore/PiSessionCostScanner.swift b/Sources/CodexBarCore/PiSessionCostScanner.swift index 635a1ef2c..49c9e6998 100644 --- a/Sources/CodexBarCore/PiSessionCostScanner.swift +++ b/Sources/CodexBarCore/PiSessionCostScanner.swift @@ -58,7 +58,6 @@ enum PiSessionCostScanner { || nowMs - cache.lastScanUnixMs > refreshMs if shouldRefresh { - let startedAt = Date() let root = self.defaultPiSessionsRoot(options: options) let startCutoff = self.dateFromDayKey(range.scanSinceKey) ?? since let files = self.listPiSessionFiles(root: root, startCutoffLocal: startCutoff) @@ -86,18 +85,6 @@ enum PiSessionCostScanner { cache.scanUntilKey = range.scanUntilKey cache.lastScanUnixMs = nowMs PiSessionCostCacheIO.save(cache: cache, cacheRoot: options.cacheRoot) - AgentDebugLogger.log( - "0.20 PI session cost scanner refreshed cache", - hypothesisId: "G", - location: "PiSessionCostScanner.swift:loadDailyReport", - data: [ - "provider": provider.rawValue, - "fileCount": String(files.count), - "cacheFiles": String(cache.files.count), - "forceRescan": options.forceRescan ? "1" : "0", - "windowExpanded": windowExpanded ? "1" : "0", - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - ]) } return self.buildReport(provider: provider, cache: cache, range: range) diff --git a/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift b/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift index 1baf368c0..13359aedb 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift @@ -76,7 +76,6 @@ public struct CodexStatusProbe { } public func fetch() async throws -> CodexStatusSnapshot { - let startedAt = Date() let env = self.environment let resolved = BinaryLocator.resolveCodexBinary(env: env, loginPATH: LoginShellPathCache.shared.current) ?? self.codexBinary @@ -84,64 +83,20 @@ public struct CodexStatusProbe { throw CodexStatusProbeError.codexNotInstalled } do { - let snapshot = try await self.runAndParse(binary: resolved, rows: 60, cols: 200, timeout: self.timeout) - AgentDebugLogger.log( - "0.20 Codex status probe completed on first attempt", - hypothesisId: "F", - location: "CodexStatusProbe.swift:fetch", - data: [ - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - "keepSessionAlive": self.keepCLISessionsAlive ? "1" : "0", - ]) - return snapshot + return try await self.runAndParse(binary: resolved, rows: 60, cols: 200, timeout: self.timeout) } catch let error as CodexStatusProbeError { // Retry only parser-level flakes with a short second attempt. switch error { case .parseFailed: - AgentDebugLogger.log( - "0.20 Codex status probe retried after parser failure", - hypothesisId: "F", - location: "CodexStatusProbe.swift:fetch", - data: [ - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - "keepSessionAlive": self.keepCLISessionsAlive ? "1" : "0", - ]) - let snapshot = try await self.runAndParse( + return try await self.runAndParse( binary: resolved, rows: 70, cols: 220, timeout: Self.parseRetryTimeoutSeconds) - AgentDebugLogger.log( - "0.20 Codex status probe completed after parser retry", - hypothesisId: "F", - location: "CodexStatusProbe.swift:fetch", - data: [ - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - "keepSessionAlive": self.keepCLISessionsAlive ? "1" : "0", - ]) - return snapshot default: - AgentDebugLogger.log( - "0.20 Codex status probe failed", - hypothesisId: "F", - location: "CodexStatusProbe.swift:fetch", - data: [ - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - "keepSessionAlive": self.keepCLISessionsAlive ? "1" : "0", - "error": String(describing: error), - ]) throw error } } catch { - AgentDebugLogger.log( - "0.20 Codex status probe failed", - hypothesisId: "F", - location: "CodexStatusProbe.swift:fetch", - data: [ - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - "keepSessionAlive": self.keepCLISessionsAlive ? "1" : "0", - "error": String(describing: error), - ]) throw error } } diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index c1c83355a..962dc956d 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -566,7 +566,6 @@ public struct UsageFetcher: Sendable { } private func loadRPCUsage() async throws -> UsageSnapshot { - let startedAt = Date() let rpc = try CodexRPCClient(environment: self.environment) defer { rpc.shutdown() } do { @@ -593,30 +592,13 @@ public struct UsageFetcher: Sendable { else { throw UsageError.noRateLimitsFound } - AgentDebugLogger.log( - "0.20 Codex RPC usage fetch succeeded", - hypothesisId: "E", - location: "UsageFetcher.swift:loadRPCUsage", - data: [ - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - "accountEmailKnown": identity.accountEmail == nil ? "0" : "1", - ]) return state.toUsageSnapshot() } catch { - AgentDebugLogger.log( - "0.20 Codex RPC usage fetch failed", - hypothesisId: "E", - location: "UsageFetcher.swift:loadRPCUsage", - data: [ - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - "error": String(describing: error), - ]) throw error } } private func loadTTYUsage(keepCLISessionsAlive: Bool) async throws -> UsageSnapshot { - let startedAt = Date() do { let status = try await CodexStatusProbe( keepCLISessionsAlive: keepCLISessionsAlive, @@ -637,27 +619,8 @@ public struct UsageFetcher: Sendable { else { throw UsageError.noRateLimitsFound } - AgentDebugLogger.log( - "0.20 Codex TTY usage fetch succeeded", - hypothesisId: "F", - location: "UsageFetcher.swift:loadTTYUsage", - data: [ - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - "keepSessionAlive": keepCLISessionsAlive ? "1" : "0", - "hasFiveHour": status.fiveHourPercentLeft == nil ? "0" : "1", - "hasWeekly": status.weeklyPercentLeft == nil ? "0" : "1", - ]) return state.toUsageSnapshot() } catch { - AgentDebugLogger.log( - "0.20 Codex TTY usage fetch failed", - hypothesisId: "F", - location: "UsageFetcher.swift:loadTTYUsage", - data: [ - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - "keepSessionAlive": keepCLISessionsAlive ? "1" : "0", - "error": String(describing: error), - ]) throw error } } @@ -669,7 +632,6 @@ public struct UsageFetcher: Sendable { } private func loadRPCCredits() async throws -> CreditsSnapshot { - let startedAt = Date() let rpc = try CodexRPCClient(environment: self.environment) defer { rpc.shutdown() } do { @@ -677,55 +639,21 @@ public struct UsageFetcher: Sendable { let limits = try await rpc.fetchRateLimits().rateLimits guard let credits = limits.credits else { throw UsageError.noRateLimitsFound } let remaining = Self.parseCredits(credits.balance) - AgentDebugLogger.log( - "0.20 Codex RPC credits fetch succeeded", - hypothesisId: "E", - location: "UsageFetcher.swift:loadRPCCredits", - data: [ - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - "creditsKnown": "1", - ]) return CreditsSnapshot(remaining: remaining, events: [], updatedAt: Date()) } catch { - AgentDebugLogger.log( - "0.20 Codex RPC credits fetch failed", - hypothesisId: "E", - location: "UsageFetcher.swift:loadRPCCredits", - data: [ - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - "error": String(describing: error), - ]) throw error } } private func loadTTYCredits(keepCLISessionsAlive: Bool) async throws -> CreditsSnapshot { - let startedAt = Date() do { let status = try await CodexStatusProbe( keepCLISessionsAlive: keepCLISessionsAlive, environment: self.environment) .fetch() guard let credits = status.credits else { throw UsageError.noRateLimitsFound } - AgentDebugLogger.log( - "0.20 Codex TTY credits fetch succeeded", - hypothesisId: "F", - location: "UsageFetcher.swift:loadTTYCredits", - data: [ - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - "keepSessionAlive": keepCLISessionsAlive ? "1" : "0", - ]) return CreditsSnapshot(remaining: credits, events: [], updatedAt: Date()) } catch { - AgentDebugLogger.log( - "0.20 Codex TTY credits fetch failed", - hypothesisId: "F", - location: "UsageFetcher.swift:loadTTYCredits", - data: [ - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - "keepSessionAlive": keepCLISessionsAlive ? "1" : "0", - "error": String(describing: error), - ]) throw error } } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 2e3f1c1a9..c1ea28ed4 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -542,7 +542,6 @@ extension CostUsageScanner { var touched: Set = [] if shouldRefresh { - let startedAt = Date() if options.forceRescan { cache = CostUsageCache() } @@ -566,18 +565,6 @@ extension CostUsageScanner { Self.pruneDays(cache: &cache, sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) cache.lastScanUnixMs = nowMs CostUsageCacheIO.save(provider: provider, cache: cache, cacheRoot: options.cacheRoot) - AgentDebugLogger.log( - "0.20 Claude local cost scanner refreshed cache", - hypothesisId: "G", - location: "CostUsageScanner+Claude.swift:loadClaudeDaily", - data: [ - "provider": provider.rawValue, - "rootCount": String(roots.count), - "touchedFiles": String(touched.count), - "cacheFiles": String(cache.files.count), - "forceRescan": options.forceRescan ? "1" : "0", - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - ]) } return Self.buildClaudeReportFromCache(cache: cache, range: range) diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index c344fa688..91de4e1ca 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -990,7 +990,6 @@ enum CostUsageScanner { || nowMs - cache.lastScanUnixMs > refreshMs if shouldRefresh { - let startedAt = Date() if options.forceRescan { cache = CostUsageCache() } @@ -1067,17 +1066,6 @@ enum CostUsageScanner { cache.roots = rootsFingerprint cache.lastScanUnixMs = nowMs CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: options.cacheRoot) - AgentDebugLogger.log( - "0.20 Codex local cost scanner refreshed cache", - hypothesisId: "G", - location: "CostUsageScanner.swift:loadCodexDaily", - data: [ - "fileCount": String(files.count), - "rootCount": String(roots.count), - "cacheFiles": String(cache.files.count), - "forceRescan": options.forceRescan ? "1" : "0", - "durationMs": String(Int(Date().timeIntervalSince(startedAt) * 1000)), - ]) } return Self.buildCodexReportFromCache(cache: cache, range: range) From ec7e8fa5d14c8109db5e3914774d12f8013783f2 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Mon, 13 Apr 2026 20:34:30 +0530 Subject: [PATCH 5/7] Clear stale snapshots for unavailable enabled providers --- Sources/CodexBar/UsageStore.swift | 10 ++++- .../UsageStoreCoverageTests.swift | 37 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index a679ebc17..fa172c228 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -346,7 +346,15 @@ final class UsageStore { /// Providers that should actually participate in background refresh/status/token work. func enabledProvidersForBackgroundWork() -> [UsageProvider] { - self.enabledProviders() + let availableProviders = Set(self.enabledProviders()) + return self.enabledProvidersForDisplay().filter { provider in + if availableProviders.contains(provider) { + return true + } + return self.snapshots[provider] != nil + || !(self.accountSnapshots[provider] ?? []).isEmpty + || self.tokenSnapshots[provider] != nil + } } var statusChecksEnabled: Bool { diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index 75213dfdc..768d38c73 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -228,6 +228,43 @@ struct UsageStoreCoverageTests { #expect(store.enabledProvidersForBackgroundWork().isEmpty) } + @Test + func unavailableProviderWithCachedSnapshotStaysEligibleForBackgroundCleanup() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-background-cleanup") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + + let metadata = ProviderRegistry.shared.metadata + for provider in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(metadata[provider]), + enabled: false) + } + try settings.setProviderEnabled( + provider: .synthetic, + metadata: #require(metadata[.synthetic]), + enabled: true) + + let store = Self.makeUsageStore(settings: settings) + let cachedSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store._setSnapshotForTesting(cachedSnapshot, provider: .synthetic) + + #expect(store.enabledProvidersForDisplay() == [.synthetic]) + #expect(store.enabledProviders().isEmpty) + #expect(store.enabledProvidersForBackgroundWork() == [.synthetic]) + + await store.refresh() + #expect(store.snapshot(for: .synthetic) != nil) + + await store.refresh() + #expect(store.snapshot(for: .synthetic) == nil) + #expect(store.error(for: .synthetic)?.isEmpty == false) + } + @Test func statusIndicatorsAndFailureGate() { #expect(!ProviderStatusIndicator.none.hasIssue) From b19c9132a98291a28107a1e326683159409924a2 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Mon, 13 Apr 2026 22:04:54 +0530 Subject: [PATCH 6/7] Clear unavailable provider state and preserve cached OpenAI web views --- .../PreferencesProviderDetailView.swift | 3 + .../StatusItemController+Animation.swift | 4 + Sources/CodexBar/UsageStore+Accessors.swift | 33 ++++- .../UsageStore+BackgroundRefresh.swift | 41 ++++--- Sources/CodexBar/UsageStore+OpenAIWeb.swift | 1 - Sources/CodexBar/UsageStore.swift | 18 ++- .../OpenAIWeb/OpenAIDashboardFetcher.swift | 5 - .../CodexManagedOpenAIWebTests.swift | 49 ++++++++ .../StatusItemAnimationSignatureTests.swift | 73 +++++++++++ .../StatusItemAnimationTests.swift | 10 +- .../UsageStoreCoverageTests.swift | 116 +++++++++++++++++- 11 files changed, 311 insertions(+), 42 deletions(-) create mode 100644 Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index edcc1d0dc..5ecff079d 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -314,6 +314,9 @@ private struct ProviderDetailInfoGrid: View { if self.store.refreshingProviders.contains(self.provider) { return "Refreshing" } + if self.store.unavailableMessage(for: self.provider) != nil { + return "Unavailable" + } return "Not fetched yet" } } diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 29c592a25..468f2d566 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -313,6 +313,7 @@ extension StatusItemController { let signature = [ "mode=brandPercent", "provider=\(primaryProvider.rawValue)", + "style=\(String(describing: style))", "primary=\(debugDouble(primary))", "weekly=\(debugDouble(weekly))", "credits=\(debugDouble(credits))", @@ -335,6 +336,7 @@ extension StatusItemController { let signature = [ "mode=openRouterFallback", "provider=\(primaryProvider.rawValue)", + "style=\(String(describing: style))", "primary=\(debugDouble(primary))", "weekly=\(debugDouble(weekly))", "credits=\(debugDouble(credits))", @@ -357,6 +359,7 @@ extension StatusItemController { let signature = [ "mode=morph", "provider=\(primaryProvider.rawValue)", + "style=\(String(describing: style))", "morph=\(debugDouble(morphProgress))", "status=\(statusIndicator.rawValue)", "anim=\(needsAnimation ? "1" : "0")", @@ -370,6 +373,7 @@ extension StatusItemController { let signature = [ "mode=icon", "provider=\(primaryProvider.rawValue)", + "style=\(String(describing: style))", "primary=\(debugDouble(primary))", "weekly=\(debugDouble(weekly))", "credits=\(debugDouble(credits))", diff --git a/Sources/CodexBar/UsageStore+Accessors.swift b/Sources/CodexBar/UsageStore+Accessors.swift index 7f8f828be..743f405e2 100644 --- a/Sources/CodexBar/UsageStore+Accessors.swift +++ b/Sources/CodexBar/UsageStore+Accessors.swift @@ -35,9 +35,36 @@ extension UsageStore { } func userFacingError(for provider: UsageProvider) -> String? { - let raw = self.errors[provider] - guard provider == .codex else { return raw } - return CodexUIErrorMapper.userFacingMessage(raw) + if let raw = self.errors[provider] { + guard provider == .codex else { return raw } + return CodexUIErrorMapper.userFacingMessage(raw) + } + return self.unavailableMessage(for: provider) + } + + func unavailableMessage(for provider: UsageProvider) -> String? { + guard self.enabledProvidersForDisplay().contains(provider), + !self.isProviderAvailable(provider) + else { + return nil + } + + switch provider { + case .synthetic: + return SyntheticSettingsError.missingToken.errorDescription + case .zai: + return ZaiSettingsError.missingToken.errorDescription + case .openrouter: + return OpenRouterSettingsError.missingToken.errorDescription + case .perplexity: + return PerplexityAPIError.missingToken.errorDescription + case .minimax: + return MiniMaxAPISettingsError.missingToken.errorDescription + case .kimi: + return KimiAPIError.missingToken.errorDescription + default: + return "\(self.metadata(for: provider).displayName) is unavailable in the current environment." + } } func status(for provider: UsageProvider) -> ProviderStatus? { diff --git a/Sources/CodexBar/UsageStore+BackgroundRefresh.swift b/Sources/CodexBar/UsageStore+BackgroundRefresh.swift index cfe602df3..46f142347 100644 --- a/Sources/CodexBar/UsageStore+BackgroundRefresh.swift +++ b/Sources/CodexBar/UsageStore+BackgroundRefresh.swift @@ -3,22 +3,35 @@ import Foundation @MainActor extension UsageStore { + private func clearProviderState(_ provider: UsageProvider) { + self.refreshingProviders.remove(provider) + self.snapshots.removeValue(forKey: provider) + self.errors[provider] = nil + self.lastSourceLabels.removeValue(forKey: provider) + self.lastFetchAttempts.removeValue(forKey: provider) + self.accountSnapshots.removeValue(forKey: provider) + self.tokenSnapshots.removeValue(forKey: provider) + self.tokenErrors[provider] = nil + self.failureGates[provider]?.reset() + self.tokenFailureGates[provider]?.reset() + self.statuses.removeValue(forKey: provider) + self.lastKnownSessionRemaining.removeValue(forKey: provider) + self.lastKnownSessionWindowSource.removeValue(forKey: provider) + self.lastTokenFetchAt.removeValue(forKey: provider) + } + func clearDisabledProviderState(enabledProviders: Set) { for provider in UsageProvider.allCases where !enabledProviders.contains(provider) { - self.refreshingProviders.remove(provider) - self.snapshots.removeValue(forKey: provider) - self.errors[provider] = nil - self.lastSourceLabels.removeValue(forKey: provider) - self.lastFetchAttempts.removeValue(forKey: provider) - self.accountSnapshots.removeValue(forKey: provider) - self.tokenSnapshots.removeValue(forKey: provider) - self.tokenErrors[provider] = nil - self.failureGates[provider]?.reset() - self.tokenFailureGates[provider]?.reset() - self.statuses.removeValue(forKey: provider) - self.lastKnownSessionRemaining.removeValue(forKey: provider) - self.lastKnownSessionWindowSource.removeValue(forKey: provider) - self.lastTokenFetchAt.removeValue(forKey: provider) + self.clearProviderState(provider) + } + } + + func clearUnavailableProviderState( + displayEnabledProviders: Set, + availableProviders: Set) + { + for provider in displayEnabledProviders where !availableProviders.contains(provider) { + self.clearProviderState(provider) } } } diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index 88851717e..08a15e4a6 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -99,7 +99,6 @@ extension UsageStore { authorityInput: authority.input, attachedAccountEmail: attachedAccountEmail, allowCodexUsageBackfill: allowCodexUsageBackfill) - OpenAIDashboardFetcher.evictCachedWebView(accountEmail: targetEmail) } func applyOpenAIDashboardFailure( diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index fa172c228..354147954 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -346,15 +346,7 @@ final class UsageStore { /// Providers that should actually participate in background refresh/status/token work. func enabledProvidersForBackgroundWork() -> [UsageProvider] { - let availableProviders = Set(self.enabledProviders()) - return self.enabledProvidersForDisplay().filter { provider in - if availableProviders.contains(provider) { - return true - } - return self.snapshots[provider] != nil - || !(self.accountSnapshots[provider] ?? []).isEmpty - || self.tokenSnapshots[provider] != nil - } + self.enabledProviders() } var statusChecksEnabled: Bool { @@ -465,6 +457,7 @@ final class UsageStore { let displayEnabledProviders = self.enabledProvidersForDisplay() let enabledProviderSet = Set(displayEnabledProviders) let refreshProviders = self.enabledProvidersForBackgroundWork() + let availableRefreshProviders = Set(self.enabledProviders()) let refreshStartedAt = Date() await ProviderRefreshContext.$current.withValue(refreshPhase) { @@ -475,11 +468,16 @@ final class UsageStore { } self.clearDisabledProviderState(enabledProviders: enabledProviderSet) + self.clearUnavailableProviderState( + displayEnabledProviders: enabledProviderSet, + availableProviders: availableRefreshProviders) await withTaskGroup(of: Void.self) { group in for provider in refreshProviders { group.addTask { await self.refreshProvider(provider) } - group.addTask { await self.refreshStatus(provider) } + if availableRefreshProviders.contains(provider) { + group.addTask { await self.refreshStatus(provider) } + } } group.addTask { await self.refreshCreditsIfNeeded(minimumSnapshotUpdatedAt: refreshStartedAt) } } diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift index d83ba7a72..fe654bfab 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift @@ -339,11 +339,6 @@ public struct OpenAIDashboardFetcher { OpenAIDashboardWebViewCache.shared.evictAll() } - public static func evictCachedWebView(accountEmail: String?) { - let store = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: accountEmail) - OpenAIDashboardWebViewCache.shared.evict(websiteDataStore: store) - } - public func probeUsagePage( websiteDataStore: WKWebsiteDataStore, logger: ((String) -> Void)? = nil, diff --git a/Tests/CodexBarTests/CodexManagedOpenAIWebTests.swift b/Tests/CodexBarTests/CodexManagedOpenAIWebTests.swift index 39757119c..5bde40281 100644 --- a/Tests/CodexBarTests/CodexManagedOpenAIWebTests.swift +++ b/Tests/CodexBarTests/CodexManagedOpenAIWebTests.swift @@ -225,6 +225,55 @@ struct CodexManagedOpenAIWebTests { #expect(observedAllowAnyAccount == false) } + @Test + func `successful dashboard apply preserves cached open A I web view for same account`() async { + let settings = self.makeSettingsStore(suite: "CodexManagedOpenAIWebTests-preserve-cache-on-success") + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + managedHomePath: "/tmp/managed-codex-home", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + settings._test_activeManagedCodexAccount = managedAccount + settings.codexActiveSource = .managedAccount(id: managedAccount.id) + defer { settings._test_activeManagedCodexAccount = nil } + + OpenAIDashboardWebsiteDataStore.clearCacheForTesting() + let cache = OpenAIDashboardWebViewCache.shared + cache.clearAllForTesting() + defer { + cache.clearAllForTesting() + OpenAIDashboardWebsiteDataStore.clearCacheForTesting() + } + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + let websiteDataStore = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: managedAccount.email) + cache.cacheEntryForTesting(websiteDataStore: websiteDataStore) + + #expect(cache.hasCachedEntry(for: websiteDataStore)) + + await store.applyOpenAIDashboard( + OpenAIDashboardSnapshot( + signedInEmail: managedAccount.email, + codeReviewRemainingPercent: 90, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + creditsRemaining: 10, + accountPlan: "Pro", + updatedAt: Date()), + targetEmail: managedAccount.email) + + #expect(cache.hasCachedEntry(for: websiteDataStore)) + #expect(cache.entryCount == 1) + } + @Test func `dashboard refresh does not target stale last known live email`() async { let settings = self.makeSettingsStore(suite: "CodexManagedOpenAIWebTests-live-system-refresh-strict-target") diff --git a/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift new file mode 100644 index 000000000..d2950ca09 --- /dev/null +++ b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift @@ -0,0 +1,73 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +struct StatusItemAnimationSignatureTests { + private func makeStatusBarForTesting() -> NSStatusBar { + let env = ProcessInfo.processInfo.environment + if env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" { + return .system + } + return NSStatusBar() + } + + @Test + func `merged render signature changes when unified icon style changes`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationSignatureTests-merged-style-signature"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.menuBarShowsBrandIconWithPercent = false + settings.syntheticAPIToken = "synthetic-test-token" + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let syntheticMeta = registry.metadata[.synthetic] { + settings.setProviderEnabled(provider: .synthetic, metadata: syntheticMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + + #expect(store.enabledProvidersForDisplay() == [.codex, .synthetic]) + #expect(store.enabledProviders() == [.codex, .synthetic]) + #expect(store.iconStyle == .combined) + controller.applyIcon(phase: nil) + let combinedSignature = controller.lastAppliedMergedIconRenderSignature + + settings.syntheticAPIToken = "" + + #expect(store.enabledProvidersForDisplay() == [.codex, .synthetic]) + #expect(store.enabledProviders() == [.codex]) + #expect(store.iconStyle == .codex) + controller.applyIcon(phase: nil) + let codexSignature = controller.lastAppliedMergedIconRenderSignature + + #expect(combinedSignature != nil) + #expect(codexSignature != nil) + #expect(combinedSignature != codexSignature) + #expect(codexSignature?.contains("style=codex") == true) + } +} diff --git a/Tests/CodexBarTests/StatusItemAnimationTests.swift b/Tests/CodexBarTests/StatusItemAnimationTests.swift index 13c5e0246..dc098aead 100644 --- a/Tests/CodexBarTests/StatusItemAnimationTests.swift +++ b/Tests/CodexBarTests/StatusItemAnimationTests.swift @@ -42,9 +42,10 @@ struct StatusItemAnimationTests { if let codexMeta = registry.metadata[.codex] { settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) } - if let claudeMeta = registry.metadata[.claude] { - settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: true) + if let openRouterMeta = registry.metadata[.openrouter] { + settings.setProviderEnabled(provider: .openrouter, metadata: openRouterMeta, enabled: true) } + settings.openRouterAPIToken = "or-token" if let geminiMeta = registry.metadata[.gemini] { settings.setProviderEnabled(provider: .gemini, metadata: geminiMeta, enabled: false) } @@ -88,9 +89,10 @@ struct StatusItemAnimationTests { if let codexMeta = registry.metadata[.codex] { settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) } - if let claudeMeta = registry.metadata[.claude] { - settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: true) + if let openRouterMeta = registry.metadata[.openrouter] { + settings.setProviderEnabled(provider: .openrouter, metadata: openRouterMeta, enabled: true) } + settings.openRouterAPIToken = "or-token" let fetcher = UsageFetcher() let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index 768d38c73..9e9258447 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -229,7 +229,34 @@ struct UsageStoreCoverageTests { } @Test - func unavailableProviderWithCachedSnapshotStaysEligibleForBackgroundCleanup() async throws { + func visibleUnavailableProviderGetsExplicitUserFacingState() throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-unavailable-message") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + + let metadata = ProviderRegistry.shared.metadata + for provider in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(metadata[provider]), + enabled: false) + } + try settings.setProviderEnabled( + provider: .synthetic, + metadata: #require(metadata[.synthetic]), + enabled: true) + + let store = Self.makeUsageStore(settings: settings) + + #expect(store.errors[.synthetic] == nil) + #expect(store.enabledProvidersForDisplay() == [.synthetic]) + #expect(store.isProviderAvailable(.synthetic) == false) + #expect(store.userFacingError(for: .synthetic) == SyntheticSettingsError.missingToken.errorDescription) + #expect(store.unavailableMessage(for: .synthetic) == SyntheticSettingsError.missingToken.errorDescription) + } + + @Test + func refreshClearsEnabledButUnavailableCachedState() async throws { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-background-cleanup") settings.refreshFrequency = .manual settings.statusChecksEnabled = false @@ -252,17 +279,96 @@ struct UsageStoreCoverageTests { secondary: nil, updatedAt: Date()) store._setSnapshotForTesting(cachedSnapshot, provider: .synthetic) + let account = ProviderTokenAccount(id: UUID(), label: "Account", token: "token", addedAt: 0, lastUsed: nil) + store.accountSnapshots[.synthetic] = [ + TokenAccountUsageSnapshot(account: account, snapshot: cachedSnapshot, error: nil, sourceLabel: "api"), + ] + store._setTokenSnapshotForTesting( + CostUsageTokenSnapshot( + sessionTokens: 10, + sessionCostUSD: 1.23, + last30DaysTokens: 100, + last30DaysCostUSD: 4.56, + daily: [], + updatedAt: Date()), + provider: .synthetic) #expect(store.enabledProvidersForDisplay() == [.synthetic]) #expect(store.enabledProviders().isEmpty) - #expect(store.enabledProvidersForBackgroundWork() == [.synthetic]) + #expect(store.enabledProvidersForBackgroundWork().isEmpty) await store.refresh() - #expect(store.snapshot(for: .synthetic) != nil) + #expect(store.snapshot(for: .synthetic) == nil) + #expect((store.accountSnapshots[.synthetic] ?? []).isEmpty) + #expect(store.tokenSnapshots[.synthetic] == nil) + #expect(store.enabledProvidersForBackgroundWork().isEmpty) + } + + @Test + func refreshClearsEnabledButUnavailableFailureState() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-background-failure-cleanup") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + + let metadata = ProviderRegistry.shared.metadata + for provider in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(metadata[provider]), + enabled: false) + } + try settings.setProviderEnabled( + provider: .synthetic, + metadata: #require(metadata[.synthetic]), + enabled: true) + + let store = Self.makeUsageStore(settings: settings) + store._setErrorForTesting("stale", provider: .synthetic) + store.statuses[.synthetic] = ProviderStatus(indicator: .major, description: "Outage", updatedAt: Date()) + store.tokenErrors[.synthetic] = "token stale" + + #expect(store.enabledProvidersForDisplay() == [.synthetic]) + #expect(store.enabledProviders().isEmpty) + #expect(store.enabledProvidersForBackgroundWork().isEmpty) await store.refresh() - #expect(store.snapshot(for: .synthetic) == nil) - #expect(store.error(for: .synthetic)?.isEmpty == false) + + #expect(store.errors[.synthetic] == nil) + #expect(store.tokenErrors[.synthetic] == nil) + #expect(store.statuses[.synthetic] == nil) + #expect(store.enabledProvidersForBackgroundWork().isEmpty) + } + + @Test + func unavailableProviderWithOnlyCachedStatusGetsSingleCleanupPass() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-background-status-cleanup") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = true + + let metadata = ProviderRegistry.shared.metadata + + for provider in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(metadata[provider]), + enabled: false) + } + try settings.setProviderEnabled( + provider: .synthetic, + metadata: #require(metadata[.synthetic]), + enabled: true) + + let store = Self.makeUsageStore(settings: settings) + store.statuses[.synthetic] = ProviderStatus(indicator: .major, description: "Outage", updatedAt: Date()) + + #expect(store.enabledProvidersForDisplay() == [.synthetic]) + #expect(store.enabledProviders().isEmpty) + #expect(store.enabledProvidersForBackgroundWork().isEmpty) + + await store.refresh() + + #expect(store.statuses[.synthetic] == nil) + #expect(store.enabledProvidersForBackgroundWork().isEmpty) } @Test From 74036083a8d45db7a262d6d86cd4ee6160d70cbf Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Mon, 13 Apr 2026 23:06:28 +0530 Subject: [PATCH 7/7] Preserve hosted submenu provider context --- .../StatusItemController+HostedSubmenus.swift | 1 + Tests/CodexBarTests/StatusMenuTests.swift | 56 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift index 2b6f12678..1676db9b1 100644 --- a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -58,6 +58,7 @@ extension StatusItemController { let unavailableItem = NSMenuItem(title: "No data available", action: nil, keyEquivalent: "") unavailableItem.isEnabled = false unavailableItem.representedObject = chartID + unavailableItem.toolTip = placeholder.toolTip menu.addItem(unavailableItem) } diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index b0b676848..c74ecf69c 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -839,6 +839,62 @@ extension StatusMenuTests { #expect(try #require(creditsIndex) < costIndex!) } + @Test + func `hosted cost submenu preserves provider context after empty hydration`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let submenu = controller.makeHostedSubviewPlaceholderMenu( + chartID: StatusItemController.costHistoryChartID, + provider: .codex) + + controller.hydrateHostedSubviewMenuIfNeeded(submenu) + #expect(submenu.items.count == 1) + #expect(submenu.items.first?.title == "No data available") + #expect(submenu.items.first?.toolTip == UsageProvider.codex.rawValue) + + store._setTokenSnapshotForTesting(CostUsageTokenSnapshot( + sessionTokens: 123, + sessionCostUSD: 0.12, + last30DaysTokens: 123, + last30DaysCostUSD: 1.23, + daily: [ + CostUsageDailyReport.Entry( + date: "2025-12-23", + inputTokens: nil, + outputTokens: nil, + totalTokens: 123, + costUSD: 1.23, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: Date()), provider: .codex) + + controller.hydrateHostedSubviewMenuIfNeeded(submenu) + #expect(submenu.items.count == 1) + #expect(submenu.items.first?.title != "No data available") + #expect(submenu.items.first?.representedObject as? String == StatusItemController.costHistoryChartID) + } + @Test func `shows extra usage for claude when using menu card sections`() { self.disableMenuCardsForTesting()