fix(desktop): middle-click works on a real three-button mouse

Chromium on Windows and Linux answers a middle press inside a scroller by
starting the autoscroll pan, and the mouseup that ends the pan never becomes
an auxclick. Every surface carrying the gesture — tab strips, the session
list, the terminal rail — is a scroller, so middle-click only ever worked on
macOS, where autoscroll doesn't exist.

Arm on pointerdown, spend on the pointerup over the same element (press one
tab, release on another and nothing happens), and cancel the middle mousedown
on every press so the pan widget can't appear on a surface that owns the
button. One helper, four call sites.
This commit is contained in:
Brooklyn Nicholson 2026-07-30 21:43:14 -05:00
parent cc4cab2f59
commit 463fbf5b16
6 changed files with 166 additions and 39 deletions

View file

@ -13,6 +13,7 @@ import type { SessionInfo } from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { middleClickHandlers } from '@/lib/middle-click'
import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
import { coarseElapsed } from '@/lib/time'
import { cn } from '@/lib/utils'
@ -168,16 +169,11 @@ function SidebarSessionRowImpl({
)}
<SidebarRowBody
className={cn('z-0 group-hover:pr-12', branchStem && 'pl-3.5')}
// Middle-click = open in a new tab (browser muscle memory). Swallow
// the mousedown so Chromium doesn't enter autoscroll mode.
onAuxClick={event => {
if (event.button === 1) {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
openSession(session.id, () => undefined, 'tab')
}
}}
// Middle-click = open in a new tab (browser muscle memory).
{...middleClickHandlers(() => {
triggerHaptic('selection')
openSession(session.id, () => undefined, 'tab')
})}
onClick={event => {
const mod = event.metaKey || event.ctrlKey
@ -213,7 +209,6 @@ function SidebarSessionRowImpl({
onResume()
}}
onMouseDown={event => event.button === 1 && event.preventDefault()}
>
{reorderable ? (
<SidebarRowGrab

View file

@ -11,6 +11,7 @@ import {
import { Tip, TipHintLabel } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { formatCombo } from '@/lib/keybinds/combo'
import { middleClickHandlers } from '@/lib/middle-click'
import { cn } from '@/lib/utils'
import { $bindings } from '@/store/keybinds'
@ -130,18 +131,8 @@ function TerminalRailItem({ active, canCloseOthers, index, term, toggleHint }: T
? 'bg-(--chrome-action-hover) text-foreground'
: 'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)}
onAuxClick={event => {
if (event.button === 1) {
event.preventDefault()
closeTerminal(term.id)
}
}}
{...middleClickHandlers(() => closeTerminal(term.id))}
onClick={() => selectTerminal(term.id)}
onMouseDown={event => {
if (event.button === 1) {
event.preventDefault()
}
}}
role="tab"
type="button"
>

View file

@ -6,7 +6,7 @@ import { PaneTab, PaneTabLabel } from './pane-tab'
afterEach(cleanup)
describe('PaneTab close gestures', () => {
it('middle-click (button 1) closes', () => {
it('middle-click closes — pointer events only, no auxclick', () => {
const onClose = vi.fn()
render(
<PaneTab onClose={onClose}>
@ -14,7 +14,9 @@ describe('PaneTab close gestures', () => {
</PaneTab>
)
fireEvent(screen.getByText('tab'), new MouseEvent('auxclick', { bubbles: true, button: 1 }))
const tab = screen.getByText('tab')
fireEvent.pointerDown(tab, { button: 1 })
fireEvent.pointerUp(tab, { button: 1 })
expect(onClose).toHaveBeenCalledTimes(1)
})

View file

@ -1,5 +1,6 @@
import * as React from 'react'
import { middleClickHandlers } from '@/lib/middle-click'
import { cn } from '@/lib/utils'
/** Inset stroke for a vertical tab rail — content-facing edge. */
@ -60,9 +61,9 @@ export const PaneTab = React.forwardRef<HTMLDivElement, PaneTabProps>(function P
active = false,
dirty = false,
onClose,
onAuxClick,
onMouseDown,
onPointerDown,
onPointerUp,
onClickCapture,
vertical = false,
side = 'left',
@ -75,6 +76,7 @@ export const PaneTab = React.forwardRef<HTMLDivElement, PaneTabProps>(function P
// Vertical rails only. Horizontal tabs draw no bottom border — the strip owns
// that rule, and a per-tab border stacked a second translucent line over it.
const edge = vertical ? (side === 'right' ? 'border-l' : 'border-r') : undefined
const middle = middleClickHandlers(onClose)
return (
<div
@ -89,16 +91,6 @@ export const PaneTab = React.forwardRef<HTMLDivElement, PaneTabProps>(function P
)}
data-active={active}
data-vertical={vertical || undefined}
onAuxClick={event => {
// Middle-click closes (browser/IDE). Swallow mousedown so Chromium
// doesn't autoscroll.
if (onClose && event.button === 1) {
event.preventDefault()
onClose()
}
onAuxClick?.(event)
}}
onClickCapture={event => {
// Sites whose tab activates on the label's own onClick (the preview
// rail) fire it AFTER our pointerdown close — swallow that stray click
@ -111,13 +103,12 @@ export const PaneTab = React.forwardRef<HTMLDivElement, PaneTabProps>(function P
onClickCapture?.(event)
}}
onMouseDown={event => {
if (onClose && event.button === 1) {
event.preventDefault()
}
middle.onMouseDown(event)
onMouseDown?.(event)
}}
onPointerDown={event => {
middle.onPointerDown(event)
// ⌘-click closes. Preempt here — the tab strips activate/drag on
// pointerdown (drag-session onTap), so we must claim the press before
// the shell's own handler starts a drag, and skip it entirely.
@ -131,6 +122,10 @@ export const PaneTab = React.forwardRef<HTMLDivElement, PaneTabProps>(function P
onPointerDown?.(event)
}}
onPointerUp={event => {
middle.onPointerUp(event)
onPointerUp?.(event)
}}
ref={ref}
{...props}
>

View file

@ -0,0 +1,86 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { middleClickHandlers } from './middle-click'
afterEach(cleanup)
/** A middle click as a real three-button mouse delivers it. Chromium on
* Windows/Linux swallows the trailing `auxclick` when the press starts
* autoscroll, so the gesture may NOT depend on that event. */
function middleClick(element: Element, upOn: Element = element) {
fireEvent.mouseDown(element, { button: 1 })
fireEvent.pointerDown(element, { button: 1 })
fireEvent.pointerUp(upOn, { button: 1 })
}
function Target({ action, id = 'target' }: { action?: () => void; id?: string }) {
return (
<button {...middleClickHandlers(action)} id={id} type="button">
{id}
</button>
)
}
describe('middleClickHandlers', () => {
it('fires without an auxclick — the event Chromium eats when autoscroll starts', () => {
const action = vi.fn()
render(<Target action={action} />)
middleClick(screen.getByText('target'))
expect(action).toHaveBeenCalledTimes(1)
})
it('cancels mousedown so the autoscroll pan widget never appears', () => {
render(<Target action={vi.fn()} />)
const down = fireEvent.mouseDown(screen.getByText('target'), { button: 1 })
expect(down).toBe(false) // preventDefault() called
})
it('cancels the middle mousedown even with no action — the surface owns the button', () => {
render(<Target />)
expect(fireEvent.mouseDown(screen.getByText('target'), { button: 1 })).toBe(false)
})
it('ignores left and right buttons', () => {
const action = vi.fn()
render(<Target action={action} />)
const target = screen.getByText('target')
fireEvent.pointerDown(target, { button: 0 })
fireEvent.pointerUp(target, { button: 0 })
fireEvent.pointerDown(target, { button: 2 })
fireEvent.pointerUp(target, { button: 2 })
expect(action).not.toHaveBeenCalled()
})
it('does nothing when the release lands on a different element', () => {
const pressed = vi.fn()
const released = vi.fn()
render(
<>
<Target action={pressed} id="pressed" />
<Target action={released} id="released" />
</>
)
middleClick(screen.getByText('pressed'), screen.getByText('released'))
expect(pressed).not.toHaveBeenCalled()
expect(released).not.toHaveBeenCalled()
})
it('a press with no action cannot arm the NEXT element it releases over', () => {
const action = vi.fn()
render(
<>
<Target id="inert" />
<Target action={action} id="live" />
</>
)
middleClick(screen.getByText('inert'), screen.getByText('live'))
expect(action).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,58 @@
import type * as React from 'react'
/** `MouseEvent.button` for the middle (wheel) button. */
const MIDDLE_BUTTON = 1
/** Where the current middle press started. One pointer holds one button, so a
* single slot is the whole state, and it's only ever compared by identity in
* the pointerup right after a value left behind by a press released
* elsewhere is inert, not stale. */
let pressedOn: EventTarget | null = null
/**
* Middle-click as a gesture that survives a real three-button mouse.
*
* `auxclick` is the obvious event and the wrong one to build on. Windows and
* Linux Chromium answer a middle press inside a scroller by starting the
* AUTOSCROLL pan, and the mouseup that ends the pan is spent stopping it
* instead of completing a click so `auxclick` never arrives. Every surface
* carrying this gesture (tab strips, the session list, the terminal rail) is a
* scroller, which is why it only ever worked on macOS, where autoscroll
* doesn't exist.
*
* Pointer events fire either way, so the gesture arms on pointerdown and is
* spent on the pointerup over the SAME element press one tab, release on
* another and nothing happens (Chrome / VS Code semantics). mousedown's default
* dies on every middle press, action or not, so the pan widget can't appear on
* a surface that owns the button.
*
* A plain factory, not a hook: tab strips call it inside `map()`.
*/
export function middleClickHandlers(action: (() => void) | undefined) {
return {
onMouseDown: (event: React.MouseEvent) => {
if (event.button === MIDDLE_BUTTON) {
event.preventDefault()
}
},
onPointerDown: (event: React.PointerEvent) => {
if (event.button === MIDDLE_BUTTON) {
pressedOn = action ? event.currentTarget : null
}
},
onPointerUp: (event: React.PointerEvent) => {
if (event.button !== MIDDLE_BUTTON) {
return
}
const armed = pressedOn === event.currentTarget
pressedOn = null
if (armed) {
action?.()
}
}
}
}