From 463fbf5b16749898dd3076c04e8647400c80df3e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 21:43:14 -0500 Subject: [PATCH 1/3] 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 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 ? ( { - 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 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?.() + } + } + } +} From c7b021ca487277c322281aef040ae743681132ba Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 21:43:15 -0500 Subject: [PATCH 2/3] fix(desktop): closing the last main tab lands on New session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workspace pane can't leave the tree, so "close the main tab" only ever had one answer wired: shift the next stacked session in. With main as the only tab there was nothing to shift and ⌘W dead-ended on the tab the user was looking at. closeWorkspaceTab is now the one answer for every entry point — stacked session still wins, and with nothing stacked main drops to a fresh New session draft. A blank draft and a full-page view stay no-ops: a blank draft already IS the post-close state. --- apps/desktop/src/app/chat/close-tab.test.ts | 112 +++++++++++++++++--- apps/desktop/src/app/chat/close-tab.ts | 77 +++++++++----- 2 files changed, 150 insertions(+), 39 deletions(-) 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) } From 193e5f84f7ac755547dc9be89e98bbe82d6f74c0 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 21:43:15 -0500 Subject: [PATCH 3/3] fix(desktop): the main tab can be closed by gesture and menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tab strip decided the close gesture from the `uncloseable` flag, which the workspace sets to keep its pane in the tree — so the one tab whose close now does something couldn't be ⌘-clicked or middle-clicked, and its right-click menu had no Close. Read the gesture off the pane's registered closer instead, with the workspace registering closeWorkspaceTab. An atom rather than a lookup, since that closer comes from a wiring effect that lands after the strip's first paint. --- apps/desktop/src/app/chat/session-tile.tsx | 16 +++++++++---- apps/desktop/src/app/contrib/wiring.tsx | 23 +++++++++++++++++-- .../pane-shell/tree/renderer/tree-group.tsx | 12 ++++++++-- .../src/components/pane-shell/tree/store.ts | 21 ++++++++++++++--- 4 files changed, 60 insertions(+), 12 deletions(-) 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/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 44d35607e1034..1306ffb641013 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -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 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))) } /**