Merge pull request #75127 from NousResearch/bb/close-last-tab

desktop: closing the last main tab lands on New session, and middle-click works on a real mouse
This commit is contained in:
brooklyn! 2026-07-30 21:58:43 -05:00 committed by GitHub
commit ab158e8088
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 376 additions and 90 deletions

View File

@ -1,9 +1,30 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const closeFocusedSessionTab = vi.fn(() => false)
const nextSessionTileForWorkspace = vi.fn<() => null | string>(() => null)
const closeSessionTile = vi.fn()
const requestFreshSession = vi.fn()
vi.mock('@/components/pane-shell/tree/store', () => ({
closeFocusedSessionTab: () => closeFocusedSessionTab()
}))
vi.mock('@/store/session-states', () => ({
closeSessionTile: (...args: unknown[]) => closeSessionTile(...args),
nextSessionTileForWorkspace: () => nextSessionTileForWorkspace()
}))
vi.mock('@/store/profile', () => ({
requestFreshSession: () => requestFreshSession()
}))
import { $rightRailActiveTabId } from '@/store/layout'
import { $previewTabs, closeRightRail, openPreview, type PreviewTarget } from '@/store/preview'
import { $activeSessionId, $selectedStoredSessionId } from '@/store/session'
import { closeActiveTab } from './close-tab'
import { $workspaceIsPage } from '../routes'
import { closeActiveTab, closeWorkspaceTab } from './close-tab'
function fileTarget(path: string): PreviewTarget {
return {
@ -16,19 +37,31 @@ function fileTarget(path: string): PreviewTarget {
}
}
/** Main is holding a loaded chat and nothing else is stacked with it. */
function loadedMainOnly() {
$selectedStoredSessionId.set('stored-a')
$activeSessionId.set('runtime-a')
}
beforeEach(() => {
vi.stubGlobal('document', { activeElement: null })
closeRightRail()
window.localStorage.clear()
$selectedStoredSessionId.set(null)
$activeSessionId.set(null)
$workspaceIsPage.set(false)
closeFocusedSessionTab.mockReturnValue(false)
nextSessionTileForWorkspace.mockReturnValue(null)
vi.clearAllMocks()
})
afterEach(() => {
vi.unstubAllGlobals()
closeRightRail()
window.localStorage.clear()
})
describe('closeActiveTab', () => {
beforeEach(() => {
vi.stubGlobal('document', { activeElement: null })
closeRightRail()
window.localStorage.clear()
})
afterEach(() => {
vi.unstubAllGlobals()
closeRightRail()
window.localStorage.clear()
})
it('closes the active file preview tab (⌘W happy path)', () => {
openPreview(fileTarget('/work/notes.md'), 'manual')
@ -50,3 +83,56 @@ describe('closeActiveTab', () => {
expect($previewTabs.get()).toHaveLength(0)
})
})
/**
* The main tab's own close. The workspace pane can never leave the tree, so
* every answer here is about what FILLS it a stacked session, or an empty
* draft. The gesture used to dead-end whenever main was the only tab.
*/
describe('closeWorkspaceTab', () => {
it('shifts the next stacked session into main', () => {
loadedMainOnly()
nextSessionTileForWorkspace.mockReturnValue('stored-b')
const load = vi.fn()
expect(closeWorkspaceTab(load)).toBe(true)
expect(closeSessionTile).toHaveBeenCalledWith('stored-b')
expect(load).toHaveBeenCalledWith('stored-b')
// Promotion refills main — it must not ALSO blank it.
expect(requestFreshSession).not.toHaveBeenCalled()
})
it('drops a lone loaded main to a fresh draft', () => {
loadedMainOnly()
expect(closeWorkspaceTab(vi.fn())).toBe(true)
expect(requestFreshSession).toHaveBeenCalledTimes(1)
})
it('empties main even with no session loader wired', () => {
loadedMainOnly()
expect(closeWorkspaceTab()).toBe(true)
expect(requestFreshSession).toHaveBeenCalledTimes(1)
})
it('is a no-op on a blank draft — that IS the post-close state', () => {
expect(closeWorkspaceTab(vi.fn())).toBe(false)
expect(requestFreshSession).not.toHaveBeenCalled()
})
it('is a no-op over a full-page view, which owns no chat tab', () => {
loadedMainOnly()
$workspaceIsPage.set(true)
expect(closeWorkspaceTab(vi.fn())).toBe(false)
expect(requestFreshSession).not.toHaveBeenCalled()
})
it('⌘W reaches it once the terminal, rail and zone tabs pass', () => {
loadedMainOnly()
expect(closeActiveTab(vi.fn())).toBe(true)
expect(requestFreshSession).toHaveBeenCalledTimes(1)
})
})

View File

@ -1,27 +1,68 @@
import { mainChatOccupied } from '@/app/open-session'
import { closeActiveTerminal } from '@/app/right-sidebar/terminal/terminals'
import { $workspaceIsPage } from '@/app/routes'
import { closeFocusedSessionTab } from '@/components/pane-shell/tree/store'
import { isFocusWithin } from '@/lib/keybinds/combo'
import { $previewTabs, closeActiveRightRailTab } from '@/store/preview'
import { requestFreshSession } from '@/store/profile'
import { $activeSessionId, $selectedStoredSessionId } from '@/store/session'
import { closeSessionTile, nextSessionTileForWorkspace } from '@/store/session-states'
/**
* Close the MAIN tab. The workspace pane itself can't leave the tree, so
* "closing" it means emptying it, and what fills the hole depends on what's
* stacked beside it:
*
* - session tabs stacked with it the next one shifts INTO main (drop its
* tile, load it as the primary the session stays alive, no busy prompt),
* - nothing stacked main drops to a fresh "New session" draft.
*
* The second half is what makes the gesture honest when main is the ONLY tab:
* W / -click / middle-click used to be a dead key there, since the only
* available answer was "remove the pane", which this app never does.
*
* Returns false when there is nothing to close a blank draft (already the
* post-close state) or a full-page view (skills / artifacts, which isn't a
* chat and owns no tab). W then stays a no-op; it never closes the window.
*
* `loadSessionIntoWorkspace` carries the app's route-based "load this session
* into main"; omitting it disables the promotion half.
*/
export function closeWorkspaceTab(loadSessionIntoWorkspace?: (storedSessionId: string) => void): boolean {
// Order matters — close the tile FIRST so the selection homes to the
// workspace instead of re-fronting the tile.
if (loadSessionIntoWorkspace) {
const next = nextSessionTileForWorkspace()
if (next) {
closeSessionTile(next)
loadSessionIntoWorkspace(next)
return true
}
}
if ($workspaceIsPage.get() || !mainChatOccupied($activeSessionId.get(), $selectedStoredSessionId.get())) {
return false
}
requestFreshSession()
return true
}
/**
* W close the tab of the context you're in, by precedence:
* 1. a focused terminal its active terminal tab,
* 2. right-rail tabs (live preview and/or file peeks),
* 3. the FOCUSED chat zone its active tab (a session tile stacked into it).
* 4. the workspace tab itself, when session tabs are stacked with it:
* the workspace can't close, so W shifts the NEXT session tab into main
* (loads it as the primary + drops its now-redundant tile).
* 4. the workspace tab itself see `closeWorkspaceTab`.
* Returns false when nothing closes, so W is a no-op it never closes the
* window (a bare workspace stays put). Shared by the keyboard path (Win/Linux)
* and the macOS menu-accelerator IPC.
* window. Shared by the keyboard path (Win/Linux) and the macOS
* menu-accelerator IPC.
*
* Steps 3-4 follow the same focused zone 19 indexes, so a second chat zone
* with its own tab strip closes ITS tab instead of main's.
*
* `loadSessionIntoWorkspace` carries the app's route-based "load this session
* into main" (the two call sites have router access); omitting it disables the
* step-4 promotion (W stays the pre-existing no-op on the main tab).
*/
export function closeActiveTab(loadSessionIntoWorkspace?: (storedSessionId: string) => void): boolean {
if (isFocusWithin('[data-terminal]')) {
@ -43,21 +84,5 @@ export function closeActiveTab(loadSessionIntoWorkspace?: (storedSessionId: stri
return true
}
// The main (workspace) tab is active and can't be closed — but if session
// tabs are stacked with it, ⌘W shifts the next one into the main tab: drop
// its tile (the session stays alive, no busy-close prompt) and load it into
// main. Order matters — close the tile FIRST so the selection homes to the
// workspace instead of re-fronting the tile.
if (loadSessionIntoWorkspace) {
const next = nextSessionTileForWorkspace()
if (next) {
closeSessionTile(next)
loadSessionIntoWorkspace(next)
return true
}
}
return false
return closeWorkspaceTab(loadSessionIntoWorkspace)
}

View File

@ -27,7 +27,7 @@ import { ModelMenuPanel } from '@/app/shell/model-menu-panel'
import { formatRefValue } from '@/components/assistant-ui/directive-text'
import { CenteredThreadSpinner } from '@/components/assistant-ui/thread/status'
import { findGroupOfPane } from '@/components/pane-shell/tree/model'
import { $layoutTree, moveTreePane, setTreeGroupHeaderHidden } from '@/components/pane-shell/tree/store'
import { $layoutTree, closeTreePane, moveTreePane, setTreeGroupHeaderHidden } from '@/components/pane-shell/tree/store'
import { Button } from '@/components/ui/button'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { transcribeAudio } from '@/hermes'
@ -504,9 +504,10 @@ export function SessionTabMenu({
}
/** The MAIN tab's menu: the same session verbs targeting the primary's loaded
* session, plus the bar's off switch (the bar sticky-shows once a tab is
* ever gained; this is the explicit way back). A fresh draft has no session
* no menu. */
* session, plus Close (the tab empties to a fresh draft the workspace pane
* itself never leaves the tree) and the bar's off switch (the bar sticky-shows
* once a tab is ever gained; this is the explicit way back). A fresh draft has
* no session no menu. */
export function WorkspaceTabMenu({ children }: { children: React.ReactElement }) {
const selected = useStore($selectedStoredSessionId)
@ -524,7 +525,12 @@ export function WorkspaceTabMenu({ children }: { children: React.ReactElement })
}
return (
<SessionTabMenu onHideTabBar={hideTabBar} storedSessionId={selected} tabPaneId="workspace">
<SessionTabMenu
onClose={() => closeTreePane('workspace')}
onHideTabBar={hideTabBar}
storedSessionId={selected}
tabPaneId="workspace"
>
{children}
</SessionTabMenu>
)

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

@ -20,7 +20,7 @@ import { FindBar } from '@/components/find-bar'
import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay'
import { NotificationStack } from '@/components/notifications'
import { DesktopOnboardingOverlay } from '@/components/onboarding'
import { $newSessionTabAction } from '@/components/pane-shell/tree/store'
import { $newSessionTabAction, registerPaneCloser } from '@/components/pane-shell/tree/store'
import { FloatingPet } from '@/components/pet/floating-pet'
import { RemoteDisplayBanner } from '@/components/remote-display-banner'
import { emitGatewayEvent } from '@/contrib/events'
@ -68,6 +68,7 @@ import { armWakeWord } from '@/store/wake-word'
import { isSecondaryWindow } from '@/store/windows'
import { useSkinCommand } from '@/themes/use-skin-command'
import { closeWorkspaceTab } from '../chat/close-tab'
import { requestComposerInsert } from '../chat/composer/focus'
import { useComposerActions } from '../chat/hooks/use-composer-actions'
import { CommandPalette } from '../command-palette'
@ -83,7 +84,14 @@ import { RemoteFolderPicker } from '../right-sidebar/files/remote-picker'
import { resetProjectTreeState } from '../right-sidebar/files/use-project-tree'
import { PersistentTerminal } from '../right-sidebar/terminal/persistent'
import { closeAllTerminals } from '../right-sidebar/terminal/terminals'
import { CRON_ROUTE, navigateToWorkspacePage, routeSessionId, SETTINGS_ROUTE, syncWorkspaceRoute } from '../routes'
import {
CRON_ROUTE,
navigateToWorkspacePage,
routeSessionId,
sessionRoute,
SETTINGS_ROUTE,
syncWorkspaceRoute
} from '../routes'
import { SessionPickerOverlay } from '../session-picker-overlay'
import { SessionSwitcher } from '../session-switcher'
import { useBackgroundQueueDrain } from '../session/hooks/use-background-queue-drain'
@ -819,6 +827,17 @@ export function ContribWiring({ children }: { children: ReactNode }) {
return () => $newSessionTabAction.set(null)
}, [openNewSessionTab])
// The MAIN tab's Close. The workspace pane can't leave the tree, so its
// closer empties it instead: the next stacked session shifts in, else main
// drops to a fresh draft. Registering it here is also what gives the tab its
// close GESTURE (⌘-click / middle-click) — the strip reads the closer, not
// the `uncloseable` flag, so the pane stays undismissable either way.
useEffect(() => {
registerPaneCloser('workspace', () => void closeWorkspaceTab(id => navigate(sessionRoute(id))))
return () => registerPaneCloser('workspace')
}, [navigate])
// The controller's entire callback surface, gathered into the stable
// `actions` bag. `nextActions` is TS-checked against WiringActions each
// render; its fields are copied into the ref object so `actions` keeps one

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

@ -31,6 +31,7 @@ import {
$hiddenTreePanes,
$narrowViewport,
$newSessionTabAction,
$panesWithCloser,
$treeDragging,
activateTreePane,
closeAllTreeTabs,
@ -177,6 +178,7 @@ export function TreeGroup({
const hiddenPanes = useStore($hiddenTreePanes)
const narrow = useStore($narrowViewport)
const newSessionTabAction = useStore($newSessionTabAction)
const panesWithCloser = useStore($panesWithCloser)
const paneFor = (id: string) => panes.find(p => p.id === id)
@ -290,6 +292,12 @@ export function TreeGroup({
// closes the session, a store-bound pane collapses).
const closeTab = (paneId: string) => (isCollapsePane(paneId) ? dismissTreePane(paneId) : closeTreePane(paneId))
// A pane whose store owns Close keeps the gesture even when the pane itself
// is uncloseable — the workspace tab empties to a fresh draft rather than
// leaving the tree.
const closeableTab = (paneId: string) =>
!paneChrome(paneFor(paneId)).uncloseable || panesWithCloser.has(paneId)
// Collapse/restore a tool panel (or plain minimize elsewhere) — the header
// chevron + tap gesture, routed so ⌃`/the titlebar toggle stay truthful.
const toggleCollapse = () => (node.minimized ? restoreTreePane(activeId) : collapseTreePane(activeId))
@ -347,7 +355,7 @@ export function TreeGroup({
role="tablist"
>
{shown.map(paneId => {
const closeable = !paneChrome(paneFor(paneId)).uncloseable
const closeable = closeableTab(paneId)
const title = paneFor(paneId)?.title ?? paneId
return (
@ -414,7 +422,7 @@ export function TreeGroup({
{shown.map(paneId => {
const isActive = paneId === activeId && !node.minimized
const chrome = paneChrome(paneFor(paneId))
const closeable = !chrome.uncloseable
const closeable = closeableTab(paneId)
const title = paneFor(paneId)?.title ?? paneId
const tab = (

View File

@ -198,9 +198,24 @@ function setDismissed(paneId: string, dismissed: boolean) {
const paneClosers: Record<string, () => void> = {}
const paneOpeners: Record<string, () => void> = {}
/** Route a pane's Close through the app store that owns its visibility. */
export function registerPaneCloser(paneId: string, close: () => void) {
paneClosers[paneId] = close
/** Pane ids whose Close an app store owns. True for the main workspace, whose
* pane can't leave the tree but whose TAB can still be emptied the close
* GESTURE (-click / middle-click) keys off this rather than `uncloseable`.
* An atom, not a lookup: a closer registered by a wiring EFFECT lands after
* the strip's first paint, and a plain read would leave that tab gestureless
* until something else happened to re-render it. */
export const $panesWithCloser = atom<ReadonlySet<string>>(new Set())
/** Route a pane's Close through the app store that owns its visibility.
* Passing no closer unregisters (a wiring effect's cleanup). */
export function registerPaneCloser(paneId: string, close?: () => void) {
if (close) {
paneClosers[paneId] = close
} else {
delete paneClosers[paneId]
}
$panesWithCloser.set(new Set(Object.keys(paneClosers)))
}
/**

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