diff --git a/Sources/CodexBar/MenuCardRefreshMonitor.swift b/Sources/CodexBar/MenuCardRefreshMonitor.swift index 781c15e8f9..dfe0960057 100644 --- a/Sources/CodexBar/MenuCardRefreshMonitor.swift +++ b/Sources/CodexBar/MenuCardRefreshMonitor.swift @@ -13,10 +13,18 @@ final class MenuCardRefreshMonitor { typealias ModelResolver = @MainActor (UsageProvider) -> UsageMenuCardView.Model? private let resolveModel: ModelResolver - var isManualRefreshInFlight = false - private var manualRefreshProvider: UsageProvider? + /// Set while an all-providers refresh is running; it freezes every provider's card. + private var globalManualRefreshInFlight = false + /// Providers with an individual manual refresh in flight. Concurrent entries are allowed so + /// refreshing one provider does not stall or unfreeze another. + private var manualRefreshProviders: Set = [] private var frozenManualRefreshModels: [UsageProvider: UsageMenuCardView.Model] = [:] + /// True while any manual refresh (global or per-provider) is running. + var isManualRefreshInFlight: Bool { + self.globalManualRefreshInFlight || !self.manualRefreshProviders.isEmpty + } + init(resolveModel: @escaping ModelResolver) { self.resolveModel = resolveModel } @@ -25,19 +33,34 @@ final class MenuCardRefreshMonitor { frozenModels: [UsageProvider: UsageMenuCardView.Model], provider: UsageProvider? = nil) { - self.frozenManualRefreshModels = frozenModels - self.manualRefreshProvider = provider - self.isManualRefreshInFlight = true + if let provider { + self.frozenManualRefreshModels[provider] = frozenModels[provider] + self.manualRefreshProviders.insert(provider) + } else { + self.frozenManualRefreshModels = frozenModels + self.globalManualRefreshInFlight = true + } + } + + /// Balances a `beginManualRefresh` with the same `provider` argument (nil ends the global refresh). + func endManualRefresh(for provider: UsageProvider? = nil) { + if let provider { + self.manualRefreshProviders.remove(provider) + self.frozenManualRefreshModels[provider] = nil + } else { + self.globalManualRefreshInFlight = false + self.frozenManualRefreshModels.removeAll(keepingCapacity: true) + } } - func endManualRefresh() { - self.isManualRefreshInFlight = false - self.manualRefreshProvider = nil + func resetManualRefresh() { + self.globalManualRefreshInFlight = false + self.manualRefreshProviders.removeAll(keepingCapacity: true) self.frozenManualRefreshModels.removeAll(keepingCapacity: true) } func isManualRefreshInFlight(for provider: UsageProvider) -> Bool { - self.isManualRefreshInFlight && (self.manualRefreshProvider == nil || self.manualRefreshProvider == provider) + self.globalManualRefreshInFlight || self.manualRefreshProviders.contains(provider) } func model( diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index 736520aacf..4067c2a4ad 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -1,6 +1,15 @@ import AppKit import CodexBarCore +extension StatusItemController { + /// Identifies which manual refresh a task belongs to, so per-provider refreshes stay independent + /// of each other and of the all-providers refresh. + enum ManualRefreshScope: Hashable { + case global + case provider(UsageProvider) + } +} + enum LoginNotificationLogic { static func notificationCopy(providerName: String) -> (title: String, body: String) { ( @@ -114,10 +123,17 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { } private func startManualRefresh(for provider: UsageProvider?) { + let scope: ManualRefreshScope = provider.map(ManualRefreshScope.provider) ?? .global let scopedRefreshInFlight = provider.map { self.store.refreshingProviders.contains($0) } ?? !self.store.refreshingProviders.isEmpty + // Two different providers may refresh concurrently, but an all-providers (.global) refresh must + // not overlap a per-provider one (or vice versa) — that would duplicate the shared fetch work. + let conflictsWithOtherScope = scope == .global + ? self.manualRefreshTasks.contains { $0.key != .global } + : self.manualRefreshTasks[.global] != nil guard !self.hasPreparedForAppShutdown, - self.manualRefreshTask == nil, + self.manualRefreshTasks[scope] == nil, + !conflictsWithOtherScope, !self.store.isRefreshing, !scopedRefreshInFlight else { return } @@ -126,9 +142,8 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { let task = Task { @MainActor [weak self] in guard let self else { return } defer { - self.manualRefreshTask = nil - self.manualRefreshProvider = nil - self.menuCardRefreshMonitor.endManualRefresh() + self.manualRefreshTasks[scope] = nil + self.menuCardRefreshMonitor.endManualRefresh(for: provider) self.updatePersistentRefreshItemsEnabled() self.prepareAttachedClosedMenusIfNeeded() } @@ -152,8 +167,7 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { interaction: .userInitiated) } } - self.manualRefreshProvider = provider - self.manualRefreshTask = task + self.manualRefreshTasks[scope] = task self.menuCardRefreshMonitor.beginManualRefresh(frozenModels: frozenModels, provider: provider) self.updatePersistentRefreshItemsEnabled() } diff --git a/Sources/CodexBar/StatusItemController+MenuTracking.swift b/Sources/CodexBar/StatusItemController+MenuTracking.swift index f303cef09b..43118d1e5a 100644 --- a/Sources/CodexBar/StatusItemController+MenuTracking.swift +++ b/Sources/CodexBar/StatusItemController+MenuTracking.swift @@ -111,7 +111,7 @@ extension StatusItemController { var isMenuDataRefreshInFlight: Bool { self.store.isRefreshing || - self.manualRefreshTask != nil || + !self.manualRefreshTasks.isEmpty || !self.store.refreshingProviders.isEmpty || UsageProvider.allCases.contains { self.store.isTokenRefreshInFlight(for: $0) } } diff --git a/Sources/CodexBar/StatusItemController+PersistentMenuActions.swift b/Sources/CodexBar/StatusItemController+PersistentMenuActions.swift index c875e87fe1..f04dc52b51 100644 --- a/Sources/CodexBar/StatusItemController+PersistentMenuActions.swift +++ b/Sources/CodexBar/StatusItemController+PersistentMenuActions.swift @@ -16,18 +16,29 @@ extension StatusItemController { } func isRefreshActionInFlight(for menu: NSMenu) -> Bool { - if self.manualRefreshTask != nil { + // An all-providers manual refresh (⌘R / overview) legitimately busies every row. + if self.manualRefreshTasks[.global] != nil { return true } if self.isMergedOverviewSelected(in: menu) { - // Overview refresh is global, so its busy state must mirror the global manual-refresh gate. - return self.store.isRefreshing || !self.store.refreshingProviders.isEmpty + // Overview stands for every provider, so it is busy while ANY manual refresh runs — + // including the post-fetch tail of a per-provider refresh, after `refreshingProviders` + // has cleared but its `.provider` task is still finishing status/token/credits work. + return self.store.isRefreshing + || !self.manualRefreshTasks.isEmpty + || !self.store.refreshingProviders.isEmpty } if let provider = self.menuProvider(for: menu) { - return self.store.isRefreshing || self.store.refreshingProviders.contains(provider) + // A manual refresh of a different provider must not grey out this provider's row: only + // reflect the global refresh, this provider's own manual refresh, and its store refresh. + return self.store.isRefreshing + || self.manualRefreshTasks[.provider(provider)] != nil + || self.store.refreshingProviders.contains(provider) } - return self.store.isRefreshing || !self.store.refreshingProviders.isEmpty + return self.store.isRefreshing + || !self.manualRefreshTasks.isEmpty + || !self.store.refreshingProviders.isEmpty } func isMergedOverviewSelected(in menu: NSMenu) -> Bool { diff --git a/Sources/CodexBar/StatusItemController+Shutdown.swift b/Sources/CodexBar/StatusItemController+Shutdown.swift index e7b7a0b664..e4b5f94a09 100644 --- a/Sources/CodexBar/StatusItemController+Shutdown.swift +++ b/Sources/CodexBar/StatusItemController+Shutdown.swift @@ -28,10 +28,11 @@ extension StatusItemController { self.menuBarCountdownRefreshTask = nil self.loginTask?.cancel() self.loginTask = nil - self.manualRefreshTask?.cancel() - self.manualRefreshTask = nil - self.manualRefreshProvider = nil - self.menuCardRefreshMonitor.endManualRefresh() + for task in self.manualRefreshTasks.values { + task.cancel() + } + self.manualRefreshTasks.removeAll() + self.menuCardRefreshMonitor.resetManualRefresh() self.screenChangeVisibilityTask?.cancel() self.screenChangeVisibilityTask = nil self.pendingScreenChangePreviousCount = nil diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 4d6c033d53..dae1718326 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -138,8 +138,10 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var fallbackMenu: NSMenu? var openMenus: [ObjectIdentifier: NSMenu] = [:] var menuRefreshTasks: [ObjectIdentifier: Task] = [:] - var manualRefreshTask: Task? - var manualRefreshProvider: UsageProvider? + /// Manual refreshes tracked per scope so refreshing one provider neither greys out nor blocks + /// a manual refresh of another. `.global` covers the all-providers refresh (⌘R / merged overview). + var manualRefreshTasks: [ManualRefreshScope: Task] = [:] + var closedMenuRebuildTasks: [ObjectIdentifier: Task] = [:] var closedMenuRebuildRequests = MenuRebuildRequestRegistry() var openMenuRebuildTasks: [ObjectIdentifier: Task] = [:] diff --git a/Tests/CodexBarTests/StatusMenuClosedPreparationTests.swift b/Tests/CodexBarTests/StatusMenuClosedPreparationTests.swift index a3b528adef..62628b01ef 100644 --- a/Tests/CodexBarTests/StatusMenuClosedPreparationTests.swift +++ b/Tests/CodexBarTests/StatusMenuClosedPreparationTests.swift @@ -185,7 +185,7 @@ extension StatusMenuTests { controller._test_manualRefreshOperation = nil } controller.refreshNow() - let task = try #require(controller.manualRefreshTask) + let task = try #require(controller.manualRefreshTasks[.global]) controller.invalidateMenus() for _ in 0..<40 { diff --git a/Tests/CodexBarTests/StatusMenuMergedOverviewRefreshTests.swift b/Tests/CodexBarTests/StatusMenuMergedOverviewRefreshTests.swift index 2bb35b2509..2812dbb9d1 100644 --- a/Tests/CodexBarTests/StatusMenuMergedOverviewRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuMergedOverviewRefreshTests.swift @@ -42,8 +42,7 @@ struct StatusMenuMergedOverviewRefreshTests { } #expect(requestCount == 0) - #expect(controller.manualRefreshTask == nil) - #expect(controller.manualRefreshProvider == nil) + #expect(controller.manualRefreshTasks.isEmpty) } private func makeSettings() -> SettingsStore { diff --git a/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift b/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift index 859f4242d2..b4fcb34d67 100644 --- a/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift @@ -172,17 +172,17 @@ struct StatusMenuPersistentRefreshTests { let gate = ManualRefreshGate() controller._test_manualRefreshOperation = { await gate.wait() } #expect(try controller.handleMenuTrackingShortcutEvent(self.keyEvent("r", keyCode: 15), menu: menu)) - for _ in 0..<20 where controller.manualRefreshTask == nil { + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil { await Task.yield() } - let task = try #require(controller.manualRefreshTask) + let task = try #require(controller.manualRefreshTasks[.provider(.codex)]) #expect(!refreshItem.isEnabled) gate.resume() await task.value - #expect(controller.manualRefreshTask == nil) + #expect(controller.manualRefreshTasks[.provider(.codex)] == nil) #expect(refreshItem.isEnabled) } @@ -260,22 +260,37 @@ struct StatusMenuPersistentRefreshTests { #expect(!refreshItem.isEnabled) controller.store.isRefreshing = false - controller.manualRefreshProvider = .claude - controller.manualRefreshTask = Task {} + + // A manual refresh scoped to another provider must not grey out this provider's row. + controller.manualRefreshTasks[.provider(.claude)] = Task {} + controller.updatePersistentRefreshItemsEnabled() + #expect(refreshItem.isEnabled) + + // This provider's own manual refresh does disable its row. + controller.manualRefreshTasks[.provider(.codex)] = Task {} + controller.updatePersistentRefreshItemsEnabled() + #expect(!refreshItem.isEnabled) + + controller.manualRefreshTasks[.provider(.codex)] = nil + controller.manualRefreshTasks[.provider(.claude)] = nil + controller.updatePersistentRefreshItemsEnabled() + #expect(refreshItem.isEnabled) + + // An all-providers refresh greys every row. + controller.manualRefreshTasks[.global] = Task {} controller.updatePersistentRefreshItemsEnabled() #expect(!refreshItem.isEnabled) - controller.manualRefreshTask = nil - controller.manualRefreshProvider = nil + controller.manualRefreshTasks[.global] = nil controller.updatePersistentRefreshItemsEnabled() #expect(refreshItem.isEnabled) refreshItem.representedObject = "notRefresh" - controller.manualRefreshTask = Task {} + controller.manualRefreshTasks[.global] = Task {} controller.updatePersistentRefreshItemsEnabled() #expect(refreshItem.isEnabled) #expect(!controller.persistentRefreshItems.allObjects.contains { $0 === refreshItem }) - controller.manualRefreshTask = nil + controller.manualRefreshTasks[.global] = nil } @Test @@ -291,9 +306,9 @@ struct StatusMenuPersistentRefreshTests { #expect(monitor.subtitle(for: .codex, fallback: fallback).style == .loading) controller.store.isRefreshing = false - monitor.isManualRefreshInFlight = true + monitor.beginManualRefresh(frozenModels: [:], provider: nil) #expect(monitor.subtitle(for: .codex, fallback: fallback).style == .loading) - monitor.isManualRefreshInFlight = false + monitor.endManualRefresh() controller.store.isRefreshing = false let now = Date() @@ -314,7 +329,7 @@ struct StatusMenuPersistentRefreshTests { #expect(failure.style == .error) #expect(failure.text == "Refresh failed") - monitor.isManualRefreshInFlight = true + monitor.beginManualRefresh(frozenModels: [:], provider: nil) #expect(monitor.subtitle(for: .codex, fallback: fallback).style == .loading) } @@ -328,7 +343,7 @@ struct StatusMenuPersistentRefreshTests { let expectedClaude = monitor.subtitle(for: .claude, fallback: fallback) monitor.beginManualRefresh(frozenModels: [.codex: codexModel], provider: .codex) - defer { monitor.endManualRefresh() } + defer { monitor.endManualRefresh(for: .codex) } #expect(monitor.isManualRefreshInFlight(for: .codex)) #expect(!monitor.isManualRefreshInFlight(for: .claude)) @@ -356,7 +371,7 @@ struct StatusMenuPersistentRefreshTests { resetDescription: nil), updatedAt: now) let fallback = try #require(controller.menuCardModel(for: .claude)) - controller.menuCardRefreshMonitor.isManualRefreshInFlight = true + controller.menuCardRefreshMonitor.beginManualRefresh(frozenModels: [:], provider: .claude) controller.store.snapshots[.claude] = UsageSnapshot( primary: RateWindow( @@ -374,7 +389,7 @@ struct StatusMenuPersistentRefreshTests { let inFlight = controller.menuCardRefreshMonitor.model(for: .claude, fallback: fallback) #expect(inFlight.metrics.map(\.percent) == fallback.metrics.map(\.percent)) - controller.menuCardRefreshMonitor.isManualRefreshInFlight = false + controller.menuCardRefreshMonitor.endManualRefresh(for: .claude) let refreshed = controller.menuCardRefreshMonitor.model(for: .claude, fallback: fallback) let expected = try #require(controller.menuCardModel(for: .claude)) @@ -730,7 +745,7 @@ struct StatusMenuPersistentRefreshTests { controller.store.errors[.codex] = "Refresh failed with a much longer replacement message that must not resize the tracked menu" let replacementErrorHeight = fittingHeight(for: errorModel) - controller.menuCardRefreshMonitor.isManualRefreshInFlight = true + controller.menuCardRefreshMonitor.beginManualRefresh(frozenModels: [:], provider: nil) let retryHeight = fittingHeight(for: errorModel) #expect(replacementErrorHeight == errorHeight) @@ -752,7 +767,7 @@ struct StatusMenuPersistentRefreshTests { controller.refreshNow() #expect(requestCount == 0) - #expect(controller.manualRefreshTask == nil) + #expect(controller.manualRefreshTasks.isEmpty) #expect(!controller.menuCardRefreshMonitor.isManualRefreshInFlight) } @@ -772,7 +787,7 @@ struct StatusMenuPersistentRefreshTests { } controller.refreshNow() - let task = try #require(controller.manualRefreshTask) + let task = try #require(controller.manualRefreshTasks[.global]) controller.refreshNow() controller.refreshNow() await Task.yield() @@ -783,7 +798,7 @@ struct StatusMenuPersistentRefreshTests { gate.resume() await task.value - #expect(controller.manualRefreshTask == nil) + #expect(controller.manualRefreshTasks[.global] == nil) #expect(!controller.menuCardRefreshMonitor.isManualRefreshInFlight) } @@ -805,37 +820,29 @@ struct StatusMenuPersistentRefreshTests { } let mouseGate = ManualRefreshGate() - var requestCount = 0 - controller._test_manualRefreshOperation = { - requestCount += 1 - await mouseGate.wait() - } + controller._test_manualRefreshOperation = { await mouseGate.wait() } let refreshItem = try #require(menu.items.first { $0.title == "Refresh" }) #expect(controller.isPersistentRefreshItem(refreshItem)) controller.performPersistentRefreshAction(in: ObjectIdentifier(menu)) - for _ in 0..<20 where controller.manualRefreshTask == nil { + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.claude)] == nil { await Task.yield() } - let mouseTask = try #require(controller.manualRefreshTask) - #expect(controller.manualRefreshProvider == .claude) - #expect(controller.isRefreshActionInFlight(for: codexMenu)) - #expect(controller.isRefreshActionInFlight(for: NSMenu())) - let codexRefreshItem = try #require(codexMenu.items.first { $0.title == "Refresh" }) - #expect(controller.isPersistentRefreshItem(codexRefreshItem)) - controller.performPersistentRefreshAction(in: ObjectIdentifier(codexMenu)) - await Task.yield() - #expect(requestCount == 1) + let mouseTask = try #require(controller.manualRefreshTasks[.provider(.claude)]) + + // Refreshing Claude greys the Claude row but must leave the Codex row available. + #expect(controller.isRefreshActionInFlight(for: menu)) + #expect(!controller.isRefreshActionInFlight(for: codexMenu)) + mouseGate.resume() await mouseTask.value let keyboardGate = ManualRefreshGate() controller._test_manualRefreshOperation = { await keyboardGate.wait() } #expect(try menu.performKeyEquivalent(with: self.keyEvent("r", keyCode: 15))) - for _ in 0..<20 where controller.manualRefreshTask == nil { + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.claude)] == nil { await Task.yield() } - let keyboardTask = try #require(controller.manualRefreshTask) - #expect(controller.manualRefreshProvider == .claude) + let keyboardTask = try #require(controller.manualRefreshTasks[.provider(.claude)]) keyboardGate.resume() await keyboardTask.value } @@ -865,8 +872,7 @@ struct StatusMenuPersistentRefreshTests { } #expect(requestCount == 0) - #expect(controller.manualRefreshTask == nil) - #expect(controller.manualRefreshProvider == nil) + #expect(controller.manualRefreshTasks.isEmpty) } @Test @@ -888,11 +894,10 @@ struct StatusMenuPersistentRefreshTests { let refreshItem = try #require(menu.items.first { $0.title == "Refresh" }) #expect(controller.isPersistentRefreshItem(refreshItem)) controller.performPersistentRefreshAction(in: ObjectIdentifier(menu)) - for _ in 0..<20 where controller.manualRefreshTask == nil { + for _ in 0..<20 where controller.manualRefreshTasks[.global] == nil { await Task.yield() } - let overviewTask = try #require(controller.manualRefreshTask) - #expect(controller.manualRefreshProvider == nil) + let overviewTask = try #require(controller.manualRefreshTasks[.global]) overviewGate.resume() await overviewTask.value @@ -901,11 +906,10 @@ struct StatusMenuPersistentRefreshTests { let providerGate = ManualRefreshGate() controller._test_manualRefreshOperation = { await providerGate.wait() } #expect(try menu.performKeyEquivalent(with: self.keyEvent("r", keyCode: 15))) - for _ in 0..<20 where controller.manualRefreshTask == nil { + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.claude)] == nil { await Task.yield() } - let providerTask = try #require(controller.manualRefreshTask) - #expect(controller.manualRefreshProvider == .claude) + let providerTask = try #require(controller.manualRefreshTasks[.provider(.claude)]) providerGate.resume() await providerTask.value } @@ -956,13 +960,13 @@ struct StatusMenuPersistentRefreshTests { } controller.refreshNow() - let task = try #require(controller.manualRefreshTask) + let task = try #require(controller.manualRefreshTasks[.global]) #expect(!refreshItem.isEnabled) gate.resume() await task.value - #expect(controller.manualRefreshTask == nil) + #expect(controller.manualRefreshTasks[.global] == nil) #expect(refreshItem.isEnabled) let fallback = MenuCardLiveSubtitle(text: "Fallback", style: .info) #expect(controller.menuCardRefreshMonitor.subtitle(for: .codex, fallback: fallback).style == .error) @@ -1092,3 +1096,125 @@ extension StatusMenuPersistentRefreshTests { #expect(refreshView.frame.height == metrics.rowHeight) } } + +extension StatusMenuPersistentRefreshTests { + @Test + func `concurrent manual refreshes keep each provider's frozen card`() throws { + let settings = self.makeSettings() + let controller = self.makeController(settings: settings) + let monitor = controller.menuCardRefreshMonitor + let now = Date() + + func quotaSnapshot(usedPercent: Double, updatedAt: Date) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: usedPercent, + windowMinutes: 300, + resetsAt: updatedAt.addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: updatedAt) + } + + // The snapshot helper supplies every enabled provider even for a provider-scoped refresh. + controller.store.snapshots[.claude] = quotaSnapshot(usedPercent: 21, updatedAt: now) + controller.store.snapshots[.codex] = quotaSnapshot(usedPercent: 15, updatedAt: now) + let claudeFrozen = try #require(controller.menuCardModel(for: .claude)) + let codexBeforeItsRefresh = try #require(controller.menuCardModel(for: .codex)) + monitor.beginManualRefresh( + frozenModels: [.claude: claudeFrozen, .codex: codexBeforeItsRefresh], + provider: .claude) + + // Each provider must freeze the card visible when its own refresh starts. + controller.store.snapshots[.claude] = quotaSnapshot(usedPercent: 65, updatedAt: now.addingTimeInterval(1)) + controller.store.snapshots[.codex] = quotaSnapshot(usedPercent: 42, updatedAt: now.addingTimeInterval(1)) + let claudeMidRefresh = try #require(controller.menuCardModel(for: .claude)) + let codexFrozen = try #require(controller.menuCardModel(for: .codex)) + monitor.beginManualRefresh( + frozenModels: [.claude: claudeMidRefresh, .codex: codexFrozen], + provider: .codex) + + let shownClaude = monitor.model(for: .claude, fallback: claudeMidRefresh) + let shownCodex = monitor.model(for: .codex, fallback: codexFrozen) + #expect(shownClaude.metrics.first?.percentLabel == "79% left") + #expect(shownCodex.metrics.first?.percentLabel == "58% left") + + // Ending Codex leaves Claude frozen; ending Claude clears it. + monitor.endManualRefresh(for: .codex) + #expect(monitor.isManualRefreshInFlight(for: .claude)) + #expect(!monitor.isManualRefreshInFlight(for: .codex)) + monitor.endManualRefresh(for: .claude) + #expect(!monitor.isManualRefreshInFlight(for: .claude)) + } + + @Test + func `refreshing one provider does not block refreshing another`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnly([.claude, .codex], settings: settings) + + let controller = self.makeController(settings: settings) + let codexMenu = try #require(controller.makeMenu(for: .codex) as? StatusItemMenu) + controller.menuWillOpen(codexMenu) + defer { controller.menuDidClose(codexMenu) } + + // Simulate a Claude manual refresh already in flight. + controller.manualRefreshTasks[.provider(.claude)] = Task {} + defer { + controller.manualRefreshTasks[.provider(.claude)]?.cancel() + controller.manualRefreshTasks[.provider(.claude)] = nil + } + + let gate = ManualRefreshGate() + controller._test_manualRefreshOperation = { await gate.wait() } + + // A Codex refresh must still start rather than being blocked by the in-flight Claude one. + controller.performPersistentRefreshAction(in: ObjectIdentifier(codexMenu)) + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil { + await Task.yield() + } + let codexTask = try #require(controller.manualRefreshTasks[.provider(.codex)]) + #expect(controller.isRefreshActionInFlight(for: codexMenu)) + + gate.resume() + await codexTask.value + } + + @Test + func `overview stays busy through a provider refresh tail and blocks a global refresh`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnly([.claude, .codex], settings: settings) + settings.mergedMenuLastSelectedWasOverview = true + + let controller = self.makeController(settings: settings) + let menu = try #require(controller.makeMenu() as? StatusItemMenu) + controller.mergedMenu = menu + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + // A per-provider Claude refresh whose task outlives the store's `refreshingProviders` window + // (the status/token/credits tail runs after the provider is removed from that set). + controller.manualRefreshTasks[.provider(.claude)] = Task {} + defer { + controller.manualRefreshTasks[.provider(.claude)]?.cancel() + controller.manualRefreshTasks[.provider(.claude)] = nil + } + + // Overview stands for every provider, so its row stays greyed even with `refreshingProviders` empty. + #expect(controller.store.refreshingProviders.isEmpty) + #expect(controller.isRefreshActionInFlight(for: menu)) + + // And a global overview refresh must not start on top of the in-flight provider refresh. + var requestCount = 0 + controller._test_manualRefreshOperation = { requestCount += 1 } + controller.performPersistentRefreshAction(in: ObjectIdentifier(menu)) + for _ in 0..<20 { + await Task.yield() + } + #expect(requestCount == 0) + #expect(controller.manualRefreshTasks[.global] == nil) + } +} diff --git a/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift b/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift index 21b969fce6..59d9f4d99b 100644 --- a/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift @@ -349,7 +349,7 @@ struct StatusMenuSwitcherRefreshTests { preferencesSelection: PreferencesSelection(), statusBar: .system) defer { - controller.manualRefreshTask?.cancel() + controller.manualRefreshTasks.values.forEach { $0.cancel() } controller.releaseStatusItemsForTesting() } @@ -401,7 +401,7 @@ struct StatusMenuSwitcherRefreshTests { #expect(inFlight.metrics.first?.percentLabel == "79% left") gate.resume() - await controller.manualRefreshTask?.value + await controller.manualRefreshTasks[.global]?.value #expect(!controller.menuCardRefreshMonitor.isManualRefreshInFlight) let completed = controller.menuCardRefreshMonitor.model(for: .codex, fallback: emptyFallback) #expect(completed.metrics.isEmpty) @@ -435,7 +435,7 @@ struct StatusMenuSwitcherRefreshTests { preferencesSelection: PreferencesSelection(), statusBar: .system) defer { - controller.manualRefreshTask?.cancel() + controller.manualRefreshTasks.values.forEach { $0.cancel() } controller.releaseStatusItemsForTesting() } @@ -450,7 +450,7 @@ struct StatusMenuSwitcherRefreshTests { let initialRefreshItem = try #require(menu.items.first { $0.title == "Refresh" }) controller.refreshNow() - let refreshTask = try #require(controller.manualRefreshTask) + let refreshTask = try #require(controller.manualRefreshTasks[.global]) #expect(!initialRefreshItem.isEnabled) var rebuildCount = 0 @@ -463,7 +463,7 @@ struct StatusMenuSwitcherRefreshTests { gate.resume() await refreshTask.value - #expect(controller.manualRefreshTask == nil) + #expect(controller.manualRefreshTasks[.global] == nil) let alternateSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) #expect(alternateSwitcher._test_simulateRuntimeClick(buttonTag: selectedButton.tag)) @@ -497,7 +497,7 @@ struct StatusMenuSwitcherRefreshTests { preferencesSelection: PreferencesSelection(), statusBar: .system) defer { - controller.manualRefreshTask?.cancel() + controller.manualRefreshTasks.values.forEach { $0.cancel() } controller.releaseStatusItemsForTesting() } @@ -507,13 +507,13 @@ struct StatusMenuSwitcherRefreshTests { controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)]?[.provider(.codex)]) let refreshItem = try #require(cache.items.first { $0.title == "Refresh" }) - controller.manualRefreshTask = Task {} + controller.manualRefreshTasks[.global] = Task {} controller.updatePersistentRefreshItemsEnabled() #expect(!refreshItem.isEnabled) menu.removeAllItems() #expect(refreshItem.menu == nil) - controller.manualRefreshTask = nil + controller.manualRefreshTasks[.global] = nil controller.updatePersistentRefreshItemsEnabled() #expect(!refreshItem.isEnabled) @@ -527,6 +527,51 @@ struct StatusMenuSwitcherRefreshTests { #expect(refreshItem.isEnabled) } + @Test + func `a provider manual refresh only greys its own tab`() { + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + Self.enableCodexAndClaude(settings) + Self.disableOverview(settings) + + 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: .system) + defer { + controller.manualRefreshTasks.values.forEach { $0.cancel() } + controller.manualRefreshTasks.removeAll() + controller.releaseStatusItemsForTesting() + } + + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + // A manual refresh of Claude must leave the Codex tab's Refresh row enabled. + controller.manualRefreshTasks[.provider(.claude)] = Task {} + #expect(!controller.isRefreshActionInFlight(for: menu)) + + // Switching to the Claude tab reflects Claude's own in-flight refresh. + settings.selectedMenuProvider = .claude + #expect(controller.isRefreshActionInFlight(for: menu)) + + // An all-providers refresh busies every tab regardless of the selected provider. + settings.selectedMenuProvider = .codex + controller.manualRefreshTasks[.provider(.claude)] = nil + controller.manualRefreshTasks[.global] = Task {} + #expect(controller.isRefreshActionInFlight(for: menu)) + } + @Test func `native image menu rows are replaced during reconciliation`() { let settings = Self.makeSettings()