mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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 <sim@local>
This commit is contained in:
co-authored by
Merge Sim
parent
0e3b71f605
commit
5287c5cdbc
+2
-2
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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<typeof createScope>) {
|
||||
let actions: ReturnType<typeof useMobileSessionTerminalCreateActions> | 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<typeof createScope>,
|
||||
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'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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])
|
||||
|
||||
+7
@@ -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<Parameters<typeof useCreateActions>[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()
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<typeof vi.fn>
|
||||
send: ReturnType<typeof vi.fn>
|
||||
} = {
|
||||
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()
|
||||
|
||||
@@ -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,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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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<T extends { id: string; parentTabId?: string }>(
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user