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 && (