From 463fbf5b16749898dd3076c04e8647400c80df3e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 21:43:14 -0500 Subject: [PATCH] fix(desktop): middle-click works on a real three-button mouse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/app/chat/sidebar/session-row.tsx | 17 ++-- .../src/app/right-sidebar/terminal/rail.tsx | 13 +-- .../src/components/ui/pane-tab.test.tsx | 6 +- apps/desktop/src/components/ui/pane-tab.tsx | 25 +++--- apps/desktop/src/lib/middle-click.test.tsx | 86 +++++++++++++++++++ apps/desktop/src/lib/middle-click.ts | 58 +++++++++++++ 6 files changed, 166 insertions(+), 39 deletions(-) create mode 100644 apps/desktop/src/lib/middle-click.test.tsx create mode 100644 apps/desktop/src/lib/middle-click.ts diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx index fcaa8b19edf..cfbeb9f2afc 100644 --- a/apps/desktop/src/app/chat/sidebar/session-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx @@ -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({ )} { - 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 ? ( { - 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" > diff --git a/apps/desktop/src/components/ui/pane-tab.test.tsx b/apps/desktop/src/components/ui/pane-tab.test.tsx index c36f03fb8ad..5b340a9fb17 100644 --- a/apps/desktop/src/components/ui/pane-tab.test.tsx +++ b/apps/desktop/src/components/ui/pane-tab.test.tsx @@ -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( @@ -14,7 +14,9 @@ describe('PaneTab close gestures', () => { ) - 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) }) diff --git a/apps/desktop/src/components/ui/pane-tab.tsx b/apps/desktop/src/components/ui/pane-tab.tsx index eb553a770e5..87545f018c6 100644 --- a/apps/desktop/src/components/ui/pane-tab.tsx +++ b/apps/desktop/src/components/ui/pane-tab.tsx @@ -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(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(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 (
(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(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(function P onPointerDown?.(event) }} + onPointerUp={event => { + middle.onPointerUp(event) + onPointerUp?.(event) + }} ref={ref} {...props} > diff --git a/apps/desktop/src/lib/middle-click.test.tsx b/apps/desktop/src/lib/middle-click.test.tsx new file mode 100644 index 00000000000..c701825932e --- /dev/null +++ b/apps/desktop/src/lib/middle-click.test.tsx @@ -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 ( + + ) +} + +describe('middleClickHandlers', () => { + it('fires without an auxclick — the event Chromium eats when autoscroll starts', () => { + const action = vi.fn() + render() + + middleClick(screen.getByText('target')) + expect(action).toHaveBeenCalledTimes(1) + }) + + it('cancels mousedown so the autoscroll pan widget never appears', () => { + render() + + 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() + + expect(fireEvent.mouseDown(screen.getByText('target'), { button: 1 })).toBe(false) + }) + + it('ignores left and right buttons', () => { + const action = vi.fn() + render() + + 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( + <> + + + + ) + + 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( + <> + + + + ) + + middleClick(screen.getByText('inert'), screen.getByText('live')) + expect(action).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/lib/middle-click.ts b/apps/desktop/src/lib/middle-click.ts new file mode 100644 index 00000000000..bba5e587431 --- /dev/null +++ b/apps/desktop/src/lib/middle-click.ts @@ -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?.() + } + } + } +}