fix: prevent split-group container teardown when switching worktrees (#726)

This commit is contained in:
Brennan Benson 2026-04-16 14:29:33 -07:00 committed by GitHub
parent 19d22b0667
commit 48c24935c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 162 additions and 14 deletions

View file

@ -30,6 +30,10 @@ import BrowserPane, { destroyPersistentWebview } from './browser-pane/BrowserPan
import { reconcileTabOrder } from './tab-bar/reconcile-order'
import TabGroupSplitLayout from './tab-group/TabGroupSplitLayout'
import { shouldAutoCreateInitialTerminal } from './terminal/initial-terminal'
import {
getEffectiveLayoutForWorktree as getEffectiveLayout,
anyMountedWorktreeHasLayout as computeAnyMountedWorktreeHasLayout
} from './terminal/split-group-mount'
import CodexRestartChip from './CodexRestartChip'
const EditorPanel = lazy(() => import('./editor/EditorPanel'))
@ -106,18 +110,8 @@ function Terminal(): React.JSX.Element | null {
? (browserTabsByWorktree[activeWorktreeId] ?? [])
: []
const getEffectiveLayoutForWorktree = useCallback(
(worktreeId: string) => {
const layout = layoutByWorktree[worktreeId]
if (layout) {
return layout
}
const groups = groupsByWorktree[worktreeId] ?? []
const fallbackGroupId = activeGroupIdByWorktree[worktreeId] ?? groups[0]?.id ?? null
if (!fallbackGroupId) {
return undefined
}
return { type: 'leaf', groupId: fallbackGroupId } as const
},
(worktreeId: string) =>
getEffectiveLayout(worktreeId, layoutByWorktree, groupsByWorktree, activeGroupIdByWorktree),
[activeGroupIdByWorktree, groupsByWorktree, layoutByWorktree]
)
const effectiveActiveLayout = activeWorktreeId
@ -215,6 +209,13 @@ function Terminal(): React.JSX.Element | null {
mountedWorktreeIdsRef.current.delete(id)
}
}
const anyMountedWorktreeHasLayout = computeAnyMountedWorktreeHasLayout(
allWorktrees.map((wt) => wt.id),
mountedWorktreeIdsRef.current,
layoutByWorktree,
groupsByWorktree,
activeGroupIdByWorktree
)
// Auto-create first tab when worktree activates
useEffect(() => {
if (!workspaceSessionReady) {
@ -826,8 +827,10 @@ function Terminal(): React.JSX.Element | null {
tab groups + terminal extend to the top of the window instead.
The old summary label (workspace / active surface) is removed. */}
{effectiveActiveLayout ? (
<div className="relative flex flex-1 min-w-0 min-h-0 overflow-hidden">
{anyMountedWorktreeHasLayout ? (
<div
className={`relative flex flex-1 min-w-0 min-h-0 overflow-hidden${effectiveActiveLayout ? '' : ' hidden'}`}
>
{/* Why: each mounted worktree surface is absolutely positioned so we
can preserve hidden trees without reflowing the active one. Keep
a relative anchor here so those panes size to the workspace body

View file

@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest'
import { getEffectiveLayoutForWorktree, anyMountedWorktreeHasLayout } from './split-group-mount'
import type { TabGroup, TabGroupLayoutNode } from '../../../../shared/types'
function makeGroup(id: string, worktreeId: string): TabGroup {
return { id, worktreeId, activeTabId: null, tabOrder: [] }
}
describe('getEffectiveLayoutForWorktree', () => {
it('returns the explicit layout when one exists', () => {
const layout: TabGroupLayoutNode = { type: 'leaf', groupId: 'g1' }
const result = getEffectiveLayoutForWorktree('wt-1', { 'wt-1': layout }, {}, {})
expect(result).toBe(layout)
})
it('falls back to a synthetic leaf from the active group', () => {
const result = getEffectiveLayoutForWorktree(
'wt-1',
{},
{ 'wt-1': [makeGroup('g1', 'wt-1'), makeGroup('g2', 'wt-1')] },
{ 'wt-1': 'g2' }
)
expect(result).toEqual({ type: 'leaf', groupId: 'g2' })
})
it('falls back to the first group when no active group is set', () => {
const result = getEffectiveLayoutForWorktree(
'wt-1',
{},
{ 'wt-1': [makeGroup('g1', 'wt-1')] },
{}
)
expect(result).toEqual({ type: 'leaf', groupId: 'g1' })
})
it('returns undefined when the worktree has no layout and no groups', () => {
const result = getEffectiveLayoutForWorktree('wt-1', {}, {}, {})
expect(result).toBeUndefined()
})
})
describe('anyMountedWorktreeHasLayout', () => {
const layout: TabGroupLayoutNode = { type: 'leaf', groupId: 'g1' }
it('returns true when the active worktree has a layout', () => {
const result = anyMountedWorktreeHasLayout(
['wt-1'],
new Set(['wt-1']),
{ 'wt-1': layout },
{},
{}
)
expect(result).toBe(true)
})
it('returns true when only a non-active mounted worktree has a layout (the bug fix)', () => {
// wt-1 has a layout, wt-2 (the newly active one) does not
const result = anyMountedWorktreeHasLayout(
['wt-1', 'wt-2'],
new Set(['wt-1', 'wt-2']),
{ 'wt-1': layout },
{},
{}
)
expect(result).toBe(true)
})
it('returns false when no mounted worktree has a layout', () => {
const result = anyMountedWorktreeHasLayout(
['wt-1', 'wt-2'],
new Set(['wt-1', 'wt-2']),
{},
{},
{}
)
expect(result).toBe(false)
})
it('ignores worktrees that exist but are not mounted', () => {
const result = anyMountedWorktreeHasLayout(
['wt-1', 'wt-2'],
new Set(['wt-2']), // only wt-2 is mounted
{ 'wt-1': layout }, // only wt-1 has a layout
{},
{}
)
expect(result).toBe(false)
})
it('considers fallback groups when no explicit layout exists', () => {
const result = anyMountedWorktreeHasLayout(
['wt-1'],
new Set(['wt-1']),
{},
{ 'wt-1': [makeGroup('g1', 'wt-1')] },
{}
)
expect(result).toBe(true)
})
})

View file

@ -0,0 +1,45 @@
import type { TabGroup, TabGroupLayoutNode } from '../../../../shared/types'
/**
* Derive the effective layout for a worktree: either its explicit layout
* or a synthetic leaf wrapping its first/active group.
*/
export function getEffectiveLayoutForWorktree(
worktreeId: string,
layoutByWorktree: Record<string, TabGroupLayoutNode | undefined>,
groupsByWorktree: Record<string, TabGroup[]>,
activeGroupIdByWorktree: Record<string, string | undefined>
): TabGroupLayoutNode | undefined {
const layout = layoutByWorktree[worktreeId]
if (layout) {
return layout
}
const groups = groupsByWorktree[worktreeId] ?? []
const fallbackGroupId = activeGroupIdByWorktree[worktreeId] ?? groups[0]?.id ?? null
if (!fallbackGroupId) {
return undefined
}
return { type: 'leaf', groupId: fallbackGroupId } as const
}
/**
* Returns true if any mounted worktree has a split-group layout.
*
* Why: the split-group container hosts ALL mounted worktrees' pane trees.
* Gating it on only the *active* worktree's layout causes the entire tree
* to unmount when switching to a newly-activated worktree that has no
* groups yet destroying PaneManagers, xterm buffers, and PTY connections.
*/
export function anyMountedWorktreeHasLayout(
allWorktreeIds: string[],
mountedWorktreeIds: ReadonlySet<string>,
layoutByWorktree: Record<string, TabGroupLayoutNode | undefined>,
groupsByWorktree: Record<string, TabGroup[]>,
activeGroupIdByWorktree: Record<string, string | undefined>
): boolean {
return allWorktreeIds.some(
(id) =>
mountedWorktreeIds.has(id) &&
getEffectiveLayoutForWorktree(id, layoutByWorktree, groupsByWorktree, activeGroupIdByWorktree)
)
}