From ec1645a5baab456f6d025ee4eb9560ebb718e838 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 07:12:29 -0500 Subject: [PATCH] fix(desktop): stay on the tab that fills a closed slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing an active layout tab always selected the previous neighbor, so focus jumped left every time. Prefer the right neighbor instead (left only at the end) — same rule terminals and the preview rail already use. --- .../src/components/pane-shell/tree/model.ts | 9 ++-- .../pane-shell/tree/remove-pane.test.ts | 41 +++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/components/pane-shell/tree/remove-pane.test.ts diff --git a/apps/desktop/src/components/pane-shell/tree/model.ts b/apps/desktop/src/components/pane-shell/tree/model.ts index 5d1c39206e6..0834360b0a2 100644 --- a/apps/desktop/src/components/pane-shell/tree/model.ts +++ b/apps/desktop/src/components/pane-shell/tree/model.ts @@ -184,9 +184,9 @@ export function normalize(node: LayoutNode): LayoutNode | null { return { ...node, children, weights } } -/** Remove a pane wherever it lives. Closing the ACTIVE tab activates its - * previous neighbor (the next one when it was first) — browser-tab feel, - * never a jump to the strip's start. */ +/** Remove a pane wherever it lives. Closing the ACTIVE tab leaves selection on + * the neighbor that fills its slot (right; left when it was last) — same rule + * as terminals and the preview rail. */ export function removePane(node: LayoutNode, paneId: string): LayoutNode | null { const walk = (n: LayoutNode): LayoutNode => { if (n.type === 'group') { @@ -198,7 +198,8 @@ export function removePane(node: LayoutNode, paneId: string): LayoutNode | null const panes = n.panes.filter(p => p !== paneId) - return { ...n, panes, active: n.active === paneId ? panes[Math.max(0, at - 1)] : n.active } + // After splice, `at` indexes the old right neighbor (clamp left at end). + return { ...n, panes, active: n.active === paneId ? (panes[Math.min(at, panes.length - 1)] ?? '') : n.active } } return { ...n, children: n.children.map(walk) } diff --git a/apps/desktop/src/components/pane-shell/tree/remove-pane.test.ts b/apps/desktop/src/components/pane-shell/tree/remove-pane.test.ts new file mode 100644 index 00000000000..b91b3af4f80 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/remove-pane.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' + +import { findGroup, group, removePane, split } from './model' + +describe('removePane close-neighbor selection', () => { + it('closing a middle active tab keeps you on the tab that slides into its slot', () => { + // [a, b, c] active=b → close b → [a, c] active=c (was right of b), not a + const next = removePane(group(['a', 'b', 'c'], { active: 'b', id: 'g' }), 'b') + + expect(next).toMatchObject({ type: 'group', panes: ['a', 'c'], active: 'c' }) + }) + + it('closing the first active tab still advances to the former next tab', () => { + const next = removePane(group(['a', 'b', 'c'], { active: 'a', id: 'g' }), 'a') + + expect(next).toMatchObject({ type: 'group', panes: ['b', 'c'], active: 'b' }) + }) + + it('closing the last active tab falls back to its left neighbor', () => { + const next = removePane(group(['a', 'b', 'c'], { active: 'c', id: 'g' }), 'c') + + expect(next).toMatchObject({ type: 'group', panes: ['a', 'b'], active: 'b' }) + }) + + it('closing a non-active tab leaves the selection alone', () => { + const next = removePane(group(['a', 'b', 'c'], { active: 'c', id: 'g' }), 'b') + + expect(next).toMatchObject({ type: 'group', panes: ['a', 'c'], active: 'c' }) + }) + + it('walks nested splits so session stacks still get the slot rule', () => { + const tree = split('row', [ + group(['workspace'], { active: 'workspace', id: 'main' }), + group(['tile:1', 'tile:2', 'tile:3'], { active: 'tile:2', id: 'stack' }) + ]) + const next = removePane(tree, 'tile:2') + const stack = next ? findGroup(next, 'stack') : null + + expect(stack).toMatchObject({ panes: ['tile:1', 'tile:3'], active: 'tile:3' }) + }) +})