fix(desktop): the main tab can be closed by gesture and menu

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.
This commit is contained in:
Brooklyn Nicholson 2026-07-30 21:43:15 -05:00
parent c7b021ca48
commit 193e5f84f7
4 changed files with 60 additions and 12 deletions

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

@ -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

@ -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)))
}
/**