fix: reuse existing right split for emulator

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-07 21:23:52 -07:00
co-authored by Orca
parent ec5bfccc2e
commit 8f266aafa1
3 changed files with 102 additions and 0 deletions
@@ -0,0 +1,52 @@
import type { TabGroupLayoutNode } from '../../../shared/types'
type SearchResult = {
containsSource: boolean
reusableGroupId: string | null
}
function firstLeafGroupId(node: TabGroupLayoutNode): string {
if (node.type === 'leaf') {
return node.groupId
}
return firstLeafGroupId(node.first)
}
function findReusableRightSplitTarget(
node: TabGroupLayoutNode,
sourceGroupId: string
): SearchResult {
if (node.type === 'leaf') {
return {
containsSource: node.groupId === sourceGroupId,
reusableGroupId: null
}
}
const first = findReusableRightSplitTarget(node.first, sourceGroupId)
if (first.containsSource) {
return {
containsSource: true,
// Why: prefer the closest existing right pane; it preserves nested split intent.
reusableGroupId:
first.reusableGroupId ??
(node.direction === 'horizontal' ? firstLeafGroupId(node.second) : null)
}
}
const second = findReusableRightSplitTarget(node.second, sourceGroupId)
return {
containsSource: second.containsSource,
reusableGroupId: second.reusableGroupId
}
}
export function findReusableRightSplitGroupId(
layout: TabGroupLayoutNode | undefined,
sourceGroupId: string
): string | null {
if (!layout) {
return null
}
return findReusableRightSplitTarget(layout, sourceGroupId).reusableGroupId
}
@@ -10,6 +10,7 @@ const mockStoreState = vi.hoisted(() => ({
dropUnifiedTab: vi.fn(),
focusGroup: vi.fn(),
groupsByWorktree: {} as Record<string, { id: string }[]>,
layoutByWorktree: {} as Record<string, unknown>,
settings: { mobileEmulatorEnabled: true },
setActiveTab: vi.fn(),
setActiveTabType: vi.fn(),
@@ -34,6 +35,7 @@ describe('ensureSimulatorTab', () => {
mockStoreState.activeGroupIdByWorktree = { 'wt-1': 'group-1' }
mockStoreState.activeWorktreeId = 'wt-1'
mockStoreState.groupsByWorktree = { 'wt-1': [{ id: 'group-1' }] }
mockStoreState.layoutByWorktree = { 'wt-1': { type: 'leaf', groupId: 'group-1' } }
mockStoreState.settings = { mobileEmulatorEnabled: true }
mockStoreState.unifiedTabsByWorktree = {
'wt-1': [{ id: 'sim-1', groupId: 'group-1', contentType: 'simulator' }]
@@ -116,6 +118,37 @@ describe('ensureSimulatorTab', () => {
expect(mockStoreState.setActiveTabType).not.toHaveBeenCalled()
})
it('reuses an existing right split when requested', async () => {
mockStoreState.groupsByWorktree = { 'wt-1': [{ id: 'group-1' }, { id: 'group-2' }] }
mockStoreState.layoutByWorktree = {
'wt-1': {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: 'group-1' },
second: { type: 'leaf', groupId: 'group-2' }
}
}
mockStoreState.unifiedTabsByWorktree = { 'wt-1': [] }
mockStoreState.createUnifiedTab.mockReturnValue({
id: 'sim-2',
groupId: 'group-2',
contentType: 'simulator'
})
const { ensureSimulatorTab } = await import('./ensure-simulator-tab')
expect(ensureSimulatorTab('wt-1', { placement: 'rightSplit' })).toBe('sim-2')
expect(mockStoreState.createUnifiedTabInSplit).not.toHaveBeenCalled()
expect(mockStoreState.createUnifiedTab).toHaveBeenCalledWith('wt-1', 'simulator', {
label: 'Mobile Emulator',
targetGroupId: 'group-2',
activate: true
})
expect(mockStoreState.activateTab).toHaveBeenCalledWith('sim-2')
expect(mockStoreState.focusGroup).toHaveBeenCalledWith('wt-1', 'group-2')
expect(mockStoreState.setActiveTabType).toHaveBeenCalledWith('simulator')
})
it('falls back to the source group when atomic right split creation fails', async () => {
mockStoreState.unifiedTabsByWorktree = { 'wt-1': [] }
mockStoreState.createUnifiedTabInSplit.mockReturnValue(null)
@@ -1,4 +1,5 @@
import { useAppStore } from '@/store'
import { findReusableRightSplitGroupId } from './emulator-right-split-target'
import { cancelPendingSimulatorPaneShutdown } from './simulator-pane-shutdown-scheduler'
import { shouldShutdownSimulatorForPaneUnmountFromTabs } from './simulator-tab-shutdown'
@@ -58,6 +59,22 @@ export function ensureSimulatorTab(
}
if (options?.placement === 'rightSplit' && shouldSurface) {
const reusableRightGroupId = findReusableRightSplitGroupId(
store.layoutByWorktree[worktreeId],
sourceGroupId
)
if (reusableRightGroupId) {
const tab = store.createUnifiedTab(worktreeId, 'simulator', {
label: 'Mobile Emulator',
targetGroupId: reusableRightGroupId,
activate: true
})
store.activateTab(tab.id)
store.setActiveTabType('simulator')
store.focusGroup(worktreeId, tab.groupId)
return tab.id
}
// Why: publish the simulator directly in its split group. A two-step
// create-then-move can persist the midpoint during dev reload/HMR.
const splitTab = store.createUnifiedTabInSplit(