mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
cc4cab2f59
commit
463fbf5b16
6 changed files with 166 additions and 39 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
86
apps/desktop/src/lib/middle-click.test.tsx
Normal file
86
apps/desktop/src/lib/middle-click.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
58
apps/desktop/src/lib/middle-click.ts
Normal file
58
apps/desktop/src/lib/middle-click.ts
Normal 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?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue