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:
parent
5424582b51
commit
5337e93cb4
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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">Couldn’t 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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…'
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue