fix(runtime): isolate paired terminal creates from host focus (#17713)

This commit is contained in:
Jinwoo Hong
2026-08-31 15:21:42 -04:00
committed by GitHub
parent 63d60e0ef0
commit db84894eef
3 changed files with 152 additions and 32 deletions
@@ -465,6 +465,43 @@ describe('paired runtime navigation isolation', () => {
).toBe('activateWorktree')
})
it('normalizes a paired focused terminal.create before host-renderer activation', async () => {
const harness = await startHarness()
const created = { handle: 'term-b', worktreeId: CLIENT_B_WORKTREE_ID, title: null }
const createTerminal = vi
.spyOn(harness.runtime, 'createTerminal')
.mockImplementation(async (_worktree, options) => {
if (options?.presentation === 'focused') {
harness.hostSelections.worktreeId = CLIENT_B_WORKTREE_ID
}
return created as never
})
vi.spyOn(harness.runtime, 'dedupeTerminalCreate').mockImplementation(
async (_owner, worktree, _mutationId, _reconcile, run) => run(worktree, undefined)
)
send(harness.clientB, {
id: 'terminal-create-b',
method: 'terminal.create',
params: {
worktree: `id:${CLIENT_B_WORKTREE_ID}`,
presentation: 'focused'
}
})
await expect(harness.readerB.next('terminal-create-b')).resolves.toMatchObject({
ok: true,
result: { terminal: created }
})
expect(createTerminal).toHaveBeenCalledWith(
`id:${CLIENT_B_WORKTREE_ID}`,
expect.objectContaining({ presentation: 'background', focus: false, activate: false })
)
expect(harness.hostSelections).toEqual({
worktreeId: HOST_WORKTREE_ID,
tabId: 'host-tab'
})
})
it('still reveals a host-originated create-with-activate on the host and every client', async () => {
const harness = await startHarness()
await subscribeBothClientEventStreams(harness)
@@ -60,4 +60,76 @@ describe('terminal.create RPC idempotency', () => {
)
expect(result).toEqual({ terminal })
})
it('does not let a paired focused create navigate the host by default', async () => {
const terminal = { handle: 'terminal-focused', worktreeId: 'worktree-1', title: null }
const createTerminal = vi.fn(async () => terminal)
const dedupeTerminalCreate = vi.fn(
async (
_clientIdentity: string,
_worktree: string | undefined,
_mutationId: string | undefined,
_reconcileExisting: boolean,
run: (worktree: string | undefined, handle: string | undefined) => Promise<typeof terminal>
) => run('id:worktree-1', undefined)
)
const method = TERMINAL_METHODS.find((candidate) => candidate.name === 'terminal.create')
if (!method) {
throw new Error('terminal.create method missing')
}
await method.handler(
{
worktree: 'id:worktree-1',
presentation: 'focused',
focus: true,
activate: true
},
{
runtime: { createTerminal, dedupeTerminalCreate },
pairedDeviceId: 'device-b',
clientKind: 'runtime'
} as unknown as RpcContext,
vi.fn()
)
expect(createTerminal).toHaveBeenCalledWith(
'id:worktree-1',
expect.objectContaining({
presentation: 'background',
focus: false,
activate: false
})
)
})
it('preserves focus for an in-process caller', async () => {
const createTerminal = vi.fn(async () => ({ handle: 'terminal-host' }))
const dedupeTerminalCreate = vi.fn(
async (
_owner: string,
_worktree: string | undefined,
_mutationId: string | undefined,
_reconcile: boolean,
run: (worktree: string | undefined, handle: string | undefined) => Promise<unknown>
) => run('id:worktree-1', undefined)
)
const method = TERMINAL_METHODS.find((candidate) => candidate.name === 'terminal.create')
if (!method) {
throw new Error('terminal.create method missing')
}
await method.handler(
{ worktree: 'id:worktree-1', presentation: 'focused', focus: true, activate: true },
{
runtime: { createTerminal, dedupeTerminalCreate }
} as unknown as RpcContext,
vi.fn()
)
expect(createTerminal).toHaveBeenCalledWith(
'id:worktree-1',
expect.objectContaining({ presentation: 'focused', focus: true, activate: true })
)
})
})
@@ -33,38 +33,49 @@ export const TERMINAL_LIFECYCLE_METHODS: RpcAnyMethod[] = [
defineMethod({
name: 'terminal.create',
params: TerminalCreateParams,
handler: async (params, { runtime, pairedDeviceId, clientId }) => ({
terminal: await runtime.dedupeTerminalCreate(
pairedDeviceId ?? clientId ?? 'local',
params.worktree,
params.clientMutationId,
params.reconcileExisting === true,
(canonicalWorktreeSelector, preAllocatedHandle) =>
runtime.createTerminal(canonicalWorktreeSelector, {
command: params.command,
startupCommandDelivery: params.startupCommandDelivery,
env: params.env,
envToDelete: params.envToDelete,
...(params.launchConfig ? { launchConfig: params.launchConfig } : {}),
...(params.resumeProviderSession
? { resumeProviderSession: params.resumeProviderSession }
: {}),
...(params.launchToken ? { launchToken: params.launchToken } : {}),
...(params.launchAgent ? { launchAgent: params.launchAgent } : {}),
...(params.terminalColorQueryReplies
? { terminalColorQueryReplies: params.terminalColorQueryReplies }
: {}),
title: params.title,
focus: params.focus === true,
rendererBacked: params.rendererBacked === true,
activate: params.activate === true,
presentation: params.presentation,
tabId: params.tabId,
leafId: params.leafId,
...(preAllocatedHandle ? { preAllocatedHandle } : {})
})
)
})
handler: async (params, { runtime, pairedDeviceId, clientId, clientKind }) => {
// A focused terminal create predates paired-client navigation. Keep the
// authority boundary here so a remote caller cannot activate the host
// renderer. This legacy RPC remains a background create for paired viewers;
// caller-local selection belongs to the session-tab RPC flow.
const pairedViewer = clientKind !== undefined
const focus = pairedViewer ? false : params.focus === true
const activate = pairedViewer ? false : params.activate === true
const presentation =
pairedViewer && params.presentation === 'focused' ? 'background' : params.presentation
return {
terminal: await runtime.dedupeTerminalCreate(
pairedDeviceId ?? clientId ?? 'local',
params.worktree,
params.clientMutationId,
params.reconcileExisting === true,
(canonicalWorktreeSelector, preAllocatedHandle) =>
runtime.createTerminal(canonicalWorktreeSelector, {
command: params.command,
startupCommandDelivery: params.startupCommandDelivery,
env: params.env,
envToDelete: params.envToDelete,
...(params.launchConfig ? { launchConfig: params.launchConfig } : {}),
...(params.resumeProviderSession
? { resumeProviderSession: params.resumeProviderSession }
: {}),
...(params.launchToken ? { launchToken: params.launchToken } : {}),
...(params.launchAgent ? { launchAgent: params.launchAgent } : {}),
...(params.terminalColorQueryReplies
? { terminalColorQueryReplies: params.terminalColorQueryReplies }
: {}),
title: params.title,
focus,
rendererBacked: params.rendererBacked === true,
activate,
presentation,
tabId: params.tabId,
leafId: params.leafId,
...(preAllocatedHandle ? { preAllocatedHandle } : {})
})
)
}
}
}),
defineMethod({
name: 'terminal.split',