diff --git a/src/renderer/src/lib/worktree-creation-flow-execute.ts b/src/renderer/src/lib/worktree-creation-flow-execute.ts index 839f454eef5..48d2954c841 100644 --- a/src/renderer/src/lib/worktree-creation-flow-execute.ts +++ b/src/renderer/src/lib/worktree-creation-flow-execute.ts @@ -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 diff --git a/src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts b/src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts new file mode 100644 index 00000000000..a30e0c455d6 --- /dev/null +++ b/src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts @@ -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, + beginPendingWorktreeCreation: vi.fn((entry: PendingWorktreeCreation) => { + store.pendingWorktreeCreations[entry.creationId] = entry + store.activePendingCreationId = entry.creationId + }), + updatePendingWorktreeCreation: vi.fn( + (creationId: string, patch: Partial) => { + 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, + 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 { + 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' }) + }) +}) diff --git a/src/renderer/src/lib/worktree-creation-flow.test.ts b/src/renderer/src/lib/worktree-creation-flow.test.ts index a599a265513..609dbb81178 100644 --- a/src/renderer/src/lib/worktree-creation-flow.test.ts +++ b/src/renderer/src/lib/worktree-creation-flow.test.ts @@ -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 { - await Promise.resolve() - await Promise.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) } describe('runBackgroundWorktreeCreation', () => { diff --git a/src/renderer/src/lib/worktree-creation-flow.ts b/src/renderer/src/lib/worktree-creation-flow.ts index dfde4e7fe58..7a46e708417 100644 --- a/src/renderer/src/lib/worktree-creation-flow.ts +++ b/src/renderer/src/lib/worktree-creation-flow.ts @@ -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) } diff --git a/src/renderer/src/lib/worktree-creation-post-create-steps.ts b/src/renderer/src/lib/worktree-creation-post-create-steps.ts new file mode 100644 index 00000000000..9e37400ccb1 --- /dev/null +++ b/src/renderer/src/lib/worktree-creation-post-create-steps.ts @@ -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 { + 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 { + 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 } + } +}