diff --git a/apps/desktop/src/app/chat/close-tab.ts b/apps/desktop/src/app/chat/close-tab.ts index bf8b2899870a..89c852593128 100644 --- a/apps/desktop/src/app/chat/close-tab.ts +++ b/apps/desktop/src/app/chat/close-tab.ts @@ -1,5 +1,5 @@ import { closeActiveTerminal } from '@/app/right-sidebar/terminal/terminals' -import { closeWorkspaceTab } from '@/components/pane-shell/tree/store' +import { closeFocusedSessionTab } from '@/components/pane-shell/tree/store' import { isFocusWithin } from '@/lib/keybinds/combo' import { $previewTabs, closeActiveRightRailTab } from '@/store/preview' import { closeSessionTile, nextSessionTileForWorkspace } from '@/store/session-states' @@ -8,14 +8,17 @@ import { closeSessionTile, nextSessionTileForWorkspace } from '@/store/session-s * ⌘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 MAIN zone → its active tab (a session tile stacked into the workspace). - * 4. the MAIN (workspace) tab itself, when session tabs are stacked with it: + * 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). * 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. * + * 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). @@ -28,15 +31,15 @@ export function closeActiveTab(loadSessionIntoWorkspace?: (storedSessionId: stri } // Gate on tab *presence*, not on the selection: a stale `$rightRailActiveTabId` - // would otherwise make ⌘W fall through to closeWorkspaceTab() and look broken - // with a tab still on screen. The store resolves which tab that is. + // would otherwise make ⌘W fall through to closeFocusedSessionTab() and look + // broken with a tab still on screen. The store resolves which tab that is. if ($previewTabs.get().length > 0) { return closeActiveRightRailTab() } - // A closeable main-zone tab (a session tile that's the active tab) closes - // outright; the uncloseable workspace tab returns false and falls through. - if (closeWorkspaceTab()) { + // A closeable tab in the focused chat zone (a session tile that's the active + // tab) closes outright; the uncloseable workspace tab falls through. + if (closeFocusedSessionTab()) { return true } diff --git a/apps/desktop/src/app/chat/session-drag.ts b/apps/desktop/src/app/chat/session-drag.ts index b4ca62802de3..16945edbe4aa 100644 --- a/apps/desktop/src/app/chat/session-drag.ts +++ b/apps/desktop/src/app/chat/session-drag.ts @@ -43,6 +43,7 @@ import { $layoutTree, $treeDragging, type DropHint, + isSessionStripPane, revealTreePane, SESSION_TILE_DRAG } from '@/components/pane-shell/tree/store' @@ -84,7 +85,7 @@ function chatZonePane(groupId: string): null | string { const tree = $layoutTree.get() const panes = tree ? (findGroup(tree, groupId)?.panes ?? []) : [] - return panes.find(p => p === 'workspace' || p.startsWith('session-tile:')) ?? null + return panes.find(isSessionStripPane) ?? null } /** diff --git a/apps/desktop/src/components/pane-shell/tree/focused-session-tab.test.ts b/apps/desktop/src/components/pane-shell/tree/focused-session-tab.test.ts new file mode 100644 index 000000000000..3e857386118f --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/focused-session-tab.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// ⌘W / ⌘T / ⌘⇧T / the strip "+" used to hardcode the workspace's zone while +// ⌘1…⌘9 followed the interacted zone — so every tab verb missed a SECOND chat +// zone the user was working in. They now resolve the same focused zone. + +describe('focused chat zone drives the tab verbs', () => { + beforeEach(() => { + window.localStorage.clear() + vi.resetModules() + }) + + afterEach(() => { + vi.resetModules() + }) + + async function setup() { + const tree = await import('@/components/pane-shell/tree/store') + const model = await import('@/components/pane-shell/tree/model') + const { registry } = await import('@/contrib/registry') + + for (const id of ['workspace', 'files', 'session-tile:a', 'session-tile:b']) { + registry.register({ + area: 'panes', + data: id === 'workspace' ? { placement: 'main', uncloseable: true } : { placement: 'main' }, + id, + render: () => null, + title: id + }) + } + + // Two chat zones side by side: main holds the workspace, the second holds + // its own session tabs (a split-off stack). + tree.declareDefaultTree( + model.split('row', [ + model.group(['workspace'], { active: 'workspace', id: 'grp-main' }), + model.group(['session-tile:a', 'session-tile:b'], { active: 'session-tile:b', id: 'grp-side' }) + ]) + ) + + return { model, tree } + } + + it('⌘T anchors the new tab to the focused zone, not always the workspace', async () => { + const { tree } = await setup() + + tree.noteActiveTreeGroup('grp-side') + expect(tree.focusedSessionTabAnchor()).toBe('session-tile:b') + + tree.noteActiveTreeGroup('grp-main') + expect(tree.focusedSessionTabAnchor()).toBe('workspace') + }) + + it('a non-chat zone (files/terminal focus) falls back to the workspace', async () => { + const { model, tree } = await setup() + + tree.declareDefaultTree( + model.split('row', [ + model.group(['workspace'], { active: 'workspace', id: 'grp-main' }), + model.group(['files'], { active: 'files', id: 'grp-files' }) + ]) + ) + tree.noteActiveTreeGroup('grp-files') + + expect(tree.focusedSessionTabAnchor()).toBe('workspace') + // ⌘W must not close the file tree just because it holds focus. + expect(tree.closeFocusedSessionTab()).toBe(false) + }) + + it('⌘W closes the focused zone active tab and leaves the workspace alone', async () => { + const { model, tree } = await setup() + + tree.noteActiveTreeGroup('grp-side') + expect(tree.closeFocusedSessionTab()).toBe(true) + expect(model.allPaneIds(tree.$layoutTree.get()!)).not.toContain('session-tile:b') + + // Back on main: the uncloseable workspace is a no-op (the caller promotes + // a stacked session instead of closing the window). + tree.noteActiveTreeGroup('grp-main') + expect(tree.closeFocusedSessionTab()).toBe(false) + expect(model.allPaneIds(tree.$layoutTree.get()!)).toContain('workspace') + }) +}) 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 31b5646813ac..8c480dbefcd0 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 @@ -39,7 +39,9 @@ import { collapseTreePane, dismissTreePane, isCollapsePane, + isSessionStripPane, moveTreePane, + noteActiveTreeGroup, restoreTreePane, SESSION_TILE_DRAG, setTreeGroupHeaderHidden, @@ -507,16 +509,26 @@ export function TreeGroup({ return {chrome.tabWrap ? chrome.tabWrap(tab) : tab} })} - {/* Plain "+" after the last tab of the MAIN strip (the workspace - zone) — always shown, no tab/button chrome, just the glyph. - Creates a new session tab (mirrors ⌘T) via the app-registered - action; hidden when unwired or the zone is minimized. */} - {node.panes.includes('workspace') && newSessionTabAction && !node.minimized && ( + {/* Plain "+" after the last tab of a CHAT strip (the workspace + zone, or any zone holding session tabs) — always shown, no + tab/button chrome, just the glyph. Creates a new session tab + (mirrors ⌘T) via the app-registered action; the pointerdown + focuses this zone first, so the tab lands in THIS strip. + Hidden when unwired or the zone is minimized. */} + {shown.some(isSessionStripPane) && newSessionTabAction && !node.minimized && (