mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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. */
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
Reference in New Issue
Block a user