fix(workspaces): settle a worktree create by outcome so a post-create throw still shows the workspace

A freshly created workspace could open with no tabs: executeWorktreeCreation's
try/catch ends when createWorktree resolves, so a throw in the tail (activation,
terminal seeding, after-wake seed, structured launch) escaped past the only code
that removes the pending entry. All three callers invoked it as a bare void, so
the rejection was unobserved, shouldShowWorktreeCreationSurface stayed true, and
the creation panel covered a workbench that was mounted and fine behind it.

The semantic boundary is createWorktree resolving. Before it, a failure is a
create failure: the three call sites now share startWorktreeCreation, whose
.catch() stamps status 'error' and toasts. After it, the worktree exists on disk,
so the tail reports a settlement value instead of relying on nothing throwing.
runWorktreePostCreateSteps returns 'complete' | 'cancelled' | 'awaiting-visibility'
under one guard and never throws; executeWorktreeCreation owns settlement, and
the two non-completing outcomes keep behaving exactly as before.
This commit is contained in:
Merge Sim
2026-09-11 18:14:23 -07:00
parent 6252f8149b
commit 2e6da4fe7b
5 changed files with 572 additions and 82 deletions
@@ -1,8 +1,6 @@
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { preflightAgentTrust } from '@/lib/agent-trust-preflight'
import { activateAndRevealWorktree, type ActivateAndRevealResult } from '@/lib/worktree-activation'
import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-initial-terminal-seeding'
import {
attachEphemeralVmRuntimeToWorkspace,
cleanupEphemeralVmRuntimeForFailedCreate,
@@ -13,16 +11,14 @@ import {
formatWorkspaceCreateError,
getWorkspaceCreateErrorToastMessage
} from '@/lib/workspace-create-error-format'
import { isAgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle'
import type { CreateWorktreeResult } from '../../../shared/worktree/create-types'
import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation'
import { createBrowserUuid } from '@/lib/browser-uuid'
import { resolveBackendDraftStartup } from '@/lib/worktree-draft-startup-view-mode'
import { buildWorktreeCreationStartupOpt } from '@/lib/worktree-creation-flow-startup'
import { launchStructuredWorktreeSession } from '@/lib/worktree-creation-structured-session'
import { runWorktreePostCreateSteps } from '@/lib/worktree-creation-post-create-steps'
import { completeWorktreeCreation } from '@/lib/worktree-creation-completion'
import { markStructuredWorktreeLaunchUnconfirmed } from '@/lib/worktree-creation-structured-recovery'
import { ensureWebRuntimeWorktreeTerminalAfterWake } from '@/lib/web-runtime-worktree-terminal-after-wake'
// Why: activePendingCreationId can outlive the terminal route when the user
// switches app views; only the terminal route renders the creation panel.
@@ -164,84 +160,40 @@ export async function executeWorktreeCreation(
(completionState.activeView === 'terminal' &&
completionState.activePendingCreationId === null))
let activation: ActivateAndRevealResult | false = false
let primaryTabId: string | null
if (shouldActivateOnCompletion && !structuredLaunch) {
activation = activateAndRevealWorktree(worktree.id, {
sidebarRevealBehavior: 'auto',
...(preparedRequest.agent !== null ? { agent: preparedRequest.agent } : {}),
...(result.setup ? { setup: result.setup } : {}),
...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}),
...(startupOpt ? { startup: startupOpt } : {}),
...(preparedRequest.issueCommand ? { issueCommand: preparedRequest.issueCommand } : {}),
...(backendSpawned ? { backendStartupTerminalSpawned: true } : {})
})
primaryTabId = activation === false ? null : activation.primaryTabId
} else {
// Keep chat creation on its pending surface until the session is ready.
const hasExplicitTerminalWork = Boolean(
startupOpt || result.setup || preparedRequest.issueCommand || result.defaultTabs
)
primaryTabId =
preparedRequest.agent !== null && !hasExplicitTerminalWork
? null
: ensureWorktreeHasInitialTerminal(
useAppStore.getState(),
worktree.id,
startupOpt,
result.setup,
preparedRequest.issueCommand,
result.defaultTabs,
{
activateCreatedTabs: false,
...(preparedRequest.agent !== null ? { callerProvidesSurface: true } : {}),
...(backendSpawned ? { backendStartupTerminalSpawned: true } : {})
}
)
if (!structuredLaunch && !backendSpawned) {
ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id, {
startup: startupOpt,
agent: preparedRequest.agent,
activate: false
})
}
// Why: past this point the worktree exists and its row is in the store, so every
// remaining step is best-effort — settlement is by returned outcome, not by whether a
// step threw, because an escaped throw would leave the creation surface covering the
// finished workspace.
const outcome = await runWorktreePostCreateSteps({
creationId,
request: preparedRequest,
result,
worktreeId: worktree.id,
structuredLaunch,
backendSpawned,
shouldActivateOnCompletion,
startupOpt,
fallbackStartupOpt
})
// The cancel path already removed the pending entry.
if (outcome.kind === 'cancelled') {
return
}
let structuredLaunchAccepted = structuredLaunch
const { agentLaunchRoute } = preparedRequest
if (
agentLaunchRoute === 'structured-native-chat' &&
isAgentSessionHandleProvider(preparedRequest.agent)
) {
const structuredSession = await launchStructuredWorktreeSession({
creationId,
request: preparedRequest,
agentLaunchRoute,
worktreeId: worktree.id,
shouldActivateOnCompletion,
fallbackStartupOpt,
activation,
primaryTabId
})
structuredLaunchAccepted = structuredSession.accepted
activation = structuredSession.activation
primaryTabId = structuredSession.primaryTabId
if (structuredSession.cancelled) {
return
}
if (structuredSession.visibilityUnknown) {
markStructuredWorktreeLaunchUnconfirmed(creationId, worktree.id)
return
}
// Why: this deliberately keeps the entry in an error state so the panel can offer a
// retry; completing here would destroy the only handle on the unconfirmed session.
if (outcome.kind === 'awaiting-visibility') {
markStructuredWorktreeLaunchUnconfirmed(creationId, worktree.id)
return
}
// Narrowed to 'complete': a new outcome variant fails to typecheck here until it is
// given its own settlement above, rather than silently settling nothing.
await completeWorktreeCreation({
creationId,
request: preparedRequest,
worktreeId: worktree.id,
structuredLaunchAccepted,
activation,
primaryTabId,
structuredLaunchAccepted: outcome.structuredLaunchAccepted,
activation: outcome.activation,
primaryTabId: outcome.primaryTabId,
startupTerminalTabId: result.startupTerminal?.tabId,
backendSpawned,
focusOnCompletion: shouldActivateOnCompletion
@@ -0,0 +1,366 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type {
PendingWorktreeCreation,
WorktreeCreationRequest
} from '@/lib/pending-worktree-creation'
import { shouldShowWorktreeCreationSurface } from '@/lib/worktree-creation-surface'
// Guards the settlement contract of executeWorktreeCreation's post-create tail. Callers fire
// and forget, and completeWorktreeCreation is the only thing that removes the pending entry,
// so a throw once createWorktree has resolved must still complete — otherwise the creation
// surface stays mounted over a finished workspace and the user sees no tabs. The tail settles
// on a returned outcome, so the two non-completing outcomes (a cancelled create, an
// unconfirmed structured launch) must survive unchanged: both deliberately keep or drop the
// entry themselves, and a blanket `finally { completeWorktreeCreation() }` would destroy the
// retry affordance the unconfirmed case depends on. Also covers the caller-side .catch()
// backstop for failures BEFORE createWorktree resolves, which really are create failures.
type TestActiveView = 'terminal' | 'tasks'
const store = {
settings: {
activeRuntimeEnvironmentId: null as string | null,
experimentalNativeChat: undefined as boolean | undefined,
openAgentTabsInChatByDefault: undefined as boolean | undefined
},
activeView: 'terminal' as TestActiveView,
activePendingCreationId: 'creation-1' as string | null,
repos: [] as { id: string; connectionId: string | null }[],
pendingWorktreeCreations: {} as Record<string, PendingWorktreeCreation>,
beginPendingWorktreeCreation: vi.fn((entry: PendingWorktreeCreation) => {
store.pendingWorktreeCreations[entry.creationId] = entry
store.activePendingCreationId = entry.creationId
}),
updatePendingWorktreeCreation: vi.fn(
(creationId: string, patch: Partial<PendingWorktreeCreation>) => {
const entry = store.pendingWorktreeCreations[creationId]
if (entry) {
store.pendingWorktreeCreations[creationId] = { ...entry, ...patch }
}
}
),
// Mirrors pending-worktree-creation.ts: drop the entry and the active pointer.
removePendingWorktreeCreation: vi.fn((creationId: string) => {
delete store.pendingWorktreeCreations[creationId]
if (store.activePendingCreationId === creationId) {
store.activePendingCreationId = null
}
}),
setActivePendingWorktreeCreation: vi.fn((creationId: string | null) => {
store.activePendingCreationId = creationId
}),
setActiveView: vi.fn((view: TestActiveView) => {
store.activeView = view
}),
setSidebarOpen: vi.fn(),
updateWorktreeMeta: vi.fn(),
createWorktree: vi.fn(),
tabsByWorktree: {} as Record<string, { id: string; launchAgent?: string }[]>,
unifiedTabsByWorktree: {}
}
vi.mock('@/store', () => ({
useAppStore: {
getState: () => store
}
}))
vi.mock('sonner', () => ({
toast: { error: vi.fn() }
}))
vi.mock('@/lib/worktree-activation', () => ({
activateAndRevealWorktree: vi.fn()
}))
vi.mock('@/lib/worktree-initial-terminal-seeding', () => ({
ensureWorktreeHasInitialTerminal: vi.fn()
}))
vi.mock('@/lib/web-runtime-worktree-terminal-after-wake', () => ({
ensureWebRuntimeWorktreeTerminalAfterWake: vi.fn()
}))
vi.mock('@/lib/worktree-creation-structured-session', () => ({
launchStructuredWorktreeSession: vi.fn()
}))
vi.mock('@/lib/workspace-activation-terminal-focus', () => ({
queueWorkspaceActivationTerminalFocus: vi.fn()
}))
vi.mock('@/lib/new-workspace', () => ({
ensureAgentStartupInTerminal: vi.fn()
}))
vi.mock('@/lib/worktree-creation-agent-seeds', () => ({
seedAgentTabStateAfterWorktreeCreate: vi.fn()
}))
vi.mock('@/lib/ephemeral-vm-workspace-target', () => ({
prepareEphemeralVmWorkspaceTarget: vi.fn()
}))
vi.mock('@/lib/ephemeral-vm-worktree-creation', () => ({
prepareRequestForCreate: vi.fn(
async (_creationId: string, request: WorktreeCreationRequest) => request
),
attachEphemeralVmRuntimeToWorkspace: vi.fn(async () => undefined),
cleanupEphemeralVmRuntimeForFailedCreate: vi.fn(async () => undefined)
}))
import { toast } from 'sonner'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-initial-terminal-seeding'
import { ensureWebRuntimeWorktreeTerminalAfterWake } from '@/lib/web-runtime-worktree-terminal-after-wake'
import { launchStructuredWorktreeSession } from '@/lib/worktree-creation-structured-session'
import { queueWorkspaceActivationTerminalFocus } from '@/lib/workspace-activation-terminal-focus'
import { seedAgentTabStateAfterWorktreeCreate } from '@/lib/worktree-creation-agent-seeds'
import { prepareRequestForCreate } from '@/lib/ephemeral-vm-worktree-creation'
import { executeWorktreeCreation } from './worktree-creation-flow-execute'
import { runBackgroundWorktreeCreation } from './worktree-creation-flow'
function makeRequest(overrides: Partial<WorktreeCreationRequest> = {}): WorktreeCreationRequest {
return {
repoId: 'repo-1',
name: 'feature',
setupDecision: 'inherit',
agent: null,
pendingFirstAgentMessageRename: false,
note: '',
startupPlan: null,
quickPrompt: '',
quickTelemetry: null,
...overrides
} as WorktreeCreationRequest
}
function makeStructuredRequest(): WorktreeCreationRequest {
return makeRequest({ agent: 'codex', agentLaunchRoute: 'structured-native-chat' })
}
function seedPendingCreation(request: WorktreeCreationRequest): void {
store.pendingWorktreeCreations = {
'creation-1': {
creationId: 'creation-1',
phase: 'fetching',
status: 'creating',
startedAt: 1,
indeterminate: false,
loaderVisible: true,
request
}
}
store.activePendingCreationId = 'creation-1'
}
function isCreationSurfaceShown(): boolean {
return shouldShowWorktreeCreationSurface({
activeView: 'terminal',
activePendingCreationId: store.activePendingCreationId,
hasActivePendingCreation:
store.activePendingCreationId !== null &&
store.pendingWorktreeCreations[store.activePendingCreationId] !== undefined
})
}
function expectSettledAndRevealed(): void {
expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('creation-1', {
cleanupVm: false
})
expect(store.pendingWorktreeCreations['creation-1']).toBeUndefined()
expect(store.activePendingCreationId).toBeNull()
// The workbench is mounted behind the creation panel; dropping the entry is what
// lets its tab chrome render.
expect(isCreationSurfaceShown()).toBe(false)
}
beforeEach(() => {
// resetAllMocks: implementations from prior tests (the injected throws) must not leak.
vi.resetAllMocks()
vi.spyOn(console, 'error').mockImplementation(() => undefined)
store.activeView = 'terminal'
store.repos = [{ id: 'repo-1', connectionId: null }]
store.tabsByWorktree = {}
store.pendingWorktreeCreations = {}
store.activePendingCreationId = null
store.createWorktree.mockResolvedValue({
worktree: { id: 'wt-1', repoId: 'repo-1' }
})
vi.mocked(launchStructuredWorktreeSession).mockResolvedValue({
accepted: true,
cancelled: false,
visibilityUnknown: false,
activation: false,
primaryTabId: null
})
})
describe('a throw in the post-create tail still completes the creation', () => {
it('activating branch: a throw in activateAndRevealWorktree completes and reveals the workspace', async () => {
const request = makeRequest()
seedPendingCreation(request)
vi.mocked(activateAndRevealWorktree).mockImplementation(() => {
throw new Error('activation exploded')
})
await executeWorktreeCreation('creation-1', request)
expectSettledAndRevealed()
})
it('background branch: a throw in the initial terminal seed completes and reveals the workspace', async () => {
store.activeView = 'tasks'
const request = makeRequest()
seedPendingCreation(request)
vi.mocked(ensureWorktreeHasInitialTerminal).mockImplementation(() => {
throw new Error('seeding exploded')
})
await executeWorktreeCreation('creation-1', request)
expectSettledAndRevealed()
})
it('background branch: a throw in after-wake seeding completes and reveals the workspace', async () => {
// User left the terminal view mid-create, so the non-activating branch runs.
store.activeView = 'tasks'
const request = makeRequest()
seedPendingCreation(request)
vi.mocked(ensureWorktreeHasInitialTerminal).mockReturnValue('tab-1')
vi.mocked(ensureWebRuntimeWorktreeTerminalAfterWake).mockImplementation(() => {
throw new Error('after-wake exploded')
})
await executeWorktreeCreation('creation-1', request)
expect(ensureWorktreeHasInitialTerminal).toHaveBeenCalled()
expectSettledAndRevealed()
})
it('structured branch: a throw in the structured launch completes and reveals the workspace', async () => {
const request = makeStructuredRequest()
seedPendingCreation(request)
vi.mocked(launchStructuredWorktreeSession).mockRejectedValue(new Error('launch exploded'))
await executeWorktreeCreation('creation-1', request)
expectSettledAndRevealed()
})
// Why: settlement reports what already landed, so a later failure does not discard the tab
// the earlier step seeded — agent startup would otherwise be delivered to no pane.
it('keeps the tab a completed step already seeded when a later step throws', async () => {
store.activeView = 'tasks'
const request = makeRequest()
seedPendingCreation(request)
vi.mocked(ensureWorktreeHasInitialTerminal).mockReturnValue('tab-1')
vi.mocked(ensureWebRuntimeWorktreeTerminalAfterWake).mockImplementation(() => {
throw new Error('after-wake exploded')
})
await executeWorktreeCreation('creation-1', request)
expect(seedAgentTabStateAfterWorktreeCreate).toHaveBeenCalledWith(
expect.objectContaining({ primaryTabId: 'tab-1' })
)
})
it('control: with no throw the same flow completes and reveals the workspace', async () => {
store.activeView = 'tasks'
const request = makeRequest()
seedPendingCreation(request)
vi.mocked(ensureWorktreeHasInitialTerminal).mockReturnValue('tab-1')
await executeWorktreeCreation('creation-1', request)
expectSettledAndRevealed()
})
})
describe('the two non-completing outcomes survive the throw guard', () => {
// A blanket `finally { completeWorktreeCreation() }` would re-seed tabs and steal focus for
// a workspace the user is already tearing down.
it('cancelled: settles nothing, because the cancel path already removed the entry', async () => {
const request = makeStructuredRequest()
seedPendingCreation(request)
vi.mocked(launchStructuredWorktreeSession).mockImplementation(async () => {
store.removePendingWorktreeCreation('creation-1')
return {
accepted: true,
cancelled: true,
visibilityUnknown: false,
activation: false as const,
primaryTabId: null
}
})
await executeWorktreeCreation('creation-1', request)
expect(seedAgentTabStateAfterWorktreeCreate).not.toHaveBeenCalled()
expect(queueWorkspaceActivationTerminalFocus).not.toHaveBeenCalled()
})
// A blanket finally would drop the entry and with it structuredLaunchRecoveryWorktreeId,
// which is the only handle the panel's retry has on the unconfirmed session.
it('awaiting-visibility: keeps the entry on an error surface so retry stays reachable', async () => {
const request = makeStructuredRequest()
seedPendingCreation(request)
vi.mocked(launchStructuredWorktreeSession).mockResolvedValue({
accepted: true,
cancelled: false,
visibilityUnknown: true,
activation: false,
primaryTabId: null
})
await executeWorktreeCreation('creation-1', request)
expect(store.updatePendingWorktreeCreation).toHaveBeenCalledWith('creation-1', {
status: 'error',
error: 'Could not confirm whether Codex chat opened. Retry to check again.',
structuredLaunchRecoveryWorktreeId: 'wt-1'
})
expect(store.removePendingWorktreeCreation).not.toHaveBeenCalled()
// The surface legitimately persists here: it is the retry affordance, not a strand.
expect(isCreationSurfaceShown()).toBe(true)
})
})
describe('a failure before createWorktree resolves is still reported as a create failure', () => {
it('becomes a visible inline error while the panel is showing it', async () => {
// Pre-create preparation runs before the in-function try/catch.
vi.mocked(prepareRequestForCreate).mockRejectedValue(new Error('prepare exploded'))
const creationId = runBackgroundWorktreeCreation(makeRequest())
await vi.waitFor(() => {
expect(store.pendingWorktreeCreations[creationId]).toMatchObject({
status: 'error',
error: 'prepare exploded'
})
})
expect(toast.error).not.toHaveBeenCalled()
expect(store.removePendingWorktreeCreation).not.toHaveBeenCalled()
expect(console.error).toHaveBeenCalledWith(
'worktree create: unhandled failure',
creationId,
expect.any(Error)
)
})
it('is announced with a toast once the user has left the panel', async () => {
store.activeView = 'tasks'
vi.mocked(prepareRequestForCreate).mockRejectedValue(new Error('prepare exploded'))
const creationId = runBackgroundWorktreeCreation(makeRequest())
// The pending surface is revealed synchronously; move away before the rejected
// preparation reaches the fire-and-forget backstop.
store.activeView = 'tasks'
await vi.waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('prepare exploded')
})
expect(store.pendingWorktreeCreations[creationId]).toMatchObject({ status: 'error' })
})
})
@@ -137,9 +137,12 @@ function makePendingCreation(request: WorktreeCreationRequest): PendingWorktreeC
}
}
// Why a macrotask, not a counted pair of microtasks: the await count in
// executeWorktreeCreation grows over time (VM preflight, post-create settlement), and a fixed
// count silently starves the assertions that follow this helper. A timer boundary drains
// whatever depth the flow has.
async function flushAsyncWorktreeCreation(): Promise<void> {
await Promise.resolve()
await Promise.resolve()
await new Promise((resolve) => setTimeout(resolve, 0))
}
describe('runBackgroundWorktreeCreation', () => {
+27 -3
View File
@@ -1,3 +1,4 @@
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import {
findPendingLinkedWorkItemCreationId,
@@ -11,11 +12,34 @@ import {
getWorktreeCreationIndeterminate
} from '@/lib/worktree-creation-flow-startup'
import { retryStructuredWorktreeLaunch } from '@/lib/worktree-creation-structured-recovery'
import {
formatWorkspaceCreateError,
getWorkspaceCreateErrorToastMessage
} from '@/lib/workspace-create-error-format'
type ContinueBackgroundWorktreeCreationOptions = {
revealCreationSurface?: boolean
}
// Why: nothing awaits these creations, so an escaped rejection would otherwise
// strand the pending entry — and the creation surface — with no error shown.
function startWorktreeCreation(creationId: string, request: WorktreeCreationRequest): void {
executeWorktreeCreation(creationId, request).catch((error: unknown) => {
console.error('worktree create: unhandled failure', creationId, error)
const store = useAppStore.getState()
if (!store.pendingWorktreeCreations[creationId]) {
return
}
const message = getWorkspaceCreateErrorToastMessage(formatWorkspaceCreateError(error))
store.updatePendingWorktreeCreation(creationId, { status: 'error', error: message })
// Why: the panel renders this error inline while its surface is visible;
// only announce it separately after the user has navigated away.
if (!(store.activeView === 'terminal' && store.activePendingCreationId === creationId)) {
toast.error(message)
}
})
}
function revealPendingCreation(
creationId: string,
request: WorktreeCreationRequest,
@@ -62,7 +86,7 @@ export function runBackgroundWorktreeCreation(request: WorktreeCreationRequest):
// client over plain HTTP). createBrowserUuid falls back to getRandomValues.
const creationId = createBrowserUuid()
revealPendingCreation(creationId, request, getInitialWorktreeCreationPhase(request))
void executeWorktreeCreation(creationId, request)
startWorktreeCreation(creationId, request)
return creationId
}
@@ -101,7 +125,7 @@ export function continueBackgroundWorktreeCreation(
store.setActiveView('terminal')
store.setSidebarOpen(true)
}
void executeWorktreeCreation(creationId, request)
startWorktreeCreation(creationId, request)
return true
}
@@ -133,5 +157,5 @@ export function retryBackgroundWorktreeCreation(creationId: string): void {
)
return
}
void executeWorktreeCreation(creationId, entry.request)
startWorktreeCreation(creationId, entry.request)
}
@@ -0,0 +1,145 @@
import { useAppStore } from '@/store'
import { activateAndRevealWorktree, type ActivateAndRevealResult } from '@/lib/worktree-activation'
import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-initial-terminal-seeding'
import { ensureWebRuntimeWorktreeTerminalAfterWake } from '@/lib/web-runtime-worktree-terminal-after-wake'
import { launchStructuredWorktreeSession } from '@/lib/worktree-creation-structured-session'
import { isAgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle'
import type { CreateWorktreeResult } from '../../../shared/worktree/create-types'
import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation'
import type { WorktreeStartupPayload } from '@/lib/worktree-startup-payload'
/** How the created workspace settled. Every variant names one settlement the caller owes. */
export type WorktreePostCreateOutcome =
| {
kind: 'complete'
activation: ActivateAndRevealResult | false
primaryTabId: string | null
structuredLaunchAccepted: boolean
}
/** Abandoned mid-flight: the cancel path already removed the pending entry. */
| { kind: 'cancelled' }
/** Structured chat may or may not have opened; the entry must stay for the retry affordance. */
| { kind: 'awaiting-visibility' }
export type WorktreePostCreateStepsArgs = {
creationId: string
request: WorktreeCreationRequest
result: CreateWorktreeResult
worktreeId: string
structuredLaunch: boolean
backendSpawned: boolean
shouldActivateOnCompletion: boolean
startupOpt: WorktreeStartupPayload | undefined
fallbackStartupOpt: WorktreeStartupPayload | undefined
}
/** Carries what already landed out of a step that throws, so settlement keeps it. */
type PostCreateProgress = {
activation: ActivateAndRevealResult | false
primaryTabId: string | null
structuredLaunchAccepted: boolean
}
function openCreatedWorkspaceSurface(
args: WorktreePostCreateStepsArgs,
progress: PostCreateProgress
): void {
const { request, result } = args
if (args.shouldActivateOnCompletion && !args.structuredLaunch) {
const activation = activateAndRevealWorktree(args.worktreeId, {
sidebarRevealBehavior: 'auto',
...(request.agent !== null ? { agent: request.agent } : {}),
...(result.setup ? { setup: result.setup } : {}),
...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}),
...(args.startupOpt ? { startup: args.startupOpt } : {}),
...(request.issueCommand ? { issueCommand: request.issueCommand } : {}),
...(args.backendSpawned ? { backendStartupTerminalSpawned: true } : {})
})
progress.activation = activation
progress.primaryTabId = activation === false ? null : activation.primaryTabId
return
}
// Keep chat creation on its pending surface until the session is ready.
const hasExplicitTerminalWork = Boolean(
args.startupOpt || result.setup || request.issueCommand || result.defaultTabs
)
progress.primaryTabId =
request.agent !== null && !hasExplicitTerminalWork
? null
: ensureWorktreeHasInitialTerminal(
useAppStore.getState(),
args.worktreeId,
args.startupOpt,
result.setup,
request.issueCommand,
result.defaultTabs,
{
activateCreatedTabs: false,
...(request.agent !== null ? { callerProvidesSurface: true } : {}),
...(args.backendSpawned ? { backendStartupTerminalSpawned: true } : {})
}
)
if (!args.structuredLaunch && !args.backendSpawned) {
ensureWebRuntimeWorktreeTerminalAfterWake(args.worktreeId, {
startup: args.startupOpt,
agent: request.agent,
activate: false
})
}
}
async function settleCreatedWorkspace(
args: WorktreePostCreateStepsArgs,
progress: PostCreateProgress
): Promise<WorktreePostCreateOutcome> {
openCreatedWorkspaceSurface(args, progress)
const { agentLaunchRoute } = args.request
if (
agentLaunchRoute !== 'structured-native-chat' ||
!isAgentSessionHandleProvider(args.request.agent)
) {
return { kind: 'complete', ...progress }
}
const structuredSession = await launchStructuredWorktreeSession({
creationId: args.creationId,
request: args.request,
agentLaunchRoute,
worktreeId: args.worktreeId,
shouldActivateOnCompletion: args.shouldActivateOnCompletion,
fallbackStartupOpt: args.fallbackStartupOpt,
activation: progress.activation,
primaryTabId: progress.primaryTabId
})
progress.structuredLaunchAccepted = structuredSession.accepted
progress.activation = structuredSession.activation
progress.primaryTabId = structuredSession.primaryTabId
if (structuredSession.cancelled) {
return { kind: 'cancelled' }
}
if (structuredSession.visibilityUnknown) {
return { kind: 'awaiting-visibility' }
}
return { kind: 'complete', ...progress }
}
/**
* Runs every step that follows a resolved `createWorktree` and reports how the workspace
* settled. Never throws: the worktree already exists on disk and its row is already in the
* store, so a failed follow-up step still has to settle the creation and show the workspace
* rather than strand the creation surface over it. Steps added here inherit that guarantee.
*/
export async function runWorktreePostCreateSteps(
args: WorktreePostCreateStepsArgs
): Promise<WorktreePostCreateOutcome> {
const progress: PostCreateProgress = {
activation: false,
primaryTabId: null,
structuredLaunchAccepted: args.structuredLaunch
}
try {
return await settleCreatedWorkspace(args, progress)
} catch (error) {
console.error('worktree create: post-create step failed', args.worktreeId, error)
return { kind: 'complete', ...progress }
}
}