From 68e0f66650ab74a344f481439b041920d3c1bc59 Mon Sep 17 00:00:00 2001 From: Perry Story Date: Thu, 21 May 2026 05:22:50 -0400 Subject: [PATCH 1/2] fix: accept first click in Codex account switcher Override acceptsFirstMouse, swallow child hit testing, and implement mouseDown/mouseUp with correct coordinate space conversion for multi-row account layouts. Root cause: the runtime-click tests synthesized hit points before NSStackView laid out the switcher rows, so every button still sat at {0,0} and the simulated click always resolved to the first account. Split from #1073. --- .../StatusItemController+SwitcherViews.swift | 90 ++++++++++++- .../StatusMenuCodexSwitcherTests.swift | 122 ++++++++++++++++++ 2 files changed, 207 insertions(+), 5 deletions(-) diff --git a/Sources/CodexBar/StatusItemController+SwitcherViews.swift b/Sources/CodexBar/StatusItemController+SwitcherViews.swift index a5fa0ae16..a564f5c12 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,48 @@ 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.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.accountID(at: location), + releasedAccountID == pressedAccountID, + let account = self.accounts.first(where: { $0.id == pressedAccountID }) + else { + return + } + 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 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 +1335,49 @@ 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 button = self.buttons.first(where: { $0.identifier?.rawValue == id }) else { return false } + self.updateConstraintsForSubtreeIfNeeded() + self.layoutSubtreeIfNeeded() + let point = self.convert(NSPoint(x: button.bounds.midX, y: button.bounds.midY), from: button) + guard let mouseDownEvent = NSEvent.mouseEvent( + with: .leftMouseDown, + location: point, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + eventNumber: 1, + clickCount: 1, + pressure: 1), + let mouseUpEvent = NSEvent.mouseEvent( + with: .leftMouseUp, + location: point, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + eventNumber: 2, + clickCount: 1, + pressure: 0) + else { + return false + } + self.mouseDown(with: mouseDownEvent) + self.mouseUp(with: mouseUpEvent) + return self.selectedAccountID == id + } + + func _test_hitTestSwallowsChildButton(id: String) -> Bool { + guard let button = self.buttons.first(where: { $0.identifier?.rawValue == id }) else { return false } + self.updateConstraintsForSubtreeIfNeeded() + self.layoutSubtreeIfNeeded() + 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..755060f3c 100644 --- a/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift +++ b/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift @@ -872,6 +872,128 @@ 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]) + } + + @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 extension StatusMenuCodexSwitcherTests { @Test From 49a1edbc3da6939d1e9924490c5b9f2985f2ef5f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 21 May 2026 18:50:59 +0100 Subject: [PATCH 2/2] fix: preserve Codex switcher tooltip hit testing --- CHANGELOG.md | 1 + .../CodexBar/StatusItemController+SwitcherViews.swift | 11 +++++++++++ .../CodexBarTests/StatusMenuCodexSwitcherTests.swift | 1 + 3 files changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dda40843d..e8e649f70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Localizations: add Spanish and Catalan language packs and fill missing localization keys (#1041). Thanks @seifreed! ### Fixed +- Codex: accept the first click in the account switcher inside menu popovers (#1079). Thanks @ptstory! - Codex/Claude: terminate PTY child process trees during probe cleanup so wrapper-launched CLI descendants do not linger after sessions finish (#1085). Thanks @mickobizzle! - OpenAI: parse Wednesday and Saturday dashboard reset lines so rate-limit reset times are not dropped on those days (#1080). Thanks @m1qaweb! - Localization: translate provider-detail labels and empty states when Simplified Chinese is selected (#1051). Thanks @wang93wei! diff --git a/Sources/CodexBar/StatusItemController+SwitcherViews.swift b/Sources/CodexBar/StatusItemController+SwitcherViews.swift index a564f5c12..90dd05c2c 100644 --- a/Sources/CodexBar/StatusItemController+SwitcherViews.swift +++ b/Sources/CodexBar/StatusItemController+SwitcherViews.swift @@ -1286,8 +1286,10 @@ final class CodexAccountSwitcherView: NSView { override func hitTest(_ point: NSPoint) -> NSView? { let descendant = super.hitTest(point) if descendant != nil, descendant !== self { + self.toolTip = (descendant as? NSButton)?.toolTip return self } + self.toolTip = nil return descendant } @@ -1379,5 +1381,14 @@ final class CodexAccountSwitcherView: NSView { let point = self.convert(NSPoint(x: button.bounds.midX, y: button.bounds.midY), from: button) return self.hitTest(point) === self } + + func _test_toolTipAfterHitTest(id: String) -> String? { + guard let button = self.buttons.first(where: { $0.identifier?.rawValue == id }) else { return nil } + self.updateConstraintsForSubtreeIfNeeded() + self.layoutSubtreeIfNeeded() + let point = self.convert(NSPoint(x: button.bounds.midX, y: button.bounds.midY), from: button) + _ = self.hitTest(point) + return self.toolTip + } #endif } diff --git a/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift b/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift index 755060f3c..4ad6ce803 100644 --- a/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift +++ b/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift @@ -904,6 +904,7 @@ extension StatusMenuCodexSwitcherTests { #expect(view.acceptsFirstMouse(for: nil) == true) #expect(view._test_hitTestSwallowsChildButton(id: "managed@example.com") == true) + #expect(view._test_toolTipAfterHitTest(id: "managed@example.com") == "managed@example.com") } @Test