fix(worktree): start the agent when a workspace is opened during creation (#33562)

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>
This commit is contained in:
Neil
2026-10-25 08:43:15 -07:00
committed by GitHub
co-authored by Eotel
parent a4e6e1f067
commit 6c9c7b0fdb
23 changed files with 403 additions and 39 deletions
+11 -9
View File
@@ -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<CreateWorktreeProgressEvent, 'checkout' | 'worktreeId'>
): 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
@@ -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({
@@ -10,12 +10,14 @@ const mocks = vi.hoisted(() => {
createTab: vi.fn(),
createUnifiedTab: vi.fn(),
begin: vi.fn(),
select: (selector: (state: { pendingWorktreeCreations: Record<string, never> }) => unknown) =>
selector({ pendingWorktreeCreations: {} }),
answers
}
})
vi.mock('@/store', () => ({
useAppStore: Object.assign(() => 'none', {
useAppStore: Object.assign(mocks.select, {
getState: () => ({
activeWorktreeId: 'wt-1',
tabsByWorktree: {},
@@ -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<string, unknown[]> = {}
const storeClosedRecords: Record<string, { worktreeId: string; closedAt: number }> = {}
const storePendingCreations: Record<string, PendingWorktreeCreation> = {}
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<typeof getState>) => 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(<Watcher />))
expect(mocks.gate).not.toHaveBeenCalled()
expect(mocks.createTab).not.toHaveBeenCalled()
mocks.storePendingCreations = {}
await act(async () => root?.render(<Watcher />))
expect(mocks.createTab).toHaveBeenCalledOnce()
})
it.each([
['claude', 'pending', 0],
['codex', 'pending', 0],
@@ -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,
@@ -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 } : {})
})
}) ?? (() => {})
)
@@ -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({
@@ -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<Record<string, PendingWorktreeCreation>> | 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<Record<string, PendingWorktreeCreation>>,
request: Pick<
@@ -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
@@ -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<WorktreeRuntimeOwnerState> & {
tabsByWorktree: Record<string, { id: string }[]>
pendingWorktreeCreations?: Record<string, PendingWorktreeCreation>
closedTerminalTabTombstonesByTabId?: ClosedTerminalTabTombstonesByTabId
defaultTerminalTabsAppliedByWorktreeId: Record<string, true>
createTab: (
@@ -56,6 +58,8 @@ export type WorktreeActivationStore = Partial<WorktreeRuntimeOwnerState> & {
}
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. */
@@ -18,6 +18,7 @@ export type WorktreeActivationSurfaceSelection = {
}
export type WorktreeActivationOptions = WorktreeActivationSurfaceSelection & {
worktreeCreationId?: string
startup?: WorktreeStartupPayload
initialCwd?: string
setup?: WorktreeSetupLaunch
@@ -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 } : {}),
@@ -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) {
@@ -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', {
@@ -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
@@ -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 })
)
)
})
@@ -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')
@@ -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
@@ -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)
})
})
@@ -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.
@@ -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({
@@ -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. */
+2
View File
@@ -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 & {