Improve new worktree startup timing

This commit is contained in:
Neil 2026-06-03 01:42:14 -07:00 committed by GitHub
parent c9c0418612
commit 3079e4690a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 698 additions and 251 deletions

View File

@ -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.

View File

@ -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<Worktree, 'id' | 'path'>
startup: CreateWorktreeArgs['startup']
setup: CreateWorktreeResult['setup']
defaultTabs: CreateWorktreeResult['defaultTabs']
settings: GlobalSettings
createdWithAgent: CreateWorktreeArgs['createdWithAgent']
}): Promise<StagedStartupResult> {
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<Pick<GlobalSettings, 'setupScriptLaunchMode'>>)
.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<string, number>,
key: string,
@ -1099,6 +1216,7 @@ export async function createRemoteWorktree(
store: Store,
mainWindow: BrowserWindow
): Promise<CreateWorktreeResult> {
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<CreateWorktreeResult> {
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()
}
}

View File

@ -242,6 +242,8 @@ describe('registerWorktreeHandlers', () => {
reconcileWorktreeBaseStatus: ReturnType<typeof vi.fn>
clearOptimisticReconcileToken: ReturnType<typeof vi.fn>
resolveManagedMrBase: ReturnType<typeof vi.fn>
createTerminal: ReturnType<typeof vi.fn>
splitTerminal: ReturnType<typeof vi.fn>
}
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/<N>/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/<N>/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',

View File

@ -683,6 +683,7 @@ type RuntimePtyController = {
cwd?: string
command?: string
env?: Record<string, string>
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<string, string>
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

View File

@ -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 }
]
})
})
})

View File

@ -0,0 +1,66 @@
import type { WorktreeCreateTiming, WorktreeCreateTimingPhase } from '../shared/types'
type TimingClock = () => number
export type WorktreeCreateTimingRecorder = {
time<T>(phase: string, operation: () => Promise<T>): Promise<T>
timeSync<T>(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<T>(phase: string, operation: () => Promise<T>): Promise<T> {
const operationStartedAt = clock()
try {
return await operation()
} finally {
recordPhase(phase, operationStartedAt)
}
},
timeSync<T>(phase: string, operation: () => T): T {
const operationStartedAt = clock()
try {
return operation()
} finally {
recordPhase(phase, operationStartedAt)
}
},
finish() {
return {
totalDurationMs: clampDuration(clock() - startedAt),
phases: [...phases]
}
}
}
}

View File

@ -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,

View File

@ -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<AppState>)
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'))

View File

@ -1064,7 +1064,8 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
...(manualOrder !== undefined ? { manualOrder } : {}),
...(workspaceStatus !== undefined ? { workspaceStatus } : {}),
...(linkedGitLabMR !== undefined ? { linkedGitLabMR } : {}),
...(linkedGitLabIssue !== undefined ? { linkedGitLabIssue } : {})
...(linkedGitLabIssue !== undefined ? { linkedGitLabIssue } : {}),
...(startup ? { startup } : {})
}
const target = getActiveRuntimeTarget(get().settings)
const result =

View File

@ -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<string, string>
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 = {