fix(terminal): focus the neighboring pane when a split pane closes (#32238)

* fix(terminal): focus the neighboring pane when a split pane closes

* test(terminal): cover focus after closing a split pane in the app

* fix(terminal): focus the neighboring pane when a split pane closes

* test(terminal): cover focus after closing a split pane in the app

* test(terminal): keep pane-close parity exclusions evidence based

---------

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
This commit is contained in:
Ibrahem Mahyob
2026-10-23 19:21:36 -07:00
committed by GitHub
co-authored by Neil
parent d11208c2f1
commit 8a63b4344a
6 changed files with 273 additions and 5 deletions
@@ -0,0 +1,136 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ManagedPane } from './pane-manager-types'
// Why: happy-dom has no canvas, so xterm cannot open; focus routing never needs a rendered terminal.
vi.mock('./pane-lifecycle', async (importOriginal) => {
const actual: object = await importOriginal()
return { ...actual, openTerminal: vi.fn() }
})
const { PaneManager } = await import('./pane-manager')
type Manager = InstanceType<typeof PaneManager>
function split(
manager: Manager,
source: ManagedPane,
direction: 'vertical' | 'horizontal'
): ManagedPane {
const pane = manager.splitPane(source.id, direction)
if (!pane) {
throw new Error(`split of pane ${source.id} failed`)
}
return pane
}
/** a | b | c | d, each split taken from the newest pane. */
function createFourColumns(manager: Manager): [ManagedPane, ManagedPane, ManagedPane, ManagedPane] {
const a = manager.createInitialPane()
const b = split(manager, a, 'vertical')
const c = split(manager, b, 'vertical')
const d = split(manager, c, 'vertical')
return [a, b, c, d]
}
describe('PaneManager focus after a pane leaves a split', () => {
let root: HTMLDivElement
let manager: Manager
beforeEach(() => {
vi.stubGlobal('requestAnimationFrame', () => 1)
vi.stubGlobal('cancelAnimationFrame', () => {})
root = document.createElement('div')
document.body.appendChild(root)
manager = new PaneManager(root, { linkOpenHint: () => '' })
})
afterEach(() => {
manager.destroy()
root.remove()
vi.unstubAllGlobals()
})
it('focuses the neighbour that takes over the space when the last of four panes closes', () => {
const [, , c, d] = createFourColumns(manager)
const focus = vi.spyOn(c.terminal, 'focus')
manager.closePane(d.id)
expect(manager.getActivePane()?.id).toBe(c.id)
expect(focus).toHaveBeenCalled()
})
it('focuses the adjacent pane of the subtree that takes over a closed middle pane', () => {
const [, b, c] = createFourColumns(manager)
manager.setActivePane(b.id)
manager.closePane(b.id)
expect(manager.getActivePane()?.id).toBe(c.id)
})
it('focuses the pane after a closed middle pane when that pane is its only sibling', () => {
const [, , c, d] = createFourColumns(manager)
manager.setActivePane(c.id)
manager.closePane(c.id)
expect(manager.getActivePane()?.id).toBe(d.id)
})
it('focuses the pane above when the lower half of a stacked split closes', () => {
const [, b] = createFourColumns(manager)
const below = split(manager, b, 'horizontal')
manager.closePane(below.id)
expect(manager.getActivePane()?.id).toBe(b.id)
})
it('walks into the edge of an orthogonal split that takes over the space', () => {
// [ a | b ] over [ c ], all left of d: closing d must land on b, which touches d's edge.
const a = manager.createInitialPane()
const d = split(manager, a, 'vertical')
split(manager, a, 'horizontal')
const b = split(manager, a, 'vertical')
manager.setActivePane(d.id)
manager.closePane(d.id)
expect(manager.getActivePane()?.id).toBe(b.id)
})
it('keeps the active pane when a different pane closes', () => {
const [, b, , d] = createFourColumns(manager)
manager.closePane(b.id)
expect(manager.getActivePane()?.id).toBe(d.id)
})
it('focuses the neighbour when the active pane is detached into another tab', () => {
const [, , c, d] = createFourColumns(manager)
expect(manager.detachPaneForExternalMove(d.id)).toBe(true)
expect(manager.getActivePane()?.id).toBe(c.id)
})
it('focuses the neighbour when the active pane is retired with its PTY kept', () => {
const [, , c, d] = createFourColumns(manager)
manager.setActivePane(c.id)
expect(manager.retirePanePreservingPty(c.id)).toBe(true)
expect(manager.getActivePane()?.id).toBe(d.id)
})
it('leaves no active pane when the only pane closes', () => {
const a = manager.createInitialPane()
manager.closePane(a.id)
expect(manager.getActivePane()).toBeNull()
})
})
@@ -0,0 +1,31 @@
import type { ManagedPaneInternal } from './pane-manager-types'
import { findPaneChildren, getSplitDirection } from './pane-tree-equalization'
/**
* The pane to focus when `closedContainer` leaves its split: the sibling subtree takes over the
* freed space, so pick its leaf touching the closed pane's edge. Must run before the DOM removal.
*/
export function findClosedPaneSuccessor(
closedContainer: HTMLElement,
panes: ReadonlyMap<number, ManagedPaneInternal>
): ManagedPaneInternal | null {
const parent = closedContainer.parentElement
if (!parent?.classList.contains('pane-split')) {
return null
}
const children = findPaneChildren(parent)
const closedIndex = children.indexOf(closedContainer)
if (closedIndex === -1) {
return null
}
const closedIsFirst = closedIndex === 0
const axis = getSplitDirection(parent)
let node: HTMLElement | undefined = closedIsFirst ? children[1] : children[closedIndex - 1]
while (node?.classList.contains('pane-split')) {
const nested = findPaneChildren(node)
// Why: an orthogonal split touches the closed edge with every child; reading order breaks the tie.
node = getSplitDirection(node) === axis && !closedIsFirst ? nested.at(-1) : nested[0]
}
const paneId = Number(node?.dataset.paneId)
return Number.isFinite(paneId) ? (panes.get(paneId) ?? null) : null
}
@@ -21,6 +21,7 @@ import { disposeWebgl } from './pane-webgl-renderer'
import { clearPendingSplitScrollRestore, scheduleSplitScrollRestore } from './pane-split-scroll'
import { reattachWebglIfNeeded } from './pane-webgl-reattach'
import { toPublicPane } from './pane-public-view'
import { findClosedPaneSuccessor } from './pane-close-focus-successor'
import { releaseTerminalScrollIntentKey } from './terminal-scroll-intent-key-store'
type MovedPaneSplitState = {
@@ -192,6 +193,8 @@ function teardownManagedPane(
return
}
const closedLeafId = pane.leafId
const successor =
args.activePaneId === args.paneId ? findClosedPaneSuccessor(pane.container, args.panes) : null
args.releasePaneIdentity(args.paneId)
removePaneContainer(args, pane)
if (reason === 'close') {
@@ -200,7 +203,7 @@ function teardownManagedPane(
// must still be able to restore it.
releaseTerminalScrollIntentKey(closedLeafId)
}
const nextActivePaneId = activateReplacementPane(args)
const nextActivePaneId = activateReplacementPane(args, successor)
applyPaneOpacity(args.panes.values(), nextActivePaneId, args.styleOptions)
for (const p of args.panes.values()) {
safeFit(p)
@@ -256,11 +259,15 @@ function removePaneContainer(args: CloseManagedPaneArgs, pane: ManagedPaneIntern
}
}
function activateReplacementPane(args: CloseManagedPaneArgs): number | null {
function activateReplacementPane(
args: CloseManagedPaneArgs,
successor: ManagedPaneInternal | null
): number | null {
if (args.activePaneId !== args.paneId) {
return args.activePaneId
}
const next = args.panes.values().next().value as ManagedPaneInternal | undefined
const next =
successor && args.panes.has(successor.id) ? successor : args.panes.values().next().value
const nextActivePaneId = next?.id ?? null
args.setActivePaneId(nextActivePaneId)
next?.terminal.focus()
@@ -7,7 +7,7 @@ export function findPaneChildren(parent: HTMLElement): HTMLElement[] {
)
}
function getSplitDirection(split: HTMLElement): 'vertical' | 'horizontal' {
export function getSplitDirection(split: HTMLElement): 'vertical' | 'horizontal' {
return split.classList.contains('is-horizontal') ? 'horizontal' : 'vertical'
}
@@ -0,0 +1,80 @@
/**
* E2E test for keyboard focus after closing a split terminal pane.
*
* User Prompt:
* - after closing the focused pane, keep typing in the pane that takes over its space
*/
import type { Page } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import {
focusActiveTerminalInput,
readTerminalPaneDomLeafOrder,
resolveActiveTabId,
waitForPaneCount,
waitForPaneIdentitySnapshot
} from './helpers/terminal'
import { closeActivePaneFromKeyboard } from './helpers/terminal-layout-journeys'
import { registerTerminalPaneMountReadiness } from './helpers/terminal-pane-mount-readiness'
import { waitForTerminalOutputForPtyId } from './artificial-opencode-pane-interactions'
const SPLIT_RIGHT_CHORD = process.platform === 'darwin' ? 'Meta+d' : 'Control+Shift+d'
async function waitThreeFrames(page: Page): Promise<void> {
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
)
)
)
}
/** The leaf whose xterm input holds DOM focus, or null when focus is anywhere else. */
async function readFocusedTerminalLeafId(page: Page, tabId: string): Promise<string | null> {
return page.evaluate((tabId) => {
const active = document.activeElement
if (!active?.classList.contains('xterm-helper-textarea')) {
return null
}
const panes = window.__paneManagers?.get(tabId)?.getPanes() ?? []
return panes.find((pane) => pane.container.contains(active))?.leafId ?? null
}, tabId)
}
// Why: keep the suite serial so headful pane tests never open multiple Electron windows at once.
test.describe.configure({ mode: 'serial' })
test.describe('Terminal close pane focus', () => {
registerTerminalPaneMountReadiness()
test('closing the last of four split panes focuses the neighbor that fills its space', async ({
orcaPage
}) => {
await waitForPaneIdentitySnapshot(orcaPage, 1)
for (const paneCount of [2, 3, 4]) {
await focusActiveTerminalInput(orcaPage)
await orcaPage.keyboard.press(SPLIT_RIGHT_CHORD)
await waitForPaneCount(orcaPage, paneCount)
}
const snapshot = await waitForPaneIdentitySnapshot(orcaPage, 4)
const [, , neighborLeafId, closedLeafId] = await readTerminalPaneDomLeafOrder(orcaPage)
expect(snapshot.activeLeafId).toBe(closedLeafId)
const neighborPtyId = neighborLeafId ? snapshot.ptyIdsByLeafId[neighborLeafId] : undefined
if (!neighborLeafId || !neighborPtyId) {
throw new Error(`No bound neighbor pane in ${JSON.stringify(snapshot)}`)
}
await closeActivePaneFromKeyboard(orcaPage, 4)
await waitThreeFrames(orcaPage)
const tabId = await resolveActiveTabId(orcaPage)
if (!tabId) {
throw new Error('No active terminal tab after closing a pane')
}
expect(await readFocusedTerminalLeafId(orcaPage, tabId)).toBe(neighborLeafId)
const marker = `CLOSE_FOCUS_${Date.now()}`
await orcaPage.keyboard.type(`echo ${marker}\r`)
await waitForTerminalOutputForPtyId(orcaPage, neighborPtyId, marker, 10_000)
})
})
@@ -7,7 +7,21 @@ import type {
* Differences from main this branch is allowed, each tied to the named bug it fixes. Keep empty
* on an inert PR. Paths are prefixes of the runner's report paths, e.g. `[0].persisted.local`.
*/
export const TERMINAL_LAYOUT_PARITY_DECLARED_DIFFERENCES: readonly DeclaredParityDifference[] = []
export const TERMINAL_LAYOUT_PARITY_DECLARED_DIFFERENCES: readonly DeclaredParityDifference[] = [
{
scenario: 'close-pane',
bugId: '#30326',
paths: [
'[0].renderer.mountedPanesByTabId.#1.leafOrder',
'[0].renderer.terminalLayoutsByTabId.#1.ptyIdsByLeafId',
'[0].renderer.terminalLayoutsByTabId.#1.root',
'[0].persisted.local.terminalLayoutsByTabId.#1.ptyIdsByLeafId',
'[0].persisted.local.terminalLayoutsByTabId.#1.root'
],
reason:
'Closing the focused pane activates its sibling instead of the first pane; leaves are labelled from the active leaf, so the same two-pane layout relabels.'
}
]
/** Paths main itself does not reproduce; reported but not failed. Remove an entry once main is fixed. */
export const TERMINAL_LAYOUT_PARITY_UNSTABLE_ON_MAIN: readonly UnstableOnMainPath[] = []