fix(menubar): make provider strip reachable and mouse-wheel scrollable

Two related bugs in the macOS menubar `AgentTabStrip`:

1. With more detected providers than fit at the default 360pt popover
   width (~7+), the off-screen provider chips were unreachable. SwiftUI's
   horizontal `ScrollView` does not scroll from click-drag, and there
   was no other affordance to reveal the hidden tabs.

2. Independent mouse wheels could not scroll the horizontal strip.
   Standard wheels emit only vertical `deltaY` with
   `hasPreciseScrollingDeltas == false`, and a horizontal SwiftUI
   `ScrollView` ignores vertical-only deltas. Trackpads (which emit
   horizontal deltas natively) already worked.

Fix:

- Wrap the strip in `ScrollViewReader` and add overflow-aware
  left/right chevron buttons that programmatically scroll to the
  next/previous visible provider via `proxy.scrollTo(_, anchor: .center)`.
  Buttons only appear when `stripContentWidth > stripViewportWidth - 30`
  and disable at the ends.
- Install an `NSEvent.addLocalMonitorForEvents(matching: .scrollWheel)`
  in `.onAppear` (removed in `.onDisappear`). When the cursor is over
  the strip and the event is non-precise (`!hasPreciseScrollingDeltas`)
  with `deltaX≈0` and `deltaY≠0`, copy the `CGEvent` and transpose
  `scrollWheelEventDeltaAxis1` / `PointDeltaAxis1` / `FixedPtDeltaAxis1`
  onto axis 2 so the underlying NSScrollView receives a real horizontal
  delta.
- Track strip hover via a `@MainActor` singleton
  `AgentTabStripScrollState` so the local-monitor closure can read the
  latest hover status without capturing stale SwiftUI state.

Trackpad events are passed through untouched, so vertical scrolling
elsewhere in the popover is unaffected.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Vaibhav Arora 2026-05-15 19:52:57 +05:30
parent 2ca92a97cf
commit 8f35dcd128

View file

@ -1,26 +1,111 @@
import AppKit
import SwiftUI
/// Shared state read by the NSEvent local monitor closure. The closure
/// snapshots its captured environment at install time, so SwiftUI @State
/// can't be used directly a reference-type holder keeps the latest hover
/// status visible to the monitor across SwiftUI updates.
@MainActor
final class AgentTabStripScrollState {
static let shared = AgentTabStripScrollState()
var isStripHovered: Bool = false
}
struct AgentTabStrip: View {
@Environment(AppStore.self) private var store
@State private var stripViewportWidth: CGFloat = 0
@State private var stripContentWidth: CGFloat = 0
@State private var scrollWheelMonitor: Any?
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 5) {
ForEach(visibleFilters) { filter in
AgentTab(
filter: filter,
cost: cost(for: filter),
isActive: store.selectedProvider == filter,
quota: store.quotaSummary(for: filter)
) {
store.switchTo(provider: filter)
GeometryReader { viewportGeo in
ScrollViewReader { proxy in
HStack(spacing: 4) {
if isOverflowing {
Button {
selectAdjacentProvider(direction: -1, proxy: proxy)
} label: {
Image(systemName: "chevron.left")
.font(.system(size: 10, weight: .semibold))
.frame(width: 18, height: 18)
}
.buttonStyle(.plain)
.foregroundStyle(canMoveBackward ? Color.primary : Color.secondary.opacity(0.35))
.disabled(!canMoveBackward)
.help("Show previous providers")
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 5) {
ForEach(visibleFilters) { filter in
AgentTab(
filter: filter,
cost: cost(for: filter),
isActive: store.selectedProvider == filter,
quota: store.quotaSummary(for: filter)
) {
store.switchTo(provider: filter)
withAnimation(.easeInOut(duration: 0.18)) {
proxy.scrollTo(filter.id, anchor: .center)
}
}
.id(filter.id)
}
}
.background(
GeometryReader { contentGeo in
Color.clear
.onAppear {
stripContentWidth = contentGeo.size.width
}
.onChange(of: contentGeo.size.width) { _, newWidth in
stripContentWidth = newWidth
}
}
)
}
.padding(.horizontal, 12)
.padding(.top, 8)
.padding(.bottom, 4)
.onHover { hovering in
AgentTabStripScrollState.shared.isStripHovered = hovering
}
if isOverflowing {
Button {
selectAdjacentProvider(direction: 1, proxy: proxy)
} label: {
Image(systemName: "chevron.right")
.font(.system(size: 10, weight: .semibold))
.frame(width: 18, height: 18)
}
.buttonStyle(.plain)
.foregroundStyle(canMoveForward ? Color.primary : Color.secondary.opacity(0.35))
.disabled(!canMoveForward)
.help("Show next providers")
}
}
.onAppear {
stripViewportWidth = viewportGeo.size.width
installScrollWheelMonitorIfNeeded()
withAnimation(.easeInOut(duration: 0.18)) {
proxy.scrollTo(store.selectedProvider.id, anchor: .center)
}
}
.onChange(of: viewportGeo.size.width) { _, newWidth in
stripViewportWidth = newWidth
}
.onChange(of: store.selectedProvider) { _, newProvider in
withAnimation(.easeInOut(duration: 0.18)) {
proxy.scrollTo(newProvider.id, anchor: .center)
}
}
.onDisappear {
removeScrollWheelMonitorIfNeeded()
}
}
.padding(.horizontal, 12)
.padding(.top, 8)
.padding(.bottom, 4)
}
.frame(height: 38)
}
private var todayAll: MenubarPayload {
@ -55,6 +140,60 @@ struct AgentTabStrip: View {
sum + (providers[key] ?? 0)
}
}
private var currentFilterIndex: Int {
visibleFilters.firstIndex(of: store.selectedProvider) ?? 0
}
private var canMoveBackward: Bool { currentFilterIndex > 0 }
private var canMoveForward: Bool { currentFilterIndex < visibleFilters.count - 1 }
private var isOverflowing: Bool { stripContentWidth > (stripViewportWidth - 30) }
private func selectAdjacentProvider(direction: Int, proxy: ScrollViewProxy) {
guard !visibleFilters.isEmpty else { return }
let targetIndex = min(max(currentFilterIndex + direction, 0), visibleFilters.count - 1)
let target = visibleFilters[targetIndex]
store.switchTo(provider: target)
withAnimation(.easeInOut(duration: 0.18)) {
proxy.scrollTo(target.id, anchor: .center)
}
}
/// Standard mouse wheels emit vertical-only scroll deltas, which a horizontal
/// `ScrollView` ignores. While the cursor is over the strip we transpose
/// vertical-axis scroll fields onto the horizontal axis so the underlying
/// NSScrollView receives a real horizontal delta. Trackpad events (precise
/// deltas, with native horizontal component) are passed through untouched
/// so vertical scrolling elsewhere in the popover is unaffected.
private func installScrollWheelMonitorIfNeeded() {
guard scrollWheelMonitor == nil else { return }
scrollWheelMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { event in
guard AgentTabStripScrollState.shared.isStripHovered,
!event.hasPreciseScrollingDeltas,
abs(event.scrollingDeltaX) < 0.001,
abs(event.scrollingDeltaY) > 0,
let cg = event.cgEvent?.copy() else {
return event
}
let lineDeltaY = cg.getIntegerValueField(.scrollWheelEventDeltaAxis1)
let pointDeltaY = cg.getDoubleValueField(.scrollWheelEventPointDeltaAxis1)
let fixedDeltaY = cg.getDoubleValueField(.scrollWheelEventFixedPtDeltaAxis1)
cg.setIntegerValueField(.scrollWheelEventDeltaAxis1, value: 0)
cg.setDoubleValueField(.scrollWheelEventPointDeltaAxis1, value: 0)
cg.setDoubleValueField(.scrollWheelEventFixedPtDeltaAxis1, value: 0)
cg.setIntegerValueField(.scrollWheelEventDeltaAxis2, value: lineDeltaY)
cg.setDoubleValueField(.scrollWheelEventPointDeltaAxis2, value: pointDeltaY)
cg.setDoubleValueField(.scrollWheelEventFixedPtDeltaAxis2, value: fixedDeltaY)
return NSEvent(cgEvent: cg) ?? event
}
}
private func removeScrollWheelMonitorIfNeeded() {
if let monitor = scrollWheelMonitor {
NSEvent.removeMonitor(monitor)
scrollWheelMonitor = nil
}
}
}
private struct AgentTab: View {