Reflow the surviving terminal when a split collapses

When two terminals are arranged in a left/right split and one side is
closed, the surviving terminal could stay rendered at the old narrow
(half) width, leaving the rest of the now full-width pane permanently
blank for an idle agent that does not repaint on its own.

The within-tab pane-close path already forces a deterministic refit on
collapse, but the tab-group split path relied solely on a 150ms-debounced
ResizeObserver, which can miss the flex-only width change. Add
useRefitOnSplitCollapse: when the layout tree loses a leaf while the
worktree is visible, dispatch the existing SYNC_FIT_PANES_EVENT across a
double rAF so every visible pane re-fits once geometry settles. Panes
already at the right size no-op via safeFit's early return.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-06-27 11:52:36 -07:00
co-authored by Orca
parent b899aef1f9
commit 5fa2dd4f67
5 changed files with 230 additions and 0 deletions
@@ -6,6 +6,7 @@ import TabGroupPanel from './TabGroupPanel'
import TabDragPreview from '../tab-bar/TabDragPreview'
import { TabDragProvider } from './tab-drag-context'
import TabPaneColumnSplitDragOverlay from './TabPaneColumnSplitDragOverlay'
import { useRefitOnSplitCollapse } from './use-refit-on-split-collapse'
import { type HoveredTabInsertion, useTabDragSplit } from './useTabDragSplit'
const MIN_RATIO = 0.15
@@ -223,6 +224,9 @@ export default function TabGroupSplitLayout({
const dragSplit = useTabDragSplit({ worktreeId, enabled: isWorktreeActive })
const hasSplits = layout.type === 'split'
// Why: reflow the surviving group's terminal when a split collapses (#6154).
useRefitOnSplitCollapse(layout, isWorktreeActive)
return (
<TabDragProvider
isTabDragActive={dragSplit.activeDrag !== null}
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import type { TabGroupLayoutNode } from '../../../../shared/types'
import { countLayoutLeaves } from './tab-group-layout-leaf-count'
describe('countLayoutLeaves', () => {
it('counts a single leaf as one', () => {
const layout: TabGroupLayoutNode = { type: 'leaf', groupId: 'a' }
expect(countLayoutLeaves(layout)).toBe(1)
})
it('counts a two-leaf split as two', () => {
const layout: TabGroupLayoutNode = {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: 'a' },
second: { type: 'leaf', groupId: 'b' }
}
expect(countLayoutLeaves(layout)).toBe(2)
})
it('counts leaves in a nested split tree', () => {
const layout: TabGroupLayoutNode = {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: 'a' },
second: {
type: 'split',
direction: 'vertical',
first: { type: 'leaf', groupId: 'b' },
second: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: 'c' },
second: { type: 'leaf', groupId: 'd' }
}
}
}
expect(countLayoutLeaves(layout)).toBe(4)
})
})
@@ -0,0 +1,9 @@
import type { TabGroupLayoutNode } from '../../../../shared/types'
/** Total number of group leaves in a tab-group split tree (leaf → 1, split → sum of children). */
export function countLayoutLeaves(node: TabGroupLayoutNode): number {
if (node.type === 'leaf') {
return 1
}
return countLayoutLeaves(node.first) + countLayoutLeaves(node.second)
}
@@ -0,0 +1,130 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TabGroupLayoutNode } from '../../../../shared/types'
import { SYNC_FIT_PANES_EVENT } from '@/constants/terminal'
import { useRefitOnSplitCollapse } from './use-refit-on-split-collapse'
const LEAF: TabGroupLayoutNode = { type: 'leaf', groupId: 'a' }
const SPLIT_2: TabGroupLayoutNode = {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: 'a' },
second: { type: 'leaf', groupId: 'b' }
}
const SPLIT_3: TabGroupLayoutNode = {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: 'a' },
second: {
type: 'split',
direction: 'vertical',
first: { type: 'leaf', groupId: 'b' },
second: { type: 'leaf', groupId: 'c' }
}
}
function Probe({
layout,
isWorktreeActive
}: {
layout: TabGroupLayoutNode
isWorktreeActive: boolean
}): null {
useRefitOnSplitCollapse(layout, isWorktreeActive)
return null
}
describe('useRefitOnSplitCollapse', () => {
let container: HTMLDivElement
let root: Root
let dispatched: number
let onSyncFit: () => void
// Two-deep rAF callback queue so the double-rAF resolves deterministically.
let rafCallbacks: FrameRequestCallback[]
const flushRaf = (): void => {
// Drain in waves so a callback scheduled by another callback runs next tick.
let guard = 0
while (rafCallbacks.length > 0 && guard < 10) {
const batch = rafCallbacks
rafCallbacks = []
for (const cb of batch) {
cb(performance.now())
}
guard += 1
}
}
const renderLayout = (layout: TabGroupLayoutNode, isWorktreeActive = true): void => {
act(() => {
root.render(<Probe layout={layout} isWorktreeActive={isWorktreeActive} />)
})
}
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
dispatched = 0
rafCallbacks = []
onSyncFit = (): void => {
dispatched += 1
}
window.addEventListener(SYNC_FIT_PANES_EVENT, onSyncFit)
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback): number => {
rafCallbacks.push(cb)
return rafCallbacks.length
})
vi.stubGlobal('cancelAnimationFrame', vi.fn())
})
afterEach(() => {
act(() => {
root.unmount()
})
window.removeEventListener(SYNC_FIT_PANES_EVENT, onSyncFit)
container.remove()
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
it('dispatches SYNC_FIT_PANES_EVENT when a split collapses to fewer leaves', () => {
renderLayout(SPLIT_2)
expect(dispatched).toBe(0)
renderLayout(LEAF)
flushRaf()
expect(dispatched).toBe(1)
})
it('dispatches when a nested split loses one leaf', () => {
renderLayout(SPLIT_3)
renderLayout(SPLIT_2)
flushRaf()
expect(dispatched).toBe(1)
})
it('does not dispatch when the leaf count increases (a split is created)', () => {
renderLayout(LEAF)
renderLayout(SPLIT_2)
flushRaf()
expect(dispatched).toBe(0)
})
it('does not dispatch when the leaf count is unchanged (e.g. ratio drag)', () => {
renderLayout(SPLIT_2)
renderLayout({ ...SPLIT_2, ratio: 0.3 })
flushRaf()
expect(dispatched).toBe(0)
})
it('does not dispatch when the worktree is not active', () => {
renderLayout(SPLIT_2, false)
renderLayout(LEAF, false)
flushRaf()
expect(dispatched).toBe(0)
})
})
@@ -0,0 +1,47 @@
import { useEffect, useRef } from 'react'
import type { TabGroupLayoutNode } from '../../../../shared/types'
import { SYNC_FIT_PANES_EVENT } from '@/constants/terminal'
import { countLayoutLeaves } from './tab-group-layout-leaf-count'
/**
* Forces a synchronous fit of all visible terminal panes when a tab-group split
* collapses (the layout tree loses a leaf), so the surviving group's xterm grid
* reflows to the new, wider container.
*
* Why: a collapsing split widens the surviving group via flexbox alone. The
* within-tab pane close has a deterministic rAF refit (onLayoutChanged →
* queueResizeAll → fitPanes); the group path otherwise relies only on a
* 150ms-debounced ResizeObserver that can miss the flex-only resize, leaving an
* idle agent's xterm pinned at the old narrow width with a blank survivor half
* (#6154). Double-rAF: the first frame commits React/flex styles, the second
* runs after layout reflow so proposeDimensions reads the new width. Only fires
* on a leaf-count *decrease* so splits/ratio drags (which have their own fit
* paths) don't trigger it, and only while the worktree is visible (the
* SYNC_FIT_PANES_EVENT listener and safeFit skip unmeasurable hidden panes).
*/
export function useRefitOnSplitCollapse(
layout: TabGroupLayoutNode,
isWorktreeActive: boolean
): void {
const prevLeafCountRef = useRef(countLayoutLeaves(layout))
useEffect(() => {
const leafCount = countLayoutLeaves(layout)
const collapsed = leafCount < prevLeafCountRef.current
prevLeafCountRef.current = leafCount
if (!isWorktreeActive || !collapsed) {
return
}
const handles = { raf1: 0, raf2: 0 }
handles.raf1 = requestAnimationFrame(() => {
handles.raf2 = requestAnimationFrame(() => {
window.dispatchEvent(new Event(SYNC_FIT_PANES_EVENT))
})
})
return () => {
cancelAnimationFrame(handles.raf1)
if (handles.raf2) {
cancelAnimationFrame(handles.raf2)
}
}
}, [layout, isWorktreeActive])
}