From 3079e4690a37d08a2524af830fda72caf85da3e9 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 3 Jun 2026 01:42:14 -0700 Subject: [PATCH] Improve new worktree startup timing --- src/main/ipc/pty.ts | 13 + src/main/ipc/worktree-remote.ts | 436 ++++++++++++------ src/main/ipc/worktrees.test.ts | 133 +++++- src/main/runtime/orca-runtime.ts | 12 +- src/main/worktree-create-timing.test.ts | 22 + src/main/worktree-create-timing.ts | 66 +++ src/renderer/src/hooks/useComposerState.ts | 176 +++---- .../src/store/slices/worktrees.test.ts | 67 +++ src/renderer/src/store/slices/worktrees.ts | 3 +- src/shared/types.ts | 21 + 10 files changed, 698 insertions(+), 251 deletions(-) create mode 100644 src/main/worktree-create-timing.test.ts create mode 100644 src/main/worktree-create-timing.ts diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index f57d6535f..17004c72a 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -1442,6 +1442,19 @@ export function registerPtyHandlers( if (isClaudeLaunch) { markClaudePtySpawned(result.id) } + if (args.telemetry) { + const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind) + const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source) + const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind) + if (agentKindParse.success && launchSourceParse.success && requestKindParse.success) { + track('agent_started', { + agent_kind: agentKindParse.data, + launch_source: launchSourceParse.data, + request_kind: requestKindParse.data, + ...getCohortAtEmit() + }) + } + } // Why: runtime-owned CLI PTYs bypass the renderer `pty:spawn` handler, // so record their spawn-time paneKey here too. Synthetic hook titles and // paneKey-scoped cache cleanup both depend on this reverse lookup. diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 0f2c4a8f4..0211096db 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -16,8 +16,10 @@ import type { CreateWorktreeArgs, CreateWorktreeResult, GitPushTarget, + GlobalSettings, LocalBaseRefRefreshResult, Repo, + Worktree, WorktreeMeta } from '../../shared/types' import { getPRForBranch } from '../github/client' @@ -46,7 +48,7 @@ import { requireSshGitProvider } from '../providers/ssh-git-dispatch' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { getActiveMultiplexer } from './ssh' import type { SshGitProvider } from '../providers/ssh-git-provider' -import { isTuiAgent } from '../../shared/tui-agent-config' +import { TUI_AGENT_CONFIG, isTuiAgent } from '../../shared/tui-agent-config' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { getSshGitUsername } from '../git/git-username' import { @@ -70,6 +72,13 @@ import { createWorktreeSymlinks } from './worktree-symlinks' import { normalizeSparseDirectories } from './sparse-checkout-directories' import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' import type { IFilesystemProvider } from '../providers/types' +import { buildSetupRunnerCommand } from '../../shared/setup-runner-command' +import { createWorktreeCreateTimingRecorder } from '../worktree-create-timing' +import { + markCodexProjectTrusted, + markCopilotFolderTrusted, + markCursorWorkspaceTrusted +} from '../agent-trust-presets' const SSH_WORKTREE_CREATE_FETCH_FRESHNESS_MS = 30_000 const SSH_WORKTREE_CREATE_FETCH_CACHE_MAX = 512 @@ -86,6 +95,114 @@ type RemoteWorktreeCreateBasePlan = { remoteTrackingBase: RemoteTrackingBase | null } +type StagedStartupResult = { + startupTerminal?: CreateWorktreeResult['startupTerminal'] + didSpawnSetup: boolean + warning?: string +} + +function appendWorktreeCreateWarning(current: string | undefined, next: string): string { + return current ? `${current} Also ${next[0]?.toLowerCase() ?? ''}${next.slice(1)}` : next +} + +async function spawnLocalStartupAndSetupTerminals(args: { + runtime: OrcaRuntimeService | undefined + worktree: Pick + startup: CreateWorktreeArgs['startup'] + setup: CreateWorktreeResult['setup'] + defaultTabs: CreateWorktreeResult['defaultTabs'] + settings: GlobalSettings + createdWithAgent: CreateWorktreeArgs['createdWithAgent'] +}): Promise { + const { runtime, worktree, startup, setup, defaultTabs, settings, createdWithAgent } = args + if (!runtime || !startup || defaultTabs?.tabs.length) { + return { didSpawnSetup: false } + } + + let warning: string | undefined + let startupTerminalHandle: string | null = null + let startupTerminal: CreateWorktreeResult['startupTerminal'] + + try { + // Why: after `git worktree add` and metadata registration, a runtime-owned + // PTY can begin booting the selected agent while setup runs in a sibling + // terminal. Earlier than this, the worktree path is not yet safe for agents. + if (isTuiAgent(createdWithAgent)) { + const preset = TUI_AGENT_CONFIG[createdWithAgent].preflightTrust + try { + if (preset === 'cursor') { + markCursorWorkspaceTrusted(worktree.path) + } else if (preset === 'copilot') { + markCopilotFolderTrusted(worktree.path) + } else if (preset === 'codex') { + markCodexProjectTrusted(worktree.path) + } + } catch { + // Best-effort: launch still proceeds and the agent can ask interactively. + } + } + const terminal = await runtime.createTerminal(`id:${worktree.id}`, { + command: startup.command, + env: startup.env, + telemetry: startup.telemetry, + activate: true + }) + startupTerminalHandle = terminal.handle + startupTerminal = { + spawned: true, + surface: terminal.surface + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + warning = `Failed to create the startup terminal for ${worktree.path}: ${message}` + console.warn(`[worktree-create] ${warning}`) + return { didSpawnSetup: false, warning } + } + + let didSpawnSetup = false + if (setup) { + try { + const setupCommand = buildSetupRunnerCommand( + setup.runnerScriptPath, + process.platform === 'win32' ? 'windows' : 'posix' + ) + const setupLaunchMode = + (settings as Partial>) + .setupScriptLaunchMode ?? 'new-tab' + if (setupLaunchMode === 'split-vertical' || setupLaunchMode === 'split-horizontal') { + if (!startupTerminalHandle) { + throw new Error('startup_terminal_missing') + } + await runtime.splitTerminal(startupTerminalHandle, { + direction: setupLaunchMode === 'split-horizontal' ? 'horizontal' : 'vertical', + command: setupCommand, + env: setup.envVars, + activate: false + }) + } else { + await runtime.createTerminal(`id:${worktree.id}`, { + title: 'Setup', + command: setupCommand, + env: setup.envVars, + activate: false + }) + } + didSpawnSetup = true + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + const nextWarning = `failed to create the setup terminal for ${worktree.path}: ${message}` + warning = appendWorktreeCreateWarning(warning, nextWarning) + console.warn(`[worktree-create] ${warning}`) + } + } + + return { + ...(startupTerminal ? { startupTerminal } : {}), + didSpawnSetup, + ...(warning ? { warning } : {}) + } +} + function setBoundedSshWorktreeCreateFetchEntry( map: Map, key: string, @@ -1099,6 +1216,7 @@ export async function createRemoteWorktree( store: Store, mainWindow: BrowserWindow ): Promise { + const timing = createWorktreeCreateTimingRecorder() const provider = requireSshGitProvider(repo.connectionId!) const fsProvider = getSshFilesystemProvider(repo.connectionId!) @@ -1280,13 +1398,15 @@ export async function createRemoteWorktree( // Create worktree via relay try { - await provider.addWorktree( - repo.path, - branchName, - remotePath, - checkoutExistingBranch - ? { checkoutExistingBranch } - : { base: baseBranch, ...(sparseDirectories.length > 0 ? { noCheckout: true } : {}) } + await timing.time('git_worktree_add', async () => + provider.addWorktree( + repo.path, + branchName, + remotePath, + checkoutExistingBranch + ? { checkoutExistingBranch } + : { base: baseBranch, ...(sparseDirectories.length > 0 ? { noCheckout: true } : {}) } + ) ) } catch (err) { if ( @@ -1330,7 +1450,9 @@ export async function createRemoteWorktree( } // Re-list to get the created worktree info - const gitWorktrees = await provider.listWorktrees(repo.path) + const gitWorktrees = await timing.time('list_created_worktree', async () => + provider.listWorktrees(repo.path) + ) const created = gitWorktrees.find( (gw) => gw.branch?.endsWith(branchName) || gw.path.endsWith(effectiveSanitizedName) ) @@ -1392,8 +1514,10 @@ export async function createRemoteWorktree( ...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}), ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}) } - const meta = store.setWorktreeMeta(worktreeId, metaUpdates) - const worktree = mergeWorktree(repo.id, created, meta) + const { worktree } = timing.timeSync('persist_metadata', () => { + const meta = store.setWorktreeMeta(worktreeId, metaUpdates) + return { worktree: mergeWorktree(repo.id, created, meta) } + }) // Why: `experimentalWorktreeSymlinks` is intentionally not wired up for // remote (SSH) worktrees. Creating symlinks on the remote host would @@ -1404,43 +1528,45 @@ export async function createRemoteWorktree( let setup: CreateWorktreeResult['setup'] let defaultTabs: CreateWorktreeResult['defaultTabs'] if (fsProvider) { - const yamlHooks = await readRemoteOrcaYaml(fsProvider, created.path) - const hooks = getEffectiveHooksFromConfig(repo, yamlHooks) - try { - defaultTabs = getDefaultTabsLaunch(yamlHooks, repo, args.setupDecision) - } catch (error) { - // Why: default tab commands share setup's run policy. If the target branch - // adds commands without a renderer decision, create the tabs but don't run them. - console.warn(`[hooks] default tab commands skipped for ${created.path}:`, error) - defaultTabs = yamlHooks?.defaultTabs - ? { tabs: yamlHooks.defaultTabs, runCommands: false } - : undefined - } - const setupScript = hooks?.scripts.setup - let shouldLaunchSetup = false - if (setupScript) { + await timing.time('prepare_setup', async () => { + const yamlHooks = await readRemoteOrcaYaml(fsProvider, created.path) + const hooks = getEffectiveHooksFromConfig(repo, yamlHooks) try { - shouldLaunchSetup = shouldRunSetupForCreate(repo, args.setupDecision) + defaultTabs = getDefaultTabsLaunch(yamlHooks, repo, args.setupDecision) } catch (error) { - // Why: the remote worktree already exists. If the created branch adds - // a setup hook without a renderer decision, skip setup instead of - // reporting successful git creation as failed. - console.warn(`[hooks] setup hook skipped for ${created.path}:`, error) + // Why: default tab commands share setup's run policy. If the target branch + // adds commands without a renderer decision, create the tabs but don't run them. + console.warn(`[hooks] default tab commands skipped for ${created.path}:`, error) + defaultTabs = yamlHooks?.defaultTabs + ? { tabs: yamlHooks.defaultTabs, runCommands: false } + : undefined } - } - if (setupScript && shouldLaunchSetup) { - try { - setup = await createRemoteSetupRunnerScript( - repo, - created.path, - setupScript, - provider, - fsProvider - ) - } catch (error) { - console.error(`[hooks] Failed to prepare setup runner for ${created.path}:`, error) + const setupScript = hooks?.scripts.setup + let shouldLaunchSetup = false + if (setupScript) { + try { + shouldLaunchSetup = shouldRunSetupForCreate(repo, args.setupDecision) + } catch (error) { + // Why: the remote worktree already exists. If the created branch adds + // a setup hook without a renderer decision, skip setup instead of + // reporting successful git creation as failed. + console.warn(`[hooks] setup hook skipped for ${created.path}:`, error) + } } - } + if (setupScript && shouldLaunchSetup) { + try { + setup = await createRemoteSetupRunnerScript( + repo, + created.path, + setupScript, + provider, + fsProvider + ) + } catch (error) { + console.error(`[hooks] Failed to prepare setup runner for ${created.path}:`, error) + } + } + }) } notifyWorktreesChanged(mainWindow, repo.id) @@ -1448,7 +1574,8 @@ export async function createRemoteWorktree( worktree, ...(setup ? { setup } : {}), ...(defaultTabs ? { defaultTabs } : {}), - ...(localBaseRefRefresh ? { localBaseRefRefresh } : {}) + ...(localBaseRefRefresh ? { localBaseRefRefresh } : {}), + timing: timing.finish() } } @@ -1459,6 +1586,7 @@ export async function createLocalWorktree( mainWindow: BrowserWindow, runtime?: OrcaRuntimeService ): Promise { + const timing = createWorktreeCreateTimingRecorder() const settings = store.getSettings() const worktreePathSettings = getWorktreePathSettings(repo, settings) @@ -1685,22 +1813,26 @@ export async function createLocalWorktree( } if (remoteTrackingRefresh) { - const result = await remoteTrackingRefresh.promise - if (!result.ok) { - throw new Error( - `Could not refresh base ref "${baseBranch}" from "${remoteTrackingRefresh.base.remote}". Check your network and try again.` - ) - } - if ( - !remoteTrackingRefresh.hadLocalBaseRef && - !(await runtime?.hasRemoteTrackingRef(repo.path, remoteTrackingRefresh.base)) - ) { - throw new Error(`Base ref "${baseBranch}" was not found after fetching.`) - } + await timing.time('refresh_base_ref', async () => { + const result = await remoteTrackingRefresh.promise + if (!result.ok) { + throw new Error( + `Could not refresh base ref "${baseBranch}" from "${remoteTrackingRefresh.base.remote}". Check your network and try again.` + ) + } + if ( + !remoteTrackingRefresh.hadLocalBaseRef && + !(await runtime?.hasRemoteTrackingRef(repo.path, remoteTrackingRefresh.base)) + ) { + throw new Error(`Base ref "${baseBranch}" was not found after fetching.`) + } + }) } if (legacyFetchPromise) { - await legacyFetchPromise + await timing.time('refresh_base_ref', async () => { + await legacyFetchPromise + }) } emitCreateWorktreeProgress(mainWindow, 'creating') @@ -1714,42 +1846,44 @@ export async function createLocalWorktree( const existingBranchOption = { checkoutExistingBranch } const addResult: AddWorktreeResult = - (await (sparseDirectories.length > 0 - ? checkoutExistingBranch - ? addSparseWorktree( - repo.path, - worktreePath, - branchName, - sparseDirectories, - baseBranch, - settings.refreshLocalBaseRefOnWorktreeCreate, - existingBranchOption - ) - : addSparseWorktree( - repo.path, - worktreePath, - branchName, - sparseDirectories, - baseBranch, - settings.refreshLocalBaseRefOnWorktreeCreate - ) - : checkoutExistingBranch - ? addWorktree( - repo.path, - worktreePath, - branchName, - baseBranch, - settings.refreshLocalBaseRefOnWorktreeCreate, - false, - existingBranchOption - ) - : addWorktree( - repo.path, - worktreePath, - branchName, - baseBranch, - settings.refreshLocalBaseRefOnWorktreeCreate - ))) ?? {} + (await timing.time('git_worktree_add', async () => + sparseDirectories.length > 0 + ? checkoutExistingBranch + ? addSparseWorktree( + repo.path, + worktreePath, + branchName, + sparseDirectories, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate, + existingBranchOption + ) + : addSparseWorktree( + repo.path, + worktreePath, + branchName, + sparseDirectories, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate + ) + : checkoutExistingBranch + ? addWorktree( + repo.path, + worktreePath, + branchName, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate, + false, + existingBranchOption + ) + : addWorktree( + repo.path, + worktreePath, + branchName, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate + ) + )) ?? {} let configuredPushTarget: GitPushTarget | undefined if (preparedPushTarget) { @@ -1765,7 +1899,9 @@ export async function createLocalWorktree( } // Re-list to get the freshly created worktree info - const gitWorktrees = await listWorktrees(repo.path) + const gitWorktrees = await timing.time('list_created_worktree', async () => + listWorktrees(repo.path) + ) const created = gitWorktrees.find((gw) => areWorktreePathsEqual(gw.path, worktreePath)) if (!created) { throw new Error('Worktree created but not found in listing') @@ -1815,8 +1951,10 @@ export async function createLocalWorktree( ...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}), ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}) } - const meta = store.setWorktreeMeta(worktreeId, metaUpdates) - const worktree = mergeWorktree(repo.id, created, meta) + const { worktree } = timing.timeSync('persist_metadata', () => { + const meta = store.setWorktreeMeta(worktreeId, metaUpdates) + return { worktree: mergeWorktree(repo.id, created, meta) } + }) // Why: the authorized-roots cache is consulted lazily on the next filesystem // access (`ensureAuthorizedRootsCache` rebuilds on demand when dirty). We // just invalidate the cache marker instead of blocking worktree creation on @@ -1829,8 +1967,11 @@ export async function createLocalWorktree( // state (e.g. `node_modules`, `.env`) see the links already in place. // Gated on the experimental flag so disabling the feature globally skips // the work even when a repo still has paths configured. - if (settings.experimentalWorktreeSymlinks && repo.symlinkPaths && repo.symlinkPaths.length > 0) { - await createWorktreeSymlinks(repo.path, created.path, repo.symlinkPaths) + const symlinkPaths = repo.symlinkPaths ?? [] + if (settings.experimentalWorktreeSymlinks && symlinkPaths.length > 0) { + await timing.time('create_symlinks', async () => { + await createWorktreeSymlinks(repo.path, created.path, symlinkPaths) + }) } // Why: the worktree's own `orca.yaml` (at the tip of the base branch) is @@ -1844,53 +1985,72 @@ export async function createLocalWorktree( // the regression this replaced. let setup: CreateWorktreeResult['setup'] let defaultTabs: CreateWorktreeResult['defaultTabs'] - const createdYamlHooks = loadHooks(worktreePath) - const createdEffectiveHooks = getEffectiveHooksFromConfig(repo, createdYamlHooks) - try { - defaultTabs = getDefaultTabsLaunch(createdYamlHooks, repo, args.setupDecision) - } catch (error) { - // Why: default tab commands share setup's run policy. If the target branch - // adds commands without a renderer decision, create the tabs but don't run them. - console.warn(`[hooks] default tab commands skipped for ${worktreePath}:`, error) - defaultTabs = createdYamlHooks?.defaultTabs - ? { tabs: createdYamlHooks.defaultTabs, runCommands: false } - : undefined - } - const setupScript = createdEffectiveHooks?.scripts.setup - let shouldLaunchSetup = false - if (setupScript) { + await timing.time('prepare_setup', async () => { + const createdYamlHooks = loadHooks(worktreePath) + const createdEffectiveHooks = getEffectiveHooksFromConfig(repo, createdYamlHooks) try { - shouldLaunchSetup = shouldRunSetupForCreate(repo, args.setupDecision) + defaultTabs = getDefaultTabsLaunch(createdYamlHooks, repo, args.setupDecision) } catch (error) { - // Why: if the target branch introduces setup hooks that the primary - // checkout did not expose, the renderer may not have collected an ask - // decision. The worktree already exists, so skip setup instead of - // turning successful git creation into an IPC failure. - console.warn(`[hooks] setup hook skipped for ${worktreePath}:`, error) + // Why: default tab commands share setup's run policy. If the target branch + // adds commands without a renderer decision, create the tabs but don't run them. + console.warn(`[hooks] default tab commands skipped for ${worktreePath}:`, error) + defaultTabs = createdYamlHooks?.defaultTabs + ? { tabs: createdYamlHooks.defaultTabs, runCommands: false } + : undefined } - } - if (setupScript && shouldLaunchSetup) { - try { - // Why: setup now runs in a visible terminal owned by the renderer so users - // can inspect failures, answer prompts, and rerun it. The main process only - // resolves policy and writes the runner script; it must not execute setup - // itself anymore or we would reintroduce the hidden background-hook behavior. - // - // Why: the git worktree already exists at this point. If runner generation - // fails, surfacing the error as a hard create failure would lie to the UI - // about the underlying git state and strand a real worktree on disk. - // Degrade to "created without setup launch" instead. - setup = createSetupRunnerScript(repo, worktreePath, setupScript) - } catch (error) { - console.error(`[hooks] Failed to prepare setup runner for ${worktreePath}:`, error) + const setupScript = createdEffectiveHooks?.scripts.setup + let shouldLaunchSetup = false + if (setupScript) { + try { + shouldLaunchSetup = shouldRunSetupForCreate(repo, args.setupDecision) + } catch (error) { + // Why: if the target branch introduces setup hooks that the primary + // checkout did not expose, the renderer may not have collected an ask + // decision. The worktree already exists, so skip setup instead of + // turning successful git creation into an IPC failure. + console.warn(`[hooks] setup hook skipped for ${worktreePath}:`, error) + } } - } + if (setupScript && shouldLaunchSetup) { + try { + // Why: setup now runs in a visible terminal owned by the renderer so users + // can inspect failures, answer prompts, and rerun it. The main process only + // resolves policy and writes the runner script; it must not execute setup + // itself anymore or we would reintroduce the hidden background-hook behavior. + // + // Why: the git worktree already exists at this point. If runner generation + // fails, surfacing the error as a hard create failure would lie to the UI + // about the underlying git state and strand a real worktree on disk. + // Degrade to "created without setup launch" instead. + setup = createSetupRunnerScript(repo, worktreePath, setupScript) + } catch (error) { + console.error(`[hooks] Failed to prepare setup runner for ${worktreePath}:`, error) + } + } + }) + + const stagedStartup = await timing.time('spawn_startup_terminal', () => + spawnLocalStartupAndSetupTerminals({ + runtime, + worktree, + startup: args.startup, + setup, + defaultTabs, + settings, + createdWithAgent: args.createdWithAgent + }) + ) notifyWorktreesChanged(mainWindow, repo.id) return { worktree, - ...(setup ? { setup } : {}), + ...(setup && !stagedStartup.didSpawnSetup ? { setup } : {}), ...(defaultTabs ? { defaultTabs } : {}), - ...(addResult.localBaseRefRefresh ? { localBaseRefRefresh: addResult.localBaseRefRefresh } : {}) + ...(addResult.localBaseRefRefresh + ? { localBaseRefRefresh: addResult.localBaseRefRefresh } + : {}), + ...(stagedStartup.startupTerminal ? { startupTerminal: stagedStartup.startupTerminal } : {}), + ...(stagedStartup.warning ? { warning: stagedStartup.warning } : {}), + timing: timing.finish() } } diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index c72fb9e78..fa2ecde8a 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -242,6 +242,8 @@ describe('registerWorktreeHandlers', () => { reconcileWorktreeBaseStatus: ReturnType clearOptimisticReconcileToken: ReturnType resolveManagedMrBase: ReturnType + createTerminal: ReturnType + splitTerminal: ReturnType } beforeEach(() => { @@ -414,7 +416,18 @@ describe('registerWorktreeHandlers', () => { recordOptimisticReconcileToken: vi.fn().mockReturnValue('token-1'), reconcileWorktreeBaseStatus: vi.fn(), clearOptimisticReconcileToken: vi.fn(), - resolveManagedMrBase: vi.fn().mockResolvedValue({ baseBranch: 'origin/mr-branch' }) + resolveManagedMrBase: vi.fn().mockResolvedValue({ baseBranch: 'origin/mr-branch' }), + createTerminal: vi.fn().mockResolvedValue({ + handle: 'term-startup', + worktreeId: 'repo-1::/workspace/improve-dashboard', + title: null, + surface: 'visible' + }), + splitTerminal: vi.fn().mockResolvedValue({ + handle: 'term-setup', + tabId: 'tab-startup', + paneRuntimeId: -1 + }) } registerWorktreeHandlers(mainWindow as never, store as never, runtimeStub as never) }) @@ -514,7 +527,7 @@ describe('registerWorktreeHandlers', () => { comment: 'keep me', isPinned: true }) - expect(result).toEqual({ comment: 'keep me', isPinned: true }) + expect(result).toMatchObject({ comment: 'keep me', isPinned: true }) }) it('auto-suffixes the branch name when the first choice collides with a remote branch', async () => { @@ -545,7 +558,7 @@ describe('registerWorktreeHandlers', () => { 'origin/main', false ) - expect(result).toEqual({ + expect(result).toMatchObject({ worktree: expect.objectContaining({ path: '/workspace/improve-dashboard-2', branch: 'improve-dashboard-2' @@ -625,7 +638,7 @@ describe('registerWorktreeHandlers', () => { 'origin/main', false ) - expect(result).toEqual({ + expect(result).toMatchObject({ worktree: expect.objectContaining({ path: '/workspace/feature-something', branch: 'feature/something' @@ -679,6 +692,80 @@ describe('registerWorktreeHandlers', () => { }) }) + it('spawns a startup terminal and setup terminal after local worktree registration', async () => { + addWorktreeMock.mockResolvedValue({}) + listWorktreesMock.mockResolvedValueOnce([ + { + path: '/workspace/improve-dashboard', + head: 'def', + branch: 'improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]) + loadHooksMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) + getEffectiveHooksMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) + getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) + shouldRunSetupForCreateMock.mockReturnValue(true) + + const result = (await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard', + createdWithAgent: 'claude', + startup: { + command: 'claude --prefill test', + env: { ORCA_AGENT_MODE: 'direct' }, + telemetry: { + agent_kind: 'claude', + launch_source: 'new_workspace_composer', + request_kind: 'new' + } + } + })) as { + setup?: unknown + startupTerminal?: { spawned: boolean; surface?: string } + timing?: { phases: { phase: string }[] } + } + + expect(runtimeStub.createTerminal).toHaveBeenNthCalledWith( + 1, + 'id:repo-1::/workspace/improve-dashboard', + { + command: 'claude --prefill test', + env: { ORCA_AGENT_MODE: 'direct' }, + telemetry: { + agent_kind: 'claude', + launch_source: 'new_workspace_composer', + request_kind: 'new' + }, + activate: true + } + ) + expect(runtimeStub.createTerminal).toHaveBeenNthCalledWith( + 2, + 'id:repo-1::/workspace/improve-dashboard', + { + title: 'Setup', + command: 'bash /workspace/repo/.git/orca/setup-runner.sh', + env: { + ORCA_ROOT_PATH: '/workspace/repo', + ORCA_WORKTREE_PATH: '/workspace/improve-dashboard' + }, + activate: false + } + ) + expect(result.setup).toBeUndefined() + expect(result.startupTerminal).toEqual({ spawned: true, surface: 'visible' }) + expect(result.timing?.phases.map((phase) => phase.phase)).toEqual( + expect.arrayContaining([ + 'git_worktree_add', + 'list_created_worktree', + 'prepare_setup', + 'spawn_startup_terminal' + ]) + ) + }) + it('checks out a selected existing local branch exactly', async () => { listWorktreesMock .mockResolvedValueOnce([ @@ -728,7 +815,7 @@ describe('registerWorktreeHandlers', () => { 'repo-1::/workspace/fix-bug-0', expect.objectContaining({ preserveBranchOnDelete: true }) ) - expect(result).toEqual({ + expect(result).toMatchObject({ worktree: expect.objectContaining({ path: '/workspace/fix-bug-0', branch: 'refs/heads/fix/bug-0' @@ -779,7 +866,7 @@ describe('registerWorktreeHandlers', () => { false, { checkoutExistingBranch: true } ) - expect(result).toEqual({ + expect(result).toMatchObject({ worktree: expect.objectContaining({ path: '/workspace/fix-bug-0-2', branch: 'refs/heads/fix/bug-0' @@ -1055,7 +1142,7 @@ describe('registerWorktreeHandlers', () => { displayName: 'Fix: dashboards for PRs' }) ) - expect(result).toEqual({ + expect(result).toMatchObject({ worktree: expect.objectContaining({ displayName: 'Fix: dashboards for PRs' }) @@ -1092,7 +1179,7 @@ describe('registerWorktreeHandlers', () => { manualOrder: 123_456 }) ) - expect(result).toEqual({ + expect(result).toMatchObject({ worktree: expect.objectContaining({ linkedIssue: 123, linkedPR: 456, @@ -1126,7 +1213,7 @@ describe('registerWorktreeHandlers', () => { createdWithAgent: 'codex' }) ) - expect(result).toEqual({ + expect(result).toMatchObject({ worktree: expect.objectContaining({ createdWithAgent: 'codex' }) @@ -1269,7 +1356,7 @@ describe('registerWorktreeHandlers', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['fetch', 'origin', 'refs/pull/1738/head'], { cwd: '/workspace/repo' }) - expect(result).toEqual({ + expect(result).toMatchObject({ baseBranch: 'abc123', pushTarget: { remoteName: 'pr-prateek-orca', @@ -1306,7 +1393,7 @@ describe('registerWorktreeHandlers', () => { ['rev-parse', '--verify', 'origin/feature/add-feature'], { cwd: '/workspace/repo' } ) - expect(result).toEqual({ + expect(result).toMatchObject({ baseBranch: 'def456', headSha: 'def456', branchNameOverride: 'feature/add-feature', @@ -1333,7 +1420,7 @@ describe('registerWorktreeHandlers', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['fetch', 'origin', 'refs/pull/1849/head'], { cwd: '/workspace/repo' }) - expect(result).toEqual({ baseBranch: 'abc123' }) + expect(result).toMatchObject({ baseBranch: 'abc123' }) }) it('falls back to refs/pull//head when branch fetch fails for a PR', async () => { @@ -1370,7 +1457,7 @@ describe('registerWorktreeHandlers', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['fetch', 'origin', 'refs/pull/1849/head'], { cwd: '/workspace/repo' }) - expect(result).toEqual({ baseBranch: 'abc123' }) + expect(result).toMatchObject({ baseBranch: 'abc123' }) }) it('does not fall back to refs/pull//head when branch fetch hits a network failure', async () => { @@ -1395,7 +1482,7 @@ describe('registerWorktreeHandlers', () => { ['fetch', 'origin', 'refs/pull/1849/head'], expect.anything() ) - expect(result).toEqual({ + expect(result).toMatchObject({ error: 'Failed to fetch origin/feat/onboarding-model-choice-782: fatal: unable to access repo: Could not resolve host: github.com' }) @@ -1420,7 +1507,7 @@ describe('registerWorktreeHandlers', () => { sourceBranch: 'feature/mr', isCrossRepository: true }) - expect(result).toEqual({ + expect(result).toMatchObject({ baseBranch: 'fork-mr-sha', pushTarget: { remoteName: 'origin', branchName: 'feature/mr' } }) @@ -1485,7 +1572,7 @@ describe('registerWorktreeHandlers', () => { manualOrder: 123_456 }) ) - expect(result).toEqual({ + expect(result).toMatchObject({ worktree: expect.objectContaining({ linkedIssue: 123, linkedPR: 456, @@ -1755,7 +1842,7 @@ describe('registerWorktreeHandlers', () => { sparsePresetId: 'preset-1' }) ) - expect(result).toEqual({ + expect(result).toMatchObject({ worktree: expect.objectContaining({ isSparse: true, sparseDirectories: ['apps/mobile', 'packages/shared'], @@ -2582,7 +2669,7 @@ describe('registerWorktreeHandlers', () => { '/workspace/improve-dashboard', 'codex exec "long command"' ) - expect(result).toEqual({ + expect(result).toMatchObject({ runnerScriptPath: '/workspace/repo/.git/orca/issue-command-runner.sh', envVars: { ORCA_ROOT_PATH: '/workspace/repo', @@ -3198,7 +3285,7 @@ describe('registerWorktreeHandlers', () => { 'origin/main', false ) - expect(result).toEqual({ + expect(result).toMatchObject({ worktree: expect.objectContaining({ path: '/workspace/improve-dashboard-3', branch: 'improve-dashboard-3' @@ -3259,7 +3346,7 @@ describe('registerWorktreeHandlers', () => { '/workspace/improve-dashboard', 'pnpm worktree:setup' ) - expect(result).toEqual({ + expect(result).toMatchObject({ worktree: expect.objectContaining({ repoId: 'repo-1', path: '/workspace/improve-dashboard', @@ -3369,7 +3456,7 @@ describe('registerWorktreeHandlers', () => { sparsePresetId: 'preset-1' }) ) - expect(result).toEqual({ + expect(result).toMatchObject({ worktree: expect.objectContaining({ repoId: 'repo-1', path: '/workspace/improve-dashboard', @@ -3505,7 +3592,7 @@ describe('registerWorktreeHandlers', () => { setupDecision: 'run' }) - expect(result).toEqual({ + expect(result).toMatchObject({ worktree: expect.objectContaining({ repoId: 'repo-1', path: '/workspace/improve-dashboard', @@ -4105,7 +4192,7 @@ describe('registerWorktreeHandlers', () => { expectedHead: 'def456' }) - expect(result).toEqual({ deleted: true }) + expect(result).toMatchObject({ deleted: true }) expect(forceDeleteLocalBranchMock).toHaveBeenCalledWith( '/workspace/repo', 'feature/test', diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 1da5e7e32..4008ac2f4 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -683,6 +683,7 @@ type RuntimePtyController = { cwd?: string command?: string env?: Record + telemetry?: WorktreeStartupLaunch['telemetry'] connectionId?: string | null worktreeId?: string preAllocatedHandle?: string @@ -7905,7 +7906,8 @@ export class OrcaRuntimeService { } const terminal = await this.createTerminal(`id:${worktree.id}`, { command: effectiveStartup.command, - env: effectiveStartup.env + env: effectiveStartup.env, + telemetry: effectiveStartup.telemetry }) if (effectiveDraftPaste) { this.pasteStartupDraftWhenReady(terminal.handle, effectiveDraftPaste) @@ -8315,7 +8317,8 @@ export class OrcaRuntimeService { } const terminal = await this.createTerminal(`id:${worktree.id}`, { command: effectiveStartup.command, - env: effectiveStartup.env + env: effectiveStartup.env, + telemetry: effectiveStartup.telemetry }) if (effectiveDraftPaste) { this.pasteStartupDraftWhenReady(terminal.handle, effectiveDraftPaste) @@ -8542,7 +8545,8 @@ export class OrcaRuntimeService { } const terminal = await this.createTerminal(`path:${result.worktree.path}`, { command: args.startup.command, - env: args.startup.env + env: args.startup.env, + telemetry: args.startup.telemetry }) if (args.startupDraftPaste) { this.pasteStartupDraftWhenReady(terminal.handle, args.startupDraftPaste) @@ -9854,6 +9858,7 @@ export class OrcaRuntimeService { opts: { command?: string env?: Record + telemetry?: WorktreeStartupLaunch['telemetry'] title?: string focus?: boolean rendererBacked?: boolean @@ -9910,6 +9915,7 @@ export class OrcaRuntimeService { cwd: worktree.path, command: opts.command, env, + telemetry: opts.telemetry, connectionId: repo?.connectionId ?? null, worktreeId: worktree.id, preAllocatedHandle diff --git a/src/main/worktree-create-timing.test.ts b/src/main/worktree-create-timing.test.ts new file mode 100644 index 000000000..6ef4d23ff --- /dev/null +++ b/src/main/worktree-create-timing.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { createWorktreeCreateTimingRecorder } from './worktree-create-timing' + +describe('createWorktreeCreateTimingRecorder', () => { + it('records ordered phase timings and total duration', async () => { + const samples = [100, 105, 112, 130, 144, 155] + const recorder = createWorktreeCreateTimingRecorder(() => samples.shift() ?? 155) + + const syncResult = recorder.timeSync('resolve_branch', () => 'branch') + const asyncResult = await recorder.time('git_worktree_add', async () => 'created') + + expect(syncResult).toBe('branch') + expect(asyncResult).toBe('created') + expect(recorder.finish()).toEqual({ + totalDurationMs: 55, + phases: [ + { phase: 'resolve_branch', startedAtMs: 5, durationMs: 7 }, + { phase: 'git_worktree_add', startedAtMs: 30, durationMs: 14 } + ] + }) + }) +}) diff --git a/src/main/worktree-create-timing.ts b/src/main/worktree-create-timing.ts new file mode 100644 index 000000000..3dc5546c5 --- /dev/null +++ b/src/main/worktree-create-timing.ts @@ -0,0 +1,66 @@ +import type { WorktreeCreateTiming, WorktreeCreateTimingPhase } from '../shared/types' + +type TimingClock = () => number + +export type WorktreeCreateTimingRecorder = { + time(phase: string, operation: () => Promise): Promise + timeSync(phase: string, operation: () => T): T + finish(): WorktreeCreateTiming +} + +function defaultClock(): number { + return performance.now() +} + +function clampDuration(value: number): number { + return Number.isFinite(value) ? Math.max(0, value) : 0 +} + +function createPhase( + phase: string, + operationStartedAt: number, + operationEndedAt: number, + rootStartedAt: number +): WorktreeCreateTimingPhase { + return { + phase, + startedAtMs: clampDuration(operationStartedAt - rootStartedAt), + durationMs: clampDuration(operationEndedAt - operationStartedAt) + } +} + +export function createWorktreeCreateTimingRecorder( + clock: TimingClock = defaultClock +): WorktreeCreateTimingRecorder { + const startedAt = clock() + const phases: WorktreeCreateTimingPhase[] = [] + + const recordPhase = (phase: string, operationStartedAt: number): void => { + phases.push(createPhase(phase, operationStartedAt, clock(), startedAt)) + } + + return { + async time(phase: string, operation: () => Promise): Promise { + const operationStartedAt = clock() + try { + return await operation() + } finally { + recordPhase(phase, operationStartedAt) + } + }, + timeSync(phase: string, operation: () => T): T { + const operationStartedAt = clock() + try { + return operation() + } finally { + recordPhase(phase, operationStartedAt) + } + }, + finish() { + return { + totalDurationMs: clampDuration(clock() - startedAt), + phases: [...phases] + } + } + } +} diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 998e6852b..6ccb4c157 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -1935,6 +1935,29 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS Boolean(tuiAgent) && !effectiveBranchNameOverride && !createDisplayName + const startupPlan = buildAgentStartupPlan({ + agent: tuiAgent, + prompt: submitStartupPrompt, + cmdOverrides: settings?.agentCmdOverrides ?? {}, + platform: CLIENT_PLATFORM + }) + + // Why: backend startup is safe only when the launch command is + // self-contained. Agents that need post-ready paste/follow-up stay on + // the renderer path so prompt delivery is not skipped. + const composerTelemetry: AgentStartedTelemetry = { + agent_kind: tuiAgentToAgentKind(tuiAgent), + launch_source: telemetrySource === 'onboarding' ? 'onboarding' : 'new_workspace_composer', + request_kind: 'new' + } + const backendStartup = + startupPlan && !startupPlan.draftPrompt && !startupPlan.followupPrompt + ? { + command: startupPlan.launchCommand, + ...(startupPlan.env ? { env: startupPlan.env } : {}), + telemetry: composerTelemetry + } + : undefined const result = await createWorktree( repoId, workspaceName, @@ -1957,7 +1980,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS resolvedInitialWorkspaceStatus, linkedGitLabMR ?? undefined, linkedGitLabIssue ?? undefined, - undefined, + backendStartup, pendingFirstAgentMessageRename ) const worktree = result.worktree @@ -1976,32 +1999,13 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS }) } : undefined - const startupPlan = buildAgentStartupPlan({ - agent: tuiAgent, - prompt: submitStartupPrompt, - cmdOverrides: settings?.agentCmdOverrides ?? {}, - platform: CLIENT_PLATFORM - }) - - // Why: thread agent_started telemetry through the queued startup so - // main fires the event after the spawn succeeds. The composer - // "create" path is the new-workspace surface; request_kind is - // `'new'` because this is always a fresh session (issue/PR-driven - // follow-ups go through launch-work-item-direct.ts). - // Why: when the composer is opened from onboarding, the first - // `agent_started` must attribute to `onboarding` so D1 activation - // can be measured against the funnel. - const composerTelemetry: AgentStartedTelemetry = { - agent_kind: tuiAgentToAgentKind(tuiAgent), - launch_source: telemetrySource === 'onboarding' ? 'onboarding' : 'new_workspace_composer', - request_kind: 'new' - } + const backendSpawnedStartup = result.startupTerminal?.spawned === true const activation = activateAndRevealWorktree(worktree.id, { sidebarRevealBehavior: 'auto', setup: result.setup, defaultTabs: result.defaultTabs, issueCommand, - ...(startupPlan + ...(startupPlan && !backendSpawnedStartup ? { startup: { command: startupPlan.launchCommand, @@ -2019,7 +2023,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } : {}) }) - if (startupPlan) { + if (startupPlan && !backendSpawnedStartup) { void ensureAgentStartupInTerminal({ worktreeId: worktree.id, primaryTabId: activation === false ? null : activation.primaryTabId, @@ -2176,66 +2180,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS Boolean(agent) && !effectiveBranchNameOverride && !createDisplayName - const result = await createWorktree( - repoId, - workspaceName, - selectedRepoIsGit ? baseBranch : undefined, - effectiveSetupDecision, - selectedRepoIsGit && sparseEnabled - ? { - directories: normalizedSparseDirectories, - ...(effectivePresetId ? { presetId: effectivePresetId } : {}) - } - : undefined, - telemetrySource, - createDisplayName, - submitLinkedIssueNumber ?? undefined, - submitLinkedPR ?? undefined, - pushTarget, - agent ?? undefined, - linkedLinearIssue, - effectiveBranchNameOverride, - resolvedInitialWorkspaceStatus, - linkedGitLabMR ?? undefined, - linkedGitLabIssue ?? undefined, - undefined, - pendingFirstAgentMessageRename - ) - const worktree = result.worktree - const trimmedNote = note.trim() - await applyWorktreeMeta(worktree.id, trimmedNote ? { comment: trimmedNote } : {}) - - // Why: quick create should draft linked source data for review instead - // of auto-executing it. Rich linked context wins over URL fallback; - // typed-only Linear entries still use the note as the startup prompt. + // Why: backend startup is safe only when the launch command is + // self-contained. Agents that need post-ready paste/follow-up stay on + // the renderer path so prompt delivery is not skipped. const { prompt: quickPrompt, draftPrompt: quickDraftPrompt } = resolveQuickCreateLinkedWorkItemPrompt(submitLinkedWorkItem, trimmedNote) - - // Why: agents that gate first-launch behind a "Do you trust this - // folder?" menu (cursor-agent, copilot) consume the bracketed paste - // as menu input. Pre-write the trust artifact so the menu is - // skipped — best-effort, errors swallowed by main. Guard the IPC - // presence so a stale preload bundle doesn't crash the launch with - // "Cannot read properties of undefined". - if (agent && worktree.path && window.api.agentTrust?.markTrusted) { - const preflight = TUI_AGENT_CONFIG[agent].preflightTrust - if (preflight) { - try { - await window.api.agentTrust.markTrusted({ - preset: preflight, - workspacePath: worktree.path - }) - } catch { - // Best-effort: continue with launch. - } - } - } - - // Why: prefer the agent's native prefill flag (currently Claude's - // `--prefill`) when it has one — sidesteps the readiness/paste race - // entirely. Falls through to the type-after-ready path for every - // other agent. const draftLaunchPlan = agent === null || !quickDraftPrompt ? null @@ -2268,10 +2218,6 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } } - // Why: only attach telemetry when an agent was selected — the - // quick path also handles "blank shell" (agent === null) where no - // agent_started event should fire. When telemetry is present main - // emits the event after pty:spawn succeeds. const quickTelemetry: AgentStartedTelemetry | null = agent === null ? null @@ -2281,11 +2227,69 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS telemetrySource === 'onboarding' ? 'onboarding' : 'new_workspace_composer', request_kind: 'new' } + const backendStartup = + startupPlan && !startupPlan.draftPrompt && !startupPlan.followupPrompt + ? { + command: startupPlan.launchCommand, + ...(startupPlan.env ? { env: startupPlan.env } : {}), + ...(quickTelemetry ? { telemetry: quickTelemetry } : {}) + } + : undefined + const result = await createWorktree( + repoId, + workspaceName, + selectedRepoIsGit ? baseBranch : undefined, + effectiveSetupDecision, + selectedRepoIsGit && sparseEnabled + ? { + directories: normalizedSparseDirectories, + ...(effectivePresetId ? { presetId: effectivePresetId } : {}) + } + : undefined, + telemetrySource, + createDisplayName, + submitLinkedIssueNumber ?? undefined, + submitLinkedPR ?? undefined, + pushTarget, + agent ?? undefined, + linkedLinearIssue, + effectiveBranchNameOverride, + resolvedInitialWorkspaceStatus, + linkedGitLabMR ?? undefined, + linkedGitLabIssue ?? undefined, + backendStartup, + pendingFirstAgentMessageRename + ) + const worktree = result.worktree + + await applyWorktreeMeta(worktree.id, trimmedNote ? { comment: trimmedNote } : {}) + + // Why: agents that gate first-launch behind a "Do you trust this + // folder?" menu (cursor-agent, copilot) consume the bracketed paste + // as menu input. Pre-write the trust artifact so the menu is + // skipped — best-effort, errors swallowed by main. Guard the IPC + // presence so a stale preload bundle doesn't crash the launch with + // "Cannot read properties of undefined". + if (agent && worktree.path && window.api.agentTrust?.markTrusted) { + const preflight = TUI_AGENT_CONFIG[agent].preflightTrust + if (preflight) { + try { + await window.api.agentTrust.markTrusted({ + preset: preflight, + workspacePath: worktree.path + }) + } catch { + // Best-effort: continue with launch. + } + } + } + + const backendSpawnedStartup = result.startupTerminal?.spawned === true const activation = activateAndRevealWorktree(worktree.id, { sidebarRevealBehavior: 'auto', setup: result.setup, defaultTabs: result.defaultTabs, - ...(startupPlan + ...(startupPlan && !backendSpawnedStartup ? { startup: { command: startupPlan.launchCommand, @@ -2303,7 +2307,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } : {}) }) - if (startupPlan) { + if (startupPlan && !backendSpawnedStartup) { void ensureAgentStartupInTerminal({ worktreeId: worktree.id, primaryTabId: activation === false ? null : activation.primaryTabId, diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 42e1e8c18..77a0b9443 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -1956,6 +1956,73 @@ describe('worktree remote runtime mutations', () => { ) }) + it('passes startup commands through local worktree creation IPC', async () => { + const store = createTestStore() + const wt = makeWorktree({ + id: 'repo1::/path/local-agent-startup', + repoId: 'repo1', + path: '/path/local-agent-startup' + }) + mockApi.worktrees.create.mockResolvedValue({ + worktree: wt, + startupTerminal: { spawned: true, surface: 'visible' } + }) + store.setState({ + worktreesByRepo: { repo1: [] } + } as Partial) + + await store + .getState() + .createWorktree( + 'repo1', + 'local-agent-startup', + undefined, + 'skip', + undefined, + 'sidebar', + 'Launch local agent', + undefined, + undefined, + undefined, + 'claude', + undefined, + undefined, + undefined, + undefined, + undefined, + { + command: "claude --prefill 'summarize repo'", + env: { ORCA_AGENT_MODE: 'direct' }, + telemetry: { + agent_kind: 'claude-code', + launch_source: 'new_workspace_composer', + request_kind: 'new' + } + } + ) + + expect(mockApi.worktrees.create).toHaveBeenCalledWith( + expect.objectContaining({ + repoId: 'repo1', + name: 'local-agent-startup', + setupDecision: 'skip', + telemetrySource: 'sidebar', + displayName: 'Launch local agent', + createdWithAgent: 'claude', + startup: { + command: "claude --prefill 'summarize repo'", + env: { ORCA_AGENT_MODE: 'direct' }, + telemetry: { + agent_kind: 'claude-code', + launch_source: 'new_workspace_composer', + request_kind: 'new' + } + } + }) + ) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + it('does not suffix branchNameOverride when runtime create reports a branch conflict', async () => { const store = createTestStore() runtimeEnvironmentCall.mockRejectedValueOnce(new Error('Branch already exists on a remote')) diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 5853f8bab..e04cccf4e 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -1064,7 +1064,8 @@ export const createWorktreeSlice: StateCreator ...(manualOrder !== undefined ? { manualOrder } : {}), ...(workspaceStatus !== undefined ? { workspaceStatus } : {}), ...(linkedGitLabMR !== undefined ? { linkedGitLabMR } : {}), - ...(linkedGitLabIssue !== undefined ? { linkedGitLabIssue } : {}) + ...(linkedGitLabIssue !== undefined ? { linkedGitLabIssue } : {}), + ...(startup ? { startup } : {}) } const target = getActiveRuntimeTarget(get().settings) const result = diff --git a/src/shared/types.ts b/src/shared/types.ts index 32101fb09..28a32691b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -22,6 +22,7 @@ import type { RepoSourceControlAiOverrides, SourceControlAiSettings } from './source-control-ai-types' +import type { AgentKind, LaunchSource, RequestKind } from './telemetry-events' // Re-exported for backward compat with renderer call sites that import // `WorkspaceCreateTelemetrySource` from '../../../shared/types'. @@ -1541,6 +1542,7 @@ export type WorktreeSetupLaunch = { export type WorktreeStartupLaunch = { command: string env?: Record + telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind } } export type WorktreeDefaultTabsLaunch = { @@ -1548,6 +1550,17 @@ export type WorktreeDefaultTabsLaunch = { runCommands: boolean } +export type WorktreeCreateTimingPhase = { + phase: string + startedAtMs: number + durationMs: number +} + +export type WorktreeCreateTiming = { + totalDurationMs: number + phases: WorktreeCreateTimingPhase[] +} + export type CreateSparseCheckoutRequest = { directories: string[] /** Set when the directories came from a saved preset and the user did not @@ -1604,6 +1617,9 @@ export type CreateWorktreeArgs = { * pre-date this prop default to `unknown` at the IPC boundary instead * of failing typecheck. */ telemetrySource?: WorkspaceSource + /** Optional startup command for callers that want the backend to spawn the + * first terminal as soon as the worktree is registered. */ + startup?: WorktreeStartupLaunch } export type CreateWorktreeResult = { @@ -1620,6 +1636,11 @@ export type CreateWorktreeResult = { warning?: string initialBaseStatus?: WorktreeBaseStatusEvent localBaseRefRefresh?: LocalBaseRefRefreshResult + startupTerminal?: { + spawned: boolean + surface?: 'visible' | 'background' + } + timing?: WorktreeCreateTiming } export type PreservedWorktreeBranch = {