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?.() + } + } + } +}