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 +}