From 37568ef72952ef5847fd8eb34b6325928892a4ce Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Fri, 11 Sep 2026 10:30:44 -0700 Subject: [PATCH] fix(mobile): negotiate split tab placement --- .../mobile-session-route-parity.test.ts | 8 ++-- .../session/use-mobile-session-foundation.ts | 3 ++ ...le-session-terminal-create-actions.test.ts | 17 ++++++++ ...-mobile-session-terminal-create-actions.ts | 8 +++- ...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 | 4 +- ...time-run-create-mobile-session-terminal.ts | 22 +++++++--- .../runtime-availability.spec.ts | 1 + .../runtime/rpc/methods/session-tabs.test.ts | 40 ++++++++++++++++++- src/main/runtime/rpc/methods/session-tabs.ts | 14 ++++++- src/shared/protocol-version.ts | 6 +++ src/shared/session-tab-placement.test.ts | 32 ++++++++++++++- src/shared/session-tab-placement.ts | 20 ++++++---- 15 files changed, 161 insertions(+), 23 deletions(-) diff --git a/mobile/src/session/mobile-session-route-parity.test.ts b/mobile/src/session/mobile-session-route-parity.test.ts index e315b353ee3..4bf325a930c 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 = '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) 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 89a9074fa88..4e097a1fc1c 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,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) 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 11548876f60..a3d61f9ca32 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,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 diff --git a/mobile/src/transport/mobile-runtime-client-capabilities.test.ts b/mobile/src/transport/mobile-runtime-client-capabilities.test.ts index bdd1330de1d..b4baa0780ca 100644 --- a/mobile/src/transport/mobile-runtime-client-capabilities.test.ts +++ b/mobile/src/transport/mobile-runtime-client-capabilities.test.ts @@ -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 ]) ) }) diff --git a/mobile/src/transport/mobile-runtime-client-capabilities.ts b/mobile/src/transport/mobile-runtime-client-capabilities.ts index 30b627a9ef3..226f0098460 100644 --- a/mobile/src/transport/mobile-runtime-client-capabilities.ts +++ b/mobile/src/transport/mobile-runtime-client-capabilities.ts @@ -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 ]) 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 49c7bbf766c..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 @@ -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, 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/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..88c446ea8dd 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,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', 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 aaec08cf46f..a4e3e46aefc 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -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, diff --git a/src/shared/session-tab-placement.test.ts b/src/shared/session-tab-placement.test.ts index 8ce0cd8c0f8..2658b3c3cc4 100644 --- a/src/shared/session-tab-placement.test.ts +++ b/src/shared/session-tab-placement.test.ts @@ -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) diff --git a/src/shared/session-tab-placement.ts b/src/shared/session-tab-placement.ts index dd810424c4e..1b4729a6156 100644 --- a/src/shared/session-tab-placement.ts +++ b/src/shared/session-tab-placement.ts @@ -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( 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