From 05c85ab8fda149476627884e6573b2cf02d4fad4 Mon Sep 17 00:00:00 2001 From: hhh2210 Date: Sun, 5 Jul 2026 21:09:42 +0800 Subject: [PATCH 1/5] fix: scope refresh-row disabling to the provider being refreshed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refreshing one provider greyed out — and single-flighted — the Refresh row for every provider, so the menu implied the others were refreshing when they were not. Replace the single global `manualRefreshTask` with a per-scope map (`.global` for the all-providers ⌘R/overview refresh, `.provider(p)` for a single provider). The disable gate now reflects only the target provider's own refresh (a global refresh still disables every row), and `startManualRefresh` guards per scope so different providers can refresh concurrently instead of one blocking the rest. `MenuCardRefreshMonitor` tracks in-flight providers as a set plus a global flag. Rewrites the refresh-row tests that encoded the old blanket behavior and adds coverage for scoped greying and concurrent per-provider refreshes. Co-Authored-By: Claude Fable 5 --- Sources/CodexBar/MenuCardRefreshMonitor.swift | 41 ++++++-- .../StatusItemController+Actions.swift | 20 ++-- .../StatusItemController+MenuTracking.swift | 2 +- ...ItemController+PersistentMenuActions.swift | 9 +- .../StatusItemController+Shutdown.swift | 9 +- Sources/CodexBar/StatusItemController.swift | 19 +++- .../StatusMenuPersistentRefreshTests.swift | 93 ++++++++++++++----- .../StatusMenuSwitcherRefreshTests.swift | 45 +++++++++ 8 files changed, 189 insertions(+), 49 deletions(-) diff --git a/Sources/CodexBar/MenuCardRefreshMonitor.swift b/Sources/CodexBar/MenuCardRefreshMonitor.swift index 781c15e8f9..06bd233491 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 + // Merge rather than replace so a second concurrent refresh keeps the first provider's frozen card. + self.frozenManualRefreshModels.merge(frozenModels) { _, new in new } + if let provider { + self.manualRefreshProviders.insert(provider) + } else { + 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) + } else { + self.globalManualRefreshInFlight = false + } + guard self.manualRefreshProviders.isEmpty, !self.globalManualRefreshInFlight else { return } + 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..a35b9e2c66 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,11 @@ 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 guard !self.hasPreparedForAppShutdown, - self.manualRefreshTask == nil, + self.manualRefreshTasks[scope] == nil, !self.store.isRefreshing, !scopedRefreshInFlight else { return } @@ -126,9 +136,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 +161,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..8891808395 100644 --- a/Sources/CodexBar/StatusItemController+PersistentMenuActions.swift +++ b/Sources/CodexBar/StatusItemController+PersistentMenuActions.swift @@ -16,7 +16,8 @@ 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 } @@ -25,7 +26,11 @@ extension StatusItemController { return self.store.isRefreshing || !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 } 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..8a8f0844a1 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -138,8 +138,23 @@ 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] = [:] + + /// The in-flight manual refresh task, preferring the all-providers one. Read by tests that await + /// whichever refresh they just started; production code keys into `manualRefreshTasks` by scope. + var manualRefreshTask: Task? { + get { self.manualRefreshTasks[.global] ?? self.manualRefreshTasks.values.first } + set { self.manualRefreshTasks[.global] = newValue } + } + + /// A per-provider manual refresh currently in flight, if any. Read only by tests; production code + /// keys into `manualRefreshTasks` directly. + var manualRefreshProvider: UsageProvider? { + for case let .provider(provider) in self.manualRefreshTasks.keys { return provider } + return nil + } var closedMenuRebuildTasks: [ObjectIdentifier: Task] = [:] var closedMenuRebuildRequests = MenuRebuildRequestRegistry() var openMenuRebuildTasks: [ObjectIdentifier: Task] = [:] diff --git a/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift b/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift index 859f4242d2..1767464743 100644 --- a/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift @@ -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.manualRefreshTask = nil - controller.manualRefreshProvider = nil + 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.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) @@ -805,11 +820,7 @@ 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)) @@ -818,13 +829,11 @@ struct StatusMenuPersistentRefreshTests { } 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) + + // 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 @@ -840,6 +849,40 @@ struct StatusMenuPersistentRefreshTests { await keyboardTask.value } + @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 `provider menu does not replace matching scoped refresh`() async throws { let settings = self.makeSettings() diff --git a/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift b/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift index 21b969fce6..ecb03a2cec 100644 --- a/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift @@ -527,6 +527,51 @@ struct StatusMenuSwitcherRefreshTests { #expect(refreshItem.isEnabled) } + @Test + func `a provider manual refresh only greys its own tab`() throws { + 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() From aa0ce5e687aa2759b30a005188e5e0ed988c4136 Mon Sep 17 00:00:00 2001 From: hhh2210 Date: Sun, 5 Jul 2026 21:30:31 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20prese?= =?UTF-8?q?rve=20frozen=20cards=20and=20exclude=20global/provider=20overla?= =?UTF-8?q?p?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MenuCardRefreshMonitor: keep an already-refreshing provider's frozen card when a second provider's refresh re-supplies models (old-wins merge), so the first card no longer jumps mid-refresh — the artifact the freeze exists to prevent. - startManualRefresh: make a .global refresh mutually exclusive with any per-provider one (and vice versa); two different providers still refresh concurrently. - Merged-overview Refresh row now reflects any in-flight manual refresh, including a per-provider refresh's post-fetch status/token tail, instead of flickering. - Make the test-only manualRefreshTask / manualRefreshProvider accessors return a value only when unambiguous (global, or exactly one task), else nil; move them to +Actions. - Add tests: concurrent frozen-card retention, and overview-busy-through-tail + global refresh exclusion. Co-Authored-By: Claude Fable 5 --- .../StatusMenuPersistentRefreshTests.swift | 151 ++++++++++++++---- .../StatusMenuSwitcherRefreshTests.swift | 2 +- 2 files changed, 118 insertions(+), 35 deletions(-) diff --git a/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift b/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift index 1767464743..16d64cddf8 100644 --- a/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift @@ -849,40 +849,6 @@ struct StatusMenuPersistentRefreshTests { await keyboardTask.value } - @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 `provider menu does not replace matching scoped refresh`() async throws { let settings = self.makeSettings() @@ -1135,3 +1101,120 @@ 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) + } + + // Claude begins refreshing: freeze its pre-refresh card (21% used -> "79% left"). + controller.store.snapshots[.claude] = quotaSnapshot(usedPercent: 21, updatedAt: now) + let claudeFrozen = try #require(controller.menuCardModel(for: .claude)) + monitor.beginManualRefresh(frozenModels: [.claude: claudeFrozen], provider: .claude) + + // Claude's snapshot moves mid-refresh; then Codex begins and re-supplies Claude's *current* + // model, because frozenManualRefreshMenuCardModels captures every enabled provider. + controller.store.snapshots[.claude] = quotaSnapshot(usedPercent: 65, 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) + + // Claude must still show its pre-refresh card, not the mid-refresh one Codex's begin supplied. + let shownClaude = monitor.model(for: .claude, fallback: claudeMidRefresh) + #expect(shownClaude.metrics.first?.percentLabel == "79% 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 ecb03a2cec..413661e90c 100644 --- a/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift @@ -528,7 +528,7 @@ struct StatusMenuSwitcherRefreshTests { } @Test - func `a provider manual refresh only greys its own tab`() throws { + func `a provider manual refresh only greys its own tab`() { let settings = Self.makeSettings() settings.statusChecksEnabled = false settings.refreshFrequency = .manual From a2fdff18e3b2e2bacf8a06255851421b8cbcdde9 Mon Sep 17 00:00:00 2001 From: hhh2210 Date: Sun, 5 Jul 2026 21:36:35 +0800 Subject: [PATCH 3/5] fix: apply frozen-card old-wins merge, global/provider exclusivity guard, scoped overview greying --- Sources/CodexBar/MenuCardRefreshMonitor.swift | 6 ++-- .../StatusItemController+Actions.swift | 31 +++++++++++++++++++ ...ItemController+PersistentMenuActions.swift | 12 +++++-- Sources/CodexBar/StatusItemController.swift | 14 +-------- 4 files changed, 45 insertions(+), 18 deletions(-) diff --git a/Sources/CodexBar/MenuCardRefreshMonitor.swift b/Sources/CodexBar/MenuCardRefreshMonitor.swift index 06bd233491..3946708286 100644 --- a/Sources/CodexBar/MenuCardRefreshMonitor.swift +++ b/Sources/CodexBar/MenuCardRefreshMonitor.swift @@ -33,8 +33,10 @@ final class MenuCardRefreshMonitor { frozenModels: [UsageProvider: UsageMenuCardView.Model], provider: UsageProvider? = nil) { - // Merge rather than replace so a second concurrent refresh keeps the first provider's frozen card. - self.frozenManualRefreshModels.merge(frozenModels) { _, new in new } + // Keep the existing frozen card for any provider already refreshing. `frozenManualRefreshMenuCardModels()` + // snapshots every enabled provider, so a second provider's begin re-supplies the first provider's + // now-mid-refresh model; old-wins prevents that from clobbering the pre-refresh card we froze for it. + self.frozenManualRefreshModels.merge(frozenModels) { existing, _ in existing } if let provider { self.manualRefreshProviders.insert(provider) } else { diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index a35b9e2c66..463f97673c 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -8,6 +8,31 @@ extension StatusItemController { case global case provider(UsageProvider) } + + /// The in-flight manual refresh task when it is unambiguous: the all-providers one, or the single + /// task when exactly one is running. Nil once several run concurrently, so tests await a specific + /// scope rather than an arbitrary task. Test-only; the setter writes `.global`, and production code + /// keys into `manualRefreshTasks` by scope. + var manualRefreshTask: Task? { + get { + if let global = self.manualRefreshTasks[.global] { return global } + return self.manualRefreshTasks.count == 1 ? self.manualRefreshTasks.values.first : nil + } + set { self.manualRefreshTasks[.global] = newValue } + } + + /// The single per-provider manual refresh in flight, or nil when there is none, a global refresh, + /// or several providers refreshing at once. Read only by tests; production code keys into + /// `manualRefreshTasks` directly. + var manualRefreshProvider: UsageProvider? { + guard self.manualRefreshTasks[.global] == nil, self.manualRefreshTasks.count == 1 else { + return nil + } + for case let .provider(provider) in self.manualRefreshTasks.keys { + return provider + } + return nil + } } enum LoginNotificationLogic { @@ -126,8 +151,14 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { 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.manualRefreshTasks[scope] == nil, + !conflictsWithOtherScope, !self.store.isRefreshing, !scopedRefreshInFlight else { return } diff --git a/Sources/CodexBar/StatusItemController+PersistentMenuActions.swift b/Sources/CodexBar/StatusItemController+PersistentMenuActions.swift index 8891808395..f04dc52b51 100644 --- a/Sources/CodexBar/StatusItemController+PersistentMenuActions.swift +++ b/Sources/CodexBar/StatusItemController+PersistentMenuActions.swift @@ -22,8 +22,12 @@ extension StatusItemController { } 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) { // A manual refresh of a different provider must not grey out this provider's row: only @@ -32,7 +36,9 @@ extension StatusItemController { || 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.swift b/Sources/CodexBar/StatusItemController.swift index 8a8f0844a1..7e8442e5a6 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -140,21 +140,9 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var menuRefreshTasks: [ObjectIdentifier: Task] = [:] /// 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). + /// Test-only accessors `manualRefreshTask` / `manualRefreshProvider` live in `+Actions`. var manualRefreshTasks: [ManualRefreshScope: Task] = [:] - /// The in-flight manual refresh task, preferring the all-providers one. Read by tests that await - /// whichever refresh they just started; production code keys into `manualRefreshTasks` by scope. - var manualRefreshTask: Task? { - get { self.manualRefreshTasks[.global] ?? self.manualRefreshTasks.values.first } - set { self.manualRefreshTasks[.global] = newValue } - } - - /// A per-provider manual refresh currently in flight, if any. Read only by tests; production code - /// keys into `manualRefreshTasks` directly. - var manualRefreshProvider: UsageProvider? { - for case let .provider(provider) in self.manualRefreshTasks.keys { return provider } - return nil - } var closedMenuRebuildTasks: [ObjectIdentifier: Task] = [:] var closedMenuRebuildRequests = MenuRebuildRequestRegistry() var openMenuRebuildTasks: [ObjectIdentifier: Task] = [:] From b28a4537c30d0860056d16f9112e216fe09ac452 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 5 Jul 2026 06:46:28 -0700 Subject: [PATCH 4/5] fix: isolate provider refresh snapshots --- Sources/CodexBar/MenuCardRefreshMonitor.swift | 10 ++++------ .../StatusMenuPersistentRefreshTests.swift | 15 ++++++++++----- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/Sources/CodexBar/MenuCardRefreshMonitor.swift b/Sources/CodexBar/MenuCardRefreshMonitor.swift index 3946708286..dfe0960057 100644 --- a/Sources/CodexBar/MenuCardRefreshMonitor.swift +++ b/Sources/CodexBar/MenuCardRefreshMonitor.swift @@ -33,13 +33,11 @@ final class MenuCardRefreshMonitor { frozenModels: [UsageProvider: UsageMenuCardView.Model], provider: UsageProvider? = nil) { - // Keep the existing frozen card for any provider already refreshing. `frozenManualRefreshMenuCardModels()` - // snapshots every enabled provider, so a second provider's begin re-supplies the first provider's - // now-mid-refresh model; old-wins prevents that from clobbering the pre-refresh card we froze for it. - self.frozenManualRefreshModels.merge(frozenModels) { existing, _ in existing } if let provider { + self.frozenManualRefreshModels[provider] = frozenModels[provider] self.manualRefreshProviders.insert(provider) } else { + self.frozenManualRefreshModels = frozenModels self.globalManualRefreshInFlight = true } } @@ -48,11 +46,11 @@ final class MenuCardRefreshMonitor { 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) } - guard self.manualRefreshProviders.isEmpty, !self.globalManualRefreshInFlight else { return } - self.frozenManualRefreshModels.removeAll(keepingCapacity: true) } func resetManualRefresh() { diff --git a/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift b/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift index 16d64cddf8..4154838136 100644 --- a/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift @@ -1121,23 +1121,28 @@ extension StatusMenuPersistentRefreshTests { updatedAt: updatedAt) } - // Claude begins refreshing: freeze its pre-refresh card (21% used -> "79% left"). + // 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)) - monitor.beginManualRefresh(frozenModels: [.claude: claudeFrozen], provider: .claude) + let codexBeforeItsRefresh = try #require(controller.menuCardModel(for: .codex)) + monitor.beginManualRefresh( + frozenModels: [.claude: claudeFrozen, .codex: codexBeforeItsRefresh], + provider: .claude) - // Claude's snapshot moves mid-refresh; then Codex begins and re-supplies Claude's *current* - // model, because frozenManualRefreshMenuCardModels captures every enabled provider. + // 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) - // Claude must still show its pre-refresh card, not the mid-refresh one Codex's begin supplied. 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) From ea52f67410afdf534ffb41b52ebb414c97f2f4d0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 5 Jul 2026 06:56:06 -0700 Subject: [PATCH 5/5] test: use scoped refresh tasks --- .../StatusItemController+Actions.swift | 25 ------------ Sources/CodexBar/StatusItemController.swift | 1 - .../StatusMenuClosedPreparationTests.swift | 2 +- ...StatusMenuMergedOverviewRefreshTests.swift | 3 +- .../StatusMenuPersistentRefreshTests.swift | 39 ++++++++----------- .../StatusMenuSwitcherRefreshTests.swift | 16 ++++---- 6 files changed, 27 insertions(+), 59 deletions(-) diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index 463f97673c..4067c2a4ad 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -8,31 +8,6 @@ extension StatusItemController { case global case provider(UsageProvider) } - - /// The in-flight manual refresh task when it is unambiguous: the all-providers one, or the single - /// task when exactly one is running. Nil once several run concurrently, so tests await a specific - /// scope rather than an arbitrary task. Test-only; the setter writes `.global`, and production code - /// keys into `manualRefreshTasks` by scope. - var manualRefreshTask: Task? { - get { - if let global = self.manualRefreshTasks[.global] { return global } - return self.manualRefreshTasks.count == 1 ? self.manualRefreshTasks.values.first : nil - } - set { self.manualRefreshTasks[.global] = newValue } - } - - /// The single per-provider manual refresh in flight, or nil when there is none, a global refresh, - /// or several providers refreshing at once. Read only by tests; production code keys into - /// `manualRefreshTasks` directly. - var manualRefreshProvider: UsageProvider? { - guard self.manualRefreshTasks[.global] == nil, self.manualRefreshTasks.count == 1 else { - return nil - } - for case let .provider(provider) in self.manualRefreshTasks.keys { - return provider - } - return nil - } } enum LoginNotificationLogic { diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 7e8442e5a6..dae1718326 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -140,7 +140,6 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var menuRefreshTasks: [ObjectIdentifier: Task] = [:] /// 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). - /// Test-only accessors `manualRefreshTask` / `manualRefreshProvider` live in `+Actions`. var manualRefreshTasks: [ManualRefreshScope: Task] = [:] var closedMenuRebuildTasks: [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 4154838136..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) } @@ -767,7 +767,7 @@ struct StatusMenuPersistentRefreshTests { controller.refreshNow() #expect(requestCount == 0) - #expect(controller.manualRefreshTask == nil) + #expect(controller.manualRefreshTasks.isEmpty) #expect(!controller.menuCardRefreshMonitor.isManualRefreshInFlight) } @@ -787,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() @@ -798,7 +798,7 @@ struct StatusMenuPersistentRefreshTests { gate.resume() await task.value - #expect(controller.manualRefreshTask == nil) + #expect(controller.manualRefreshTasks[.global] == nil) #expect(!controller.menuCardRefreshMonitor.isManualRefreshInFlight) } @@ -824,11 +824,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[.provider(.claude)] == nil { await Task.yield() } - let mouseTask = try #require(controller.manualRefreshTask) - #expect(controller.manualRefreshProvider == .claude) + 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)) @@ -840,11 +839,10 @@ struct StatusMenuPersistentRefreshTests { 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 } @@ -874,8 +872,7 @@ struct StatusMenuPersistentRefreshTests { } #expect(requestCount == 0) - #expect(controller.manualRefreshTask == nil) - #expect(controller.manualRefreshProvider == nil) + #expect(controller.manualRefreshTasks.isEmpty) } @Test @@ -897,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 @@ -910,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 } @@ -965,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) diff --git a/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift b/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift index 413661e90c..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)