feat: non-blocking worktree creation with in-tab progress (#4729)

* feat: non-blocking worktree creation with in-tab progress

The Create Worktree modal stayed open with a spinning button for the full
create IPC (base-ref git fetch + `git worktree add`, ~10-15s on heavy
repos) and only dismissed once it resolved, so the user stared at a frozen
modal with no way to work elsewhere.

Run creation in the background instead. On submit the modal closes
immediately and an in-tab "Creating worktree…" panel shows live setup
status, wiring the previously-unused `createWorktree:progress` main->renderer
event via a per-creation correlation id. A sidebar row tracks each
in-flight create, the user can navigate to other worktrees or cancel while
it runs, and on success it swaps to the real worktree + terminal in one
frame. Failure shows the error in the panel with retry; remote/runtime
targets (no progress events) show an indeterminate spinner.

Pending creations live in a separate store map rather than a faked Worktree
row, so git-status, the tab model, persistence, and PTY spawning are
untouched. Only the composer quick-create path changes; other createWorktree
callers keep their synchronous behavior.

* refactor: present in-flight worktree creates as inline tabs and rows

Rework the two surfaces that show an in-flight create so each reads like
the real thing it stands in for.

The in-tab panel is now a faux tab: a tab strip carrying the new
worktree's name (the title) over a quiet top-left status line, instead of
a centered card with a step checklist. An in-flight create reads as a real
workspace tab whose content is loading, the title and status never
duplicate each other, and the handoff to the terminal stays a same-frame
swap. Failure shows the error inline with retry.

In the sidebar, a pending create now renders as an inline row under its
target repo group — where the worktree will land — replacing the separate
strip that pinned every in-flight create to the top of the list.

* fix: keep pending worktree rows visible without repo metadata

---------

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
This commit is contained in:
Trevin Chow 2026-06-08 13:30:13 -07:00 committed by GitHub
parent 5424582b51
commit 5337e93cb4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1081 additions and 128 deletions

View File

@ -1134,10 +1134,11 @@ export function notifyWorktreesChanged(mainWindow: BrowserWindow, repoId: string
// "Creating worktree..." label if no event arrives.
export function emitCreateWorktreeProgress(
mainWindow: BrowserWindow,
phase: 'fetching' | 'creating'
phase: 'fetching' | 'creating',
creationId?: string
): void {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('createWorktree:progress', { phase })
mainWindow.webContents.send('createWorktree:progress', { creationId, phase })
}
}
@ -1564,7 +1565,7 @@ export async function createLocalWorktree(
remoteTrackingBase = await runtime.resolveRemoteTrackingBase(repo.path, baseBranch)
if (remoteTrackingBase) {
const hasLocalBaseRef = await runtime.hasRemoteTrackingRef(repo.path, remoteTrackingBase)
emitCreateWorktreeProgress(mainWindow, 'fetching')
emitCreateWorktreeProgress(mainWindow, 'fetching', args.creationId)
remoteTrackingRefresh = {
base: remoteTrackingBase,
hadLocalBaseRef: hasLocalBaseRef,
@ -1581,14 +1582,14 @@ export async function createLocalWorktree(
.fetchRemoteWithCache(repo.path, fallbackRemote)
.then(() => undefined)
.catch(() => undefined)
emitCreateWorktreeProgress(mainWindow, 'fetching')
emitCreateWorktreeProgress(mainWindow, 'fetching', args.creationId)
}
} else {
const remote = baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin'
legacyFetchPromise = gitExecFileAsync(['fetch', remote], { cwd: repo.path })
.then(() => undefined)
.catch(() => undefined)
emitCreateWorktreeProgress(mainWindow, 'fetching')
emitCreateWorktreeProgress(mainWindow, 'fetching', args.creationId)
}
const workspaceRoot = computeWorkspaceRoot(repo.path, worktreePathSettings)
@ -1774,7 +1775,7 @@ export async function createLocalWorktree(
await legacyFetchPromise
})
}
emitCreateWorktreeProgress(mainWindow, 'creating')
emitCreateWorktreeProgress(mainWindow, 'creating', args.creationId)
let preparedPushTarget: GitPushTarget | undefined
if (args.pushTarget) {

View File

@ -819,6 +819,13 @@ export type PreloadApi = {
listDetected: (args: { repoId: string }) => Promise<DetectedWorktreeListResult>
listAll: () => Promise<Worktree[]>
create: (args: CreateWorktreeArgs) => Promise<CreateWorktreeResult>
/** Two-phase progress for a background `create`, correlated by
* `creationId`. Renderer routes each event to its pending creation's
* status surface; the remote/runtime create path emits nothing, so the
* surface falls back to an indeterminate spinner. */
onCreateProgress: (
callback: (data: { creationId?: string; phase: 'fetching' | 'creating' }) => void
) => () => void
prefetchCreateBase: (args: { repoId: string; baseBranch?: string }) => Promise<void>
resolvePrBase: (args: {
repoId: string

View File

@ -544,6 +544,17 @@ const api = {
create: (args) => ipcRenderer.invoke('worktrees:create', args),
onCreateProgress: (
callback: (data: { creationId?: string; phase: 'fetching' | 'creating' }) => void
): (() => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
data: { creationId?: string; phase: 'fetching' | 'creating' }
) => callback(data)
ipcRenderer.on('createWorktree:progress', listener)
return () => ipcRenderer.removeListener('createWorktree:progress', listener)
},
prefetchCreateBase: (args) => ipcRenderer.invoke('worktrees:prefetchCreateBase', args),
resolvePrBase: (args) => ipcRenderer.invoke('worktrees:resolvePrBase', args),

View File

@ -214,6 +214,9 @@ function WindowControls(): React.JSX.Element {
}
const Landing = lazy(() => import('./components/Landing'))
const WorktreeCreationPanel = lazy(
() => import('./components/worktree-creation/WorktreeCreationPanel')
)
const TaskPage = lazy(() => import('./components/TaskPage'))
const AutomationsPage = lazy(() => import('./components/automations/AutomationsPage'))
const ActivityPrototypePage = lazy(() => import('./components/activity/ActivityPrototypePage'))
@ -325,6 +328,16 @@ function App(): React.JSX.Element {
const featureInteractions = useAppStore((s) => s.featureInteractions)
const contextualToursAutoEligible = useAppStore((s) => s.contextualToursAutoEligible)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const activePendingCreationId = useAppStore((s) => s.activePendingCreationId)
// Why: the creation loader is debounced — a fast create resolves before its
// entry's loaderVisible flips, so the content area keeps showing the prior
// workspace (or Landing) and never flashes a loader. Only a create still
// pending past the debounce gates the loader and hides the terminal.
const activeCreationLoaderVisible = useAppStore(
(s) =>
s.activePendingCreationId != null &&
s.pendingWorktreeCreations[s.activePendingCreationId]?.loaderVisible === true
)
// Why: App swaps the sidebar between workspace and landing layouts when the
// active workspace is slept/deleted. Keep virtualized scroll memory above
// that remount so the left workspace list doesn't restart at scrollTop 0.
@ -1797,7 +1810,9 @@ function App(): React.JSX.Element {
<div className="flex flex-1 min-w-0 min-h-0 flex-col">
<div
className={
activeView !== 'terminal' || !activeWorktreeId
activeView !== 'terminal' ||
!activeWorktreeId ||
activeCreationLoaderVisible
? 'hidden flex-1 min-w-0 min-h-0'
: 'flex flex-1 min-w-0 min-h-0'
}
@ -1827,7 +1842,16 @@ function App(): React.JSX.Element {
{activeView === 'activity' ? <ActivityPrototypePage /> : null}
{activeView === 'space' ? <WorkspaceSpacePage /> : null}
{activeView === 'mobile' ? <MobilePage /> : null}
{activeView === 'terminal' && !activeWorktreeId ? <Landing /> : null}
{activeView === 'terminal' &&
activeCreationLoaderVisible &&
activePendingCreationId ? (
<WorktreeCreationPanel creationId={activePendingCreationId} />
) : null}
{activeView === 'terminal' &&
!activeWorktreeId &&
!activeCreationLoaderVisible ? (
<Landing />
) : null}
</RecoverableRenderErrorBoundary>
</Suspense>
</div>

View File

@ -0,0 +1,90 @@
import React from 'react'
import { AlertTriangle, Loader2, X } from 'lucide-react'
import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
import {
getCreationProgressLabel,
type PendingWorktreeCreation
} from '@/lib/pending-worktree-creation'
function statusLabel(entry: PendingWorktreeCreation): string {
if (entry.status === 'error') {
return entry.error ?? 'Creation failed'
}
return getCreationProgressLabel(entry)
}
/**
* Sidebar row for an in-progress (or failed) worktree create. Rendered inline in
* the worktree list under its target repo, so the new workspace appears where it
* will land. Self-contained: reads its own entry + active state by creationId.
*/
export function PendingWorktreeRow({
creationId
}: {
creationId: string
}): React.JSX.Element | null {
const entry = useAppStore((s) => s.pendingWorktreeCreations[creationId])
const active = useAppStore((s) => s.activePendingCreationId === creationId)
if (!entry) {
return null
}
const isError = entry.status === 'error'
return (
<div
className={cn(
'group flex w-full items-center gap-1 rounded-md transition-colors',
active
? 'border border-sidebar-ring/35 bg-sidebar-accent/70 ring-1 ring-sidebar-ring/30'
: 'border border-transparent hover:bg-sidebar-accent/60'
)}
>
<button
type="button"
// Why: never route this through setActiveWorktree — there is no real
// worktree yet. activePendingCreationId drives the content loader instead.
onClick={() => {
const store = useAppStore.getState()
store.setActivePendingWorktreeCreation(creationId)
store.updatePendingWorktreeCreation(creationId, { loaderVisible: true })
store.setActiveView('terminal')
}}
className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-left"
>
<span className="flex size-4 shrink-0 items-center justify-center">
{isError ? (
<AlertTriangle className="size-3.5 text-destructive" />
) : (
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
)}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium text-sidebar-foreground">
{entry.request.displayName || entry.request.name}
</span>
<span
className={cn(
'block truncate text-[11px]',
isError ? 'text-destructive/90' : 'text-muted-foreground'
)}
>
{statusLabel(entry)}
</span>
</span>
</button>
<button
type="button"
title="Cancel"
aria-label="Cancel worktree creation"
onClick={() => useAppStore.getState().removePendingWorktreeCreation(creationId)}
className={cn(
'mr-1 flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground transition-opacity hover:bg-sidebar-accent hover:text-foreground focus-visible:opacity-100',
isError ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
)}
>
<X className="size-3.5" />
</button>
</div>
)
}

View File

@ -20,6 +20,7 @@ import {
Workflow
} from 'lucide-react'
import { useAppStore } from '@/store'
import { useShallow } from 'zustand/react/shallow'
import type { AppState } from '@/store/types'
import {
getAllWorktreesFromState,
@ -28,6 +29,7 @@ import {
useWorktreeMap
} from '@/store/selectors'
import WorktreeCard from './WorktreeCard'
import { PendingWorktreeRow } from './PendingWorktreeRow'
import WorktreeCardAgents, {
SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT
} from './WorktreeCardAgents'
@ -639,6 +641,9 @@ export function getRenderRowKey(row: RenderRow): string {
if (row.type === 'imported-worktrees-card') {
return `imported:${row.key}`
}
if (row.type === 'pending-creation') {
return `pending:${row.creationId}`
}
return `wt:${row.worktree.id}`
}
@ -652,7 +657,7 @@ export function getWorktreeDragGroups(rows: Row[]): WorktreeDragGroup[] {
groups.push({ key: current.key, worktreeIds: current.ids })
continue
}
if (row.type === 'imported-worktrees-card') {
if (row.type === 'imported-worktrees-card' || row.type === 'pending-creation') {
continue
}
if (!current) {
@ -3563,6 +3568,24 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
)
}
if (row.type === 'pending-creation') {
return (
<div
key={vItem.key}
role="presentation"
data-worktree-virtual-row
data-worktree-virtual-row-key={String(vItem.key)}
data-worktree-virtual-row-start={vItem.start}
data-index={vItem.index}
ref={measureVirtualRowElement}
className="absolute left-0 right-0 top-0 px-2 pb-1.5"
style={{ transform: getVirtualRowTransform(vItem.start) }}
>
<PendingWorktreeRow creationId={row.creationId} />
</div>
)
}
const itemWorkspaceStatus =
groupBy === 'workspace-status'
? getWorkspaceStatus(row.worktree, workspaceStatuses)
@ -4082,6 +4105,27 @@ const WorktreeList = React.memo(function WorktreeList({
}, [filterRepoIds, groupBy, repos, worktreesByRepo])
const allRepoIds = useMemo(() => repos.map((r) => r.id), [repos])
// Why: buildRows only needs which creates exist and their repo. Subscribe on a
// flat key array (value-compared by useShallow) so progress updates
// (phase/loaderVisible) don't churn it and rebuild the whole sidebar row model
// on every creation tick. Split on the first space — the creationId is a UUID,
// so it has none and the repoId (which may contain spaces) stays intact.
const pendingCreationKeys = useAppStore(
useShallow((s) =>
Object.values(s.pendingWorktreeCreations ?? {}).map(
(creation) => `${creation.creationId} ${creation.request.repoId}`
)
)
)
const pendingCreations = useMemo(
() =>
pendingCreationKeys.map((key) => {
const separator = key.indexOf(' ')
return { creationId: key.slice(0, separator), repoId: key.slice(separator + 1) }
}),
[pendingCreationKeys]
)
// Build flat row list for rendering
const rows: Row[] = useMemo(
() =>
@ -4100,7 +4144,8 @@ const WorktreeList = React.memo(function WorktreeList({
settings,
projectGroups,
placeholderRepoIds,
importedWorktreesByRepo
importedWorktreesByRepo,
pendingCreations
),
[
groupBy,
@ -4116,7 +4161,8 @@ const WorktreeList = React.memo(function WorktreeList({
settings,
projectGroups,
placeholderRepoIds,
importedWorktreesByRepo
importedWorktreesByRepo,
pendingCreations
]
)
// Why: status headers change during wake (inactive -> active). Key only on

View File

@ -9,6 +9,7 @@ type WorktreeDragUnitRow =
| { type: 'header'; key: string }
| { type: 'item'; worktree: { id: string }; depth: number }
| { type: 'imported-worktrees-card' }
| { type: 'pending-creation' }
export function getWorktreeDragUnitGroups(
rows: readonly WorktreeDragUnitRow[]
@ -26,7 +27,7 @@ export function getWorktreeDragUnitGroups(
})
continue
}
if (row.type === 'imported-worktrees-card') {
if (row.type === 'imported-worktrees-card' || row.type === 'pending-creation') {
continue
}
if (!current) {

View File

@ -9,7 +9,8 @@ import {
getGroupKeysForWorktree,
getLineageGroupKey,
getLineageRenderInfo,
getPRGroupKey
getPRGroupKey,
type PendingCreationRef
} from './worktree-list-groups'
import type {
DetectedWorktree,
@ -1663,3 +1664,112 @@ describe('WorktreeList header styles', () => {
expect(source).toContain('color={repoHeaderColor}')
})
})
describe('buildRows pending creations', () => {
function makePendingCreation(creationId: string, repoId: string): PendingCreationRef {
return { creationId, repoId }
}
it('nests a pending creation under its repo, above the repo worktrees', () => {
const rows = buildRows(
'repo',
[worktree],
repoMap,
null,
new Set(),
undefined,
undefined,
undefined,
{},
new Map([[worktree.id, worktree]]),
false,
undefined,
[],
new Set(),
new Map(),
[makePendingCreation('c1', repo.id)]
)
const types = rows.map((row) => row.type)
const headerIndex = types.indexOf('header')
const pendingIndex = rows.findIndex(
(row) => row.type === 'pending-creation' && row.creationId === 'c1'
)
const itemIndex = types.indexOf('item')
expect(headerIndex).toBeGreaterThanOrEqual(0)
expect(pendingIndex).toBe(headerIndex + 1)
expect(pendingIndex).toBeLessThan(itemIndex)
})
it('creates a repo group for a pending creation in a repo with no worktrees yet', () => {
const rows = buildRows(
'repo',
[],
repoMap,
null,
new Set(),
undefined,
undefined,
undefined,
{},
new Map(),
false,
undefined,
[],
new Set(),
new Map(),
[makePendingCreation('c1', repo.id)]
)
expect(rows.map((row) => row.type)).toEqual(['header', 'pending-creation'])
})
it('keeps a pending creation visible when its repo metadata is temporarily missing', () => {
const rows = buildRows(
'repo',
[],
new Map(),
null,
new Set(),
undefined,
undefined,
undefined,
{},
new Map(),
false,
undefined,
[],
new Set(),
new Map(),
[makePendingCreation('c1', repo.id)]
)
expect(rows).toMatchObject([
{ type: 'header', key: `repo:${repo.id}`, label: 'Unknown' },
{ type: 'pending-creation', creationId: 'c1', repo: undefined }
])
})
it('surfaces pending creations at the top for non-repo groupings', () => {
const rows = buildRows(
'none',
[worktree],
repoMap,
null,
new Set(),
undefined,
undefined,
undefined,
{},
new Map([[worktree.id, worktree]]),
false,
undefined,
[],
new Set(),
new Map(),
[makePendingCreation('c1', repo.id)]
)
expect(rows[0]).toMatchObject({ type: 'pending-creation', creationId: 'c1' })
})
})

View File

@ -70,7 +70,32 @@ export type ImportedWorktreesCardRow = {
placement: 'repo-group' | 'pinned-fallback'
}
export type Row = GroupHeaderRow | WorktreeRow | ImportedWorktreesCardRow
export type PendingCreationRow = {
type: 'pending-creation'
key: string
creationId: string
repo: Repo | undefined
}
/** Minimal shape buildRows needs for an in-flight create. Deliberately not the
* full PendingWorktreeCreation: row identity depends only on which creates
* exist and their repo, so callers can subscribe on this stable shape and keep
* progress-field churn (phase/loaderVisible) from rebuilding the whole list. */
export type PendingCreationRef = { creationId: string; repoId: string }
export type Row = GroupHeaderRow | WorktreeRow | ImportedWorktreesCardRow | PendingCreationRow
function buildPendingCreationRow(
creation: PendingCreationRef,
repoMap: Map<string, Repo>
): PendingCreationRow {
return {
type: 'pending-creation',
key: `pending:${creation.creationId}`,
creationId: creation.creationId,
repo: repoMap.get(creation.repoId)
}
}
type OrderedGroupEntry = [string, { label: string; items: Worktree[]; repo?: Repo }]
@ -506,10 +531,27 @@ export function buildRows(
settings?: AppState['settings'],
projectGroups: readonly ProjectGroup[] = [],
placeholderRepoIds: ReadonlySet<string> = new Set(),
importedWorktreesByRepo: ReadonlyMap<string, ImportedWorktreesCardCandidate> = new Map()
importedWorktreesByRepo: ReadonlyMap<string, ImportedWorktreesCardCandidate> = new Map(),
pendingCreations: readonly PendingCreationRef[] = []
): Row[] {
const result: Row[] = []
const pendingByRepo = new Map<string, PendingCreationRef[]>()
for (const creation of pendingCreations) {
const list = pendingByRepo.get(creation.repoId) ?? []
list.push(creation)
pendingByRepo.set(creation.repoId, list)
}
// Why: non-repo groupings have no repo section to nest an in-progress create
// under, so surface them at the very top (where the old global strip sat)
// rather than dropping them. Repo grouping nests them under their repo below.
if (groupBy !== 'repo' && pendingCreations.length > 0) {
for (const creation of pendingCreations) {
result.push(buildPendingCreationRow(creation, repoMap))
}
}
const visibleUnpinnedRepoIds = new Set(
worktrees.filter((worktree) => !worktree.isPinned).map((worktree) => worktree.repoId)
)
@ -597,6 +639,18 @@ export function buildRows(
}
}
}
if (groupBy === 'repo') {
for (const repoId of pendingByRepo.keys()) {
const key = `repo:${repoId}`
if (!grouped.has(key)) {
// Why: creating the first worktree in a repo leaves it with no group yet;
// ensure one so the in-progress row nests under its repo instead of being
// dropped.
const repo = repoMap.get(repoId)
grouped.set(key, { label: repo?.displayName ?? 'Unknown', items: [], repo })
}
}
}
const orderedGroups: OrderedGroupEntry[] = []
if (groupBy === 'pr-status') {
@ -680,11 +734,18 @@ export function buildRows(
result.push(header)
if (!isCollapsed) {
if (groupBy === 'repo' && repo) {
const candidate = importedWorktreesByRepo.get(repo.id)
if (groupBy === 'repo') {
const repoId = repo?.id ?? key.slice('repo:'.length)
const candidate = importedWorktreesByRepo.get(repoId)
if (candidate) {
result.push(buildImportedWorktreesCardRow(candidate, 'repo-group'))
}
// Why: surface in-progress creates at the top of their own repo so the
// new workspace appears where it will land, not flashed to the very top
// of the sidebar.
for (const creation of pendingByRepo.get(repoId) ?? []) {
result.push(buildPendingCreationRow(creation, repoMap))
}
}
const items = groupBy === 'repo' ? orderMainWorktreeFirst(group.items) : group.items
appendWorktreeRows(result, items, repoMap, lineageById, worktreeMap, {

View File

@ -6,6 +6,7 @@ import { PINNED_GROUP_KEY } from './worktree-list-groups'
export const GROUP_HEADER_ROW_HEIGHT = 28
const SECONDARY_GROUP_HEADER_TOP_MARGIN = 4
const IMPORTED_WORKTREES_LINE_ROW_HEIGHT = 36
const PENDING_CREATION_ROW_HEIGHT = 56
type WorktreeItemRow = Extract<Row, { type: 'item' }>
export type RenderRow = Row | { type: 'lineage-group'; key: string; rows: WorktreeItemRow[] }
@ -46,6 +47,9 @@ export function estimateRenderRowSize(
if (row?.type === 'imported-worktrees-card') {
return IMPORTED_WORKTREES_LINE_ROW_HEIGHT
}
if (row?.type === 'pending-creation') {
return PENDING_CREATION_ROW_HEIGHT
}
return 116
}

View File

@ -0,0 +1,92 @@
import React from 'react'
import { AlertTriangle, GitBranch, Loader2, RotateCcw, X } from 'lucide-react'
import { useAppStore } from '@/store'
import { retryBackgroundWorktreeCreation } from '@/lib/worktree-creation-flow'
import { getCreationProgressLabel } from '@/lib/pending-worktree-creation'
/**
* In-frame creation state, shown in the workspace content area while a worktree
* is being created. Presented as a faux tab: a tab strip carrying the new
* worktree's name (the title) over a body that holds the live status. This lets
* the in-progress create read as a real workspace tab whose content is loading,
* so the handoff to the real terminal is a same-frame swap and the title
* (name) and the body status never duplicate each other. Its appearance is
* debounced upstream so fast creates never paint it.
*/
export default function WorktreeCreationPanel({
creationId
}: {
creationId: string
}): React.JSX.Element | null {
const entry = useAppStore((s) => s.pendingWorktreeCreations[creationId])
if (!entry) {
return null
}
const dismiss = (): void => useAppStore.getState().removePendingWorktreeCreation(creationId)
const isError = entry.status === 'error'
const title = entry.request.displayName || entry.request.name
return (
<div className="absolute inset-0 flex flex-col bg-background">
{/* Faux tab strip: mirrors the real tab row (height, border, bg) so the
create reads as a workspace tab. Carries only the worktree name + a
cancel control the live status lives in the body below. */}
<div className="flex h-[36px] shrink-0 items-stretch border-b border-border bg-card">
<div className="flex h-full max-w-[240px] items-center gap-1.5 border-r border-border px-2.5 text-xs">
{isError ? (
<AlertTriangle className="size-3.5 shrink-0 text-destructive" />
) : (
// Why: a static worktree glyph (not a spinner) keeps the tab reading
// as a normal tab; the single loading spinner lives in the body.
<GitBranch className="size-3.5 shrink-0 text-muted-foreground" />
)}
<span className="truncate font-medium text-foreground">{title}</span>
<button
type="button"
title="Cancel"
aria-label="Cancel worktree creation"
onClick={dismiss}
className="flex size-4 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X className="size-3" />
</button>
</div>
</div>
{/* Body: a quiet top-left annotation on the surface the terminal will
fill the same spot terminal output appears so creation terminal
reads as one frame filling in. */}
<div className="min-h-0 flex-1 p-3">
{isError ? (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span className="font-medium text-destructive">Couldnt create worktree</span>
<span className="text-muted-foreground">
{entry.error ?? 'Something went wrong while creating the worktree.'}
</span>
<button
type="button"
onClick={() => retryBackgroundWorktreeCreation(creationId)}
className="inline-flex items-center gap-1 text-foreground hover:underline"
>
<RotateCcw className="size-3" />
Retry
</button>
<button
type="button"
onClick={dismiss}
className="text-muted-foreground hover:text-foreground hover:underline"
>
Dismiss
</button>
</div>
) : (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3.5 shrink-0 animate-spin" />
<span>{getCreationProgressLabel(entry)}</span>
</div>
)}
</div>
</div>
)
}

View File

@ -14,8 +14,9 @@ import {
normalizeGitHubLinkQuery
} from '@/lib/github-links'
import { activateAndRevealWorktree, type AgentStartedTelemetry } from '@/lib/worktree-activation'
import { runBackgroundWorktreeCreation } from '@/lib/worktree-creation-flow'
import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation'
import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup'
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
import { filterEnabledTuiAgents, isTuiAgentEnabled } from '../../../shared/tui-agent-selection'
import { tuiAgentToAgentKind } from '@/lib/telemetry'
import { isGitRepoKind } from '../../../shared/repo-kind'
@ -2292,91 +2293,50 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
...(quickTelemetry ? { telemetry: quickTelemetry } : {})
}
: undefined
const result = await createWorktree(
const request: WorktreeCreationRequest = {
repoId,
workspaceName,
selectedRepoIsGit ? baseBranch : undefined,
effectiveSetupDecision,
selectedRepoIsGit && sparseEnabled
name: workspaceName,
...(createDisplayName ? { displayName: createDisplayName } : {}),
...(selectedRepoIsGit && baseBranch ? { baseBranch } : {}),
setupDecision: effectiveSetupDecision,
...(selectedRepoIsGit && sparseEnabled
? {
directories: normalizedSparseDirectories,
...(effectivePresetId ? { presetId: effectivePresetId } : {})
}
: undefined,
telemetrySource,
createDisplayName,
submitLinkedIssueNumber ?? undefined,
submitLinkedPR ?? undefined,
pushTarget,
agent ?? undefined,
linkedLinearIssue,
effectiveBranchNameOverride,
resolvedInitialWorkspaceStatus,
linkedGitLabMR ?? undefined,
linkedGitLabIssue ?? undefined,
backendStartup,
pendingFirstAgentMessageRename
)
const worktree = result.worktree
await applyWorktreeMeta(worktree.id, trimmedNote ? { comment: trimmedNote } : {})
// Why: agents that gate first-launch behind a "Do you trust this
// folder?" menu (cursor-agent, copilot) consume the bracketed paste
// as menu input. Pre-write the trust artifact so the menu is
// skipped — best-effort, errors swallowed by main. Guard the IPC
// presence so a stale preload bundle doesn't crash the launch with
// "Cannot read properties of undefined".
if (agent && worktree.path && window.api.agentTrust?.markTrusted) {
const preflight = TUI_AGENT_CONFIG[agent].preflightTrust
if (preflight) {
try {
await window.api.agentTrust.markTrusted({
preset: preflight,
workspacePath: worktree.path
})
} catch {
// Best-effort: continue with launch.
}
}
}
const backendSpawnedStartup = result.startupTerminal?.spawned === true
const activation = activateAndRevealWorktree(worktree.id, {
sidebarRevealBehavior: 'auto',
setup: result.setup,
defaultTabs: result.defaultTabs,
...(startupPlan && !backendSpawnedStartup
? {
startup: {
command: startupPlan.launchCommand,
...(startupPlan.env ? { env: startupPlan.env } : {}),
...(agent === 'command-code' && quickPrompt.trim().length > 0
? {
initialAgentStatus: {
agent,
prompt: quickPrompt.trim()
}
}
: {}),
...(quickTelemetry ? { telemetry: quickTelemetry } : {})
sparseCheckout: {
directories: normalizedSparseDirectories,
...(effectivePresetId ? { presetId: effectivePresetId } : {})
}
}
: {})
})
if (startupPlan && !backendSpawnedStartup) {
void ensureAgentStartupInTerminal({
worktreeId: worktree.id,
primaryTabId: activation === false ? null : activation.primaryTabId,
startup: startupPlan
})
: {}),
...(telemetrySource ? { telemetrySource } : {}),
...(submitLinkedIssueNumber != null ? { linkedIssue: submitLinkedIssueNumber } : {}),
...(submitLinkedPR != null ? { linkedPR: submitLinkedPR } : {}),
...(pushTarget ? { pushTarget } : {}),
agent,
...(linkedLinearIssue ? { linkedLinearIssue } : {}),
...(effectiveBranchNameOverride
? { branchNameOverride: effectiveBranchNameOverride }
: {}),
...(resolvedInitialWorkspaceStatus
? { workspaceStatus: resolvedInitialWorkspaceStatus }
: {}),
...(linkedGitLabMR != null ? { linkedGitLabMR } : {}),
...(linkedGitLabIssue != null ? { linkedGitLabIssue } : {}),
...(backendStartup ? { startup: backendStartup } : {}),
pendingFirstAgentMessageRename,
note: trimmedNote,
startupPlan,
quickPrompt,
quickTelemetry
}
setSidebarOpen(true)
// Why: git fetch + `git worktree add` can take 1015s; holding the modal
// hostage to that made it feel frozen, so hand off to a background flow and
// close the modal immediately.
if (persistDraft) {
clearNewWorkspaceDraft()
}
onCreated?.()
queueNewWorkspaceTerminalFocus(worktree.id, activation)
runBackgroundWorktreeCreation(request)
} catch (error) {
const formattedError = formatWorkspaceCreateError(error)
setCreateError(formattedError)
@ -2387,12 +2347,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
},
[
agentPrompt,
applyWorktreeMeta,
baseBranch,
branchNameOverride,
branchNameOverridePreservesNameEdits,
clearNewWorkspaceDraft,
createWorktree,
fallbackCreatureName,
effectiveLinkedPR,
linkedGitLabIssue,
@ -2417,7 +2375,6 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
settings?.agentCmdOverrides,
settings?.autoRenameBranchFromWork,
disabledTuiAgents,
setSidebarOpen,
setupDecision,
sparseEnabled,
sparseError,

View File

@ -859,6 +859,18 @@ export function useIpcEvents(): void {
})
)
// Why: drive each background creation's status panel by routing the main
// process's two-phase progress to its pending entry via the correlation id.
// Guarded with `?.` so a stale preload bundle doesn't crash the listener set.
unsubs.push(
window.api.worktrees.onCreateProgress?.((data) => {
if (!data.creationId) {
return
}
useAppStore.getState().updatePendingWorktreeCreation(data.creationId, { phase: data.phase })
}) ?? (() => {})
)
if (window.api.gh?.onPRRefreshEvent) {
unsubs.push(
window.api.gh.onPRRefreshEvent((event) => {

View File

@ -0,0 +1,90 @@
import type {
CreateSparseCheckoutRequest,
GitPushTarget,
SetupDecision,
TuiAgent,
WorkspaceCreateTelemetrySource,
WorkspaceStatus,
WorktreeStartupLaunch
} from '../../../shared/types'
import type { AgentStartupPlan } from '@/lib/tui-agent-startup'
import type { AgentStartedTelemetry } from '@/lib/worktree-activation'
/** Two-phase status reported by the main process while a worktree is created.
* `fetching` covers the base-ref git fetch; `creating` covers `git worktree
* add`. The remote/runtime path emits neither, so consumers must tolerate a
* phase that never advances past `fetching`. */
export type WorktreeCreationPhase = 'fetching' | 'creating'
/**
* Everything needed to run a worktree create in the background and reproduce it
* verbatim on retry. Captured at the composer's submit cut point after all
* interactive preflight (trust/setup decisions) has resolved so the modal can
* close immediately and the work outlives it. Must stay plain-serializable
* (no closures/refs) so a pending entry can hold it for the panel's Retry.
*/
export type WorktreeCreationRequest = {
repoId: string
name: string
displayName?: string
baseBranch?: string
setupDecision: SetupDecision
sparseCheckout?: CreateSparseCheckoutRequest
telemetrySource?: WorkspaceCreateTelemetrySource
linkedIssue?: number
linkedPR?: number
pushTarget?: GitPushTarget
agent: TuiAgent | null
linkedLinearIssue?: string
branchNameOverride?: string
workspaceStatus?: WorkspaceStatus
linkedGitLabMR?: number
linkedGitLabIssue?: number
/** Backend-spawn startup payload (`createWorktree` arg). Present only when the
* agent launch is self-contained; otherwise the renderer drives startup via
* `startupPlan`. */
startup?: WorktreeStartupLaunch
pendingFirstAgentMessageRename: boolean
/** Post-create note persisted as the worktree comment. */
note: string
/** Renderer-side launch plan used to seed the first terminal when the backend
* did not already spawn it. Null for blank-shell creates. */
startupPlan: AgentStartupPlan | null
quickPrompt: string
quickTelemetry: AgentStartedTelemetry | null
}
/** Renderer-only, session-ephemeral record of an in-flight (or failed) worktree
* creation. Drives the sidebar strip and the in-tab creation panel. Never
* persisted an app reload drops it and the worktree (if main finished it)
* reconciles in via the normal `worktrees:changed` refresh. Display fields
* (name, repo, agent) live on `request`, the single source of truth reused on
* retry. */
export type PendingWorktreeCreation = {
creationId: string
phase: WorktreeCreationPhase
status: 'creating' | 'error'
/** True when the create runs over a remote/runtime target that emits no phase
* progress the panel shows a single indeterminate spinner rather than a
* stepped checklist that would freeze on the first step. */
indeterminate: boolean
/** Gates the in-frame loader so fast creates never flash it: false until the
* create has been pending past the debounce delay (or it errors). Until then
* the prior workspace content stays visible and a fast create swaps straight
* to its terminal. */
loaderVisible: boolean
error?: string
request: WorktreeCreationRequest
}
/** Human-readable progress line for an in-flight create, shared by the in-frame
* loader and the sidebar row so the two never drift. Caller handles the error
* case; this only covers the in-progress states. */
export function getCreationProgressLabel(
entry: Pick<PendingWorktreeCreation, 'phase' | 'indeterminate'>
): string {
if (entry.indeterminate) {
return 'Setting up your workspace…'
}
return entry.phase === 'creating' ? 'Creating worktree…' : 'Fetching base branch…'
}

View File

@ -39,6 +39,14 @@ import { resumeSleepingAgentSessionsForWorktree } from '@/lib/resume-sleeping-ag
* telemetry-plan.md§Agent launch semantics. */
export type AgentStartedTelemetry = EventProps<'agent_started'>
/** Startup command threaded onto a worktree's first terminal at activation. */
export type WorktreeStartupPayload = {
command: string
env?: Record<string, string>
initialAgentStatus?: { agent: TuiAgent; prompt: string }
telemetry?: AgentStartedTelemetry
}
// Why: issue commands can originate from two sources with different shapes —
// (1) a repo-level runner script generated by main (WorktreeSetupLaunch), or
// (2) a user-typed command template substituted in the TaskPage flow.
@ -109,13 +117,7 @@ export type ActivateAndRevealResult = {
primaryTabId: string | null
}
function buildCreatedAgentReopenStartup(worktree: Worktree):
| {
command: string
env?: Record<string, string>
telemetry: AgentStartedTelemetry
}
| undefined {
function buildCreatedAgentReopenStartup(worktree: Worktree): WorktreeStartupPayload | undefined {
const agent = worktree.createdWithAgent
if (!isTuiAgent(agent)) {
return undefined
@ -146,12 +148,7 @@ function buildCreatedAgentReopenStartup(worktree: Worktree):
export function activateAndRevealWorktree(
worktreeId: string,
opts?: {
startup?: {
command: string
env?: Record<string, string>
initialAgentStatus?: { agent: TuiAgent; prompt: string }
telemetry?: AgentStartedTelemetry
}
startup?: WorktreeStartupPayload
setup?: WorktreeSetupLaunch
defaultTabs?: WorktreeDefaultTabsLaunch
issueCommand?: IssueCommandLaunch
@ -304,12 +301,7 @@ export function ensureWebRuntimeWorktreeTerminalAfterWake(worktreeId: string): v
export function ensureWorktreeHasInitialTerminal(
store: WorktreeActivationStore,
worktreeId: string,
startup?: {
command: string
env?: Record<string, string>
initialAgentStatus?: { agent: TuiAgent; prompt: string }
telemetry?: AgentStartedTelemetry
},
startup?: WorktreeStartupPayload,
setup?: WorktreeSetupLaunch,
issueCommand?: IssueCommandLaunch,
defaultTabs?: WorktreeDefaultTabsLaunch
@ -374,14 +366,7 @@ export function ensureWorktreeHasInitialTerminal(
function applyDefaultTerminalTabs(
store: WorktreeActivationStore,
worktreeId: string,
startup:
| {
command: string
env?: Record<string, string>
initialAgentStatus?: { agent: TuiAgent; prompt: string }
telemetry?: AgentStartedTelemetry
}
| undefined,
startup: WorktreeStartupPayload | undefined,
setup: WorktreeSetupLaunch | undefined,
issueCommand: IssueCommandLaunch | undefined,
defaultTabs: WorktreeDefaultTabsLaunch | undefined

View File

@ -0,0 +1,237 @@
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
import {
activateAndRevealWorktree,
ensureWorktreeHasInitialTerminal,
type ActivateAndRevealResult,
type WorktreeStartupPayload
} from '@/lib/worktree-activation'
import { ensureAgentStartupInTerminal } from '@/lib/new-workspace'
import { queueNewWorkspaceTerminalFocus } from '@/lib/new-workspace-terminal-focus'
import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import {
formatWorkspaceCreateError,
getWorkspaceCreateErrorToastMessage
} from '@/lib/workspace-create-error-format'
import type { CreateWorktreeResult } from '../../../shared/types'
import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation'
// Why: most local creates finish in well under this window; holding the loader
// back this long means a fast create swaps prior content → terminal with no
// loader flash, while a genuinely slow create still surfaces one promptly.
const CREATION_LOADER_DEBOUNCE_MS = 280
// Why: mirrors the startup-opt the composer used to build inline. The renderer
// only seeds the first terminal when the backend did not already spawn it.
function buildStartupOpt(
request: WorktreeCreationRequest,
backendSpawned: boolean
): WorktreeStartupPayload | undefined {
const plan = request.startupPlan
if (!plan || backendSpawned) {
return undefined
}
return {
command: plan.launchCommand,
...(plan.env ? { env: plan.env } : {}),
// Why: command-code shows its prompt in the tab status before the first
// hook fires, so the prompt is threaded through here.
...(request.agent === 'command-code' && request.quickPrompt.trim().length > 0
? { initialAgentStatus: { agent: request.agent, prompt: request.quickPrompt.trim() } }
: {}),
...(request.quickTelemetry ? { telemetry: request.quickTelemetry } : {})
}
}
async function preflightAgentTrust(request: WorktreeCreationRequest, path: string): Promise<void> {
// Why: trust-gated agents (cursor-agent, copilot) consume the bracketed paste
// as menu input on first launch. Pre-write the trust artifact before any
// terminal spawns. Best-effort — the worktree already exists, so a failure
// here must not strand it.
if (!request.agent || !window.api.agentTrust?.markTrusted) {
return
}
const preflight = TUI_AGENT_CONFIG[request.agent].preflightTrust
if (!preflight) {
return
}
try {
await window.api.agentTrust.markTrusted({ preset: preflight, workspacePath: path })
} catch {
// Best-effort: continue with launch.
}
}
async function executeWorktreeCreation(
creationId: string,
request: WorktreeCreationRequest
): Promise<void> {
let result: CreateWorktreeResult
try {
result = await useAppStore
.getState()
.createWorktree(
request.repoId,
request.name,
request.baseBranch,
request.setupDecision,
request.sparseCheckout,
request.telemetrySource,
request.displayName,
request.linkedIssue,
request.linkedPR,
request.pushTarget,
request.agent ?? undefined,
request.linkedLinearIssue,
request.branchNameOverride,
request.workspaceStatus,
request.linkedGitLabMR,
request.linkedGitLabIssue,
request.startup,
request.pendingFirstAgentMessageRename,
creationId
)
} catch (error) {
// Why: a missing entry means the user cancelled mid-flight — abandon
// silently rather than surfacing an error for work they already dismissed.
if (!useAppStore.getState().pendingWorktreeCreations[creationId]) {
return
}
const message = getWorkspaceCreateErrorToastMessage(formatWorkspaceCreateError(error))
// Why: an error must surface immediately even if it lands before the loader
// debounce fired, so force the loader visible alongside the error.
useAppStore.getState().updatePendingWorktreeCreation(creationId, {
status: 'error',
error: message,
loaderVisible: true
})
// Why: only toast when the panel isn't already showing this error (the user
// navigated away), so a visible failure isn't announced twice.
if (useAppStore.getState().activePendingCreationId !== creationId) {
toast.error(message)
}
return
}
const worktree = result.worktree
// Why: if the user dismissed/cancelled while the create was in flight, the entry
// is gone. Git already made the worktree on disk, but don't auto-provision (trust
// write, terminal, agent, note) work they abandoned — it surfaces as a plain row
// via worktrees:changed and provisions lazily on first open.
if (!useAppStore.getState().pendingWorktreeCreations[creationId]) {
return
}
const backendSpawned = result.startupTerminal?.spawned === true
const startupOpt = buildStartupOpt(request, backendSpawned)
if (worktree.path) {
await preflightAgentTrust(request, worktree.path)
}
// `createWorktree` already inserted the real worktree row. Whether we steal
// the view depends on whether the user is still watching this creation.
const stillActive = useAppStore.getState().activePendingCreationId === creationId
let activation: ActivateAndRevealResult | false = false
let primaryTabId: string | null
if (stillActive) {
activation = activateAndRevealWorktree(worktree.id, {
sidebarRevealBehavior: 'auto',
...(result.setup ? { setup: result.setup } : {}),
...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}),
...(startupOpt ? { startup: startupOpt } : {})
})
primaryTabId = activation === false ? null : activation.primaryTabId
} else {
// The user moved on. Seed the worktree's terminal + setup in the background
// (setActiveTab only writes global focus for the active worktree, so this is
// safe) without yanking them back to it.
primaryTabId = ensureWorktreeHasInitialTerminal(
useAppStore.getState(),
worktree.id,
startupOpt,
result.setup,
undefined,
result.defaultTabs
)
}
// Why: clearing synchronously right after activation lets React commit the
// panel→terminal swap in one frame — no two-row flicker, no empty-terminal flash.
useAppStore.getState().removePendingWorktreeCreation(creationId)
if (request.startupPlan && !backendSpawned) {
void ensureAgentStartupInTerminal({
worktreeId: worktree.id,
primaryTabId,
startup: request.startupPlan
})
}
if (stillActive) {
queueNewWorkspaceTerminalFocus(worktree.id, activation)
}
// Why: awaiting the note IPC before the swap would add a visible round-trip to
// the panel→terminal transition; it's cosmetic, so it runs last.
if (request.note) {
try {
await useAppStore.getState().updateWorktreeMeta(worktree.id, { comment: request.note })
} catch {
console.error('Failed to update worktree meta after creation')
}
}
}
/**
* Kick off a worktree create in the background. The caller (the composer) has
* already resolved every interactive decision into `request`, so this returns
* immediately and the work outlives the now-closed modal. Progress and errors
* surface on the pending creation's sidebar row and content panel.
*/
export function runBackgroundWorktreeCreation(request: WorktreeCreationRequest): void {
const creationId = crypto.randomUUID()
const store = useAppStore.getState()
// Why: the remote/runtime create path emits no progress events, so the stepped
// checklist would freeze on step 1. Mark it indeterminate up front so the panel
// shows a single spinner instead of implying phase progress that never arrives.
const indeterminate = getActiveRuntimeTarget(store.settings).kind !== 'local'
store.beginPendingWorktreeCreation({
creationId,
phase: 'fetching',
status: 'creating',
indeterminate,
loaderVisible: false,
request
})
// Why: the creation panel only renders under the terminal view (App content
// router), so force it active so the panel is what fills the content area.
store.setActiveView('terminal')
store.setSidebarOpen(true)
// Why: debounce the loader so a fast create never flashes it. The prior
// workspace stays visible until the delay elapses; if the create resolves
// first, removePendingWorktreeCreation clears the entry and this update no-ops.
setTimeout(() => {
useAppStore.getState().updatePendingWorktreeCreation(creationId, { loaderVisible: true })
}, CREATION_LOADER_DEBOUNCE_MS)
void executeWorktreeCreation(creationId, request)
}
/** Re-run a failed creation from its panel, reusing the captured request. */
export function retryBackgroundWorktreeCreation(creationId: string): void {
const store = useAppStore.getState()
const entry = store.pendingWorktreeCreations[creationId]
if (!entry) {
return
}
store.updatePendingWorktreeCreation(creationId, {
status: 'creating',
phase: 'fetching',
error: undefined
})
store.setActivePendingWorktreeCreation(creationId)
store.setActiveView('terminal')
store.setSidebarOpen(true)
void executeWorktreeCreation(creationId, entry.request)
}

View File

@ -18,6 +18,10 @@ import type {
WorktreeMeta
} from '../../../../shared/types'
import type { TerminalGitHubPRLink } from '@/lib/terminal-github-pr-link-detector'
import type {
PendingWorktreeCreation,
WorktreeCreationPhase
} from '@/lib/pending-worktree-creation'
export { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id'
export type WorktreeDeleteState = {
@ -37,6 +41,21 @@ export type WorktreeSlice = {
detectedWorktreesByRepo: Record<string, DetectedWorktreeListResult>
worktreeLineageById: Record<string, WorktreeLineage>
activeWorktreeId: string | null
/**
* In-flight / failed background worktree creations, keyed by a renderer
* `creationId`. Kept separate from `worktreesByRepo` on purpose a real
* worktree row only exists once `git worktree add` succeeds, so faking one
* here would ripple through git-status, the tab model, persistence, and PTY
* spawning. Session-only; never persisted.
*/
pendingWorktreeCreations: Record<string, PendingWorktreeCreation>
/**
* The pending creation currently filling the workspace content area (the
* "Creating worktree…" panel). Distinct from `activeWorktreeId`, which stays
* strictly real, so navigating to/away from a pending creation never routes a
* fake id through `setActiveWorktree` or nav-history.
*/
activePendingCreationId: string | null
// Why: signals the matching worktree card's inline title editor to open. The
// workspace.rename shortcut sets this; the card clears it on consume.
renamingWorktreeId: string | null
@ -109,8 +128,29 @@ export type WorktreeSlice = {
linkedGitLabMR?: number,
linkedGitLabIssue?: number,
startup?: WorktreeStartupLaunch,
pendingFirstAgentMessageRename?: boolean
pendingFirstAgentMessageRename?: boolean,
/** When set, correlates the backend's `createWorktree:progress` events to a
* renderer pending creation. Synchronous callers omit it. */
creationId?: string
) => Promise<CreateWorktreeResult>
/** Register an in-flight background creation and make it the active surface. */
beginPendingWorktreeCreation: (entry: PendingWorktreeCreation) => void
/** Merge a status patch (phase/error/status/loaderVisible) into an existing
* pending entry. */
updatePendingWorktreeCreation: (
creationId: string,
patch: {
phase?: WorktreeCreationPhase
status?: 'creating' | 'error'
error?: string
loaderVisible?: boolean
}
) => void
/** Drop a pending entry (on success or dismiss), clearing the active surface
* if it pointed at this creation. */
removePendingWorktreeCreation: (creationId: string) => void
/** Point the content panel at a pending creation (or clear it with null). */
setActivePendingWorktreeCreation: (creationId: string | null) => void
prefetchWorktreeCreateBase: (repoId: string, baseBranch?: string) => Promise<void>
removeWorktree: (
worktreeId: string,

View File

@ -80,6 +80,7 @@ const mockApi = {
globalThis.window = { api: mockApi }
import { createWorktreeSlice } from './worktrees'
import type { PendingWorktreeCreation } from '@/lib/pending-worktree-creation'
import { getHostedReviewCacheKey } from './hosted-review'
import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from './github-cache-key'
import {
@ -3476,3 +3477,112 @@ describe('setWorktreesPinnedAndReveal', () => {
expect(store.getState().worktreesByRepo.repo1[2].isPinned).toBe(true)
})
})
function makePendingCreation(
creationId: string,
overrides: Partial<PendingWorktreeCreation> = {}
): PendingWorktreeCreation {
return {
creationId,
phase: 'fetching',
status: 'creating',
indeterminate: false,
loaderVisible: false,
request: {
repoId: 'repo1',
name: 'feature',
setupDecision: 'inherit',
agent: null,
pendingFirstAgentMessageRename: false,
note: '',
startupPlan: null,
quickPrompt: '',
quickTelemetry: null
},
...overrides
}
}
describe('pending worktree creation state', () => {
beforeEach(() => {
vi.clearAllMocks()
resetRemoteRuntimeMocks()
})
it('beginPendingWorktreeCreation registers the entry and makes it the active surface', () => {
const store = createTestStore()
store.getState().beginPendingWorktreeCreation(makePendingCreation('c1'))
expect(store.getState().pendingWorktreeCreations.c1).toBeDefined()
expect(store.getState().activePendingCreationId).toBe('c1')
})
it('updatePendingWorktreeCreation skips the write when the patch changes nothing', () => {
const store = createTestStore()
store.getState().beginPendingWorktreeCreation(makePendingCreation('c1'))
const before = store.getState().pendingWorktreeCreations
store.getState().updatePendingWorktreeCreation('c1', { phase: 'fetching' })
// Same map reference => no subscriber notification on a no-op progress event.
expect(store.getState().pendingWorktreeCreations).toBe(before)
})
it('updatePendingWorktreeCreation applies a real phase change', () => {
const store = createTestStore()
store.getState().beginPendingWorktreeCreation(makePendingCreation('c1'))
store.getState().updatePendingWorktreeCreation('c1', { phase: 'creating' })
expect(store.getState().pendingWorktreeCreations.c1.phase).toBe('creating')
})
it('updatePendingWorktreeCreation is a no-op for an unknown id', () => {
const store = createTestStore()
const before = store.getState().pendingWorktreeCreations
store.getState().updatePendingWorktreeCreation('missing', { status: 'error', error: 'x' })
expect(store.getState().pendingWorktreeCreations).toBe(before)
})
it('removePendingWorktreeCreation clears the active surface only when it points at the removed entry', () => {
const store = createTestStore()
store.getState().beginPendingWorktreeCreation(makePendingCreation('c1'))
store.getState().beginPendingWorktreeCreation(makePendingCreation('c2'))
// c2 is active now; removing the background c1 must not steal the surface.
store.getState().removePendingWorktreeCreation('c1')
expect(store.getState().pendingWorktreeCreations.c1).toBeUndefined()
expect(store.getState().activePendingCreationId).toBe('c2')
store.getState().removePendingWorktreeCreation('c2')
expect(store.getState().activePendingCreationId).toBeNull()
})
it('setActivePendingWorktreeCreation ignores unknown ids but always accepts null', () => {
const store = createTestStore()
store.getState().beginPendingWorktreeCreation(makePendingCreation('c1'))
store.getState().setActivePendingWorktreeCreation('missing')
expect(store.getState().activePendingCreationId).toBe('c1')
store.getState().setActivePendingWorktreeCreation(null)
expect(store.getState().activePendingCreationId).toBeNull()
})
it('setActiveWorktree dismisses the creation panel even when re-selecting the already-active worktree', () => {
const store = createTestStore()
const wt = makeWorktree({ id: 'repo1::/path/a', repoId: 'repo1' })
store.setState({
worktreesByRepo: { repo1: [wt] },
activeWorktreeId: wt.id,
activePendingCreationId: 'c1',
pendingWorktreeCreations: { c1: makePendingCreation('c1') }
} as unknown as Partial<AppState>)
store.getState().setActiveWorktree(wt.id)
expect(store.getState().activePendingCreationId).toBeNull()
})
})

View File

@ -761,6 +761,8 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
detectedWorktreesByRepo: {},
worktreeLineageById: {},
activeWorktreeId: null,
pendingWorktreeCreations: {},
activePendingCreationId: null,
renamingWorktreeId: null,
deleteStateByWorktreeId: {},
baseStatusByWorktreeId: {},
@ -1088,7 +1090,8 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
linkedGitLabMR,
linkedGitLabIssue,
startup,
pendingFirstAgentMessageRename
pendingFirstAgentMessageRename,
creationId
) => {
const retryableConflictPatterns = [
/already exists locally/i,
@ -1135,7 +1138,8 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
...(workspaceStatus !== undefined ? { workspaceStatus } : {}),
...(linkedGitLabMR !== undefined ? { linkedGitLabMR } : {}),
...(linkedGitLabIssue !== undefined ? { linkedGitLabIssue } : {}),
...(startup ? { startup } : {})
...(startup ? { startup } : {}),
...(creationId ? { creationId } : {})
}
const target = getActiveRuntimeTarget(get().settings)
const result =
@ -1226,6 +1230,62 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
}
},
beginPendingWorktreeCreation: (entry) => {
set((s) => ({
pendingWorktreeCreations: { ...s.pendingWorktreeCreations, [entry.creationId]: entry },
activePendingCreationId: entry.creationId
}))
},
updatePendingWorktreeCreation: (creationId, patch) => {
set((s) => {
const entry = s.pendingWorktreeCreations[creationId]
if (!entry) {
return {}
}
// Why: the main process re-emits the same phase across mutually-exclusive
// fetch paths; skip the write when nothing changes so the strip and panel
// don't re-render on a no-op progress event.
const hasChange = (Object.keys(patch) as (keyof typeof patch)[]).some(
(key) => patch[key] !== entry[key]
)
if (!hasChange) {
return {}
}
return {
pendingWorktreeCreations: {
...s.pendingWorktreeCreations,
[creationId]: { ...entry, ...patch }
}
}
})
},
removePendingWorktreeCreation: (creationId) => {
set((s) => {
if (!s.pendingWorktreeCreations[creationId]) {
return {}
}
const { [creationId]: _removed, ...rest } = s.pendingWorktreeCreations
return {
pendingWorktreeCreations: rest,
// Why: only clear the active surface if it pointed here, so dismissing a
// background creation the user already navigated away from doesn't yank
// them off whatever they're now looking at.
...(s.activePendingCreationId === creationId ? { activePendingCreationId: null } : {})
}
})
},
setActivePendingWorktreeCreation: (creationId) => {
set((s) => {
if (creationId !== null && !s.pendingWorktreeCreations[creationId]) {
return {}
}
return { activePendingCreationId: creationId }
})
},
removeWorktree: async (worktreeId, force) => {
set((s) => ({
deleteStateByWorktreeId: {
@ -2125,7 +2185,10 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
set((s) => {
if (!worktreeId) {
return {
activeWorktreeId: null
activeWorktreeId: null,
// Why: activating any real worktree (or clearing it) must dismiss the
// background-creation panel so the user isn't stranded on it.
activePendingCreationId: null
}
}
@ -2317,6 +2380,10 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
: { ...s.activeTabTypeByWorktree, [worktreeId]: activeTabType }
const hasStateChange =
s.activeWorktreeId !== worktreeId ||
// Why: a pending-creation panel can be showing while activeWorktreeId is
// still the prior worktree. Re-selecting that same worktree must clear
// the panel, so a non-null activePendingCreationId counts as a change.
s.activePendingCreationId !== null ||
s.activeFileId !== activeFileId ||
s.activeBrowserTabId !== activeBrowserTabId ||
s.activeTabType !== activeTabType ||
@ -2334,6 +2401,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
return {
activeWorktreeId: worktreeId,
activePendingCreationId: null,
activeFileId,
activeBrowserTabId,
activeTabType,

View File

@ -1035,6 +1035,9 @@ function createWorktreesApi(): NonNullable<Partial<PreloadApi>['worktrees']> {
manualOrder: args.manualOrder
})
},
// Why: the runtime create path emits no two-phase progress, so the web
// client's creation panel simply falls back to an indeterminate spinner.
onCreateProgress: () => noopUnsubscribe,
prefetchCreateBase: async ({ repoId, baseBranch }) => {
await callRuntimeResult('worktree.prefetchCreateBase', {
repo: repoId,

View File

@ -1635,6 +1635,10 @@ export type CreateWorktreeArgs = {
/** Optional startup command for callers that want the backend to spawn the
* first terminal as soon as the worktree is registered. */
startup?: WorktreeStartupLaunch
/** Correlates `createWorktree:progress` events back to a specific pending
* creation in the renderer, so concurrent background creates each drive
* their own status surface. Omitted by synchronous callers. */
creationId?: string
}
export type CreateWorktreeResult = {