diff --git a/apps/desktop/src/app/chat/close-tab.test.ts b/apps/desktop/src/app/chat/close-tab.test.ts index 44368c80403ef..0326efebeadfa 100644 --- a/apps/desktop/src/app/chat/close-tab.test.ts +++ b/apps/desktop/src/app/chat/close-tab.test.ts @@ -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) + }) +}) diff --git a/apps/desktop/src/app/chat/close-tab.ts b/apps/desktop/src/app/chat/close-tab.ts index 89c8525931284..0d75455bd80ec 100644 --- a/apps/desktop/src/app/chat/close-tab.ts +++ b/apps/desktop/src/app/chat/close-tab.ts @@ -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 ⌘1…⌘9 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) } diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx index 086d69fdf5330..27954abc2a560 100644 --- a/apps/desktop/src/app/chat/session-tile.tsx +++ b/apps/desktop/src/app/chat/session-tile.tsx @@ -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 ( - + closeTreePane('workspace')} + onHideTabBar={hideTabBar} + storedSessionId={selected} + tabPaneId="workspace" + > {children} ) diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx index fcaa8b19edfd3..cfbeb9f2afcc5 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 ? ( $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 diff --git a/apps/desktop/src/app/right-sidebar/terminal/rail.tsx b/apps/desktop/src/app/right-sidebar/terminal/rail.tsx index 8a81a16925b84..ced1675032965 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/rail.tsx +++ b/apps/desktop/src/app/right-sidebar/terminal/rail.tsx @@ -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" > diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx index 9df6e3d44d083..a4ad89465f920 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx @@ -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 = ( diff --git a/apps/desktop/src/components/pane-shell/tree/store.ts b/apps/desktop/src/components/pane-shell/tree/store.ts index 49ca0587c7e5c..4bb24460ddf0a 100644 --- a/apps/desktop/src/components/pane-shell/tree/store.ts +++ b/apps/desktop/src/components/pane-shell/tree/store.ts @@ -198,9 +198,24 @@ function setDismissed(paneId: string, dismissed: boolean) { const paneClosers: Record void> = {} const paneOpeners: Record 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>(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))) } /** diff --git a/apps/desktop/src/components/ui/pane-tab.test.tsx b/apps/desktop/src/components/ui/pane-tab.test.tsx index c36f03fb8ada3..5b340a9fb1717 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 eb553a770e5a7..87545f018c67d 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 0000000000000..c701825932e45 --- /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 0000000000000..bba5e5874311f --- /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?.() + } + } + } +}