From 5337e93cb48fe0c728b79afcc56b98e3e48ea2cd Mon Sep 17 00:00:00 2001 From: Trevin Chow Date: Mon, 8 Jun 2026 13:30:13 -0700 Subject: [PATCH] feat: non-blocking worktree creation with in-tab progress (#4729) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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> --- src/main/ipc/worktree-remote.ts | 13 +- src/preload/api-types.ts | 7 + src/preload/index.ts | 11 + src/renderer/src/App.tsx | 28 ++- .../components/sidebar/PendingWorktreeRow.tsx | 90 +++++++ .../src/components/sidebar/WorktreeList.tsx | 52 +++- .../components/sidebar/worktree-drag-units.ts | 3 +- .../sidebar/worktree-list-groups.test.ts | 112 ++++++++- .../sidebar/worktree-list-groups.ts | 69 ++++- .../sidebar/worktree-list-virtual-rows.ts | 4 + .../WorktreeCreationPanel.tsx | 92 +++++++ src/renderer/src/hooks/useComposerState.ts | 117 +++------ src/renderer/src/hooks/useIpcEvents.ts | 12 + .../src/lib/pending-worktree-creation.ts | 90 +++++++ src/renderer/src/lib/worktree-activation.ts | 39 +-- .../src/lib/worktree-creation-flow.ts | 237 ++++++++++++++++++ .../src/store/slices/worktree-helpers.ts | 42 +++- .../src/store/slices/worktrees.test.ts | 110 ++++++++ src/renderer/src/store/slices/worktrees.ts | 74 +++++- src/renderer/src/web/web-preload-api.ts | 3 + src/shared/types.ts | 4 + 21 files changed, 1081 insertions(+), 128 deletions(-) create mode 100644 src/renderer/src/components/sidebar/PendingWorktreeRow.tsx create mode 100644 src/renderer/src/components/worktree-creation/WorktreeCreationPanel.tsx create mode 100644 src/renderer/src/lib/pending-worktree-creation.ts create mode 100644 src/renderer/src/lib/worktree-creation-flow.ts diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index eef2f9c94..e876168fc 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -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) { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 23a4c0021..fcb3753ba 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -819,6 +819,13 @@ export type PreloadApi = { listDetected: (args: { repoId: string }) => Promise listAll: () => Promise create: (args: CreateWorktreeArgs) => Promise + /** 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 resolvePrBase: (args: { repoId: string diff --git a/src/preload/index.ts b/src/preload/index.ts index df0e5e8a5..1995e9bd4 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index ad7ac8717..19a3a00c4 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 {
: null} {activeView === 'space' ? : null} {activeView === 'mobile' ? : null} - {activeView === 'terminal' && !activeWorktreeId ? : null} + {activeView === 'terminal' && + activeCreationLoaderVisible && + activePendingCreationId ? ( + + ) : null} + {activeView === 'terminal' && + !activeWorktreeId && + !activeCreationLoaderVisible ? ( + + ) : null}
diff --git a/src/renderer/src/components/sidebar/PendingWorktreeRow.tsx b/src/renderer/src/components/sidebar/PendingWorktreeRow.tsx new file mode 100644 index 000000000..1e224f534 --- /dev/null +++ b/src/renderer/src/components/sidebar/PendingWorktreeRow.tsx @@ -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 ( +
+ + +
+ ) +} diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index da049255f..bfe158c86 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -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 ( +
+ +
+ ) + } + 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 diff --git a/src/renderer/src/components/sidebar/worktree-drag-units.ts b/src/renderer/src/components/sidebar/worktree-drag-units.ts index 775feb0e8..29a8bc209 100644 --- a/src/renderer/src/components/sidebar/worktree-drag-units.ts +++ b/src/renderer/src/components/sidebar/worktree-drag-units.ts @@ -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) { diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index f3ee0dbbb..6b9ae31c9 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -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' }) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index 7741cb82f..0c58bf5d3 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -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 +): 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 = new Set(), - importedWorktreesByRepo: ReadonlyMap = new Map() + importedWorktreesByRepo: ReadonlyMap = new Map(), + pendingCreations: readonly PendingCreationRef[] = [] ): Row[] { const result: Row[] = [] + const pendingByRepo = new Map() + 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, { diff --git a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts index ff28d2b4e..89394356b 100644 --- a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts @@ -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 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 } diff --git a/src/renderer/src/components/worktree-creation/WorktreeCreationPanel.tsx b/src/renderer/src/components/worktree-creation/WorktreeCreationPanel.tsx new file mode 100644 index 000000000..b343a2d75 --- /dev/null +++ b/src/renderer/src/components/worktree-creation/WorktreeCreationPanel.tsx @@ -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 ( +
+ {/* 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. */} +
+
+ {isError ? ( + + ) : ( + // Why: a static worktree glyph (not a spinner) keeps the tab reading + // as a normal tab; the single loading spinner lives in the body. + + )} + {title} + +
+
+ + {/* 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. */} +
+ {isError ? ( +
+ Couldn’t create worktree + + {entry.error ?? 'Something went wrong while creating the worktree.'} + + + +
+ ) : ( +
+ + {getCreationProgressLabel(entry)} +
+ )} +
+
+ ) +} diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 7b9a389cb..399124995 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -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 10–15s; 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, diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 7c4f2b96b..3936345dd 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -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) => { diff --git a/src/renderer/src/lib/pending-worktree-creation.ts b/src/renderer/src/lib/pending-worktree-creation.ts new file mode 100644 index 000000000..50ba096ac --- /dev/null +++ b/src/renderer/src/lib/pending-worktree-creation.ts @@ -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 +): string { + if (entry.indeterminate) { + return 'Setting up your workspace…' + } + return entry.phase === 'creating' ? 'Creating worktree…' : 'Fetching base branch…' +} diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 38090b593..470682679 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -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 + 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 - 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 - 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 - 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 - initialAgentStatus?: { agent: TuiAgent; prompt: string } - telemetry?: AgentStartedTelemetry - } - | undefined, + startup: WorktreeStartupPayload | undefined, setup: WorktreeSetupLaunch | undefined, issueCommand: IssueCommandLaunch | undefined, defaultTabs: WorktreeDefaultTabsLaunch | undefined diff --git a/src/renderer/src/lib/worktree-creation-flow.ts b/src/renderer/src/lib/worktree-creation-flow.ts new file mode 100644 index 000000000..017a2c80f --- /dev/null +++ b/src/renderer/src/lib/worktree-creation-flow.ts @@ -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 { + // 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 { + 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) +} diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index 39751a6e9..4fabef17d 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -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 worktreeLineageById: Record 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 + /** + * 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 + /** 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 removeWorktree: ( worktreeId: string, diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index e23e7519a..d666b067a 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -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 { + 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) + + store.getState().setActiveWorktree(wt.id) + + expect(store.getState().activePendingCreationId).toBeNull() + }) +}) diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index a3ac8bef8..7eee0b108 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -761,6 +761,8 @@ export const createWorktreeSlice: StateCreator detectedWorktreesByRepo: {}, worktreeLineageById: {}, activeWorktreeId: null, + pendingWorktreeCreations: {}, + activePendingCreationId: null, renamingWorktreeId: null, deleteStateByWorktreeId: {}, baseStatusByWorktreeId: {}, @@ -1088,7 +1090,8 @@ export const createWorktreeSlice: StateCreator linkedGitLabMR, linkedGitLabIssue, startup, - pendingFirstAgentMessageRename + pendingFirstAgentMessageRename, + creationId ) => { const retryableConflictPatterns = [ /already exists locally/i, @@ -1135,7 +1138,8 @@ export const createWorktreeSlice: StateCreator ...(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 } }, + 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 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 : { ...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 return { activeWorktreeId: worktreeId, + activePendingCreationId: null, activeFileId, activeBrowserTabId, activeTabType, diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 029127549..666b4327d 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1035,6 +1035,9 @@ function createWorktreesApi(): NonNullable['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, diff --git a/src/shared/types.ts b/src/shared/types.ts index 02642283c..dfc4c4b1b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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 = {