mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 08:03:14 +00:00
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:
@@ -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])
|
||||
}
|
||||
Reference in New Issue
Block a user