From 34f0ab6f4291d3278cf740c3f1ca30d2a412ec05 Mon Sep 17 00:00:00 2001 From: Perry Story Date: Wed, 20 May 2026 18:58:24 -0400 Subject: [PATCH 1/4] perf: suppress redundant icon observer callbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a derived signature guard to observeStoreIconChanges() so updateIcons() only runs when icon-relevant state actually changes. Remove isRefreshing from iconObservationToken — it toggles every refresh cycle and has no bearing on the rendered menu bar icon, causing the observer to fire continuously on the main thread. With both changes, the main dispatch queue path drops from ~38/42 samples in a 5-second spindump to idle between refresh intervals. --- Sources/CodexBar/StatusItemController.swift | 50 +++++++++++ Sources/CodexBar/UsageStore.swift | 1 - ...tusItemIconObservationSignatureTests.swift | 89 +++++++++++++++++++ 3 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index db0ec82fd..5a22cdda9 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -151,6 +151,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var providerSwitcherUpdateToken = 0 var lastAppliedMergedIconRenderSignature: String? var lastAppliedProviderIconRenderSignatures: [UsageProvider: String] = [:] + var lastObservedStoreIconWorkSignature: String? var lastKnownScreenCount: Int var pendingScreenChangePreviousCount: Int? var screenChangeVisibilityTask: Task? @@ -358,11 +359,59 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin Task { @MainActor [weak self] in guard let self else { return } self.observeStoreIconChanges() + let signature = self.storeIconObservationSignature() + guard signature != self.lastObservedStoreIconWorkSignature else { return } + self.lastObservedStoreIconWorkSignature = signature self.updateIcons() } } } + func storeIconObservationSignature() -> String { + let showBrandPercent = self.settings.menuBarShowsBrandIconWithPercent + let mergeIcons = self.shouldMergeIcons + let needsAnimation = self.needsMenuBarIconAnimation() + let providerSignatures = UsageProvider.allCases.map { + self.providerStoreIconObservationSignature(for: $0, showBrandPercent: showBrandPercent) + }.joined(separator: "||") + let visibleProviders = self.store.enabledProvidersForDisplay().map(\.rawValue).sorted().joined(separator: ",") + return [ + "merge=\(mergeIcons ? "1" : "0")", + "visible=\(visibleProviders)", + "iconStyle=\(String(describing: self.store.iconStyle))", + "brandPercent=\(showBrandPercent ? "1" : "0")", + "needsAnimation=\(needsAnimation ? "1" : "0")", + providerSignatures, + ].joined(separator: "|") + } + + private func providerStoreIconObservationSignature(for provider: UsageProvider, showBrandPercent: Bool) -> String { + let snapshot = self.store.snapshot(for: provider) + let stale = self.store.isStale(provider: provider) + let status = self.store.statusIndicator(for: provider).rawValue + let isVisibleForAnimation = self.shouldMergeIcons ? self.isEnabled(provider) : self.isVisible(provider) + let isAnimating = isVisibleForAnimation && !stale && snapshot == nil + let isRefreshingWarpPlaceholder = self.store.refreshingProviders.contains(provider) + let creditsRemaining = provider == .codex + ? self.store.codexMenuBarCreditsRemaining( + snapshotOverride: snapshot, + now: snapshot?.updatedAt ?? Date()) + : nil + let displayText = showBrandPercent ? self.menuBarDisplayText(for: provider, snapshot: snapshot) : nil + + return [ + provider.rawValue, + "style=\(String(describing: self.store.style(for: provider)))", + "snapshot=\(String(describing: snapshot))", + "stale=\(stale ? "1" : "0")", + "status=\(status)", + "anim=\(isAnimating ? "1" : "0")", + "refreshing=\(isRefreshingWarpPlaceholder ? "1" : "0")", + "credits=\(String(describing: creditsRemaining))", + "text=\(displayText ?? "nil")", + ].joined(separator: "|") + } + private func observeDebugForceAnimation() { withObservationTracking { _ = self.store.debugForceAnimation @@ -542,6 +591,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin #if DEBUG guard !self.isReleasedForTesting else { return } #endif + self.lastObservedStoreIconWorkSignature = self.storeIconObservationSignature() // 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 diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index d798152d4..5c7cee212 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -43,7 +43,6 @@ extension UsageStore { _ = self.openAIDashboard _ = self.lastOpenAIDashboardError _ = self.openAIDashboardRequiresLogin - _ = self.isRefreshing _ = self.refreshingProviders _ = self.statuses _ = self.historicalPaceRevision diff --git a/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift b/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift new file mode 100644 index 000000000..6660c264e --- /dev/null +++ b/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift @@ -0,0 +1,89 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusItemIconObservationSignatureTests { + private func makeController(suiteName: String) -> (SettingsStore, UsageStore, StatusItemController) { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: suiteName), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = true + settings.refreshFrequency = .manual + settings.menuBarShowsBrandIconWithPercent = false + settings.mergeIcons = 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) + store._setSnapshotForTesting(Self.makeSnapshot(provider: .codex, email: "icon@example.com"), provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + return (settings, store, controller) + } + + @Test + func `store icon observation signature ignores refresh and status metadata churn`() { + let (_, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-refresh-metadata") + defer { controller.releaseStatusItemsForTesting() } + + store.statuses[.codex] = ProviderStatus( + indicator: .none, + description: "initial", + updatedAt: Date(timeIntervalSince1970: 10)) + let baseline = controller.storeIconObservationSignature() + + store.isRefreshing = true + store.statuses[.codex] = ProviderStatus( + indicator: .none, + description: "same indicator, newer timestamp", + updatedAt: Date(timeIntervalSince1970: 20)) + + #expect(controller.storeIconObservationSignature() == baseline) + } + + @Test + func `store icon observation signature changes when status indicator changes`() { + let (_, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-status-indicator") + defer { controller.releaseStatusItemsForTesting() } + + store.statuses[.codex] = ProviderStatus( + indicator: .none, + description: "initial", + updatedAt: Date(timeIntervalSince1970: 10)) + let baseline = controller.storeIconObservationSignature() + + store.statuses[.codex] = ProviderStatus( + indicator: .major, + description: "major outage", + updatedAt: Date(timeIntervalSince1970: 20)) + + #expect(controller.storeIconObservationSignature() != baseline) + } + + private static func makeSnapshot(provider: UsageProvider, email: String) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 100), + identity: ProviderIdentitySnapshot( + providerID: provider, + accountEmail: email, + accountOrganization: nil, + loginMethod: "plus")) + } +} From 42db1b0d32911a25001cbb6c621fdc5870821263 Mon Sep 17 00:00:00 2001 From: Perry Story Date: Wed, 20 May 2026 18:58:36 -0400 Subject: [PATCH 2/4] perf: background OpenAI web scrape and credits fetch for regular refreshes Move refreshOpenAIDashboardIfNeeded() and refreshCreditsIfNeeded() into unstructured tasks for non-forced refreshes so the UI never blocks on slow web scrapes during the regular polling cycle. Forced refreshes (manual pull-to-refresh) still await inline. --- Sources/CodexBar/UsageStore.swift | 24 ++- .../CodexManagedOpenAIWebRefreshTests.swift | 192 ++++++++++++++++++ 2 files changed, 212 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 5c7cee212..dc7fffb7d 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -554,7 +554,15 @@ final class UsageStore { group.addTask { await self.refreshStatus(provider) } } } - group.addTask { await self.refreshCreditsIfNeeded(minimumSnapshotUpdatedAt: refreshStartedAt) } + if forceTokenUsage { + group.addTask { await self.refreshCreditsIfNeeded(minimumSnapshotUpdatedAt: refreshStartedAt) } + } + } + + if !forceTokenUsage { + Task { @MainActor [weak self] in + await self?.refreshCreditsIfNeeded(minimumSnapshotUpdatedAt: refreshStartedAt) + } } if forceTokenUsage { @@ -586,9 +594,17 @@ final class UsageStore { ]) if shouldRefreshOpenAIWeb { let codexDashboardGuard = self.currentCodexOpenAIWebRefreshGuard() - await self.refreshOpenAIDashboardIfNeeded( - force: forceTokenUsage, - expectedGuard: codexDashboardGuard) + if forceTokenUsage { + await self.refreshOpenAIDashboardIfNeeded( + force: true, + expectedGuard: codexDashboardGuard) + } else { + Task { @MainActor [weak self] in + await self?.refreshOpenAIDashboardIfNeeded( + force: false, + expectedGuard: codexDashboardGuard) + } + } } if forceTokenUsage, self.openAIDashboardRequiresLogin { diff --git a/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift b/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift index 36f3fe902..6c7a7be79 100644 --- a/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift +++ b/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift @@ -6,6 +6,129 @@ import Testing @Suite(.serialized) @MainActor struct CodexManagedOpenAIWebRefreshTests { + @Test + func `regular refresh does not await OpenAI web scrape`() async { + let settings = self.makeSettingsStore(suite: "CodexManagedOpenAIWebRefreshTests-regular-refresh-nonblocking") + settings.statusChecksEnabled = false + if let codexMeta = ProviderRegistry.shared.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + let managedHomeURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try? Self.writeCodexAuthFile( + homeURL: managedHomeURL, + email: "managed@example.com", + plan: "Pro") + defer { try? FileManager.default.removeItem(at: managedHomeURL) } + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + managedHomePath: managedHomeURL.path, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + settings._test_activeManagedCodexAccount = managedAccount + settings.codexActiveSource = .managedAccount(id: managedAccount.id) + defer { settings._test_activeManagedCodexAccount = nil } + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + let blocker = BlockingManagedOpenAIDashboardLoader() + let completion = RefreshCompletionProbe() + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + store._test_codexCreditsLoaderOverride = { + CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) + } + defer { store._test_codexCreditsLoaderOverride = nil } + store._test_openAIDashboardLoaderOverride = { _, _, _ in + try await blocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = nil } + + let refreshTask = Task { + await store.refresh(forceTokenUsage: false) + await completion.markCompleted() + } + + try? await Task.sleep(for: .milliseconds(200)) + + #expect(await blocker.startedCount() == 1) + #expect(await completion.isCompleted == true) + + await blocker.resumeNext(with: .success(OpenAIDashboardSnapshot( + signedInEmail: managedAccount.email, + codeReviewRemainingPercent: 95, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + creditsRemaining: 25, + accountPlan: "Pro", + updatedAt: Date()))) + + await refreshTask.value + } + + @Test + func `regular refresh does not await Codex credits fetch`() async { + let settings = self.makeSettingsStore( + suite: "CodexManagedOpenAIWebRefreshTests-regular-refresh-nonblocking-credits") + settings.statusChecksEnabled = false + settings.openAIWebAccessEnabled = false + if let codexMeta = ProviderRegistry.shared.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + let managedHomeURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try? Self.writeCodexAuthFile( + homeURL: managedHomeURL, + email: "managed@example.com", + plan: "Pro") + defer { try? FileManager.default.removeItem(at: managedHomeURL) } + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + managedHomePath: managedHomeURL.path, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + settings._test_activeManagedCodexAccount = managedAccount + settings.codexActiveSource = .managedAccount(id: managedAccount.id) + defer { settings._test_activeManagedCodexAccount = nil } + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + let blocker = BlockingCreditsLoader() + let completion = RefreshCompletionProbe() + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + store._test_codexCreditsLoaderOverride = { + try await blocker.awaitResult() + } + defer { store._test_codexCreditsLoaderOverride = nil } + + let refreshTask = Task { + await store.refresh(forceTokenUsage: false) + await completion.markCompleted() + } + + try? await Task.sleep(for: .milliseconds(200)) + + #expect(await blocker.startedCount() == 1) + #expect(await completion.isCompleted == true) + + await blocker.resumeNext(with: .success(CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()))) + + await refreshTask.value + } + @Test func `manual cookie import bypasses same account refresh coalescing`() async { let settings = self.makeSettingsStore(suite: "CodexManagedOpenAIWebRefreshTests-manual-import-bypass-coalesce") @@ -305,6 +428,44 @@ struct CodexManagedOpenAIWebRefreshTests { settings.codexCookieSource = .auto return settings } + + private static func writeCodexAuthFile(homeURL: URL, email: String, plan: String, accountId: String? = nil) throws { + try FileManager.default.createDirectory(at: homeURL, withIntermediateDirectories: true) + var tokens: [String: Any] = [ + "accessToken": "access-token", + "refreshToken": "refresh-token", + "idToken": Self.fakeJWT(email: email, plan: plan, accountId: accountId), + ] + if let accountId { + tokens["accountId"] = accountId + } + let data = try JSONSerialization.data(withJSONObject: ["tokens": tokens], options: [.sortedKeys]) + try data.write(to: homeURL.appendingPathComponent("auth.json")) + } + + private static func fakeJWT(email: String, plan: String, accountId: String? = nil) -> String { + let header = (try? JSONSerialization.data(withJSONObject: ["alg": "none"])) ?? Data() + var authClaims: [String: Any] = [ + "chatgpt_plan_type": plan, + ] + if let accountId { + authClaims["chatgpt_account_id"] = accountId + } + let payload = (try? JSONSerialization.data(withJSONObject: [ + "email": email, + "chatgpt_plan_type": plan, + "https://api.openai.com/auth": authClaims, + ])) ?? Data() + + func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "=", with: "") + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + } + + return "\(base64URL(header)).\(base64URL(payload))." + } } private enum ManagedDashboardTestError: LocalizedError { @@ -318,6 +479,14 @@ private enum ManagedDashboardTestError: LocalizedError { } } +private actor RefreshCompletionProbe { + private(set) var isCompleted = false + + func markCompleted() { + self.isCompleted = true + } +} + private actor BlockingManagedOpenAIDashboardLoader { private var continuations: [CheckedContinuation, Never>] = [] private var startWaiters: [(count: Int, continuation: CheckedContinuation)] = [] @@ -362,6 +531,29 @@ private actor BlockingManagedOpenAIDashboardLoader { } } +private actor BlockingCreditsLoader { + private var continuations: [CheckedContinuation, Never>] = [] + private var started = 0 + + func awaitResult() async throws -> CreditsSnapshot { + self.started += 1 + let result = await withCheckedContinuation { continuation in + self.continuations.append(continuation) + } + return try result.get() + } + + func startedCount() -> Int { + self.started + } + + func resumeNext(with result: Result) { + guard !self.continuations.isEmpty else { return } + let continuation = self.continuations.removeFirst() + continuation.resume(returning: result) + } +} + private actor OpenAIDashboardImportCallTracker { private var calls: Int = 0 private var waiters: [(count: Int, continuation: CheckedContinuation)] = [] From e9e9fb8d1d2cc10af8fc17f3491d4acb937d1510 Mon Sep 17 00:00:00 2001 From: Perry Story Date: Wed, 20 May 2026 18:58:46 -0400 Subject: [PATCH 3/4] fix: accept first click in Codex account switcher The plain NSButton inside CodexAccountSwitcherView was dropping the first click because the menu popover wasn't key. Add acceptsFirstMouse, swallow child hit testing so the parent view handles mouse events directly, and forward mouseDown/mouseUp with press tracking to prevent drag-off-then-release misfires. --- .../StatusItemController+SwitcherViews.swift | 57 +++++++++++++-- .../StatusMenuCodexSwitcherTests.swift | 69 +++++++++++++++++++ 2 files changed, 121 insertions(+), 5 deletions(-) diff --git a/Sources/CodexBar/StatusItemController+SwitcherViews.swift b/Sources/CodexBar/StatusItemController+SwitcherViews.swift index a5fa0ae16..eda4c91f9 100644 --- a/Sources/CodexBar/StatusItemController+SwitcherViews.swift +++ b/Sources/CodexBar/StatusItemController+SwitcherViews.swift @@ -1036,6 +1036,7 @@ final class CodexAccountSwitcherView: NSView { private let accounts: [CodexVisibleAccount] private let onSelect: (CodexVisibleAccount) -> Void private var selectedAccountID: String + private var pressedAccountID: String? private var buttons: [NSButton] = [] private let preferredSize: NSSize private let rowSpacing: CGFloat = 4 @@ -1278,10 +1279,44 @@ final class CodexAccountSwitcherView: NSView { } } + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { + true + } + + override func hitTest(_ point: NSPoint) -> NSView? { + let descendant = super.hitTest(point) + if descendant != nil, descendant !== self { + return self + } + return descendant + } + + override func mouseDown(with event: NSEvent) { + let location = self.convert(event.locationInWindow, from: nil) + self.pressedAccountID = self.buttons.first(where: { $0.frame.contains(location) })?.identifier?.rawValue + } + + override func mouseUp(with event: NSEvent) { + defer { self.pressedAccountID = nil } + guard let pressedAccountID = self.pressedAccountID else { return } + let location = self.convert(event.locationInWindow, from: nil) + guard let releasedAccountID = self.buttons.first(where: { $0.frame.contains(location) })?.identifier?.rawValue, + releasedAccountID == pressedAccountID, + let account = self.accounts.first(where: { $0.id == pressedAccountID }) + else { + return + } + self.applySelection(account) + } + @objc private func handleSelect(_ sender: NSButton) { - guard let accountID = sender.identifier?.rawValue else { return } - guard let account = self.accounts.first(where: { $0.id == accountID }) else { return } - self.selectedAccountID = accountID + guard let accountID = sender.identifier?.rawValue, + let account = self.accounts.first(where: { $0.id == accountID }) else { return } + self.applySelection(account) + } + + private func applySelection(_ account: CodexVisibleAccount) { + self.selectedAccountID = account.id self.updateButtonStyles() self.onSelect(account) } @@ -1296,8 +1331,20 @@ final class CodexAccountSwitcherView: NSView { } func _test_selectAccount(id: String) { - guard let button = self.buttons.first(where: { $0.identifier?.rawValue == id }) else { return } - self.handleSelect(button) + guard let account = self.accounts.first(where: { $0.id == id }) else { return } + self.applySelection(account) + } + + func _test_simulateRuntimeClick(id: String) -> Bool { + guard let account = self.accounts.first(where: { $0.id == id }) else { return false } + self.applySelection(account) + return true + } + + func _test_hitTestSwallowsChildButton(id: String) -> Bool { + guard let button = self.buttons.first(where: { $0.identifier?.rawValue == id }) else { return false } + let point = self.convert(NSPoint(x: button.bounds.midX, y: button.bounds.midY), from: button) + return self.hitTest(point) === self } #endif } diff --git a/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift b/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift index a312dceaf..73df280b4 100644 --- a/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift +++ b/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift @@ -872,6 +872,75 @@ struct StatusMenuCodexSwitcherTests { } } +extension StatusMenuCodexSwitcherTests { + @Test + func `codex account switcher swallows child button hit testing for first click`() throws { + let managedID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let accounts = [ + CodexVisibleAccount( + id: "live@example.com", + email: "live@example.com", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: true, + canRemove: false), + CodexVisibleAccount( + id: "managed@example.com", + email: "managed@example.com", + storedAccountID: managedID, + selectionSource: .managedAccount(id: managedID), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true), + ] + let view = CodexAccountSwitcherView( + accounts: accounts, + selectedAccountID: accounts.first?.id, + width: 220, + onSelect: { _ in }) + + #expect(view.acceptsFirstMouse(for: nil) == true) + #expect(view._test_hitTestSwallowsChildButton(id: "managed@example.com") == true) + } + + @Test + func `codex account switcher routes runtime click path to selected account`() throws { + let managedID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let accounts = [ + CodexVisibleAccount( + id: "live@example.com", + email: "live@example.com", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: true, + canRemove: false), + CodexVisibleAccount( + id: "managed@example.com", + email: "managed@example.com", + storedAccountID: managedID, + selectionSource: .managedAccount(id: managedID), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true), + ] + var selectedAccount: CodexVisibleAccount? + let view = CodexAccountSwitcherView( + accounts: accounts, + selectedAccountID: accounts.first?.id, + width: 220, + onSelect: { selectedAccount = $0 }) + + #expect(view._test_simulateRuntimeClick(id: "managed@example.com") == true) + #expect(selectedAccount == accounts[1]) + } +} + @MainActor extension StatusMenuCodexSwitcherTests { @Test From 57904af3040008a90b3e3cd50aca8ced19a27b96 Mon Sep 17 00:00:00 2001 From: Perry Story Date: Wed, 20 May 2026 19:19:11 -0400 Subject: [PATCH 4/4] fix: resolve multi-row Codex switcher hit testing --- .../StatusItemController+SwitcherViews.swift | 20 +++++-- .../StatusMenuCodexSwitcherTests.swift | 53 +++++++++++++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBar/StatusItemController+SwitcherViews.swift b/Sources/CodexBar/StatusItemController+SwitcherViews.swift index eda4c91f9..c4926164c 100644 --- a/Sources/CodexBar/StatusItemController+SwitcherViews.swift +++ b/Sources/CodexBar/StatusItemController+SwitcherViews.swift @@ -1293,14 +1293,14 @@ final class CodexAccountSwitcherView: NSView { override func mouseDown(with event: NSEvent) { let location = self.convert(event.locationInWindow, from: nil) - self.pressedAccountID = self.buttons.first(where: { $0.frame.contains(location) })?.identifier?.rawValue + self.pressedAccountID = self.accountID(at: location) } override func mouseUp(with event: NSEvent) { defer { self.pressedAccountID = nil } guard let pressedAccountID = self.pressedAccountID else { return } let location = self.convert(event.locationInWindow, from: nil) - guard let releasedAccountID = self.buttons.first(where: { $0.frame.contains(location) })?.identifier?.rawValue, + guard let releasedAccountID = self.accountID(at: location), releasedAccountID == pressedAccountID, let account = self.accounts.first(where: { $0.id == pressedAccountID }) else { @@ -1309,6 +1309,10 @@ final class CodexAccountSwitcherView: NSView { self.applySelection(account) } + private func accountID(at pointInSelf: NSPoint) -> String? { + self.buttons.first(where: { self.convert($0.bounds, from: $0).contains(pointInSelf) })?.identifier?.rawValue + } + @objc private func handleSelect(_ sender: NSButton) { guard let accountID = sender.identifier?.rawValue, let account = self.accounts.first(where: { $0.id == accountID }) else { return } @@ -1336,7 +1340,17 @@ final class CodexAccountSwitcherView: NSView { } func _test_simulateRuntimeClick(id: String) -> Bool { - guard let account = self.accounts.first(where: { $0.id == id }) else { return false } + guard let button = self.buttons.first(where: { $0.identifier?.rawValue == id }) else { return false } + let point = self.convert(NSPoint(x: button.bounds.midX, y: button.bounds.midY), from: button) + self.pressedAccountID = self.accountID(at: point) + defer { self.pressedAccountID = nil } + guard let pressedAccountID = self.pressedAccountID, + let releasedAccountID = self.accountID(at: point), + releasedAccountID == pressedAccountID, + let account = self.accounts.first(where: { $0.id == pressedAccountID }) + else { + return false + } self.applySelection(account) return true } diff --git a/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift b/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift index 73df280b4..755060f3c 100644 --- a/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift +++ b/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift @@ -939,6 +939,59 @@ extension StatusMenuCodexSwitcherTests { #expect(view._test_simulateRuntimeClick(id: "managed@example.com") == true) #expect(selectedAccount == accounts[1]) } + + @Test + func `codex account switcher runtime click resolves second row buttons`() throws { + let managedID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let secondManagedID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-222222222222")) + let accounts = [ + CodexVisibleAccount( + id: "live@example.com", + email: "live@example.com", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: true, + canRemove: false), + CodexVisibleAccount( + id: "managed@example.com", + email: "managed@example.com", + storedAccountID: managedID, + selectionSource: .managedAccount(id: managedID), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true), + CodexVisibleAccount( + id: "team@example.com", + email: "team@example.com", + storedAccountID: secondManagedID, + selectionSource: .managedAccount(id: secondManagedID), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true), + CodexVisibleAccount( + id: "second-row@example.com", + email: "second-row@example.com", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: false, + isLive: true, + canReauthenticate: true, + canRemove: false), + ] + var selectedAccount: CodexVisibleAccount? + let view = CodexAccountSwitcherView( + accounts: accounts, + selectedAccountID: accounts.first?.id, + width: 220, + onSelect: { selectedAccount = $0 }) + + #expect(view._test_simulateRuntimeClick(id: "second-row@example.com") == true) + #expect(selectedAccount == accounts[3]) + } } @MainActor