From 5287c5cdbc48ae1e86ba29c8dbb7f894889cf7b1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:42:40 -0700 Subject: [PATCH] fix(mobile): stop a created tab from jumping when the host snapshot lands (#20069) * fix(mobile): stop a created tab from jumping when the host snapshot lands Creating a tab from the mobile session strip painted the new tab at the end of the strip and then visibly jumped it to a different slot a beat later. The client asked the host to insert the tab after the active tab, but then predicted a different placement for its own optimistic paint: afterTabId: activeSessionTabId ?? undefined // host: splice(insertAfter + 1) ... return [...prev, { ...created, isActive: true }] // client: append Two independent placements that disagree, so the optimistic frame is wrong by construction and the tab snaps to its real slot on the next published snapshot. The disagreement dates to 57a70d2ac0 ("Fix mobile session tab authority"), which introduced afterTabId and left the append in place. Before that the client used terminal.create with no anchor, so both sides appended and agreed. Rather than teach the client to re-derive the host's rule, both sides now call one shared placeCreatedSessionTab, and the client captures a single afterTabId for the request and the paint so they cannot drift apart again. The host change is a pure refactor onto the shared helper; the 1260-test runtime characterization suite is unchanged. The mobile route-parity hash pin moves once because handleCreateTerminal's body changed - it is the only one of the 12 extracted functions that differs. * fix(mobile): keep split terminal placement stable * fix(mobile): negotiate split tab placement * fix(e2e): run worktree first-paint probe on a mapped window * test(mobile): type tab placement updater * test(mobile): model current host in create recordings --------- Co-authored-by: Merge Sim --- ...erminal-session.tabs.createterminal-1.json | 4 +- ...ssion.create-terminal-terminal.send-1.json | 4 +- ...nal-ignores-a-second-create-in-flight.json | 4 +- ...minal-launches-an-agent-quick-command.json | 4 +- .../session-create-terminal-refused.json | 4 +- ...ssion-create-terminal-replaces-active.json | 4 +- ...-create-terminal-runs-a-quick-command.json | 4 +- .../session-create-terminal-with-prompt.json | 4 +- ...on-create-terminal-without-active-tab.json | 4 +- ...ession-create-terminal-without-handle.json | 4 +- .../mobile-session-route-parity.test.ts | 11 +- .../session/use-mobile-session-foundation.ts | 3 + ...le-session-terminal-create-actions.test.ts | 113 +++++++++++- ...-mobile-session-terminal-create-actions.ts | 26 ++- .../session-terminal-create-mount-adapters.ts | 7 + .../mutants/operation-mutations.ts | 4 +- ...mobile-runtime-client-capabilities.test.ts | 4 +- .../mobile-runtime-client-capabilities.ts | 2 + ...-runtime-create-mobile-session-terminal.ts | 3 + ...e-runtime-owned-mobile-session-terminal.ts | 19 +- ...time-run-create-mobile-session-terminal.ts | 22 ++- .../mobile-session-tabs-part-03.spec.ts | 165 ++++++++++++++++++ .../runtime-availability.spec.ts | 1 + .../runtime/rpc/methods/session-tabs.test.ts | 47 ++++- src/main/runtime/rpc/methods/session-tabs.ts | 14 +- src/shared/protocol-version.ts | 5 + src/shared/session-tab-placement.test.ts | 91 ++++++++++ src/shared/session-tab-placement.ts | 29 +++ 28 files changed, 553 insertions(+), 53 deletions(-) create mode 100644 src/shared/session-tab-placement.test.ts create mode 100644 src/shared/session-tab-placement.ts diff --git a/mobile/rpc-foundation/goldens/matrix-session.create-terminal-session.tabs.createterminal-1.json b/mobile/rpc-foundation/goldens/matrix-session.create-terminal-session.tabs.createterminal-1.json index 0e6cbcec813..8d3c8f5191c 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.create-terminal-session.tabs.createterminal-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.create-terminal-session.tabs.createterminal-1.json @@ -4,9 +4,9 @@ "namedDeltas": [], "runnerVersion": 1, "baseline": "4b876758d3158a8eb6b798055d8db7c58d1cd4a9", - "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "d4fa125899cd863c03dbf96657d46b8c7d6c0ccf522b342bc4f164ed1e3becfe", - "adapterSha256": "f0cc9511c5e878b6dd78e2911b5c14351b41b7bb944e5e03c2fa1ddbcdc7ea10", + "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", "scenarioSha256": "ae1572c1670073d0ac3d2d3abd0e7bee9910cbe4a0b09ecd941e887eeb1db165", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-session.create-terminal-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-session.create-terminal-terminal.send-1.json index 74939755b87..2316ed8267d 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.create-terminal-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.create-terminal-terminal.send-1.json @@ -4,9 +4,9 @@ "namedDeltas": [], "runnerVersion": 1, "baseline": "4b876758d3158a8eb6b798055d8db7c58d1cd4a9", - "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "d4fa125899cd863c03dbf96657d46b8c7d6c0ccf522b342bc4f164ed1e3becfe", - "adapterSha256": "f0cc9511c5e878b6dd78e2911b5c14351b41b7bb944e5e03c2fa1ddbcdc7ea10", + "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", "scenarioSha256": "62930616f1017ffd4b74546c80b17f3aa3bcee52a6ad62ec144d95872fd31321", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-ignores-a-second-create-in-flight.json b/mobile/rpc-foundation/goldens/session-create-terminal-ignores-a-second-create-in-flight.json index b7a421c06b5..645b421fe2f 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-ignores-a-second-create-in-flight.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-ignores-a-second-create-in-flight.json @@ -4,9 +4,9 @@ "namedDeltas": [], "runnerVersion": 1, "baseline": "4b876758d3158a8eb6b798055d8db7c58d1cd4a9", - "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "d4fa125899cd863c03dbf96657d46b8c7d6c0ccf522b342bc4f164ed1e3becfe", - "adapterSha256": "f0cc9511c5e878b6dd78e2911b5c14351b41b7bb944e5e03c2fa1ddbcdc7ea10", + "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", "scenarioSha256": "df99b9c0ebcffb3d8f173c87783862edb0a66befcc3cd73f1096871f45963913", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-launches-an-agent-quick-command.json b/mobile/rpc-foundation/goldens/session-create-terminal-launches-an-agent-quick-command.json index 3a29ade314b..7c14a533074 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-launches-an-agent-quick-command.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-launches-an-agent-quick-command.json @@ -4,9 +4,9 @@ "namedDeltas": [], "runnerVersion": 1, "baseline": "4b876758d3158a8eb6b798055d8db7c58d1cd4a9", - "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "d4fa125899cd863c03dbf96657d46b8c7d6c0ccf522b342bc4f164ed1e3becfe", - "adapterSha256": "f0cc9511c5e878b6dd78e2911b5c14351b41b7bb944e5e03c2fa1ddbcdc7ea10", + "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", "scenarioSha256": "fb9828d5e13917ce394061b2d702890e5198df04ce803363b2ce9f939921d00e", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-refused.json b/mobile/rpc-foundation/goldens/session-create-terminal-refused.json index 52e5ed1d65e..65f0d42db22 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-refused.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-refused.json @@ -4,9 +4,9 @@ "namedDeltas": [], "runnerVersion": 1, "baseline": "4b876758d3158a8eb6b798055d8db7c58d1cd4a9", - "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "d4fa125899cd863c03dbf96657d46b8c7d6c0ccf522b342bc4f164ed1e3becfe", - "adapterSha256": "f0cc9511c5e878b6dd78e2911b5c14351b41b7bb944e5e03c2fa1ddbcdc7ea10", + "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", "scenarioSha256": "e9c32bdf3357ca5d69ac07cb7bbc63fe279c1a23c44e5c79fa0b1db4fd1c55dd", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-replaces-active.json b/mobile/rpc-foundation/goldens/session-create-terminal-replaces-active.json index 9859c64ec2a..c6894dc7659 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-replaces-active.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-replaces-active.json @@ -4,9 +4,9 @@ "namedDeltas": [], "runnerVersion": 1, "baseline": "4b876758d3158a8eb6b798055d8db7c58d1cd4a9", - "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "d4fa125899cd863c03dbf96657d46b8c7d6c0ccf522b342bc4f164ed1e3becfe", - "adapterSha256": "f0cc9511c5e878b6dd78e2911b5c14351b41b7bb944e5e03c2fa1ddbcdc7ea10", + "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", "scenarioSha256": "726e19307f1e82f0c8ae9a555ec9c27c8eea7f8f40fa700dc0b71c54ecc9ca11", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-runs-a-quick-command.json b/mobile/rpc-foundation/goldens/session-create-terminal-runs-a-quick-command.json index ac5fd3048eb..a2533f1d473 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-runs-a-quick-command.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-runs-a-quick-command.json @@ -4,9 +4,9 @@ "namedDeltas": [], "runnerVersion": 1, "baseline": "4b876758d3158a8eb6b798055d8db7c58d1cd4a9", - "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "d4fa125899cd863c03dbf96657d46b8c7d6c0ccf522b342bc4f164ed1e3becfe", - "adapterSha256": "f0cc9511c5e878b6dd78e2911b5c14351b41b7bb944e5e03c2fa1ddbcdc7ea10", + "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", "scenarioSha256": "4dfbe31daae64a03fc588f2095235fdd9b37f26ecccefe596c35c8ef8f623651", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-with-prompt.json b/mobile/rpc-foundation/goldens/session-create-terminal-with-prompt.json index 36267793f93..45fc104a13c 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-with-prompt.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-with-prompt.json @@ -4,9 +4,9 @@ "namedDeltas": [], "runnerVersion": 1, "baseline": "4b876758d3158a8eb6b798055d8db7c58d1cd4a9", - "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "d4fa125899cd863c03dbf96657d46b8c7d6c0ccf522b342bc4f164ed1e3becfe", - "adapterSha256": "f0cc9511c5e878b6dd78e2911b5c14351b41b7bb944e5e03c2fa1ddbcdc7ea10", + "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", "scenarioSha256": "fd654a7e34ae8004543a34a7a301d51858989fdc2bffb02ddf6edb814071b22d", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-without-active-tab.json b/mobile/rpc-foundation/goldens/session-create-terminal-without-active-tab.json index 7d9b32610b2..9dc529f48ff 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-without-active-tab.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-without-active-tab.json @@ -4,9 +4,9 @@ "namedDeltas": [], "runnerVersion": 1, "baseline": "4b876758d3158a8eb6b798055d8db7c58d1cd4a9", - "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "d4fa125899cd863c03dbf96657d46b8c7d6c0ccf522b342bc4f164ed1e3becfe", - "adapterSha256": "f0cc9511c5e878b6dd78e2911b5c14351b41b7bb944e5e03c2fa1ddbcdc7ea10", + "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", "scenarioSha256": "da78ad25ea15e1c714f0147d34207529362743a56a519153f36b09345dee0ebc", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-without-handle.json b/mobile/rpc-foundation/goldens/session-create-terminal-without-handle.json index 3ec3d2faf25..02c68764064 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-without-handle.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-without-handle.json @@ -4,9 +4,9 @@ "namedDeltas": [], "runnerVersion": 1, "baseline": "4b876758d3158a8eb6b798055d8db7c58d1cd4a9", - "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3", "recorderSha256": "d4fa125899cd863c03dbf96657d46b8c7d6c0ccf522b342bc4f164ed1e3becfe", - "adapterSha256": "f0cc9511c5e878b6dd78e2911b5c14351b41b7bb944e5e03c2fa1ddbcdc7ea10", + "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", "scenarioSha256": "f1952a0e2c9108dadf2f5b85ac1f43ec9db09db0ce81a575bb4dd3a8d18d9c83", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/src/session/mobile-session-route-parity.test.ts b/mobile/src/session/mobile-session-route-parity.test.ts index bc488edfe68..4ae381c4ed9 100644 --- a/mobile/src/session/mobile-session-route-parity.test.ts +++ b/mobile/src/session/mobile-session-route-parity.test.ts @@ -62,8 +62,8 @@ const HOST_COMPONENT_NAMES = new Set([ 'View' ]) -const HEAD_MAIN_HOOK_SHA256 = 'c7a1bbc0588a5d27797bbab13168e76eb20200288921fdc3347632c2b4afd0ae' -const HEAD_HOOK_BINDING_SHA256 = '06edf1a4314eba41b1d3e1cb67b0cfab2a936aef7d127c5dc48e789c9adc6c8f' +const HEAD_MAIN_HOOK_SHA256 = '1b436d21f48e4d7b316178ba9eb7d8f0d3801ffd4e42b6b8987adb1cfcbac570' +const HEAD_HOOK_BINDING_SHA256 = '5b324d661574950c24c47ad9675afc40f34bf3d6dc0ea7b81a469cf708803dc8' const HEAD_CALLBACK_IDENTITY_SHA256 = '2a9e4825df007f6ef53b81aa5004991d6318eee7507b44d625c07e630be432eb' // Pins that no callback body in the route changed unnoticed. Body text, not behaviour: the sends @@ -86,9 +86,10 @@ const HEAD_CONTENT_HOOK_SHA256 = '9c3b612fef3f370d66873aefdbe1d701f20cb64ded31fe // `handleClearTerminal`, whose send became `terminalBufferClear`, in step 7 for the browser tab // create, whose `{ browserPageId?: string }` cast its schema now carries, and once more for // `handleCreateTerminal`, whose send became `sessionTabCreateTerminal` and whose `response.ok` -// branch became that operation's own throw-the-host-message acceptance. +// branch became that operation's own throw-the-host-message acceptance. Refreshed for negotiated +// optimistic placement, which defers to legacy host snapshots when ownership paths disagree. const HEAD_NESTED_FUNCTION_SHA256 = - 'e77614fd8ae98cce4009636520f0f3acb17e583d7395f954385a779b1decb7d1' + '923b5ea7fe3330cbd98213b72736bf1f653115ddb5492cb8eb8306d8ca4f28e8' const HEAD_NATIVE_REGISTRATION_SHA256 = 'cab85e4e4a3f43289ba93ddea9ccce57aea83e0bf14fd1620a965aad0c1cb49e' const HEAD_NATIVE_REMOVAL_SHA256 = @@ -494,7 +495,7 @@ describe('mobile session route extraction parity', () => { const contentBindings = CONTENT_COMPONENT_NAMES.flatMap( (name) => readHookFacts(name, definitions).bindings ) - expect(main.hooks).toHaveLength(269) + expect(main.hooks).toHaveLength(270) expect(hash(main.hooks)).toBe(HEAD_MAIN_HOOK_SHA256) expect(hash(main.bindings)).toBe(HEAD_HOOK_BINDING_SHA256) expect(main.callbacks).toHaveLength(77) diff --git a/mobile/src/session/use-mobile-session-foundation.ts b/mobile/src/session/use-mobile-session-foundation.ts index fa2f9607bbc..80a67a35702 100644 --- a/mobile/src/session/use-mobile-session-foundation.ts +++ b/mobile/src/session/use-mobile-session-foundation.ts @@ -14,6 +14,7 @@ import { isFloatingWorkspaceWorktreeId } from './floating-workspace' import { useLiveWorktreeName } from './use-live-worktree-name' import { useMissingWorktreeBounce } from './use-missing-worktree-bounce' import { hostRouteWithNotice } from '../host-route-notice' +import { useHostProtocolGates } from '../components/HostProtocolGate' export function useMobileSessionFoundation() { const { @@ -36,6 +37,7 @@ export function useMobileSessionFoundation() { const insets = useSafeAreaInsets() // Why: shared client per host owned by RpcClientProvider (docs/mobile-shared-client-per-host.md). const { client, clientId, state: connState } = useHostClient(hostId) + const { hostCapabilities } = useHostProtocolGates() const reconnectAttempts = useReconnectAttempt(hostId) const lastConnectedAt = useLastConnectedAt(hostId) const forceReconnectHost = useForceReconnect() @@ -98,6 +100,7 @@ export function useMobileSessionFoundation() { client, clientId, connState, + hostCapabilities, reconnectAttempts, lastConnectedAt, forceReconnectHost, 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 17afae7380f..f4e5b3eea13 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 @@ -4,6 +4,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { useMobileSessionTerminalCreateActions } from './use-mobile-session-terminal-create-actions' +import { SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' + +type PlacementTab = { id: string; parentTabId?: string } +type PlacementUpdater = (previous: PlacementTab[]) => PlacementTab[] vi.mock('../platform/haptics', () => ({ triggerSuccess: vi.fn(), @@ -36,10 +40,11 @@ function createScope(client: RpcClient) { return { worktreeId: 'workspace-1', client, + hostCapabilities: [], connState: 'connected', setTerminals: vi.fn(), terminalsRef: { current: [] }, - setSessionTabs: vi.fn(), + setSessionTabs: vi.fn<(updater: PlacementUpdater) => void>(), defaultTerminalHandlesToLiveInput: vi.fn(), setActiveHandle: vi.fn(), activeSessionTabId: 'existing-tab', @@ -307,3 +312,109 @@ describe('mobile + Codex tab creation routing', () => { expect(scope.setCreateError).toHaveBeenCalledWith('Failed to create terminal') }) }) + +describe('optimistic placement of a created tab', () => { + let renderer: ReactTestRenderer | undefined + afterEach(() => renderer?.unmount()) + + async function createTerminal(scope: ReturnType) { + let actions: ReturnType | undefined + function Harness() { + actions = useMobileSessionTerminalCreateActions(scope as never) + return null + } + await act(async () => { + renderer = create(createElement(Harness)) + }) + await act(async () => { + await actions?.handleCreateTerminal() + }) + } + + function tabIdsAfterCreate( + scope: ReturnType, + prior: PlacementTab[] + ): string[] { + const updater = scope.setSessionTabs.mock.calls.at(-1)?.[0] + if (!updater) { + throw new Error('Expected a session tab updater') + } + return updater(prior).map((tab) => tab.id) + } + + it('paints the created tab after the anchor it asked the host for, not at the end', async () => { + const scope = createScope(clientReturning(terminalCreateResponse())) + scope.hostCapabilities = [SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY] + await createTerminal(scope) + + expect(scope.setSessionTabs).toHaveBeenCalled() + // The request anchored on the active tab, so the paint must land in the same slot the host + // splices into; appending here is what made the tab jump on the next snapshot. + expect(tabIdsAfterCreate(scope, [{ id: 'existing-tab' }, { id: 'trailing-tab' }])).toEqual([ + 'existing-tab', + 'terminal-tab-1', + 'trailing-tab' + ]) + }) + + it('paints after the active split parent, matching headed host placement', async () => { + const scope = createScope(clientReturning(terminalCreateResponse())) + scope.hostCapabilities = [SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY] + scope.activeSessionTabId = 'existing-tab::left' + await createTerminal(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('waits for an older host snapshot instead of guessing its placement', async () => { + const scope = createScope(clientReturning(terminalCreateResponse())) + scope.activeSessionTabId = 'existing-tab::left' + await createTerminal(scope) + + expect(scope.setSessionTabs).not.toHaveBeenCalled() + expect(scope.pendingActiveSessionTabIdRef.current).toBe('terminal-tab-1') + expect(scope.pendingActiveTerminalHandleRef.current).toBe('terminal-1') + expect(scope.subscribeToTerminal).toHaveBeenCalledWith('terminal-1') + }) + + it('sends the same anchor it paints with', async () => { + const scope = createScope(clientReturning(terminalCreateResponse())) + scope.hostCapabilities = [SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY] + await createTerminal(scope) + + expect(scope.client.sendRequest).toHaveBeenCalledWith( + 'session.tabs.createTerminal', + expect.objectContaining({ afterTabId: 'existing-tab' }) + ) + }) + + it('appends when the anchor is not in the client list, matching the host fallback', async () => { + const scope = createScope(clientReturning(terminalCreateResponse())) + scope.hostCapabilities = [SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY] + await createTerminal(scope) + + expect(tabIdsAfterCreate(scope, [{ id: 'unrelated-tab' }])).toEqual([ + 'unrelated-tab', + 'terminal-tab-1' + ]) + }) + + it('leaves the list alone when the host snapshot already placed the tab', async () => { + const scope = createScope(clientReturning(terminalCreateResponse())) + scope.hostCapabilities = [SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY] + await createTerminal(scope) + + const prior = [{ id: 'existing-tab' }, { id: 'terminal-tab-1' }, { id: 'trailing-tab' }] + expect(tabIdsAfterCreate(scope, prior)).toEqual([ + 'existing-tab', + 'terminal-tab-1', + 'trailing-tab' + ]) + }) +}) diff --git a/mobile/src/session/use-mobile-session-terminal-create-actions.ts b/mobile/src/session/use-mobile-session-terminal-create-actions.ts index ef0c799dd39..0697f8c554b 100644 --- a/mobile/src/session/use-mobile-session-terminal-create-actions.ts +++ b/mobile/src/session/use-mobile-session-terminal-create-actions.ts @@ -13,11 +13,14 @@ import type { MobileSessionTab, Terminal } from './mobile-session-route-types' import type { MobileSessionAttachmentsModel } from './use-mobile-session-attachments' import { isAgentSessionHandleProvider } from '../../../src/shared/agent-session-provider-handle' import { createMobileStructuredAgentSession } from './mobile-structured-agent-session-launch' +import { placeCreatedSessionTab } from '../../../src/shared/session-tab-placement' +import { SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' export function useMobileSessionTerminalCreateActions(scope: MobileSessionAttachmentsModel) { const { worktreeId, client, + hostCapabilities, connState, setTerminals, terminalsRef, @@ -105,9 +108,12 @@ export function useMobileSessionTerminalCreateActions(scope: MobileSessionAttach return } } + const afterTabId = activeSessionTabId ?? undefined + const hostSupportsGroupedPlacement = + hostCapabilities?.includes(SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY) === true const response = await sessionTabCreateTerminal.request(client, { worktree: `id:${worktreeId}`, - afterTabId: activeSessionTabId ?? undefined, + afterTabId, clientMutationId, ...(options?.startupCommand ? { command: options.startupCommand } : {}), ...(options?.startupCommandDelivery @@ -135,12 +141,18 @@ export function useMobileSessionTerminalCreateActions(scope: MobileSessionAttach pendingActiveSessionTabIdRef.current = created.id activeSessionTabTypeRef.current = 'terminal' setActiveSessionTabId(created.id) - setSessionTabs((prev) => { - if (prev.some((tab) => tab.id === created.id)) { - return prev - } - return [...prev, { ...created, isActive: true }] - }) + // An older headed host places after the parent while an older headless host places after the + // leaf. Without the capability, wait for the host snapshot instead of guessing. + if (hostSupportsGroupedPlacement) { + setSessionTabs((prev) => { + if (prev.some((tab) => tab.id === created.id)) { + return prev + } + return placeCreatedSessionTab(prev, { ...created, isActive: true }, afterTabId, { + afterParentGroup: true + }) + }) + } if (typeof created.terminal === 'string') { const createdHandle = created.terminal defaultTerminalHandlesToLiveInput([createdHandle]) diff --git a/mobile/src/test-support/rpc-recording/adapters/session-terminal-create-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/session-terminal-create-mount-adapters.ts index 370687c4305..865357d01ca 100644 --- a/mobile/src/test-support/rpc-recording/adapters/session-terminal-create-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/session-terminal-create-mount-adapters.ts @@ -8,6 +8,7 @@ import type { Terminal } from '../../../session/mobile-session-route-types' import type { TuiAgent } from '../../../../../src/shared/tui-agent' +import { SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY } from '../../../../../src/shared/protocol-version' const PREVIOUS_HANDLE = 'terminal-0' @@ -71,6 +72,7 @@ export function sessionTerminalCreateMountAdapters( let activeHandle: string | null = PREVIOUS_HANDLE let worktreeId = '' let activeSessionTabId: string | null = null + let hostCapabilities: string[] = [SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY] let creating = false let createError = '' const terminalsRef = { current: terminals } @@ -91,6 +93,7 @@ export function sessionTerminalCreateMountAdapters( mountFixture[0]>({ worktreeId, client, + hostCapabilities, connState: 'connected', setTerminals: (update) => { terminals = typeof update === 'function' ? update(terminals) : update @@ -148,6 +151,10 @@ export function sessionTerminalCreateMountAdapters( activeSessionTabId = typeof args.activeSessionTabId === 'string' ? args.activeSessionTabId : null activeSessionTabIdRef.current = activeSessionTabId + hostCapabilities = + args.supportsSplitGroupPlacement === false + ? [] + : [SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY] deviceTokenRef.current = typeof args.deviceToken === 'string' ? args.deviceToken : null return hook.mount() } diff --git a/mobile/src/test-support/rpc-recording/mutants/operation-mutations.ts b/mobile/src/test-support/rpc-recording/mutants/operation-mutations.ts index 7dee845037a..569f8f763fc 100644 --- a/mobile/src/test-support/rpc-recording/mutants/operation-mutations.ts +++ b/mobile/src/test-support/rpc-recording/mutants/operation-mutations.ts @@ -221,8 +221,8 @@ export const OPERATION_MUTATIONS = { // there. Invisible to any scenario whose session already has an active tab. 'create-after-tab-id-null': { file: 'use-mobile-session-terminal-create-actions.ts', - before: ' afterTabId: activeSessionTabId ?? undefined,', - after: ' afterTabId: activeSessionTabId,' + before: ' const afterTabId = activeSessionTabId ?? undefined', + after: ' const afterTabId = activeSessionTabId' }, // Swaps the two quick-command members, so a saved shell command arrives as an agent prompt and an // agent prompt arrives as a startup command. Invisible to any scenario that fills neither. diff --git a/mobile/src/transport/mobile-runtime-client-capabilities.test.ts b/mobile/src/transport/mobile-runtime-client-capabilities.test.ts index 96db82ed05b..b0c38166dff 100644 --- a/mobile/src/transport/mobile-runtime-client-capabilities.test.ts +++ b/mobile/src/transport/mobile-runtime-client-capabilities.test.ts @@ -3,6 +3,7 @@ import { AGENT_LAUNCH_RUNTIME_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' @@ -21,7 +22,8 @@ describe('mobile runtime client capabilities', () => { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, - AGENT_SESSION_TURN_ITEM_CAPABILITY + AGENT_SESSION_TURN_ITEM_CAPABILITY, + SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY ]) ) }) diff --git a/mobile/src/transport/mobile-runtime-client-capabilities.ts b/mobile/src/transport/mobile-runtime-client-capabilities.ts index 03050c65f28..e388c7ff2d8 100644 --- a/mobile/src/transport/mobile-runtime-client-capabilities.ts +++ b/mobile/src/transport/mobile-runtime-client-capabilities.ts @@ -3,6 +3,7 @@ import { AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' @@ -13,6 +14,7 @@ export const MOBILE_RUNTIME_CLIENT_CAPABILITIES = remoteRuntimeClientCapabilitie AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY, // Opts into the typed turn record; without it the host sends the legacy status carrier. AGENT_SESSION_TURN_ITEM_CAPABILITY, // Mobile renders either launch outcome — a structured chat or a terminal agent — so it may ask diff --git a/src/main/runtime/orca-runtime-create-mobile-session-terminal.ts b/src/main/runtime/orca-runtime-create-mobile-session-terminal.ts index 8dd5b510735..8f6d71a9e89 100644 --- a/src/main/runtime/orca-runtime-create-mobile-session-terminal.ts +++ b/src/main/runtime/orca-runtime-create-mobile-session-terminal.ts @@ -29,6 +29,9 @@ export class OrcaRuntimeWithCreateMobileSessionTerminal extends OrcaRuntimeWithC clientNavigationId?: string navigation?: RuntimeNavigationTarget clientMutationId?: string + // Older mobile clients optimistically append; preserve that placement until they advertise + // split-group ordering support. + supportsSplitGroupPlacement?: boolean signal?: AbortSignal } = {} ): Promise { diff --git a/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts b/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts index 1bb45f838e0..f1b8fa16af5 100644 --- a/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts +++ b/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts @@ -10,6 +10,7 @@ import type { } from '../../shared/runtime-types' import { randomUUID } from 'node:crypto' import { parsePaneKey } from '../../shared/stable-pane-id' +import { placeCreatedSessionTab } from '../../shared/session-tab-placement' import { buildHeadlessMobileSessionTabGroups, buildMaterializedHeadlessParentLayout, @@ -31,6 +32,7 @@ export class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal extends Orca launchAgent?: TuiAgent viewMode?: 'terminal' | 'chat' targetGroupId?: string + supportsSplitGroupPlacement?: boolean launchConfig?: SleepingAgentLaunchConfig signal?: AbortSignal } = {} @@ -103,21 +105,18 @@ export class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal extends Orca parentLayout, isActive: activate } - const tabs = (existing?.tabs ?? []) - .filter((candidate) => candidate.id !== tab.id) - .map((candidate) => ({ + const tabs = placeCreatedSessionTab( + (existing?.tabs ?? []).map((candidate) => ({ ...candidate, ...(candidate.type === 'terminal' && candidate.parentTabId === parentTabId ? { parentLayout } : {}), isActive: activate ? false : candidate.isActive - })) - const insertAfter = afterTabId ? tabs.findIndex((candidate) => candidate.id === afterTabId) : -1 - if (insertAfter >= 0) { - tabs.splice(insertAfter + 1, 0, tab) - } else { - tabs.push(tab) - } + })), + tab, + afterTabId, + { afterParentGroup: opts.supportsSplitGroupPlacement !== false } + ) const next: RuntimeMobileSessionTabsSnapshot = { worktree: worktreeId, // Why: a fresh epoch retires the current publisher, so clients drop its later tab updates. diff --git a/src/main/runtime/orca-runtime-run-create-mobile-session-terminal.ts b/src/main/runtime/orca-runtime-run-create-mobile-session-terminal.ts index 0caedfa1bba..05621ac9792 100644 --- a/src/main/runtime/orca-runtime-run-create-mobile-session-terminal.ts +++ b/src/main/runtime/orca-runtime-run-create-mobile-session-terminal.ts @@ -32,6 +32,7 @@ export class OrcaRuntimeWithRunCreateMobileSessionTerminal extends OrcaRuntimeWi activate?: boolean clientNavigationId?: string clientMutationId?: string + supportsSplitGroupPlacement?: boolean signal?: AbortSignal } = {} ): Promise { @@ -41,14 +42,23 @@ export class OrcaRuntimeWithRunCreateMobileSessionTerminal extends OrcaRuntimeWi const worktreeId = workspace.id const cwd = this.resolveWorkspaceTerminalStartupCwd(workspace, opts.cwd) this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) + // Older mobile clients append their optimistic tab, so make the host append too until the + // client advertises the grouped placement contract. + const requestedAfterTabId = opts.afterTabId + const afterTabId = + opts.clientNavigationId && opts.supportsSplitGroupPlacement === false + ? undefined + : requestedAfterTabId let afterDesktopTabId: string | undefined - if (opts.afterTabId) { + if (requestedAfterTabId) { const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) - const anchor = snapshot?.tabs.find((tab) => tab.id === opts.afterTabId) + const anchor = snapshot?.tabs.find((tab) => tab.id === requestedAfterTabId) if (!anchor) { throw new Error('after_tab_not_found') } - afterDesktopTabId = anchor.type === 'terminal' ? anchor.parentTabId : anchor.id + if (afterTabId) { + afterDesktopTabId = anchor.type === 'terminal' ? anchor.parentTabId : anchor.id + } } const startupCommand = await this.resolveMobileSessionTerminalCommand(workspace, opts) this.assertStableReadyGraph(graphEpoch) @@ -60,7 +70,7 @@ export class OrcaRuntimeWithRunCreateMobileSessionTerminal extends OrcaRuntimeWi return await this.createRuntimeOwnedMobileSessionTerminal( worktreeId, opts.activate !== false, - opts.afterTabId, + afterTabId, { command: startupCommand.command, cwd, @@ -70,6 +80,7 @@ export class OrcaRuntimeWithRunCreateMobileSessionTerminal extends OrcaRuntimeWi launchAgent: startupCommand.launchAgent, viewMode: opts.viewMode, targetGroupId: opts.targetGroupId, + supportsSplitGroupPlacement: opts.supportsSplitGroupPlacement, launchConfig: startupCommand.launchConfig, signal: opts.signal } @@ -184,7 +195,7 @@ export class OrcaRuntimeWithRunCreateMobileSessionTerminal extends OrcaRuntimeWi return await this.createRuntimeOwnedMobileSessionTerminal( worktreeId, opts.activate !== false, - opts.afterTabId, + afterTabId, { command: startupCommand.command, cwd, @@ -195,6 +206,7 @@ export class OrcaRuntimeWithRunCreateMobileSessionTerminal extends OrcaRuntimeWi launchAgent: startupCommand.launchAgent, viewMode: opts.viewMode, targetGroupId: opts.targetGroupId, + supportsSplitGroupPlacement: opts.supportsSplitGroupPlacement, launchConfig: startupCommand.launchConfig, signal: opts.signal } 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..0eef4165af1 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,171 @@ describe('OrcaRuntimeService', () => { ]) }) + it.each([ + { + label: 'after the split parent for a capable client', + supportsSplitGroupPlacement: true, + expectedOrder: (createdId: string) => [ + 'split::left', + 'split::right', + createdId, + 'trailing::leaf' + ] + }, + { + label: 'at the end for an older client', + supportsSplitGroupPlacement: false, + expectedOrder: (createdId: string) => [ + 'split::left', + 'split::right', + 'trailing::leaf', + createdId + ] + } + ])('places a runtime-owned terminal $label', async (testCase) => { + 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', + clientNavigationId: 'mobile-client', + supportsSplitGroupPlacement: testCase.supportsSplitGroupPlacement + }) + + expect( + (await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs.map((tab) => tab.id) + ).toEqual(testCase.expectedOrder(created.tab.id)) + }) + + it('asks a headed host to append for an older client', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn(), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'renderer:legacy-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 + } + ] + } + ] + }) + runtime['waitForMobileTerminalSurface'] = vi.fn().mockResolvedValue({ + tab: { + type: 'terminal', + id: 'created::leaf', + parentTabId: 'created', + leafId: 'leaf', + title: 'Terminal', + status: 'ready', + terminal: 'term_created', + isActive: false + }, + publicationEpoch: 'renderer:legacy-placement', + snapshotVersion: 2 + }) + const webContents: { + isDestroyed: () => boolean + setBackgroundThrottling: ReturnType + send: ReturnType + } = { + isDestroyed: () => false, + setBackgroundThrottling: vi.fn(), + send: vi.fn() + } + webContents.send.mockImplementation( + (_channel: string, payload: { requestId: string; afterTabId?: string }) => { + expect(payload.afterTabId).toBeUndefined() + ipcMain.emit( + 'terminal:tabCreateReply', + { sender: webContents }, + { requestId: payload.requestId, tabId: 'created', title: 'Terminal' } + ) + } + ) + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents + }) + + await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + afterTabId: 'split::left', + clientNavigationId: 'legacy-client', + supportsSplitGroupPlacement: false, + activate: false + }) + + expect(webContents.send).toHaveBeenCalledWith( + 'terminal:requestTabCreate', + expect.objectContaining({ afterTabId: undefined }) + ) + }) + 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/main/runtime/orca-runtime-tests/runtime-availability.spec.ts b/src/main/runtime/orca-runtime-tests/runtime-availability.spec.ts index c1814c904ee..8a818e3ca6a 100644 --- a/src/main/runtime/orca-runtime-tests/runtime-availability.spec.ts +++ b/src/main/runtime/orca-runtime-tests/runtime-availability.spec.ts @@ -42,6 +42,7 @@ describe('OrcaRuntimeService', () => { expect(status.capabilities).toContain('workspace-ports.v1') expect(status.capabilities).toContain('mobile.tasks.v1') expect(status.capabilities).toContain('terminal.quick-commands.v1') + expect(status.capabilities).toContain('session-tabs.split-group-placement.v1') expect(status.capabilities).toContain('worktree.create-idempotency.v1') expect(status.worktreeCreateIdempotency).toEqual({ dedupeTtlMs: 60_000 }) expect(status.capabilities).toContain('files.mutation-ownership.v1') diff --git a/src/main/runtime/rpc/methods/session-tabs.test.ts b/src/main/runtime/rpc/methods/session-tabs.test.ts index f295d2626da..44d0dd104eb 100644 --- a/src/main/runtime/rpc/methods/session-tabs.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' import type { OrcaRuntimeService } from '../../orca-runtime' -import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' import { SESSION_TAB_METHODS } from './session-tabs' import { visibleSnapshot } from './session-tabs-snapshot.test-fixture' @@ -497,6 +500,48 @@ describe('session tab RPC methods', () => { ) }) + it.each([ + { + label: 'present', + clientCapabilities: [SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY], + expectedSupport: true + }, + { label: 'absent', clientCapabilities: [], expectedSupport: false } + ])('passes split-group placement support when the capability is $label', async (testCase) => { + const runtime = { + getRuntimeId: () => 'test-runtime', + createMobileSessionTerminal: vi.fn().mockResolvedValue({ + tab: { type: 'terminal', id: 'tab-1::leaf-1' }, + publicationEpoch: 'epoch-1', + snapshotVersion: 1 + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + + await dispatcher.dispatchStreaming( + makeRequest('session.tabs.createTerminal', { + worktree: 'id:wt-1', + afterTabId: 'tab-1', + clientMutationId: 'create-1' + }), + () => {}, + { + clientKind: 'runtime', + pairedDeviceId: 'device-a', + clientCapabilities: testCase.clientCapabilities + } + ) + + expect(runtime.createMobileSessionTerminal).toHaveBeenCalledWith( + 'id:wt-1', + expect.objectContaining({ + afterTabId: 'tab-1', + clientNavigationId: 'device-a', + supportsSplitGroupPlacement: testCase.expectedSupport + }) + ) + }) + it('preserves legacy agent creation for mixed-version clients', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index d6441ee84cb..40e1525f148 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -18,6 +18,7 @@ import { restoreStructuredTabsIfSupported } from './structured-session-tab-resto import { isStructuredNativeChatEnabled } from './structured-agent-session-policy' import { assertLegacyAiVaultResumeCommandAllowed } from '../../../ai-vault/structured-session-ownership' import { SessionTabsUnsubscribeAllParams } from '../../../../shared/rpc-contract/session-tabs-params' +import { SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' export const SESSION_TAB_METHODS = [ defineMethod({ @@ -46,7 +47,10 @@ export const SESSION_TAB_METHODS = [ defineMethod({ name: 'session.tabs.createTerminal', params: CreateTerminalTab, - handler: async (params, { runtime, signal, clientKind, pairedDeviceId }) => { + handler: async ( + params, + { runtime, signal, clientKind, pairedDeviceId, clientCapabilities } + ) => { if (params.command) { await assertLegacyAiVaultResumeCommandAllowed(params.command, () => runtime.ensureStructuredAgentSessionHost() @@ -74,6 +78,14 @@ export const SESSION_TAB_METHODS = [ clientKind }), clientMutationId: params.clientMutationId, + ...(pairedDeviceId + ? { + supportsSplitGroupPlacement: + clientCapabilities?.includes( + SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY + ) === true + } + : {}), // Why: a dead client connection must cancel the surface wait instead // of running down the timeout and rolling back a live tab (#7718). signal diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 789cdc23582..1b9734627ab 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -139,6 +139,10 @@ export const TERMINAL_CREATE_SHELL_SELECTION_RUNTIME_CAPABILITY = export const SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY = 'session-tabs.close-intent.v1' as const export const SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY = 'session-tabs.authoritative-inventory.v1' as const +// Why: this proves both headed and runtime-owned host paths place after a complete split parent. +// Legacy host paths disagree, so clients without this capability defer placement to the snapshot. +export const SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY = + 'session-tabs.split-group-placement.v1' as const // Why: a client advertising this retains every terminal retirement proof it receives until the // surface is published live again, so a session-tabs stream sends each proof once instead of // repeating the host's whole bounded list on every title tick. @@ -336,6 +340,7 @@ export const RUNTIME_CAPABILITIES = [ TERMINAL_CREATE_SHELL_SELECTION_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, + SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, REMOTE_SERVER_UPDATE_CAPABILITY, AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY, diff --git a/src/shared/session-tab-placement.test.ts b/src/shared/session-tab-placement.test.ts new file mode 100644 index 00000000000..2658b3c3cc4 --- /dev/null +++ b/src/shared/session-tab-placement.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import { placeCreatedSessionTab } from './session-tab-placement' + +const created = { id: 'new' } + +describe('placeCreatedSessionTab', () => { + it('inserts directly after the anchor', () => { + expect( + placeCreatedSessionTab([{ id: 'a' }, { id: 'b' }, { id: 'c' }], created, 'a').map((t) => t.id) + ).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', + { afterParentGroup: true } + ).map((tab) => tab.id) + ).toEqual(['split::left', 'split::right', 'new', 'trailing']) + }) + + it('keeps legacy leaf placement when split grouping is not negotiated', () => { + 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', 'new', 'split::right', 'trailing']) + }) + + it('finds split siblings after an interleaved tab', () => { + expect( + placeCreatedSessionTab( + [ + { id: 'split::left', parentTabId: 'split' }, + { id: 'trailing' }, + { id: 'split::right', parentTabId: 'split' } + ], + { id: 'new' }, + 'split::left', + { afterParentGroup: true } + ).map((tab) => tab.id) + ).toEqual(['split::left', 'trailing', 'split::right', 'new']) + }) + + it('appends when the anchor is the last tab', () => { + expect( + placeCreatedSessionTab([{ id: 'a' }, { id: 'b' }], created, 'b').map((t) => t.id) + ).toEqual(['a', 'b', 'new']) + }) + + it('appends when the anchor is absent', () => { + expect( + placeCreatedSessionTab([{ id: 'a' }, { id: 'b' }], created, 'missing').map((t) => t.id) + ).toEqual(['a', 'b', 'new']) + }) + + it('appends when no anchor is given', () => { + for (const anchor of [undefined, null, '']) { + expect(placeCreatedSessionTab([{ id: 'a' }], created, anchor).map((t) => t.id)).toEqual([ + 'a', + 'new' + ]) + } + }) + + it('re-places a tab that is already in the list instead of duplicating it', () => { + expect( + placeCreatedSessionTab([{ id: 'a' }, { id: 'new' }, { id: 'b' }], created, 'b').map( + (t) => t.id + ) + ).toEqual(['a', 'b', 'new']) + }) + + it('does not mutate the input list', () => { + const tabs = [{ id: 'a' }, { id: 'b' }] + placeCreatedSessionTab(tabs, created, 'a') + expect(tabs.map((t) => t.id)).toEqual(['a', 'b']) + }) +}) diff --git a/src/shared/session-tab-placement.ts b/src/shared/session-tab-placement.ts new file mode 100644 index 00000000000..1b4729a6156 --- /dev/null +++ b/src/shared/session-tab-placement.ts @@ -0,0 +1,29 @@ +export type SessionTabPlacementOptions = { + afterParentGroup?: boolean +} + +/** Places a created tab after the anchor, or after its parent group when enabled. */ +export function placeCreatedSessionTab( + tabs: readonly T[], + created: T, + afterTabId: string | null | undefined, + options: SessionTabPlacementOptions = {} +): T[] { + const next = tabs.filter((tab) => tab.id !== created.id) + const anchor = afterTabId ? next.findIndex((tab) => tab.id === afterTabId) : -1 + if (anchor < 0) { + next.push(created) + return next + } + let insertAfter = anchor + const anchorParentTabId = next[anchor].parentTabId + if (options.afterParentGroup && anchorParentTabId) { + for (let index = anchor + 1; index < next.length; index += 1) { + if (next[index].parentTabId === anchorParentTabId) { + insertAfter = index + } + } + } + next.splice(insertAfter + 1, 0, created) + return next +}