mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
fix(mobile): negotiate split tab placement
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user