Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down
101 changes: 96 additions & 5 deletions Sources/CodexBar/StatusItemController+SwitcherViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1278,10 +1279,50 @@ 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 {
self.toolTip = (descendant as? NSButton)?.toolTip
return self
}
self.toolTip = nil
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)
}
Expand All @@ -1296,8 +1337,58 @@ 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
}

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
}
123 changes: 123 additions & 0 deletions Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -872,6 +872,129 @@ 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)
#expect(view._test_toolTipAfterHitTest(id: "managed@example.com") == "managed@example.com")
}

@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
Expand Down