diff --git a/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts b/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts index 2260c1af2f2c..04480ac091c4 100644 --- a/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts +++ b/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts @@ -3,9 +3,9 @@ * * A paired client (a phone, or a desktop client of a remote server) that launches into an existing * workspace gets the new tab as its own selection, recorded the way `session.tabs.createTerminal` - * selects the tab it creates for its caller. In-process callers keep today's behaviour, and a - * launch that creates its workspace keeps `worktree.create`'s navigation, whose host activation is - * what runs the new workspace's setup. + * selects the tab it creates for its caller. In-process callers keep today's behaviour, and their + * launch that creates its workspace keeps `worktree.create`'s navigation. A paired client's create + * never activates the host window: its setup and default tabs are provisioned in the background. */ import type { AgentLaunchTarget } from '../../../../shared/agent-launch-intent' diff --git a/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts b/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts index 3331c8091816..f51e53369eae 100644 --- a/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts @@ -132,6 +132,8 @@ describe('agent.launch with the real floating workspace resolver', () => { startupAgent: 'codex', // Derived on the host by the window's rule, from the same chat-view setting. viewMode: 'terminal', + // A paired device's launch never moves the desktop window. + surfaceOwner: false, onPtySpawnDispatched: expect.any(Function) } ) diff --git a/src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts b/src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts index 7d75ebcc56b7..75a801d75983 100644 --- a/src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts @@ -74,6 +74,15 @@ const PHONE: Partial = { ] } +const PAIRED_DESKTOP: Partial = { + clientKind: 'runtime', + pairedDeviceId: 'device-2', + clientCapabilities: [ + AGENT_LAUNCH_RUNTIME_CAPABILITY, + AGENT_LAUNCH_UNSTARTED_TAB_CLIENT_CAPABILITY + ] +} + const PHONE_READING_TAB_CLOSED: Partial = { ...OLD_PHONE, clientCapabilities: [ @@ -722,8 +731,9 @@ describe('whose view moves', () => { undefined, 'focus-in-workspace' ], - ['a phone reveals the workspace on the desktop, as before', PHONE, 'focused', 'reveal-owner'], - ['a phone by default reveals it too', PHONE, undefined, 'reveal-owner'], + ['a phone asking for focus leaves the desktop where it is', PHONE, 'focused', 'none'], + ['a phone by default leaves it too', PHONE, undefined, 'none'], + ['a paired desktop leaves the host window too', PAIRED_DESKTOP, 'focused', 'none'], ['background moves nobody', CLI, 'background', 'none'] ] as const)('%s', async (_name, context, presentation, viewer) => { const runtime = hostWithWindow() @@ -733,6 +743,31 @@ describe('whose view moves', () => { expect(runtime.published[0]?.viewer).toBe(viewer) }) + it.each([ + ['a phone that reads no early tab', OLD_PHONE, replayLaunch], + ['a phone launch with no operation id', PHONE, plainLaunch] + ] as const)( + 'leaves the desktop where it is when the agent spawns with no tab shown first: %s', + async (_name, context, run) => { + const runtime = hostWithWindow() + + await run(runtime, {}, context) + + expect(runtime.published).toHaveLength(0) + expect(terminalOptions(runtime)).toMatchObject({ surfaceOwner: false }) + } + ) + + it('still reveals a local launch that spawns with no tab shown first', async () => { + const runtime = hostWithWindow() + + await plainLaunch(runtime, {}, CLI) + + expect(runtime.published).toHaveLength(0) + expect(runtime.createTerminal).toHaveBeenCalledOnce() + expect(terminalOptions(runtime)).not.toHaveProperty('surfaceOwner') + }) + it("moves the phone's own selection unless it asked for the background", async () => { const focused = hostWithWindow({ terminalPaneKey: PANE_KEY }) await replayLaunch(focused, { paneKey: PANE_KEY }, PHONE) diff --git a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts index d25fa7acd28d..0727f31bef0b 100644 --- a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts +++ b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts @@ -37,7 +37,10 @@ import { type TerminalSpawnDispatch } from '../../../agent-launch/agent-launch-not-started' import { AgentLaunchTabClosedError } from '../../../../shared/agent-launch-tab-closed' -import type { EarlyAgentLaunchTab } from './agent-launch-tab-publication' +import { + agentLaunchMovesHostWindow, + type EarlyAgentLaunchTab +} from './agent-launch-tab-publication' /** Replay-safe launches keep the nested attach in the same stable caller namespace as the launch. */ export function agentLaunchSurfaceFactory( @@ -51,6 +54,7 @@ export function agentLaunchSurfaceFactory( // close can both land after admission. earlyTab: Pick | null = null ): AgentLaunchSurfaceFactory { + const movesHostWindow = agentLaunchMovesHostWindow(context) return { createStructuredSession: async ({ worktreeId, @@ -154,7 +158,7 @@ export function agentLaunchSurfaceFactory( ...(paneKey ? { ...paneIdentity(paneKey), requireFreshPane: true } : {}), ...(launchSource ? { launchSource } : {}), ...(viewMode ? { viewMode } : {}), - ...(earlyTab?.windowShowsTab() ? { surfaceOwner: false as const } : {}), + ...(earlyTab?.windowShowsTab() || !movesHostWindow ? { surfaceOwner: false as const } : {}), onPtySpawnDispatched: terminalSpawn.onPtySpawnDispatched }) const terminal = await created.catch(terminalSpawn.rethrow) diff --git a/src/main/runtime/rpc/methods/agent-launch-tab-publication.ts b/src/main/runtime/rpc/methods/agent-launch-tab-publication.ts index c27e2edf8539..fd4d29bd5476 100644 --- a/src/main/runtime/rpc/methods/agent-launch-tab-publication.ts +++ b/src/main/runtime/rpc/methods/agent-launch-tab-publication.ts @@ -48,25 +48,33 @@ import type { RpcContext } from '../core' import type { AgentLaunchParams } from './agent-launch-schemas' import { agentLaunchOperationCallerKey } from './agent-launch-replay' +/** Whether a launch may move the host window at all: a paired device's launch moves only its own + * view, as its browser tabs do. The tab shown first and the spawn's own reveal both ask this. */ +export function agentLaunchMovesHostWindow( + context: Pick +): boolean { + return ( + context.caller?.kind === 'desktop' || + navigationTargetsHost(resolveRuntimeNavigationTarget({ clientKind: context.clientKind })) + ) +} + /** * Whose screen moves. `presentation` is `terminal.create`'s field with its meaning (focused: the * host selects the tab; background: nothing moves). The desktop's own view is the host window, - * which follows only while it still shows that workspace. A caller whose navigation is elsewhere (a - * paired device) gets the window's reveal it always got, as well as its own selection. + * which follows only while it still shows that workspace. A paired device's launch leaves the host + * window where it is; the device selects the tab for itself. */ export function agentLaunchTabViewerRule( context: Pick, presentation: AgentLaunchParams['presentation'] ): AgentLaunchTabViewerRule { - if (presentation === 'background') { + if (presentation === 'background' || !agentLaunchMovesHostWindow(context)) { return 'none' } if (context.caller?.kind === 'desktop') { return 'focus-in-workspace' } - if (!navigationTargetsHost(resolveRuntimeNavigationTarget({ clientKind: context.clientKind }))) { - return 'reveal-owner' - } return presentation === 'focused' ? 'focus-window' : 'reveal-owner' } diff --git a/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts b/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts index 72cc48c3bc3e..ff79e44b7642 100644 --- a/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts +++ b/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts @@ -23,6 +23,7 @@ import { resolveRpcWorkspaceCreatorProvenance } from '../workspace-creator-conte import { buildManagedWorktreeCreateArgs } from './worktree-create-args' import { toAgentLaunchPreferences } from '../../../../shared/agent-launch-preferences' import type { AgentLaunchParams } from './agent-launch-schemas' +import { agentLaunchMovesHostWindow } from './agent-launch-tab-publication' type WorktreeCreateParams = Extract< AgentLaunchParams['target'], @@ -63,7 +64,7 @@ export function agentLaunchWorkspaceFactory( const result = await runtime.createManagedWorktree({ ...buildManagedWorktreeCreateArgs( { - ...params, + ...(agentLaunchMovesHostWindow(context) ? params : withoutHostActivation(params)), ...(startupAgent ? { startupAgent } : {}), // Only ever set alongside `startupAgent`, which is what the create requires; the // executor offers it only to an agent that takes its prompt on argv, and it rides only @@ -125,6 +126,21 @@ export function agentLaunchWorkspaceFactory( } } +/** + * A paired device's create leaves the host window where it is, as the rest of its launch does: + * activating would switch the desktop to the new workspace and reveal the startup terminal there. + * Without it, setup and default tabs are provisioned in the background instead. `runHooks` means + * "run setup, and activate", so only its setup half is kept. + */ +function withoutHostActivation(params: WorktreeCreateParams): WorktreeCreateParams { + return { + ...params, + activate: false, + runHooks: false, + ...(params.runHooks === true ? { setupDecision: 'run' as const } : {}) + } +} + async function waitForStructuredSetup( runtime: Pick, receipt: Awaited>['setupReceipt'] diff --git a/src/main/runtime/rpc/methods/agent-launch.test.ts b/src/main/runtime/rpc/methods/agent-launch.test.ts index 4f15465565e6..178746facc2b 100644 --- a/src/main/runtime/rpc/methods/agent-launch.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch.test.ts @@ -378,6 +378,67 @@ describe('the worktree factory', () => { }) }) +describe('whose window a new workspace moves', () => { + const ACTIVATING_CREATE_LAUNCH = { + agent: 'claude', + target: { + kind: 'create-worktree', + create: { ...CREATE_LAUNCH.target.create, activate: true, runHooks: true } + } + } + const PAIRED_DESKTOP: Partial = { ...CAPABLE_CLIENT, clientKind: 'runtime' } + + it.each([ + ['a phone', 'chat', CAPABLE_CLIENT], + ['a phone', 'terminal', CAPABLE_CLIENT], + ['a paired desktop', 'chat', PAIRED_DESKTOP], + ['a paired desktop', 'terminal', PAIRED_DESKTOP] + ] as const)( + '%s creates without activating the host, and its setup still runs (%s)', + async (_name, mode, context) => { + const runtime = runtimeStub(mode === 'terminal' ? { settings: {} } : {}) + await launch(ACTIVATING_CREATE_LAUNCH, runtime, context) + + // Unactivated, the create provisions setup and default tabs in the background. + expect(createArgs(runtime)).toMatchObject({ + activate: false, + runHooks: false, + setupDecision: 'run', + awaitTerminalProvisioning: true + }) + } + ) + + it("keeps a paired device's own setup decision when it asked only to activate", async () => { + const runtime = runtimeStub() + await launch( + { + agent: 'claude', + target: { + kind: 'create-worktree', + create: { ...CREATE_LAUNCH.target.create, activate: true, setupDecision: 'skip' } + } + }, + runtime + ) + + expect(createArgs(runtime)).toMatchObject({ + activate: false, + runHooks: false, + setupDecision: 'skip' + }) + }) + + it('still activates a create the CLI asked to activate', async () => { + const runtime = runtimeStub({ settings: {} }) + await launch(ACTIVATING_CREATE_LAUNCH, runtime, {}) + + const args = createArgs(runtime) + expect(args).toMatchObject({ activate: true, runHooks: true }) + expect(args.setupDecision).toBeUndefined() + }) +}) + describe('the structured session factory', () => { it('creates the session for the worktree the launch just made, and activates it', async () => { const runtime = runtimeStub() @@ -482,6 +543,8 @@ describe('the terminal factory', () => { startupAgent: 'claude', // The host derives the tab's first view by the window's rule; chat view is on by default here. viewMode: 'chat', + // A paired device's launch never moves the desktop window. + surfaceOwner: false, onPtySpawnDispatched: expect.any(Function) }) expect(createStructuredSession).not.toHaveBeenCalled() @@ -507,6 +570,7 @@ describe('the terminal factory', () => { startupAgent: 'gemini', // No native chat renderer for this agent, so its tab opens as the terminal. viewMode: 'terminal', + surfaceOwner: false, onPtySpawnDispatched: expect.any(Function) }) expect(result.worktreeId).toBe('wt-7')