Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/main/runtime/rpc/methods/agent-launch-caller-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
)
Expand Down
39 changes: 37 additions & 2 deletions src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@ const PHONE: Partial<RpcContext> = {
]
}

const PAIRED_DESKTOP: Partial<RpcContext> = {
clientKind: 'runtime',
pairedDeviceId: 'device-2',
clientCapabilities: [
AGENT_LAUNCH_RUNTIME_CAPABILITY,
AGENT_LAUNCH_UNSTARTED_TAB_CLIENT_CAPABILITY
]
}

const PHONE_READING_TAB_CLOSED: Partial<RpcContext> = {
...OLD_PHONE,
clientCapabilities: [
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down
8 changes: 6 additions & 2 deletions src/main/runtime/rpc/methods/agent-launch-surfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -51,6 +54,7 @@ export function agentLaunchSurfaceFactory(
// close can both land after admission.
earlyTab: Pick<EarlyAgentLaunchTab, 'windowShowsTab' | 'closedByUser'> | null = null
): AgentLaunchSurfaceFactory {
const movesHostWindow = agentLaunchMovesHostWindow(context)
return {
createStructuredSession: async ({
worktreeId,
Expand Down Expand Up @@ -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)
Expand Down
20 changes: 14 additions & 6 deletions src/main/runtime/rpc/methods/agent-launch-tab-publication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RpcContext, 'caller' | 'clientKind'>
): 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<RpcContext, 'caller' | 'clientKind'>,
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'
}

Expand Down
18 changes: 17 additions & 1 deletion src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<OrcaRuntimeService, 'waitForSetupTerminalCompletion'>,
receipt: Awaited<ReturnType<OrcaRuntimeService['createManagedWorktree']>>['setupReceipt']
Expand Down
64 changes: 64 additions & 0 deletions src/main/runtime/rpc/methods/agent-launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RpcContext> = { ...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()
Expand Down Expand Up @@ -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()
Expand All @@ -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')
Expand Down
Loading