fix(mobile): negotiate split tab placement

This commit is contained in:
Merge Sim
2026-09-11 10:34:22 -07:00
parent ff8ebfb438
commit 37568ef729
15 changed files with 161 additions and 23 deletions
@@ -62,8 +62,8 @@ const HOST_COMPONENT_NAMES = new Set([
'View'
])
const HEAD_MAIN_HOOK_SHA256 = '10071240ef9edafc2b9c8bed73be83dceaf7828e3b29f17dab55da020a7697a6'
const HEAD_HOOK_BINDING_SHA256 = '1dadb8c3dc0573ea20659ce7251629669e618dd0effaeac3a4536b29c2e865a1'
const HEAD_MAIN_HOOK_SHA256 = '5c18b82b70218f71ea9eff5316ee2e1e9b6555bc1ea2f19f24ede519a5933857'
const HEAD_HOOK_BINDING_SHA256 = '9ee9c7d7d651e001499d906428a0987994a291517911cef7d529d41e4c61cb62'
const HEAD_CALLBACK_IDENTITY_SHA256 =
'2a9e4825df007f6ef53b81aa5004991d6318eee7507b44d625c07e630be432eb'
const HEAD_CALLBACK_BODY_SHA256 = 'af7f3c62954250d4be7ee432ecd10dc2689792aad8230fed2d1d68bbc892d776'
@@ -73,7 +73,7 @@ const HEAD_CONTENT_HOOK_SHA256 = '9c3b612fef3f370d66873aefdbe1d701f20cb64ded31fe
// after the anchor it sent the host instead of appending. Only that body changed; the other 11
// extracted functions are byte-identical.
const HEAD_NESTED_FUNCTION_SHA256 =
'90b88dfc168c664d87b240af14f6c4e19fa506a3d1005fcd9ef29df1befc810c'
'e54db6971e298dc27b59da9cbb2c455b85f0e1224cdf00482410ac43d179b02d'
const HEAD_NATIVE_REGISTRATION_SHA256 =
'cab85e4e4a3f43289ba93ddea9ccce57aea83e0bf14fd1620a965aad0c1cb49e'
const HEAD_NATIVE_REMOVAL_SHA256 =
@@ -475,7 +475,7 @@ describe('mobile session route extraction parity', () => {
const contentBindings = CONTENT_COMPONENT_NAMES.flatMap(
(name) => readHookFacts(name, definitions).bindings
)
expect(main.hooks).toHaveLength(266)
expect(main.hooks).toHaveLength(267)
expect(hash(main.hooks)).toBe(HEAD_MAIN_HOOK_SHA256)
expect(hash(main.bindings)).toBe(HEAD_HOOK_BINDING_SHA256)
expect(main.callbacks).toHaveLength(77)
@@ -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,
@@ -4,6 +4,7 @@ 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'
vi.mock('../platform/haptics', () => ({
triggerSuccess: vi.fn(),
@@ -36,6 +37,7 @@ function createScope(client: RpcClient) {
return {
worktreeId: 'workspace-1',
client,
hostCapabilities: [],
connState: 'connected',
setTerminals: vi.fn(),
terminalsRef: { current: [] },
@@ -307,6 +309,7 @@ describe('optimistic placement of a created tab', () => {
it('paints after the active split parent, matching headed host placement', async () => {
const scope = createScope(clientReturning(terminalCreateResponse()))
scope.hostCapabilities = [SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY]
scope.activeSessionTabId = 'existing-tab::left'
await createLegacyTerminal(scope)
@@ -319,6 +322,20 @@ describe('optimistic placement of a created tab', () => {
).toEqual(['existing-tab::left', 'existing-tab::right', 'terminal-tab-1', 'trailing-tab'])
})
it('keeps legacy leaf placement for an older host', async () => {
const scope = createScope(clientReturning(terminalCreateResponse()))
scope.activeSessionTabId = 'existing-tab::left'
await createLegacyTerminal(scope)
expect(
tabIdsAfterCreate(scope, [
{ id: 'existing-tab::left', parentTabId: 'existing-tab' },
{ id: 'existing-tab::right', parentTabId: 'existing-tab' },
{ id: 'trailing-tab' }
])
).toEqual(['existing-tab::left', 'terminal-tab-1', 'existing-tab::right', 'trailing-tab'])
})
it('sends the same anchor it paints with', async () => {
const scope = createScope(clientReturning(terminalCreateResponse()))
await createLegacyTerminal(scope)
@@ -13,11 +13,13 @@ import type { MobileSessionAttachmentsModel } from './use-mobile-session-attachm
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,
@@ -127,7 +129,11 @@ export function useMobileSessionTerminalCreateActions(scope: MobileSessionAttach
if (prev.some((tab) => tab.id === created.id)) {
return prev
}
return placeCreatedSessionTab(prev, { ...created, isActive: true }, afterTabId)
return placeCreatedSessionTab(prev, { ...created, isActive: true }, afterTabId, {
afterParentGroup: hostCapabilities?.includes(
SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY
)
})
})
if (typeof created.terminal === 'string') {
const createdHandle = created.terminal
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
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'
@@ -20,7 +21,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
])
)
})
@@ -2,6 +2,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'
@@ -12,6 +13,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
])
@@ -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<RuntimeMobileSessionCreateTerminalResult> {
@@ -32,6 +32,7 @@ export class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal extends Orca
launchAgent?: TuiAgent
viewMode?: 'terminal' | 'chat'
targetGroupId?: string
supportsSplitGroupPlacement?: boolean
launchConfig?: SleepingAgentLaunchConfig
signal?: AbortSignal
} = {}
@@ -113,7 +114,8 @@ export class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal extends Orca
isActive: activate ? false : candidate.isActive
})),
tab,
afterTabId
afterTabId,
{ afterParentGroup: opts.supportsSplitGroupPlacement !== false }
)
const next: RuntimeMobileSessionTabsSnapshot = {
worktree: worktreeId,
@@ -32,6 +32,7 @@ export class OrcaRuntimeWithRunCreateMobileSessionTerminal extends OrcaRuntimeWi
activate?: boolean
clientNavigationId?: string
clientMutationId?: string
supportsSplitGroupPlacement?: boolean
signal?: AbortSignal
} = {}
): Promise<RuntimeMobileSessionCreateTerminalResult> {
@@ -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
}
@@ -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')
@@ -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,41 @@ describe('session tab RPC methods', () => {
)
})
it('passes split-group placement support to paired terminal creation', async () => {
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: [SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY]
}
)
expect(runtime.createMobileSessionTerminal).toHaveBeenCalledWith(
'id:wt-1',
expect.objectContaining({
afterTabId: 'tab-1',
clientNavigationId: 'device-a',
supportsSplitGroupPlacement: true
})
)
})
it('preserves legacy agent creation for mixed-version clients', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
+13 -1
View File
@@ -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
+6
View File
@@ -122,6 +122,11 @@ export const TERMINAL_CREATE_IDEMPOTENCY_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: older hosts splice a created terminal after the requested leaf, while newer hosts place it
// after the complete split parent. Clients gate optimistic placement on this capability so an old
// host cannot overwrite a grouped optimistic order with its legacy leaf-level 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.
@@ -281,6 +286,7 @@ export const RUNTIME_CAPABILITIES = [
TERMINAL_CREATE_IDEMPOTENCY_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,
+31 -1
View File
@@ -19,11 +19,41 @@ describe('placeCreatedSessionTab', () => {
{ id: 'trailing' }
],
{ id: 'new' },
'split::left'
'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)
+12 -8
View File
@@ -1,8 +1,13 @@
/** Places a created tab after the anchor's top-level terminal group, or appends when unanchored. */
export type SessionTabPlacementOptions = {
afterParentGroup?: boolean
}
/** Places a created tab after the anchor, or after its parent group when enabled. */
export function placeCreatedSessionTab<T extends { id: string; parentTabId?: string }>(
tabs: readonly T[],
created: T,
afterTabId: string | null | undefined
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
@@ -12,12 +17,11 @@ export function placeCreatedSessionTab<T extends { id: string; parentTabId?: str
}
let insertAfter = anchor
const anchorParentTabId = next[anchor].parentTabId
if (anchorParentTabId) {
while (
insertAfter + 1 < next.length &&
next[insertAfter + 1].parentTabId === anchorParentTabId
) {
insertAfter += 1
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)