From 193e5f84f7ac755547dc9be89e98bbe82d6f74c0 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 21:43:15 -0500 Subject: [PATCH] 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 086d69fdf53..27954abc2a5 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 44d35607e10..1306ffb6410 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 9df6e3d44d0..a4ad89465f9 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 49ca0587c7e..4bb24460ddf 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))) } /**