From ff8ebfb43856b7dbdcc4aafe4019ac7b0d88a44d Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Fri, 11 Sep 2026 10:11:20 -0700 Subject: [PATCH] fix(mobile): keep split terminal placement stable --- ...le-session-terminal-create-actions.test.ts | 14 +++++ .../mobile-session-tabs-part-03.spec.ts | 58 +++++++++++++++++++ src/shared/session-tab-placement.test.ts | 14 +++++ src/shared/session-tab-placement.ts | 23 ++++---- 4 files changed, 99 insertions(+), 10 deletions(-) diff --git a/mobile/src/session/use-mobile-session-terminal-create-actions.test.ts b/mobile/src/session/use-mobile-session-terminal-create-actions.test.ts index 847ad1316c0..89a9074fa88 100644 --- a/mobile/src/session/use-mobile-session-terminal-create-actions.test.ts +++ b/mobile/src/session/use-mobile-session-terminal-create-actions.test.ts @@ -305,6 +305,20 @@ describe('optimistic placement of a created tab', () => { ]) }) + it('paints after the active split parent, matching headed host placement', async () => { + const scope = createScope(clientReturning(terminalCreateResponse())) + scope.activeSessionTabId = 'existing-tab::left' + await createLegacyTerminal(scope) + + expect( + tabIdsAfterCreate(scope, [ + { id: 'existing-tab::left', parentTabId: 'existing-tab' }, + { id: 'existing-tab::right', parentTabId: 'existing-tab' }, + { id: 'trailing-tab' } + ]) + ).toEqual(['existing-tab::left', 'existing-tab::right', 'terminal-tab-1', 'trailing-tab']) + }) + it('sends the same anchor it paints with', async () => { const scope = createScope(clientReturning(terminalCreateResponse())) await createLegacyTerminal(scope) diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-03.spec.ts index 55c3aae4170..e3aa0774fa5 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-03.spec.ts @@ -303,6 +303,64 @@ describe('OrcaRuntimeService', () => { ]) }) + it('places a runtime-owned terminal after every leaf in the anchored split parent', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-created-after-split' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.syncWindowGraph(0, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'headless:split-placement', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: 'split::left', + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: 'split::left', + parentTabId: 'split', + leafId: 'left', + title: 'Split', + isActive: true + }, + { + type: 'terminal', + id: 'split::right', + parentTabId: 'split', + leafId: 'right', + title: 'Split', + isActive: false + }, + { + type: 'terminal', + id: 'trailing::leaf', + parentTabId: 'trailing', + leafId: 'leaf', + title: 'Trailing', + isActive: false + } + ] + } + ] + }) + + const created = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + afterTabId: 'split::left' + }) + + expect( + (await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs.map((tab) => tab.id) + ).toEqual(['split::left', 'split::right', created.tab.id, 'trailing::leaf']) + }) + it('leases renderer publication for a paired create and preserves host-owned inventory', async () => { const leafId = '91919191-9191-4919-8919-919191919191' const spawn = vi.fn() diff --git a/src/shared/session-tab-placement.test.ts b/src/shared/session-tab-placement.test.ts index f57d2ebf9cb..8ce0cd8c0f8 100644 --- a/src/shared/session-tab-placement.test.ts +++ b/src/shared/session-tab-placement.test.ts @@ -10,6 +10,20 @@ describe('placeCreatedSessionTab', () => { ).toEqual(['a', 'new', 'b', 'c']) }) + it('inserts after all leaves of a split parent', () => { + expect( + placeCreatedSessionTab( + [ + { id: 'split::left', parentTabId: 'split' }, + { id: 'split::right', parentTabId: 'split' }, + { id: 'trailing' } + ], + { id: 'new' }, + 'split::left' + ).map((tab) => tab.id) + ).toEqual(['split::left', 'split::right', 'new', 'trailing']) + }) + it('appends when the anchor is the last tab', () => { expect( placeCreatedSessionTab([{ id: 'a' }, { id: 'b' }], created, 'b').map((t) => t.id) diff --git a/src/shared/session-tab-placement.ts b/src/shared/session-tab-placement.ts index 7409414daf3..dd810424c4e 100644 --- a/src/shared/session-tab-placement.ts +++ b/src/shared/session-tab-placement.ts @@ -1,12 +1,5 @@ -/** - * Where a newly created session tab lands in a tab list. - * - * Shared because a client that optimistically paints a created tab and the host that publishes - * the authoritative snapshot must agree. When they disagreed — the client appending while the - * host spliced after `afterTabId` — the new tab painted at the end and then visibly jumped to its - * real slot as soon as the host frame landed. - */ -export function placeCreatedSessionTab( +/** Places a created tab after the anchor's top-level terminal group, or appends when unanchored. */ +export function placeCreatedSessionTab( tabs: readonly T[], created: T, afterTabId: string | null | undefined @@ -17,6 +10,16 @@ export function placeCreatedSessionTab( next.push(created) return next } - next.splice(anchor + 1, 0, created) + let insertAfter = anchor + const anchorParentTabId = next[anchor].parentTabId + if (anchorParentTabId) { + while ( + insertAfter + 1 < next.length && + next[insertAfter + 1].parentTabId === anchorParentTabId + ) { + insertAfter += 1 + } + } + next.splice(insertAfter + 1, 0, created) return next }