fix(mobile): keep split terminal placement stable

This commit is contained in:
Merge Sim
2026-09-11 10:11:20 -07:00
parent d1502d1d53
commit ff8ebfb438
4 changed files with 99 additions and 10 deletions
@@ -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)
@@ -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()
+14
View File
@@ -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)
+13 -10
View File
@@ -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<T extends { id: string }>(
/** Places a created tab after the anchor's top-level terminal group, or appends when unanchored. */
export function placeCreatedSessionTab<T extends { id: string; parentTabId?: string }>(
tabs: readonly T[],
created: T,
afterTabId: string | null | undefined
@@ -17,6 +10,16 @@ export function placeCreatedSessionTab<T extends { id: string }>(
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
}