fix(desktop): stay on the tab that fills a closed slot

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.
This commit is contained in:
Brooklyn Nicholson 2026-07-30 07:12:29 -05:00
parent 383829df2e
commit ec1645a5ba
2 changed files with 46 additions and 4 deletions

View file

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

View file

@ -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' })
})
})