fix(desktop): closing a tool tab in the chat's zone fronts the chat

This commit is contained in:
brooklyn!
2026-09-24 04:22:49 -05:00
parent d892451a45
commit 7ba3a71611
2 changed files with 91 additions and 17 deletions

View File

@@ -596,8 +596,18 @@ export function closeFocusedSessionTab(): boolean {
* A tool panel's closer is its visibility STORE, so routing Close through
* `closeTreePane` only collapsed the zone to a rail — the tab stayed put and
* Close read as a no-op. Dismiss first so the store listener's collapse lands
* on an absent pane instead of minimizing a shared zone's surviving sibling. */
* on an absent pane instead of minimizing a shared zone's surviving sibling.
* That listener is then a no-op, so an ACTIVE tab sharing the chat's zone
* hands its slot over here, by the same rule the toggle uses — otherwise
* removePane fronts whichever neighbour filled the gap (#79002). */
export function closeToolPane(paneId: string) {
const group = paneGroup(paneId)
const handoff = group?.active === paneId ? chatZoneHandoff(group, paneId) : null
if (group && handoff) {
activateTreePane(group.id, handoff)
}
dismissTreePane(paneId)
paneClosers[paneId]?.()
}
@@ -1808,6 +1818,31 @@ function paneGroup(paneId: string) {
return tree ? findGroupOfPane(tree, paneId) : null
}
/** Who takes the active slot when `paneId` steps out of the front of a SHARED
* zone that also hosts the uncloseable (workspace) pane: the workspace —
* New Session semantics — never an arbitrary adjacent sibling.
* [workspace, files, review, terminal] with the terminal active must land on
* workspace, not on review via `panes[at - 1]`, which stranded the user on a
* tool pane. Null when the zone has no uncloseable pane (a pure tool
* zone keeps its own rule). Shared by collapse (toggle) and Close (tab ✕). */
function chatZoneHandoff(group: { panes: string[] }, paneId: string): null | string {
const anchor = group.panes.find(isUncloseablePane)
if (!anchor) {
return null
}
if (anchor !== paneId) {
return anchor
}
// Defensive: the uncloseable pane itself (never bound to a tool toggle
// store) — fall back to the sibling.
const at = group.panes.indexOf(paneId)
return group.panes[at - 1] ?? group.panes[at + 1] ?? null
}
/** Collapse/restore a pane's ZONE to a minimized rail — its tab stays visible.
* Store-driven (one-way): a tool panel's $open store mirrors here via
* bindPaneCollapse, so a toggle collapses rather than hides. */
@@ -1825,23 +1860,11 @@ export function setPaneCollapsed(paneId: string, collapsed: boolean) {
// on every boot (broke collapse persistence).
if (group.panes.length > 1) {
if (collapsed && group.active === paneId) {
if (group.panes.some(isUncloseablePane)) {
// Workspace can't minimize (strands the app) → hand the active slot to
// the uncloseable (workspace) pane rather than an arbitrary adjacent
// sibling. [workspace, files, review, terminal] with the terminal
// active must land on workspace (New Session semantics), not on review
// via `panes[at - 1]` — which left the user stranded on a tool pane
// and (before the overlay fix) the terminal visually foreground.
const anchor = group.panes.find(isUncloseablePane)
const at = group.panes.indexOf(paneId)
// Workspace can't minimize (strands the app) → hand it the active slot.
const handoff = chatZoneHandoff(group, paneId)
if (anchor && anchor !== paneId) {
activateTreePane(group.id, anchor)
} else {
// Defensive: collapsing the uncloseable pane itself (never bound to
// a tool toggle store) — fall back to the sibling.
activateTreePane(group.id, group.panes[at - 1] ?? group.panes[at + 1])
}
if (handoff) {
activateTreePane(group.id, handoff)
} else {
setTreeGroupMinimized(group.id, true) // pure tool zone folds as a unit
}

View File

@@ -11,6 +11,7 @@ import {
$layoutTree,
activateTreePane,
bindToolPaneCollapse,
closeTabPane,
closeToolPane,
isPaneVisible,
revealTreePane,
@@ -297,6 +298,56 @@ describe('collapsing the active terminal in a shared group with the workspace',
})
})
describe('closing an active tool tab (✕ / ⌘W) that shares the chat zone', () => {
// #79002: a terminal dragged into the chat's zone, closed from its tab ✕,
// left the user on whatever tab sat next to it (review) instead of the chat.
// removePane's neighbour rule ran first, and the store listener's collapse
// then no-oped on a pane that had already left the tree. Same destination
// as putting the pane away with its toggle: the uncloseable workspace.
const chatZoneActive = () => {
const tree = $layoutTree.get()
return tree?.type === 'group' ? tree.active : undefined
}
it.each([
['terminal', ['workspace', 'files', 'review', 'terminal']],
['logs', ['workspace', 'review', 'logs', 'files']]
])('closing %s hands the active slot to the workspace, not a neighbour', (paneId, panes) => {
$layoutTree.set(group(panes, { active: paneId, id: 'g-chat' }))
const $open = atom(true)
bindPaneCollapse(paneId, $open)
closeTabPane(paneId)
expect(allPaneIds($layoutTree.get()!)).not.toContain(paneId)
expect(chatZoneActive()).toBe('workspace')
expect(isPaneVisible('workspace')).toBe(true)
expect($open.get()).toBe(false) // toggle store stays truthful
})
it('leaves the active tab alone when the closed tool tab was in the background', () => {
$layoutTree.set(group(['workspace', 'review', 'terminal'], { active: 'review', id: 'g-chat' }))
bindPaneCollapse('terminal', atom(true))
closeTabPane('terminal')
expect(chatZoneActive()).toBe('review')
})
it('still hands a pure tool zone to the neighbouring tool tab', () => {
stackTree({ active: 'terminal' })
bindPaneCollapse('terminal', atom(true))
bindPaneCollapse('logs', atom(true))
closeTabPane('terminal')
expect(toolZone()?.panes).toEqual(['logs'])
expect(toolZone()?.active).toBe('logs')
})
})
describe('a terminal that owns its own zone (Default / Terminal deck / Quad)', () => {
// Only the Focus preset stacks the terminal with the workspace. Everywhere
// else it sits in a group of its own, beside the chat rather than over it —