diff --git a/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx b/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx index db37a19d241..b782751742a 100644 --- a/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx +++ b/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx @@ -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 ( { + 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) + }) +}) diff --git a/src/renderer/src/components/tab-group/tab-group-layout-leaf-count.ts b/src/renderer/src/components/tab-group/tab-group-layout-leaf-count.ts new file mode 100644 index 00000000000..22c3b21531d --- /dev/null +++ b/src/renderer/src/components/tab-group/tab-group-layout-leaf-count.ts @@ -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) +} diff --git a/src/renderer/src/components/tab-group/use-refit-on-split-collapse.test.tsx b/src/renderer/src/components/tab-group/use-refit-on-split-collapse.test.tsx new file mode 100644 index 00000000000..62562a9c66f --- /dev/null +++ b/src/renderer/src/components/tab-group/use-refit-on-split-collapse.test.tsx @@ -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() + }) + } + + 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) + }) +}) diff --git a/src/renderer/src/components/tab-group/use-refit-on-split-collapse.ts b/src/renderer/src/components/tab-group/use-refit-on-split-collapse.ts new file mode 100644 index 00000000000..398ecee3c07 --- /dev/null +++ b/src/renderer/src/components/tab-group/use-refit-on-split-collapse.ts @@ -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]) +}