From 6c9c7b0fdbdce1237d6139b09ff5546ba4d851bf Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 25 Oct 2026 08:43:15 -0700 Subject: [PATCH] fix(worktree): start the agent when a workspace is opened during creation (#33562) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a workspace while its local creation is still pending no longer seeds an idle terminal that steals focus from (or drops) the agent launch: the creating event carries the new workspace id, activation during creation shows the existing "Creating worktree…" panel, and the workspace takes over when creation finishes. A terminal the user explicitly opens is kept and the agent gets its own tab. Fixes #25807 Supersedes #25812 Co-authored-by: Eotel <49769982+Eotel@users.noreply.github.com> --- src/main/ipc/worktree-remote.ts | 20 +++-- .../ipc/worktrees-local-create-flow.test.ts | 17 ++-- .../initial-terminal-admission-wait.test.tsx | 4 +- ...nitial-terminal-structured-launch.test.tsx | 60 ++++++++++--- .../use-terminal-watcher-effects.ts | 13 ++- .../ipc-events/project-catalog-ipc-bridge.ts | 3 +- .../src/lib/pending-worktree-creation.test.ts | 30 +++++++ .../src/lib/pending-worktree-creation.ts | 20 +++++ .../worktree-activation-setup-script.test.ts | 39 ++++++++ .../lib/worktree-activation-store-contract.ts | 4 + .../worktree-activation-surface-selection.ts | 1 + src/renderer/src/lib/worktree-activation.ts | 10 +++ .../src/lib/worktree-creation-flow-execute.ts | 10 +-- ...worktree-creation-flow-ready-toast.test.ts | 2 + ...ree-creation-flow-stranded-surface.test.ts | 63 ++++++++++++- .../src/lib/worktree-creation-flow.test.ts | 6 +- .../src/lib/worktree-creation-flow.ts | 3 +- .../worktree-creation-host-agent-launch.ts | 30 ++++++- .../worktree-creation-initial-surface.test.ts | 88 +++++++++++++++++++ .../worktree-creation-structured-session.ts | 3 +- .../lib/worktree-initial-terminal-seeding.ts | 13 +++ .../src/store/slices/worktree-helpers.ts | 1 + src/shared/worktree/create-types.ts | 2 + 23 files changed, 403 insertions(+), 39 deletions(-) create mode 100644 src/renderer/src/lib/worktree-creation-initial-surface.test.ts diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index f4e88e45708..45ff482f5e3 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -76,7 +76,7 @@ import { computeValidatedBranchName } from './worktree-branch-name' import { mergeWorktree } from './worktree-metadata-merge' import { resolveCreatedWorktree } from './created-worktree-reconciliation' import { notifyLineageRepos } from '../worktree-lineage-change-notification' -import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' +import { composeWorktreeId, getRepoIdFromWorktreeId } from '../../shared/worktree/id' import { getConnectionExecutionHostId, getRepoExecutionHostId, @@ -924,14 +924,10 @@ export function emitCreateWorktreeProgress( mainWindow: BrowserWindow, phase: 'fetching' | 'creating', creationId?: string, - checkout?: WorktreeCheckoutProgress | null + detail?: Pick ): void { if (!mainWindow.isDestroyed()) { - const event: CreateWorktreeProgressEvent = { - creationId, - phase, - ...(checkout !== undefined ? { checkout } : {}) - } + const event: CreateWorktreeProgressEvent = { creationId, phase, ...detail } mainWindow.webContents.send('createWorktree:progress', event) } } @@ -997,7 +993,7 @@ async function performLocalWorktreeCreate( ...(creationId ? { onCheckoutProgress: (checkout: WorktreeCheckoutProgress | null) => - emitCreateWorktreeProgress(mainWindow, 'creating', creationId, checkout) + emitCreateWorktreeProgress(mainWindow, 'creating', creationId, { checkout }) } : {}) }) @@ -1345,7 +1341,10 @@ async function performLocalWorktreeCreate( await legacyFetchPromise }) } - emitCreateWorktreeProgress(mainWindow, 'creating', args.creationId) + // Why: the catalog watcher can publish the checkout mid-create; the renderer must not seed it a shell yet. + emitCreateWorktreeProgress(mainWindow, 'creating', args.creationId, { + worktreeId: composeWorktreeId(repo.id, worktreePath) + }) // Defer fork remote creation/fetch until first synchronization; read-only review creation needs neither. const preparedPushTarget: GitPushTarget | undefined = args.pushTarget @@ -1497,6 +1496,9 @@ async function performLocalWorktreeCreate( } const worktreeId = `${repo.id}::${created.path}` + if (created.path !== worktreePath) { + emitCreateWorktreeProgress(mainWindow, 'creating', args.creationId, { worktreeId }) + } const now = Date.now() // Review head SHA/ref seeds creation; Source Control comparison uses the review target branch. const metadataBaseRef = args.compareBaseRef ?? remoteTrackingBase?.ref ?? baseBranch diff --git a/src/main/ipc/worktrees-local-create-flow.test.ts b/src/main/ipc/worktrees-local-create-flow.test.ts index e97dcbbe44c..15cb18ab13d 100644 --- a/src/main/ipc/worktrees-local-create-flow.test.ts +++ b/src/main/ipc/worktrees-local-create-flow.test.ts @@ -1,6 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { resolve } from 'node:path' -import type { CreateWorktreeResult } from '../../shared/worktree/create-types' import { resolveRegisteredWorktreePath } from './registered-worktree-roots-cache' import { computeWorkspaceRootAsync } from './worktree-logic' import type * as WorktreeLogic from './worktree-logic' @@ -345,10 +344,18 @@ describe('registerWorktreeHandlers', () => { } ]) - const result = (await handlers['worktrees:create'](null, { + addWorktreeMock.mockImplementationOnce(async () => { + expect(mainWindow.webContents.send).toHaveBeenCalledWith('createWorktree:progress', { + phase: 'creating', + creationId: 'creation-1', + worktreeId: 'repo-1::/workspace/improve-dashboard-2' + }) + }) + const result = await handlers['worktrees:create'](null, { repoId: 'repo-1', - name: 'improve-dashboard' - })) as CreateWorktreeResult + name: 'improve-dashboard', + creationId: 'creation-1' + }) expect(addWorktreeMock).toHaveBeenCalledWith( '/workspace/repo', @@ -357,7 +364,7 @@ describe('registerWorktreeHandlers', () => { 'origin/main', false, false, - {} + { onCheckoutProgress: expect.any(Function) } ) expect(result).toMatchObject({ worktree: expect.objectContaining({ diff --git a/src/renderer/src/components/terminal/initial-terminal-admission-wait.test.tsx b/src/renderer/src/components/terminal/initial-terminal-admission-wait.test.tsx index 87d0989e745..25bb2e9480d 100644 --- a/src/renderer/src/components/terminal/initial-terminal-admission-wait.test.tsx +++ b/src/renderer/src/components/terminal/initial-terminal-admission-wait.test.tsx @@ -10,12 +10,14 @@ const mocks = vi.hoisted(() => { createTab: vi.fn(), createUnifiedTab: vi.fn(), begin: vi.fn(), + select: (selector: (state: { pendingWorktreeCreations: Record }) => unknown) => + selector({ pendingWorktreeCreations: {} }), answers } }) vi.mock('@/store', () => ({ - useAppStore: Object.assign(() => 'none', { + useAppStore: Object.assign(mocks.select, { getState: () => ({ activeWorktreeId: 'wt-1', tabsByWorktree: {}, diff --git a/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx b/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx index 08d3738aa8b..7b2d99f1929 100644 --- a/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx +++ b/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx @@ -3,6 +3,7 @@ import { act, StrictMode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' import { useTerminalWatcherEffects } from '../use-terminal-watcher-effects' +import type { PendingWorktreeCreation } from '@/lib/pending-worktree-creation' import { claimEmptyWorkspaceDefaultSurface, releaseEmptyWorkspaceDefaultSurface @@ -11,6 +12,7 @@ import { const mocks = vi.hoisted(() => { const storeTabsByWorktree: Record = {} const storeClosedRecords: Record = {} + const storePendingCreations: Record = {} return { gate: vi.fn(), resume: vi.fn(), @@ -18,18 +20,24 @@ const mocks = vi.hoisted(() => { launchStatus: vi.fn((_worktreeId: string, _provider: string): string => 'idle'), createTab: vi.fn(), storeTabsByWorktree, - storeClosedRecords + storeClosedRecords, + storePendingCreations } }) -vi.mock('@/store', () => ({ - useAppStore: Object.assign(() => mocks.authority, { - getState: () => ({ - activeWorktreeId: 'wt-1', - tabsByWorktree: mocks.storeTabsByWorktree, - closedTerminalTabTombstonesByTabId: mocks.storeClosedRecords - }) +vi.mock('@/store', () => { + const getState = () => ({ + activeWorktreeId: 'wt-1', + tabsByWorktree: mocks.storeTabsByWorktree, + closedTerminalTabTombstonesByTabId: mocks.storeClosedRecords, + pendingWorktreeCreations: mocks.storePendingCreations }) -})) + return { + useAppStore: Object.assign( + (selector: (state: ReturnType) => unknown) => selector(getState()), + { getState } + ) + } +}) vi.mock('@/lib/worktree-agent-activation-gate', () => ({ gateWorktreeAgentActivation: mocks.gate })) @@ -41,7 +49,7 @@ vi.mock('@/lib/resume-sleeping-agent-session', () => ({ resumeSleepingAgentSessionsForWorktree: mocks.resume })) vi.mock('@/lib/workspace-terminal-host-authority', () => ({ - createWorkspaceTerminalHostAuthoritySelector: () => () => 'none' + createWorkspaceTerminalHostAuthoritySelector: () => () => mocks.authority })) vi.mock('../terminal-pane/terminal-parked-tab-watchers', () => ({ pruneParkedTerminalWatchers: vi.fn(), @@ -60,6 +68,7 @@ afterEach(async () => { mocks.authority = 'none' mocks.storeTabsByWorktree = {} mocks.storeClosedRecords = {} + mocks.storePendingCreations = {} }) function Watcher({ restored = true, hydrated = false, worktreeId = 'wt-1' } = {}): null { @@ -101,6 +110,37 @@ function Watcher({ restored = true, hydrated = false, worktreeId = 'wt-1' } = {} } describe('passive terminal seeding during native chat creation', () => { + it('waits for worktree creation before checking whether a fallback shell is needed', async () => { + mocks.storePendingCreations['creation-1'] = { + creationId: 'creation-1', + worktreeId: 'wt-1', + phase: 'creating', + status: 'creating', + startedAt: 1, + indeterminate: false, + loaderVisible: true, + request: { + repoId: 'repo-1', + name: 'workspace', + setupDecision: 'inherit', + agent: 'codex', + pendingFirstAgentMessageRename: false, + note: '', + startupPlan: null, + quickPrompt: '', + quickTelemetry: null + } + } + mocks.gate.mockResolvedValue('empty') + root = createRoot(document.createElement('div')) + await act(async () => root?.render()) + expect(mocks.gate).not.toHaveBeenCalled() + expect(mocks.createTab).not.toHaveBeenCalled() + + mocks.storePendingCreations = {} + await act(async () => root?.render()) + expect(mocks.createTab).toHaveBeenCalledOnce() + }) it.each([ ['claude', 'pending', 0], ['codex', 'pending', 0], diff --git a/src/renderer/src/components/use-terminal-watcher-effects.ts b/src/renderer/src/components/use-terminal-watcher-effects.ts index c7b8c76b818..5967c3ff532 100644 --- a/src/renderer/src/components/use-terminal-watcher-effects.ts +++ b/src/renderer/src/components/use-terminal-watcher-effects.ts @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef } from 'react' import { findActivityTerminalPortal } from './activity/activity-terminal-portal' import { shouldAutoCreateInitialTerminal } from './terminal/initial-terminal' +import { findPendingWorktreeCreationId } from '@/lib/pending-worktree-creation' import { canWatcherCoverParkedTerminalTab, disposeAllParkedTerminalWatchers, @@ -202,9 +203,18 @@ export function useTerminalWatcherEffects(controller: TerminalWatcherController) [activeWorktreeId] ) const activeWorktreeHostAuthority = useAppStore(hostAuthoritySelector) + const activeWorktreeCreationPending = useAppStore( + (state) => + findPendingWorktreeCreationId(state.pendingWorktreeCreations, activeWorktreeId) !== null + ) useEffect(() => { - if (!workspaceSessionReady || !terminalStartupRestorationReady || !activeWorktreeId) { + if ( + !workspaceSessionReady || + !terminalStartupRestorationReady || + !activeWorktreeId || + activeWorktreeCreationPending + ) { return } // An unanswered owner is not an empty host; seeding before its census duplicates tabs (STA-4658). @@ -262,6 +272,7 @@ export function useTerminalWatcherEffects(controller: TerminalWatcherController) }, [ activeWorktreeId, activeWorktreeHostAuthority, + activeWorktreeCreationPending, createTab, reconcileWorktreeTabModel, terminalStartupRestorationReady, diff --git a/src/renderer/src/hooks/ipc-events/project-catalog-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/project-catalog-ipc-bridge.ts index c4caa46bab0..a9caf6ade1c 100644 --- a/src/renderer/src/hooks/ipc-events/project-catalog-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/project-catalog-ipc-bridge.ts @@ -89,7 +89,8 @@ export function registerProjectCatalogIpcBridge( // Why: every event without a live meter (a phase change, or null when git finished) clears it. useAppStore.getState().updatePendingWorktreeCreation(data.creationId, { phase: data.phase, - checkoutProgress: data.checkout ?? undefined + checkoutProgress: data.checkout ?? undefined, + ...(data.worktreeId ? { worktreeId: data.worktreeId } : {}) }) }) ?? (() => {}) ) diff --git a/src/renderer/src/lib/pending-worktree-creation.test.ts b/src/renderer/src/lib/pending-worktree-creation.test.ts index b015e3ce96c..0ccd0253779 100644 --- a/src/renderer/src/lib/pending-worktree-creation.test.ts +++ b/src/renderer/src/lib/pending-worktree-creation.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { findPendingLinkedWorkItemCreationId, + findPendingWorktreeCreationId, type PendingWorktreeCreation, type WorktreeCreationRequest } from './pending-worktree-creation' @@ -35,6 +36,35 @@ function pending( } } +describe('findPendingWorktreeCreationId', () => { + it('matches the resolved checkout rather than a requested name', () => { + const entry = { ...pending('create-1', request()), worktreeId: 'repo-1::/workspace-2' } + expect(findPendingWorktreeCreationId({ entry }, entry.worktreeId)).toBe('create-1') + expect(findPendingWorktreeCreationId({ entry }, 'repo-1::/workspace')).toBeNull() + expect(findPendingWorktreeCreationId(undefined, entry.worktreeId)).toBeNull() + }) + + it('allows a failed checkout to open normally', () => { + const entry: PendingWorktreeCreation = { + ...pending('create-1', request()), + worktreeId: 'repo-1::/workspace', + status: 'error' + } + expect(findPendingWorktreeCreationId({ entry }, entry.worktreeId ?? null)).toBeNull() + }) + + it('matches Windows path spelling while keeping repositories distinct', () => { + const entry = { + ...pending('create-1', request()), + worktreeId: 'repo-1::C:\\workspaces\\feature' + } + expect(findPendingWorktreeCreationId({ entry }, 'repo-1::c:/workspaces/feature/')).toBe( + 'create-1' + ) + expect(findPendingWorktreeCreationId({ entry }, 'repo-2::c:/workspaces/feature/')).toBeNull() + }) +}) + describe('findPendingLinkedWorkItemCreationId', () => { it('deduplicates the same linked item on the same execution host', () => { const existing = request({ diff --git a/src/renderer/src/lib/pending-worktree-creation.ts b/src/renderer/src/lib/pending-worktree-creation.ts index 65f017b01ed..a01a87f7cb7 100644 --- a/src/renderer/src/lib/pending-worktree-creation.ts +++ b/src/renderer/src/lib/pending-worktree-creation.ts @@ -1,4 +1,5 @@ import type { ExecutionHostId } from '../../../shared/execution-host' +import { worktreeIdsEqual } from '../../../shared/worktree/id' import type { FolderWorkspaceLinkedTask } from '../../../shared/folder-workspace-types' import type { TuiAgent } from '../../../shared/tui-agent' import type { WorkspaceSource as WorkspaceCreateTelemetrySource } from '../../../shared/workspace-source' @@ -117,6 +118,8 @@ export type WorktreeCreationRequest = { /** Session-only creation status drives sidebar/panel and vanishes on reload; completed worktrees reconcile normally. request owns display fields and retry intent so they cannot drift. */ export type PendingWorktreeCreation = { creationId: string + /** Resolved before checkout publication so passive activation can defer its initial surface. */ + worktreeId?: string phase: WorktreeCreationPhase status: 'creating' | 'error' startedAt: number @@ -131,6 +134,23 @@ export type PendingWorktreeCreation = { request: WorktreeCreationRequest } +export function findPendingWorktreeCreationId( + pendingCreations: Readonly> | undefined, + worktreeId: string | null +): string | null { + if (!pendingCreations || !worktreeId) { + return null + } + return ( + Object.values(pendingCreations).find( + (entry) => + entry.status === 'creating' && + entry.worktreeId !== undefined && + worktreeIdsEqual(entry.worktreeId, worktreeId) + )?.creationId ?? null + ) +} + export function findPendingLinkedWorkItemCreationId( pendingCreations: Readonly>, request: Pick< diff --git a/src/renderer/src/lib/worktree-activation-setup-script.test.ts b/src/renderer/src/lib/worktree-activation-setup-script.test.ts index a8bfced0a35..7ed04bccb61 100644 --- a/src/renderer/src/lib/worktree-activation-setup-script.test.ts +++ b/src/renderer/src/lib/worktree-activation-setup-script.test.ts @@ -146,6 +146,45 @@ describe('ensureWorktreeHasInitialTerminal', () => { }) }) + it.each([true, false])( + 'keeps a blank shell and queues setup-gated Codex in a new terminal (activate=%s)', + (activateCreatedTabs) => { + let createdIndex = 1 + const store = createMockStore({ + tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] }, + createTab: vi.fn(() => ({ id: `tab-${++createdIndex}` })), + reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 1 })) + }) + + const result = ensureWorktreeHasInitialTerminal( + store, + 'wt-1', + { command: 'codex', launchAgent: 'codex' }, + { runnerScriptPath: '/tmp/setup.sh', envVars: {}, waitForAgentStartup: true }, + undefined, + undefined, + { createNewTerminalForStartup: true, activateCreatedTabs } + ) + + expect(result).toBe('tab-2') + const startups = createdTabStartups(store) + expect(Object.keys(startups)).toEqual(['tab-2', 'tab-3']) + expect(startups['tab-2']).toEqual( + expect.objectContaining({ + env: expect.objectContaining({ + [SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV]: expect.stringContaining('exec codex') + }) + }) + ) + expect(startups['tab-3']).toEqual( + expect.objectContaining({ command: expect.stringContaining('bash /tmp/setup.sh') }) + ) + if (!activateCreatedTabs) { + expect(store.setActiveTab).not.toHaveBeenCalled() + } + } + ) + it('gates startup behind setup completion when both are provided in new-tab mode', () => { setLegacySetupScriptLaunchMode('new-tab') let createdIndex = 0 diff --git a/src/renderer/src/lib/worktree-activation-store-contract.ts b/src/renderer/src/lib/worktree-activation-store-contract.ts index 576fb75b067..6760729b3ae 100644 --- a/src/renderer/src/lib/worktree-activation-store-contract.ts +++ b/src/renderer/src/lib/worktree-activation-store-contract.ts @@ -9,9 +9,11 @@ import type { import type { WorktreeRuntimeOwnerState } from '@/lib/worktree-runtime-owner-state' import type { AgentStartedTelemetry } from '@/lib/worktree-startup-payload' import type { ClosedTerminalTabTombstonesByTabId } from '../../../shared/closed-terminal-tab-tombstones' +import type { PendingWorktreeCreation } from './pending-worktree-creation' export type WorktreeActivationStore = Partial & { tabsByWorktree: Record + pendingWorktreeCreations?: Record closedTerminalTabTombstonesByTabId?: ClosedTerminalTabTombstonesByTabId defaultTerminalTabsAppliedByWorktreeId: Record createTab: ( @@ -56,6 +58,8 @@ export type WorktreeActivationStore = Partial & { } export type InitialTerminalOptions = { + /** Only the creation owner may seed a plain surface before its pending entry is cleared. */ + worktreeCreationId?: string activateCreatedTabs?: boolean backendStartupTerminalSpawned?: boolean /** Create a preserved fallback startup beside setup/default terminals. */ diff --git a/src/renderer/src/lib/worktree-activation-surface-selection.ts b/src/renderer/src/lib/worktree-activation-surface-selection.ts index 566ad5c3e69..cb7f196235d 100644 --- a/src/renderer/src/lib/worktree-activation-surface-selection.ts +++ b/src/renderer/src/lib/worktree-activation-surface-selection.ts @@ -18,6 +18,7 @@ export type WorktreeActivationSurfaceSelection = { } export type WorktreeActivationOptions = WorktreeActivationSurfaceSelection & { + worktreeCreationId?: string startup?: WorktreeStartupPayload initialCwd?: string setup?: WorktreeSetupLaunch diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index cf16a831a2a..d0f03fea58d 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -38,6 +38,7 @@ import { gateAndReseedEmptyWorkspace } from './worktree-activation-gated-empty-r import { liftSidebarFiltersHidingWorktree } from './worktree-activation-sidebar-filters' import { isFloatingWorkspaceId } from '../../../shared/floating-workspace-worktree' import { revealFloatingWorkspacePanel } from './floating-workspace-panel-reveal' +import { findPendingWorktreeCreationId } from './pending-worktree-creation' import { markVisited, readPreviousActive } from './worktree-activation-visit' import { selectFolderWorkspaces } from '@/store/workspace-record-selectors' @@ -180,6 +181,14 @@ export function activateAndRevealWorktree( if (!wt) { return false } + const pendingCreationId = findPendingWorktreeCreationId(state.pendingWorktreeCreations, wt.id) + // Why: a checkout published mid-create shows its creation progress until that creation hands off. + if (pendingCreationId && opts?.worktreeCreationId !== pendingCreationId) { + state.setActivePendingWorktreeCreation(pendingCreationId) + state.updatePendingWorktreeCreation(pendingCreationId, { loaderVisible: true }) + state.setActiveView('terminal') + return { primaryTabId: null } + } const hasActivationWork = Boolean( opts?.startup || opts?.setup || opts?.defaultTabs || opts?.issueCommand ) @@ -258,6 +267,7 @@ export function activateAndRevealWorktree( opts?.issueCommand, opts?.defaultTabs, { + ...(opts?.worktreeCreationId ? { worktreeCreationId: opts.worktreeCreationId } : {}), ...(opts?.backendStartupTerminalSpawned ? { backendStartupTerminalSpawned: true } : {}), ...(opts?.createNewTerminalForStartup ? { createNewTerminalForStartup: true } : {}), ...(providesInitialSurface ? { callerProvidesSurface: true } : {}), diff --git a/src/renderer/src/lib/worktree-creation-flow-execute.ts b/src/renderer/src/lib/worktree-creation-flow-execute.ts index 2c07ffc9e3f..ee2e015cd21 100644 --- a/src/renderer/src/lib/worktree-creation-flow-execute.ts +++ b/src/renderer/src/lib/worktree-creation-flow-execute.ts @@ -160,7 +160,7 @@ export async function executeWorktreeCreation( await attachEphemeralVmRuntimeToWorkspace(preparedRequest, worktree.id) const backendSpawned = result.startupTerminal?.spawned === true - const windowStartup = windowStartupAfterCreate(preparedRequest, result, backendSpawned) + const windowStartup = windowStartupAfterCreate(creationId, preparedRequest, result) const startupOpt = windowStartup.startup // Hand off only if still viewing creation/new workspace; preserve later navigation and toast instead (#9944). @@ -181,7 +181,7 @@ export async function executeWorktreeCreation( ...(defaultTabs ? { defaultTabs } : {}), ...(startupOpt ? { startup: startupOpt } : {}), ...(preparedRequest.issueCommand ? { issueCommand: preparedRequest.issueCommand } : {}), - ...(backendSpawned ? { backendStartupTerminalSpawned: true } : {}), + ...windowStartup.terminalOptions, ...(setupIsOnlySurface ? { providesInitialSurface: true } : {}) }) primaryTabId = activation === false ? null : activation.primaryTabId @@ -198,7 +198,7 @@ export async function executeWorktreeCreation( // Startup terminal ids and stamped agent tabs are the only safe primary // ids when activation returned no result. primaryTabId = verifiedLaunchTabId - } else if (existingTabs.length === 0) { + } else if (existingTabs.length === 0 || startupOpt) { try { primaryTabId = ensureWorktreeHasInitialTerminal( useAppStore.getState(), @@ -208,7 +208,7 @@ export async function executeWorktreeCreation( preparedRequest.issueCommand, defaultTabs, // Activation failed before providing its promised surface, so recovery must seed one. - { ...onCreationHost, ...(backendSpawned && { backendStartupTerminalSpawned: true }) } + { ...onCreationHost, ...windowStartup.terminalOptions } ) } catch (recoveryError) { console.error('worktree create: recovery seed failed', worktree.id, recoveryError) @@ -249,7 +249,7 @@ export async function executeWorktreeCreation( activateCreatedTabs: false, ...onCreationHost, ...(callerProvidesSurface ? { callerProvidesSurface: true } : {}), - ...(backendSpawned ? { backendStartupTerminalSpawned: true } : {}) + ...windowStartup.terminalOptions } ) } catch (error) { diff --git a/src/renderer/src/lib/worktree-creation-flow-ready-toast.test.ts b/src/renderer/src/lib/worktree-creation-flow-ready-toast.test.ts index ffdc80bce7a..97b9c211b52 100644 --- a/src/renderer/src/lib/worktree-creation-flow-ready-toast.test.ts +++ b/src/renderer/src/lib/worktree-creation-flow-ready-toast.test.ts @@ -269,6 +269,7 @@ describe('a creation that finishes after the user moved on (#9944)', () => { // The host already adopted the agent tab, so activation must seed nothing beside it; // worktree-creation-backend-startup-focus.test.ts pins that this lands focus on that tab. expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', { + worktreeCreationId: 'creation-1', sidebarRevealBehavior: 'auto', agent: 'claude', backendStartupTerminalSpawned: true @@ -301,6 +302,7 @@ describe('a creation that finishes after the user moved on (#9944)', () => { await vi.waitFor(() => expect(store.removePendingWorktreeCreation).toHaveBeenCalled()) expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', { + worktreeCreationId: 'creation-1', sidebarRevealBehavior: 'auto' }) expect(queueWorkspaceActivationTerminalFocus).toHaveBeenCalledWith('wt-1', { 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 index 60b8820615d..4711fee5eb0 100644 --- a/src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts +++ b/src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts @@ -169,6 +169,67 @@ beforeEach(() => { }) describe('a throw after createWorktree succeeds no longer strands the creation surface', () => { + it.each(['active', 'background', 'activation-failure'] as const)( + '%s creation preserves Codex startup when a blank terminal already exists', + async (scenario) => { + const request = makeRequest({ + agent: 'codex', + startupPlan: { + agent: 'codex', + launchCommand: 'codex', + expectedProcess: 'codex', + followupPrompt: '', + launchConfig: { agentArgs: '', agentEnv: {} } + } + }) + const setup = { + runnerScriptPath: '/tmp/setup.sh', + envVars: {}, + waitForAgentStartup: true + } + seedPendingCreation(request) + store.tabsByWorktree = { 'wt-1': [{ id: 'existing-shell' }] } + store.createWorktree.mockResolvedValue({ + worktree: { id: 'wt-1', repoId: 'repo-1' }, + setup + }) + if (scenario === 'background') { + store.activeView = 'tasks' + } + if (scenario === 'activation-failure') { + vi.mocked(activateAndRevealWorktree).mockImplementation(() => { + throw new Error('reveal failed with a blank terminal already present') + }) + } + + await executeWorktreeCreation('creation-1', request) + + if (scenario === 'active') { + expect(activateAndRevealWorktree).toHaveBeenCalledWith( + 'wt-1', + expect.objectContaining({ + startup: expect.objectContaining({ command: 'codex' }), + setup, + createNewTerminalForStartup: true + }) + ) + } else { + expect(ensureWorktreeHasInitialTerminal).toHaveBeenCalledWith( + store, + 'wt-1', + expect.objectContaining({ command: 'codex' }), + setup, + undefined, + undefined, + expect.objectContaining({ createNewTerminalForStartup: true }) + ) + } + expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('creation-1', { + cleanupVm: false + }) + } + ) + it('activating branch: a planless agent throw recovers a terminal and completes', async () => { const request = makeRequest({ agent: 'claude' }) seedPendingCreation(request) @@ -191,7 +252,7 @@ describe('a throw after createWorktree succeeds no longer strands the creation s undefined, undefined, undefined, - {} + { worktreeCreationId: 'creation-1' } ) expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('creation-1', { cleanupVm: false diff --git a/src/renderer/src/lib/worktree-creation-flow.test.ts b/src/renderer/src/lib/worktree-creation-flow.test.ts index 222054a2765..8e21b97da33 100644 --- a/src/renderer/src/lib/worktree-creation-flow.test.ts +++ b/src/renderer/src/lib/worktree-creation-flow.test.ts @@ -537,7 +537,7 @@ describe('staged background worktree creation', () => { undefined, undefined, undefined, - { activateCreatedTabs: false, backendStartupTerminalSpawned: true } + expect.objectContaining({ activateCreatedTabs: false, backendStartupTerminalSpawned: true }) ) expect(queueWorkspaceActivationTerminalFocus).not.toHaveBeenCalled() expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('creation-1', { @@ -571,7 +571,7 @@ describe('staged background worktree creation', () => { undefined, { command: 'gh issue view 42' }, undefined, - { activateCreatedTabs: false } + { activateCreatedTabs: false, worktreeCreationId: 'creation-1' } ) ) }) @@ -667,7 +667,7 @@ describe('staged background worktree creation', () => { setup, undefined, undefined, - { activateCreatedTabs: false, callerProvidesSurface: true } + expect.objectContaining({ activateCreatedTabs: false, callerProvidesSurface: true }) ) ) }) diff --git a/src/renderer/src/lib/worktree-creation-flow.ts b/src/renderer/src/lib/worktree-creation-flow.ts index 05f9c1dddca..d838f207a81 100644 --- a/src/renderer/src/lib/worktree-creation-flow.ts +++ b/src/renderer/src/lib/worktree-creation-flow.ts @@ -129,7 +129,8 @@ export function retryBackgroundWorktreeCreation(creationId: string): void { phase: getInitialWorktreeCreationPhase(entry.request), error: undefined, provisioningLog: undefined, - checkoutProgress: undefined + checkoutProgress: undefined, + worktreeId: undefined }) store.setActivePendingWorktreeCreation(creationId) store.setActiveView('terminal') diff --git a/src/renderer/src/lib/worktree-creation-host-agent-launch.ts b/src/renderer/src/lib/worktree-creation-host-agent-launch.ts index 44b368bb39c..e98bb29d1e5 100644 --- a/src/renderer/src/lib/worktree-creation-host-agent-launch.ts +++ b/src/renderer/src/lib/worktree-creation-host-agent-launch.ts @@ -5,6 +5,7 @@ import type { WorktreeStartupPayload } from '@/lib/worktree-startup-payload' import { buildWorktreeCreationStartupOpt } from '@/lib/worktree-creation-flow-startup' import { agentLaunchPaneNoticeText } from '@/components/terminal-pane/agent-launch-pane-notice-text' import type { TuiAgent } from '../../../shared/tui-agent' +import type { InitialTerminalOptions } from '@/lib/worktree-activation-store-contract' import type { CreatedWorktreeResult, HostAgentLaunch @@ -31,8 +32,35 @@ export function hostPlannableAgentLaunch( } } -/** Host-planned launches also own window startup/recovery; starting either here could duplicate the agent. */ +type CreationTerminalOptions = Pick< + InitialTerminalOptions, + 'worktreeCreationId' | 'createNewTerminalForStartup' | 'backendStartupTerminalSpawned' +> + +/** A terminal opened mid-create must not swallow the agent launch; only this creation may seed past its pending guard. */ export function windowStartupAfterCreate( + creationId: string, + request: WorktreeCreationRequest, + result: CreatedWorktreeResult +): { + startup?: WorktreeStartupPayload + agent: TuiAgent | null + terminalOptions: CreationTerminalOptions +} { + const backendSpawned = result.startupTerminal?.spawned === true + const plan = planWindowStartup(request, result, backendSpawned) + return { + ...plan, + terminalOptions: { + worktreeCreationId: creationId, + ...(plan.startup ? { createNewTerminalForStartup: true } : {}), + ...(backendSpawned ? { backendStartupTerminalSpawned: true } : {}) + } + } +} + +/** Host-planned launches also own window startup/recovery; starting either here could duplicate the agent. */ +function planWindowStartup( request: WorktreeCreationRequest, result: CreatedWorktreeResult, backendSpawned: boolean diff --git a/src/renderer/src/lib/worktree-creation-initial-surface.test.ts b/src/renderer/src/lib/worktree-creation-initial-surface.test.ts new file mode 100644 index 00000000000..9fcfc93997f --- /dev/null +++ b/src/renderer/src/lib/worktree-creation-initial-surface.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import { useAppStore } from '@/store' +import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' +import type { PendingWorktreeCreation } from './pending-worktree-creation' +import { activateAndRevealWorktree } from './worktree-activation' +import { ensureWorktreeHasInitialTerminal } from './worktree-initial-terminal-seeding' +import { + createMockStore, + createdTabStartups, + registerWorktreeActivationReset +} from './worktree-activation.test-fixture' + +registerWorktreeActivationReset() + +function creatingEntry(): PendingWorktreeCreation { + return { + creationId: 'creation-1', + worktreeId: 'wt-1', + phase: 'creating', + status: 'creating', + startedAt: 1, + indeterminate: false, + loaderVisible: true, + request: { + repoId: 'repo-1', + name: 'workspace', + setupDecision: 'inherit', + agent: 'codex', + pendingFirstAgentMessageRename: false, + note: '', + startupPlan: null, + quickPrompt: '', + quickTelemetry: null + } + } +} + +function creatingStore() { + return createMockStore({ + settings: {}, + pendingWorktreeCreations: { 'creation-1': creatingEntry() } + }) +} + +describe('initial surface during worktree creation', () => { + it('does not seed a fallback shell when an unfinished checkout is opened', () => { + const store = creatingStore() + expect(ensureWorktreeHasInitialTerminal(store, 'wt-1')).toBeNull() + expect(store.createTab).not.toHaveBeenCalled() + }) + + it('lets the matching creation owner complete a blank-terminal request', () => { + const store = creatingStore() + expect( + ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, undefined, undefined, undefined, { + worktreeCreationId: 'creation-1' + }) + ).toBe('tab-1') + expect(store.createTab).toHaveBeenCalledOnce() + }) + + it('does not block another workspace or an explicit agent launch', () => { + expect(ensureWorktreeHasInitialTerminal(creatingStore(), 'wt-other')).toBe('tab-1') + const store = creatingStore() + expect( + ensureWorktreeHasInitialTerminal(store, 'wt-1', { command: 'codex', launchAgent: 'codex' }) + ).toBe('tab-1') + expect(store.createTab).toHaveBeenCalledOnce() + expect(createdTabStartups(store)['tab-1']).toEqual({ command: 'codex', launchAgent: 'codex' }) + }) + + it('shows the creation panel instead of an empty workspace when the row is opened early', () => { + useAppStore.setState({ + activeView: 'tasks', + activePendingCreationId: null, + worktreesByRepo: { 'repo-1': [makeWorktree({ id: 'wt-1', repoId: 'repo-1' })] }, + pendingWorktreeCreations: { 'creation-1': { ...creatingEntry(), loaderVisible: false } } + }) + + expect(activateAndRevealWorktree('wt-1')).toEqual({ primaryTabId: null }) + + const state = useAppStore.getState() + expect(state.activePendingCreationId).toBe('creation-1') + expect(state.activeView).toBe('terminal') + expect(state.activeWorktreeId).not.toBe('wt-1') + expect(state.pendingWorktreeCreations['creation-1']?.loaderVisible).toBe(true) + }) +}) diff --git a/src/renderer/src/lib/worktree-creation-structured-session.ts b/src/renderer/src/lib/worktree-creation-structured-session.ts index 800f078c814..93a0f6b9b8a 100644 --- a/src/renderer/src/lib/worktree-creation-structured-session.ts +++ b/src/renderer/src/lib/worktree-creation-structured-session.ts @@ -108,7 +108,8 @@ export async function launchStructuredWorktreeSession( if (args.shouldActivateOnCompletion && !activation) { try { activation = activateAndRevealWorktree(args.worktreeId, { - providesInitialSurface: true + providesInitialSurface: true, + worktreeCreationId: args.creationId }) } catch (error) { // Why: without a revealed workspace the provisional tab has no visible owner. diff --git a/src/renderer/src/lib/worktree-initial-terminal-seeding.ts b/src/renderer/src/lib/worktree-initial-terminal-seeding.ts index fea29b7a5df..1946c1b45c6 100644 --- a/src/renderer/src/lib/worktree-initial-terminal-seeding.ts +++ b/src/renderer/src/lib/worktree-initial-terminal-seeding.ts @@ -27,6 +27,7 @@ import { import { applyDefaultTerminalTabs } from '@/lib/worktree-default-terminal-tabs' import { openDefaultAgentChatInEmptyWorkspace } from '@/lib/empty-workspace-default-agent-chat' import { isEmptyWorkspaceDefaultSurfacePending } from '@/lib/empty-workspace-default-surface-claims' +import { findPendingWorktreeCreationId } from './pending-worktree-creation' function getSetupRunnerCommandPlatformForLaunch(setup: WorktreeSetupLaunch): 'windows' | 'posix' { return getSetupRunnerCommandPlatformForPath( @@ -174,6 +175,18 @@ export function ensureWorktreeHasInitialTerminal( } const hasExplicitLaunchWork = Boolean(sequencedStartup || setup || issueCommand) + const pendingCreationId = findPendingWorktreeCreationId( + ownerState.pendingWorktreeCreations, + worktreeId + ) + // Why: a checkout can become selectable before creation has handed off its chosen surface. + if ( + pendingCreationId && + opts?.worktreeCreationId !== pendingCreationId && + !hasExplicitLaunchWork + ) { + return null + } if ( renderableTabCount === 0 && !shouldSeedWorktreePrimaryTerminal({ diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index 29ab5ad73c8..4a47477223e 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -195,6 +195,7 @@ export type WorktreeSlice = { request?: PendingWorktreeCreation['request'] provisioningLog?: string checkoutProgress?: PendingWorktreeCreation['checkoutProgress'] + worktreeId?: string } ) => void /** Drop pending/active creation; VM cleanup applies to cancellation/dismissal, never successful handoff. */ diff --git a/src/shared/worktree/create-types.ts b/src/shared/worktree/create-types.ts index 6adbd51fa9d..79a07b9ecc2 100644 --- a/src/shared/worktree/create-types.ts +++ b/src/shared/worktree/create-types.ts @@ -144,6 +144,8 @@ export type CreateWorktreeProgressEvent = { creationId?: string phase: 'fetching' | 'creating' checkout?: WorktreeCheckoutProgress | null + /** Local creates name the checkout before Git publishes it. */ + worktreeId?: string } export type AdoptProvisionedRootArgs = CreateWorktreeArgs & {