diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 0edefd1c8e2e..6f0c2a9e4599 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -169,6 +169,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts', 'src/main/runtime/rpc/methods/agent-launch-replay.test.ts', 'src/main/runtime/rpc/methods/agent-launch-session-reservation.test.ts', + 'src/main/runtime/rpc/methods/agent-launch-workspace-activation.test.ts', 'src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts', 'src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts', 'src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts', diff --git a/src/main/runtime/orca-runtime-create-managed-remote-worktree.ts b/src/main/runtime/orca-runtime-create-managed-remote-worktree.ts index 179d796d0e41..5385a31b3b74 100644 --- a/src/main/runtime/orca-runtime-create-managed-remote-worktree.ts +++ b/src/main/runtime/orca-runtime-create-managed-remote-worktree.ts @@ -40,7 +40,8 @@ export class OrcaRuntimeWithCreateManagedRemoteWorktree extends OrcaRuntimeWithC setup, startup, defaultTabs, - args.navigation + args.navigation, + args.launchActivation ), invalidateResolvedWorktrees: () => this.invalidateResolvedWorktreeCache(), invalidateWorktreeScan: (repoId) => this.invalidateWorktreeScanCacheForRepo(repoId), diff --git a/src/main/runtime/orca-runtime-create-managed-worktree.ts b/src/main/runtime/orca-runtime-create-managed-worktree.ts index 10eb7080e1f0..8cd9c6c425f9 100644 --- a/src/main/runtime/orca-runtime-create-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-create-managed-worktree.ts @@ -9,7 +9,10 @@ import { ExecutionHostNotDispatchableError } from '../providers/execution-host-p import { createRuntimeFolderWorktree } from './runtime-folder-worktree-create' import { createRuntimeLocalManagedWorktree } from './runtime-local-worktree-create' import type { PreparationRearmHolder } from '../worktree-create-preparation' -import { prepareRuntimeLocalWorktreeSetup } from './runtime-local-worktree-setup' +import { + buildRuntimeLocalWorktreeSetupReceipt, + prepareRuntimeLocalWorktreeSetup +} from './runtime-local-worktree-setup' import { invalidateAuthorizedRootsCacheForRepo } from '../ipc/filesystem-auth' import { startRuntimeLocalWorktreeTerminals } from './runtime-local-worktree-terminal-startup' import { trackRuntimeWorkspaceCreate } from '../workspace-create-telemetry' @@ -111,7 +114,8 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork setup, startup, undefined, - args.navigation + args.navigation, + args.launchActivation ) } }) @@ -255,7 +259,8 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork activationSetup, startup, activationDefaultTabs, - args.navigation + args.navigation, + args.launchActivation ) } }) @@ -279,23 +284,15 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork ...(returnedSetup ? { setup: returnedSetup } : {}), ...(args.awaitTerminalProvisioning ? { - setupReceipt: { - requested: effectiveDecision, + setupReceipt: buildRuntimeLocalWorktreeSetupReceipt({ + effectiveDecision, hookFound, - startupPolicy: setup?.waitForAgentStartup - ? ('wait-for-setup' as const) - : ('start-immediately' as const), - state: !hookFound - ? ('not_configured' as const) - : effectiveDecision === 'skip' || !shouldRunSetup - ? ('skipped' as const) - : // Why: the in-process hook is already executing, so reporting - // spawn_failed would strand callers that retry on it. - didSpawnSetup || didStartInProcessSetupHook - ? ('running' as const) - : ('spawn_failed' as const), - ...(setupTerminalHandle ? { terminalHandle: setupTerminalHandle } : {}) - } + setup, + shouldRunSetup, + didSpawnSetup, + didStartInProcessSetupHook, + setupTerminalHandle + }) } : {}), ...(defaultTabs ? { defaultTabs } : {}), diff --git a/src/main/runtime/orca-runtime-notify-ssh-state-changed.ts b/src/main/runtime/orca-runtime-notify-ssh-state-changed.ts index bcd9b1f46657..8d24b94e600f 100644 --- a/src/main/runtime/orca-runtime-notify-ssh-state-changed.ts +++ b/src/main/runtime/orca-runtime-notify-ssh-state-changed.ts @@ -7,7 +7,10 @@ import type { CreateWorktreeResult } from '../../shared/worktree/create-types' import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' import { navigationTargetsClients, navigationTargetsHost } from '../../shared/runtime-navigation' -import { toRuntimeActivateWorktreeEvent } from '../../shared/runtime-client-events' +import { + toRuntimeActivateWorktreeEvent, + type RuntimeActivatedLaunch +} from '../../shared/runtime-client-events' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' import type { BrowserBackend } from '../browser/browser-backend' import type { EmulatorBridge } from '../emulator/emulator-bridge' @@ -163,11 +166,12 @@ export class OrcaRuntimeWithNotifySshStateChanged extends OrcaRuntimeWithGetStat setup?: CreateWorktreeResult['setup'], startup?: WorktreeStartupLaunch, defaultTabs?: CreateWorktreeResult['defaultTabs'], - navigationTarget?: RuntimeNavigationTarget + navigationTarget?: RuntimeNavigationTarget, + launch?: RuntimeActivatedLaunch ): void { const navigation = navigationTarget ?? 'host' if (navigationTargetsHost(navigation)) { - this.notifyHostActivateWorktree(repoId, worktreeId, setup, startup, defaultTabs) + this.notifyHostActivateWorktree(repoId, worktreeId, setup, startup, defaultTabs, launch) } if (navigationTargetsClients(navigation)) { this.notifyClientsActivateWorktree( @@ -176,7 +180,8 @@ export class OrcaRuntimeWithNotifySshStateChanged extends OrcaRuntimeWithGetStat setup, startup, defaultTabs, - navigation + navigation, + launch ) } } @@ -186,9 +191,18 @@ export class OrcaRuntimeWithNotifySshStateChanged extends OrcaRuntimeWithGetStat worktreeId: string, setup?: CreateWorktreeResult['setup'], startup?: WorktreeStartupLaunch, - defaultTabs?: CreateWorktreeResult['defaultTabs'] + defaultTabs?: CreateWorktreeResult['defaultTabs'], + launch?: RuntimeActivatedLaunch ): void { - this.notifier?.activateWorktree(repoId, worktreeId, setup, startup, defaultTabs) + // Every other activation keeps its call exactly as before. + this.notifier?.activateWorktree( + repoId, + worktreeId, + setup, + startup, + defaultTabs, + ...(launch ? [launch] : []) + ) } protected notifyClientsActivateWorktree( @@ -197,10 +211,19 @@ export class OrcaRuntimeWithNotifySshStateChanged extends OrcaRuntimeWithGetStat setup?: CreateWorktreeResult['setup'], startup?: WorktreeStartupLaunch, defaultTabs?: CreateWorktreeResult['defaultTabs'], - navigation: RuntimeNavigationTarget = 'clients' + navigation: RuntimeNavigationTarget = 'clients', + launch?: RuntimeActivatedLaunch ): void { this.emitClientEvent( - toRuntimeActivateWorktreeEvent(repoId, worktreeId, setup, startup, defaultTabs, navigation) + toRuntimeActivateWorktreeEvent( + repoId, + worktreeId, + setup, + startup, + defaultTabs, + navigation, + launch + ) ) } diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts index d371ac3f96f0..0fbbe8e8f911 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts @@ -257,6 +257,54 @@ describe('OrcaRuntimeService', () => { ) }) + it('names the desktop launch that created the worktree on its activation', async () => { + const runtime = new OrcaRuntimeService(store) + const activateWorktree = vi.fn() + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree, + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-launch') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-launch') + vi.mocked(getEffectiveHooks).mockReturnValue(null) + vi.mocked(listWorktrees).mockResolvedValueOnce([ + { + path: '/tmp/workspaces/runtime-launch', + head: 'def', + branch: 'runtime-launch', + isBare: false, + isMainWorktree: false + } + ]) + + await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-launch', + activate: true, + launchActivation: { operationId: 'op-1' } + }) + + expect(activateWorktree).toHaveBeenCalledWith( + 'repo-1', + expect.any(String), + undefined, + undefined, + undefined, + { operationId: 'op-1' } + ) + }) + it('stamps createdAt alongside lastActivityAt so CLI-created worktrees get the Recent-sort grace window', async () => { // Why: without createdAt, ambient PTY bumps in other worktrees can push the new one below them in Recent sort. const runtime = new OrcaRuntimeService(store) diff --git a/src/main/runtime/rpc/methods/agent-launch-workspace-activation.test.ts b/src/main/runtime/rpc/methods/agent-launch-workspace-activation.test.ts new file mode 100644 index 000000000000..5082603b8ee4 --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-launch-workspace-activation.test.ts @@ -0,0 +1,82 @@ +/** + * A desktop `agent.launch` create names itself on the activation that opens its workspace, so the + * window that asked binds the workspace to that launch. A phone's or the CLI's create never does: + * a paired device's launch leaves the desktop window alone, and the CLI's window holds no launch. + */ + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecordStore } from '../../agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' +import type { RpcContext } from '../core' +import { DESKTOP_RPC_CALLER } from '../rpc-caller-identity' +import { + CAPABLE_CLIENT, + methodNamed, + rpcContext, + runtimeStub, + setAgentLaunchRecordStore +} from './agent-launch.test-fixture' + +vi.mock('./structured-agent-session-create', () => ({ + createStructuredAgentSessionForWorktree: async () => ({ + ok: true, + value: { sessionId: 'sess-1' } + }) +})) + +const { AGENT_LAUNCH_METHODS } = await import('./agent-launch') +const AGENT_LAUNCH_REPLAY = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launchReplay') + +// The ledger admits against `Date.now()`, so the id must be dated now. +const OPERATION_ID = `${Date.now()}-000000000000000000000000000000a7` + +let directory: string +let store: AgentSessionRecordStore + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-agent-launch-activation-')) + store = await openTestAgentSessionRecordStore(directory) + setAgentLaunchRecordStore(store) +}) + +afterEach(async () => { + setAgentLaunchRecordStore(null) + await rm(directory, { recursive: true, force: true }) +}) + +async function createArgsFor(context: Partial, activate: boolean) { + const runtime = runtimeStub() + await AGENT_LAUNCH_REPLAY.handler( + AGENT_LAUNCH_REPLAY.params.parse({ + agent: 'claude', + target: { kind: 'create-worktree', create: { repo: 'id:repo-1', name: 'task', activate } }, + operationId: OPERATION_ID + }), + rpcContext(runtime, context) + ) + const [args] = runtime.createManagedWorktree.mock.calls[0] ?? [] + return args +} + +describe('the activation of a workspace an agent.launch creates', () => { + it('names the desktop’s own launch', async () => { + const args = await createArgsFor({ caller: DESKTOP_RPC_CALLER }, true) + expect(args?.launchActivation).toEqual({ operationId: OPERATION_ID }) + }) + + it.each([ + ['a paired phone', CAPABLE_CLIENT], + ['the CLI', {}] + ] as const)('never names %s’s launch', async (_who, context) => { + const args = await createArgsFor(context, true) + expect(args).not.toHaveProperty('launchActivation') + }) + + it('names nothing for a create that does not open its workspace', async () => { + const args = await createArgsFor({ caller: DESKTOP_RPC_CALLER }, false) + expect(args).not.toHaveProperty('launchActivation') + }) +}) 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 ff79e44b7642..666ff24a304e 100644 --- a/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts +++ b/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts @@ -34,7 +34,9 @@ const STRUCTURED_SETUP_WAIT_TIMEOUT_MS = 60_000 export function agentLaunchWorkspaceFactory( context: RpcContext, - agent: TuiAgent + agent: TuiAgent, + /** The launch's own id, absent for an unrecorded launch. */ + operationId?: string ): AgentLaunchWorkspaceFactory { return { createWorktree: async ({ @@ -96,6 +98,10 @@ export function agentLaunchWorkspaceFactory( // The launch owns the agent whichever surface it settles on, so the workspace records // it even when no startup terminal was created for it. createdWithAgent: agent, + // Only the desktop's own launch opens the workspace in its window as that launch's. + ...(operationId && context.caller?.kind === 'desktop' && params.activate === true + ? { launchActivation: { operationId } } + : {}), // Structured sessions have no startup command to sequence behind setup. Provision the // setup terminal synchronously and attach a completion token so the launch can wait // before creating the chat surface. diff --git a/src/main/runtime/rpc/methods/agent-launch.ts b/src/main/runtime/rpc/methods/agent-launch.ts index 15aeb28bd125..1e0f585e96d1 100644 --- a/src/main/runtime/rpc/methods/agent-launch.ts +++ b/src/main/runtime/rpc/methods/agent-launch.ts @@ -98,6 +98,7 @@ function callerRendersLaunchedChat( /** What a launch admitted under an operation id carries into its execution. */ type ReplaySafeLaunch = { + operationId: string attachOperationId: string callerKey: string terminalSpawn: TerminalSpawnDispatch @@ -125,7 +126,7 @@ async function runAgentLaunch( replaySafe?.terminalSpawn, view.early ), - workspaces: agentLaunchWorkspaceFactory(context, intent.agent), + workspaces: agentLaunchWorkspaceFactory(context, intent.agent, replaySafe?.operationId), ...(callerRendersLaunchedChat(context, intent.agent) ? {} : { callerRendersStructured: false }), // The tab is shown as it is published, not after a prompt that can take a minute to land. onSurfacePublished: (surface) => { @@ -247,6 +248,7 @@ async function executeAdmittedAgentLaunch( let result: AgentLaunchResult try { result = await runAgentLaunch(intent, context, view, { + operationId: params.operationId, attachOperationId: admission.attachOperationId, callerKey: admission.callerKey, terminalSpawn, diff --git a/src/main/runtime/runtime-local-worktree-setup.test.ts b/src/main/runtime/runtime-local-worktree-setup.test.ts index 87716eec98f0..f4c0555a604a 100644 --- a/src/main/runtime/runtime-local-worktree-setup.test.ts +++ b/src/main/runtime/runtime-local-worktree-setup.test.ts @@ -13,7 +13,10 @@ vi.mock('../hooks', () => ({ runHook: mocks.runHook })) -import { prepareRuntimeLocalWorktreeSetup } from './runtime-local-worktree-setup' +import { + buildRuntimeLocalWorktreeSetupReceipt, + prepareRuntimeLocalWorktreeSetup +} from './runtime-local-worktree-setup' const askRepo: Repo = { id: 'repo-1', @@ -49,6 +52,18 @@ describe('runtime create setup for a hook the new branch added', () => { expect(result.shouldRunSetup).toBe(false) expect(result.warning).toContain('pass --setup run to run it') expect(mocks.runHook).not.toHaveBeenCalled() + expect( + buildRuntimeLocalWorktreeSetupReceipt({ + ...result, + didSpawnSetup: false, + setupTerminalHandle: null + }) + ).toEqual({ + requested: 'inherit', + hookFound: true, + startupPolicy: 'start-immediately', + state: 'skipped' + }) }) it('still runs setup when the caller decided to', async () => { @@ -56,5 +71,78 @@ describe('runtime create setup for a hook the new branch added', () => { expect(result.shouldRunSetup).toBe(true) expect(mocks.runHook).toHaveBeenCalledOnce() + expect( + buildRuntimeLocalWorktreeSetupReceipt({ + ...result, + didSpawnSetup: false, + setupTerminalHandle: null + }) + ).toEqual({ + requested: 'run', + hookFound: true, + startupPolicy: 'start-immediately', + state: 'running' + }) + }) + + it('reports an absent hook without requiring a terminal', async () => { + mocks.effectiveHooks.mockReturnValue(null) + const result = await prepare({ setupDecision: 'run' }) + + expect( + buildRuntimeLocalWorktreeSetupReceipt({ + ...result, + didSpawnSetup: false, + setupTerminalHandle: null + }) + ).toEqual({ + requested: 'run', + hookFound: false, + startupPolicy: 'start-immediately', + state: 'not_configured' + }) + }) + + it('reports a failed runner spawn when no in-process hook is running', () => { + expect( + buildRuntimeLocalWorktreeSetupReceipt({ + effectiveDecision: 'run', + hookFound: true, + shouldRunSetup: true, + didSpawnSetup: false, + didStartInProcessSetupHook: false, + setupTerminalHandle: null + }) + ).toEqual({ + requested: 'run', + hookFound: true, + startupPolicy: 'start-immediately', + state: 'spawn_failed' + }) + }) + + it('reports the spawned setup terminal and its agent wait policy', () => { + expect( + buildRuntimeLocalWorktreeSetupReceipt({ + effectiveDecision: 'run', + hookFound: true, + setup: { + command: 'setup-runner', + runnerScriptPath: '/worktrees/app/setup.sh', + envVars: {}, + waitForAgentStartup: true + }, + shouldRunSetup: true, + didSpawnSetup: true, + didStartInProcessSetupHook: false, + setupTerminalHandle: 'setup-terminal' + }) + ).toEqual({ + requested: 'run', + hookFound: true, + startupPolicy: 'wait-for-setup', + state: 'running', + terminalHandle: 'setup-terminal' + }) }) }) diff --git a/src/main/runtime/runtime-local-worktree-setup.ts b/src/main/runtime/runtime-local-worktree-setup.ts index f9da4312c346..9c769c305697 100644 --- a/src/main/runtime/runtime-local-worktree-setup.ts +++ b/src/main/runtime/runtime-local-worktree-setup.ts @@ -14,6 +14,31 @@ export function resolveRuntimeSetupDecision( return request.runHooks ? 'run' : (request.setupDecision ?? 'inherit') } +export function buildRuntimeLocalWorktreeSetupReceipt(args: { + effectiveDecision: SetupDecision + hookFound: boolean + setup?: CreateWorktreeResult['setup'] + shouldRunSetup: boolean + didSpawnSetup: boolean + didStartInProcessSetupHook: boolean + setupTerminalHandle: string | null +}): NonNullable { + return { + requested: args.effectiveDecision, + hookFound: args.hookFound, + startupPolicy: args.setup?.waitForAgentStartup ? 'wait-for-setup' : 'start-immediately', + state: !args.hookFound + ? 'not_configured' + : args.effectiveDecision === 'skip' || !args.shouldRunSetup + ? 'skipped' + : // An in-process hook is already running; spawn_failed would trigger needless retries. + args.didSpawnSetup || args.didStartInProcessSetupHook + ? 'running' + : 'spawn_failed', + ...(args.setupTerminalHandle ? { terminalHandle: args.setupTerminalHandle } : {}) + } +} + export async function prepareRuntimeLocalWorktreeSetup(args: { request: RuntimeManagedWorktreeCreateArgs repo: Repo diff --git a/src/main/runtime/runtime-managed-worktree-create-types.ts b/src/main/runtime/runtime-managed-worktree-create-types.ts index 08a151bd88ee..4ca081d8dc92 100644 --- a/src/main/runtime/runtime-managed-worktree-create-types.ts +++ b/src/main/runtime/runtime-managed-worktree-create-types.ts @@ -13,6 +13,7 @@ import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' import type { TaskSourceContext } from '../../shared/task-source-context' import type { WorktreeStartupDraftPaste } from './runtime-worktree-agent-startup' import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' +import type { RuntimeActivatedLaunch } from '../../shared/runtime-client-events' export type RuntimeManagedWorktreeCreateArgs = { repoSelector: string @@ -54,6 +55,9 @@ export type RuntimeManagedWorktreeCreateArgs = { /** Main-internal: set by a caller that delivers an uncarried `startupPrompt` itself, so the text * rides only a typed line that can carry it; reports whether it did. */ onStartupPromptCarry?: (carried: boolean) => void + /** Main-internal: the desktop's own `agent.launch` this create belongs to, named on the activation + * so its window binds the workspace to that launch and opens no tab of its own. */ + launchActivation?: RuntimeActivatedLaunch /** Per-launch inputs used when `startupAgent` is the created terminal surface. */ startupAgentArgs?: string | null startupCwd?: string diff --git a/src/main/runtime/runtime-notifier-contract.ts b/src/main/runtime/runtime-notifier-contract.ts index d13da3976f1d..697e627b7f5d 100644 --- a/src/main/runtime/runtime-notifier-contract.ts +++ b/src/main/runtime/runtime-notifier-contract.ts @@ -14,6 +14,7 @@ import type { WorktreeRemoteBranchConflictEvent } from '../../shared/worktree/base-ref-drift-types' import type { CreateWorktreeResult } from '../../shared/worktree/create-types' +import type { RuntimeActivatedLaunch } from '../../shared/runtime-client-events' import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' import type { RuntimeBrowserDriverState, @@ -40,7 +41,8 @@ export type RuntimeNotifier = { worktreeId: string, setup?: CreateWorktreeResult['setup'], startup?: WorktreeStartupLaunch, - defaultTabs?: CreateWorktreeResult['defaultTabs'] + defaultTabs?: CreateWorktreeResult['defaultTabs'], + launch?: RuntimeActivatedLaunch ): void createTerminal( worktreeId: string, diff --git a/src/main/window/runtime-window-lifecycle.ts b/src/main/window/runtime-window-lifecycle.ts index 95abe7c35158..0c312ed6c864 100644 --- a/src/main/window/runtime-window-lifecycle.ts +++ b/src/main/window/runtime-window-lifecycle.ts @@ -4,6 +4,7 @@ import { ipcMain } from 'electron' import type { BrowserWindow } from 'electron' import type { CreateWorktreeResult } from '../../shared/worktree/create-types' import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' +import type { RuntimeActivatedLaunch } from '../../shared/runtime-client-events' import type { RuntimeMarkdownReadTabResult, RuntimeMarkdownSaveTabResult @@ -51,14 +52,16 @@ export function registerRuntimeWindowLifecycle( worktreeId, setup?: CreateWorktreeResult['setup'], startup?: WorktreeStartupLaunch, - defaultTabs?: CreateWorktreeResult['defaultTabs'] + defaultTabs?: CreateWorktreeResult['defaultTabs'], + launch?: RuntimeActivatedLaunch ) => { send('ui:activateWorktree', { repoId, worktreeId, ...(setup ? { setup } : {}), ...(startup ? { startup } : {}), - ...(defaultTabs ? { defaultTabs } : {}) + ...(defaultTabs ? { defaultTabs } : {}), + ...(launch ? { launch } : {}) }) }, createTerminal: (worktreeId, opts) => diff --git a/src/preload/api/ui-bridge-tab-and-browser-commands.ts b/src/preload/api/ui-bridge-tab-and-browser-commands.ts index 4967d3971d7e..66e22c711dd2 100644 --- a/src/preload/api/ui-bridge-tab-and-browser-commands.ts +++ b/src/preload/api/ui-bridge-tab-and-browser-commands.ts @@ -12,6 +12,7 @@ import type { } from '../../shared/worktree/launch-types' import { browserFindSubscriptions } from '../preload-runtime-support' import type { PreloadApi } from '../api-types' +import type { RuntimeActivatedLaunch } from '../../shared/runtime-client-events' export const uiTabAndBrowserCommandsApi = { onRequestTabSetProfile: ( @@ -198,6 +199,7 @@ export const uiTabAndBrowserCommandsApi = { setup?: WorktreeSetupLaunch startup?: { command: string; env?: Record } defaultTabs?: WorktreeDefaultTabsLaunch + launch?: RuntimeActivatedLaunch }) => void ): (() => void) => { const listener = ( @@ -208,6 +210,7 @@ export const uiTabAndBrowserCommandsApi = { setup?: WorktreeSetupLaunch startup?: { command: string; env?: Record } defaultTabs?: WorktreeDefaultTabsLaunch + launch?: RuntimeActivatedLaunch } ) => callback(data) ipcRenderer.on('ui:activateWorktree', listener) diff --git a/src/preload/api/ui-command-event-api.ts b/src/preload/api/ui-command-event-api.ts index 02dce476eeca..0bc8eddf26c2 100644 --- a/src/preload/api/ui-command-event-api.ts +++ b/src/preload/api/ui-command-event-api.ts @@ -7,6 +7,7 @@ import type { } from '../../shared/agent-launch-tab-publication' import type { AgentLaunchPaneVerdictEvent } from '../../shared/agent-launch-pane-verdict' import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' +import type { RuntimeActivatedLaunch } from '../../shared/runtime-client-events' import type { TerminalSurfaceCloseTarget } from '../../shared/terminal-surface-close-target' import type { WorktreeDefaultTabsLaunch, @@ -151,6 +152,7 @@ export type UiCommandEventApi = { setup?: WorktreeSetupLaunch startup?: WorktreeStartupLaunch defaultTabs?: WorktreeDefaultTabsLaunch + launch?: RuntimeActivatedLaunch }) => void ) => () => void onCreateTerminal: ( diff --git a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts index 240991417728..f11e36732c8b 100644 --- a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts @@ -99,21 +99,24 @@ export function installAppLifetimeIpcEvents( registerWorkspaceShortcutIpcBridge(unsubs) registerOsMarkdownFileOpenBridge(unsubs) unsubs.push( - window.api.ui.onActivateWorktree(({ repoId, worktreeId, setup, startup, defaultTabs }) => { - void worktreeRuntime - .activateNotifiedWorktree( - { - type: 'activateWorktree', - repoId, - worktreeId, - ...(setup ? { setup } : {}), - ...(startup ? { startup } : {}), - ...(defaultTabs ? { defaultTabs } : {}) - }, - { allowRuntimeEnvironment: false } - ) - .catch((error) => console.error('Failed to activate CLI-created worktree:', error)) - }) + window.api.ui.onActivateWorktree( + ({ repoId, worktreeId, setup, startup, defaultTabs, launch }) => { + void worktreeRuntime + .activateNotifiedWorktree( + { + type: 'activateWorktree', + repoId, + worktreeId, + ...(setup ? { setup } : {}), + ...(startup ? { startup } : {}), + ...(defaultTabs ? { defaultTabs } : {}), + ...(launch ? { launch } : {}) + }, + { allowRuntimeEnvironment: false } + ) + .catch((error) => console.error('Failed to activate CLI-created worktree:', error)) + } + ) ) registerTerminalPresentationIpcBridge(unsubs) diff --git a/src/renderer/src/hooks/ipc-events/worktree-event-runtime-activation.test.ts b/src/renderer/src/hooks/ipc-events/worktree-event-runtime-activation.test.ts index 606d0ef6adc7..5e7ebb3eb7d6 100644 --- a/src/renderer/src/hooks/ipc-events/worktree-event-runtime-activation.test.ts +++ b/src/renderer/src/hooks/ipc-events/worktree-event-runtime-activation.test.ts @@ -1,5 +1,6 @@ import { afterEach, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' +import { ownAgentLaunchWorkspaceActivation } from '@/lib/agent-launch-workspace-activation' import { createWorktreeEventRuntime } from './worktree-event-runtime' const { activateAndRevealWorktree } = vi.hoisted(() => ({ @@ -58,3 +59,43 @@ it.each([false, true])( } } ) + +function launchActivation() { + vi.spyOn(initialState, 'fetchWorktrees').mockResolvedValue(false) + useAppStore.setState({ + fetchWorktrees: initialState.fetchWorktrees, + getKnownWorktreeById: vi.fn(() => undefined) + }) + return createWorktreeEventRuntime([], () => false).activateNotifiedWorktree( + { + type: 'activateWorktree', + repoId: 'repo-1', + worktreeId: 'wt-new', + launch: { operationId: 'op-1' } + }, + { allowRuntimeEnvironment: false } + ) +} + +it('hands a launch-created workspace to the launch this window is running', async () => { + const owner = vi.fn() + const release = ownAgentLaunchWorkspaceActivation('op-1', owner) + try { + await launchActivation() + + expect(owner).toHaveBeenCalledWith('wt-new') + // The launch decides whether to show it (the user may have moved on), not the activation. + expect(activateAndRevealWorktree).not.toHaveBeenCalled() + } finally { + release() + } +}) + +it('opens a launch-created workspace with no shell of its own when no launch here owns it', async () => { + await launchActivation() + + expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-new', { + providesInitialSurface: true, + notifyHostRuntime: false + }) +}) diff --git a/src/renderer/src/hooks/ipc-events/worktree-event-runtime.ts b/src/renderer/src/hooks/ipc-events/worktree-event-runtime.ts index 71ac01101b58..be8a92cc242b 100644 --- a/src/renderer/src/hooks/ipc-events/worktree-event-runtime.ts +++ b/src/renderer/src/hooks/ipc-events/worktree-event-runtime.ts @@ -1,4 +1,5 @@ import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { takeAgentLaunchWorkspaceActivation } from '@/lib/agent-launch-workspace-activation' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host' import type { RuntimeClientEvent } from '../../../../shared/runtime-client-events' import type { AppState } from '../../store/types' @@ -144,7 +145,8 @@ export function createWorktreeEventRuntime( worktreeId, setup, startup, - defaultTabs + defaultTabs, + launch }: Extract, options: WorktreeEventActivationOptions ): Promise => { @@ -170,10 +172,15 @@ export function createWorktreeEventRuntime( useAppStore.getState().getKnownWorktreeById(worktreeId, options.executionHostId) ) // Why: use the canonical activation path so the CLI switch records a back/forward visit, or the nav buttons ignore it. + if (launch && takeAgentLaunchWorkspaceActivation(launch.operationId, worktreeId)) { + return + } activateAndRevealWorktree(worktreeId, { ...(setup ? { setup } : {}), ...(startup ? { startup } : {}), ...(defaultTabs ? { defaultTabs } : {}), + // The launch that created this workspace brings its first tab; a bare shell beside it is noise. + ...(launch ? { providesInitialSurface: true } : {}), ...(!existedBeforeFetch && existsAfterFetch ? { sidebarRevealBehavior: 'auto' } : {}), // Why: this activation came from the host runtime stream; echoing it back can create a selection loop. notifyHostRuntime: false, diff --git a/src/renderer/src/lib/agent-launch-workspace-activation.ts b/src/renderer/src/lib/agent-launch-workspace-activation.ts new file mode 100644 index 000000000000..b6d5c4897cde --- /dev/null +++ b/src/renderer/src/lib/agent-launch-workspace-activation.ts @@ -0,0 +1,39 @@ +/** + * The workspace a desktop `agent.launch` create opened, by that launch's operation id. + * + * The host names the launch on the activation it already sends when the create opens the + * workspace, before the launch answers (which waits on prompt delivery). The launch this window is + * running takes that activation over: it decides whether to show the workspace (the user may have + * moved on) and lays out its own first tab. With no such launch here (a reloaded window, another + * window's launch), the window opens the workspace as for any activation. The launch's answer + * carries the same `worktreeId`, so a launch that missed this still learns it. + */ + +type ActivationOwner = (worktreeId: string) => void + +const owners = new Map() + +export function ownAgentLaunchWorkspaceActivation( + operationId: string, + owner: ActivationOwner +): () => void { + owners.set(operationId, owner) + return () => { + if (owners.get(operationId) === owner) { + owners.delete(operationId) + } + } +} + +/** True when a launch running in this window took the activation over. */ +export function takeAgentLaunchWorkspaceActivation( + operationId: string, + worktreeId: string +): boolean { + const owner = owners.get(operationId) + if (!owner) { + return false + } + owner(worktreeId) + return true +} diff --git a/src/shared/runtime-client-events.ts b/src/shared/runtime-client-events.ts index 8e3350a8387d..63bec8522845 100644 --- a/src/shared/runtime-client-events.ts +++ b/src/shared/runtime-client-events.ts @@ -48,6 +48,9 @@ export type RuntimeClientEvent = defaultTabs?: WorktreeDefaultTabsLaunch /** Absent on older hosts; clients must not infer navigation intent from a broadcast. */ navigation?: RuntimeNavigationTarget + /** Absent on older hosts and on every other activation; a client that ignores it opens the + * workspace as it does for a CLI create. */ + launch?: RuntimeActivatedLaunch } export type RuntimeClientEventStreamMessage = @@ -60,6 +63,10 @@ export type RuntimeClientEventStreamMessage = | RuntimeClientEvent | { type: 'end' } +/** The desktop's own `agent.launch` that created this workspace. The launch brings the workspace's + * first tab itself, and the window that started it finds its launch by this id. */ +export type RuntimeActivatedLaunch = { operationId: string } + export type RuntimeActivateWorktreeEvent = Extract export type AutomationsChangedEvent = Extract @@ -75,13 +82,15 @@ export function toRuntimeActivateWorktreeEvent( setup?: CreateWorktreeResult['setup'], startup?: WorktreeStartupLaunch, defaultTabs?: CreateWorktreeResult['defaultTabs'], - navigation?: RuntimeNavigationTarget + navigation?: RuntimeNavigationTarget, + launch?: RuntimeActivatedLaunch ): RuntimeActivateWorktreeEvent { return { type: 'activateWorktree', repoId, worktreeId, ...(navigation ? { navigation } : {}), + ...(launch ? { launch } : {}), ...(setup ? { setup } : {}), ...(startup ? { startup } : {}), ...(defaultTabs ? { defaultTabs } : {})