feat: select parent worktree for nesting when creating workspace

- Record app-picked parents as manual actions, not CLI-flag equivalents,
  ensuring the same user action carries consistent cleanup semantics across hosts
- Gracefully retry without parent if the selection goes stale, warn the
  user instead of failing
- Refactor create into modules: parent resolution, payload building,
  state merge
This commit is contained in:
Jinjing
2026-08-27 11:41:44 -07:00
parent 988a590eeb
commit 143dead689
21 changed files with 1036 additions and 413 deletions
+55 -1
View File
@@ -3,7 +3,10 @@ import type { CreateWorktreeArgs } from '../../shared/worktree/create-types'
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
import type { Worktree } from '../../shared/worktree/types'
import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace-scope'
import { recordWorkspaceLineageForCreatedWorktree } from './worktree-remote'
import {
assertAttachableParentWorkspace,
recordWorkspaceLineageForCreatedWorktree
} from './worktree-remote'
const CHILD_ID = 'repo-1::/repos/child'
const PARENT_ID = 'repo-1::/repos/parent'
@@ -209,3 +212,54 @@ describe('recordWorkspaceLineageForCreatedWorktree', () => {
expect(result).toEqual({ lineage: null, workspaceLineage: null })
})
})
describe('assertAttachableParentWorkspace', () => {
const childKey = worktreeWorkspaceKey(CHILD_ID)
beforeEach(() => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
})
function assertParent(
store: ReturnType<typeof createStore>,
parentWorkspace: CreateWorktreeArgs['parentWorkspace']
) {
assertAttachableParentWorkspace(store as never, parentWorkspace, childKey)
}
// Why: the pick can go stale between the composer and the create; nesting is decoration, not the job.
it.each([
['worktree', () => worktreeWorkspaceKey(PARENT_ID)],
['folder', () => folderWorkspaceKey('folder-gone')]
])('degrades instead of failing when the %s parent disappeared', (_label, key) => {
const store = createStore({})
expect(() => assertParent(store, key())).not.toThrow()
expect(console.warn).toHaveBeenCalled()
})
it('accepts a parent that still exists', () => {
const store = createStore({ metaById: { [PARENT_ID]: parentMeta() } })
expect(() => assertParent(store, worktreeWorkspaceKey(PARENT_ID))).not.toThrow()
expect(console.warn).not.toHaveBeenCalled()
})
it('rejects a self-referential parent', () => {
const store = createStore({ metaById: { [CHILD_ID]: parentMeta() } })
expect(() => assertParent(store, childKey)).toThrow(/cannot be attached to itself/)
})
it('rejects a malformed parent key', () => {
const store = createStore({})
expect(() => assertParent(store, 'not-a-workspace-key' as never)).toThrow(
/Invalid parent workspace/
)
})
})
+10 -5
View File
@@ -198,7 +198,11 @@ function getSetupRunnerCommandPlatformForLaunch(
return getSetupRunnerCommandPlatformForPath(setup?.runnerScriptPath ?? '', fallbackPlatform)
}
function validateWorkspaceLineageParentBeforeCreate(
/** Why not throw on a missing parent: nesting is optional decoration, and the pick can go stale
* between the composer and the create. A malformed or self-referential key is a bad request and
* still fails; a parent that simply disappeared degrades to an unattached workspace, matching the
* runtime path and `recordWorkspaceLineageForCreatedWorktree`. */
export function assertAttachableParentWorkspace(
store: Store,
parentWorkspace: CreateWorktreeArgs['parentWorkspace'],
childWorkspaceKey: ReturnType<typeof worktreeWorkspaceKey>
@@ -214,10 +218,11 @@ function validateWorkspaceLineageParentBeforeCreate(
throw new Error(`Invalid parent workspace: ${parentWorkspace}`)
}
if (parentScope.type === 'folder' && !store.getFolderWorkspace(parentScope.folderWorkspaceId)) {
throw new Error(`Parent folder workspace not found: ${parentWorkspace}`)
console.warn(`[worktree-create] parent folder workspace not found: ${parentWorkspace}`)
return
}
if (parentScope.type === 'worktree' && !store.getWorktreeMeta(parentScope.worktreeId)) {
throw new Error(`Parent worktree workspace not found: ${parentWorkspace}`)
console.warn(`[worktree-create] parent worktree workspace not found: ${parentWorkspace}`)
}
}
@@ -1746,7 +1751,7 @@ export async function createRemoteWorktree(
)
}
validateWorkspaceLineageParentBeforeCreate(
assertAttachableParentWorkspace(
store,
args.parentWorkspace,
worktreeWorkspaceKey(`${repo.id}::${remotePath}`)
@@ -2389,7 +2394,7 @@ export async function createLocalWorktree(
)
}
validateWorkspaceLineageParentBeforeCreate(
assertAttachableParentWorkspace(
store,
args.parentWorkspace,
worktreeWorkspaceKey(`${repo.id}::${worktreePath}`)
+46
View File
@@ -6468,6 +6468,52 @@ describe('OrcaRuntimeService', () => {
}
})
// Why: the desktop composer sends `parentWorkspace` too, and a bare selector defaults to CLI
// provenance — the same user action must not carry different cleanup semantics per host.
it('records an app-selected parent workspace as a manual action', async () => {
vi.mocked(addWorktree).mockClear()
const created = {
path: '/tmp/workspaces/manual-child',
head: 'def',
branch: 'refs/heads/manual-child',
isBare: false,
isMainWorktree: false
}
const childId = `${TEST_REPO_ID}::${created.path}`
const metaById: Record<string, WorktreeMeta> = {}
const runtimeStore = {
...createFolderWorkspaceRuntimeStore(),
getAllWorktreeMeta: () => metaById,
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
setWorktreeMeta: (worktreeId: string, meta: Partial<WorktreeMeta>) => {
metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta }
return metaById[worktreeId]
},
setWorkspaceLineage: vi.fn((lineage: WorkspaceLineage) => lineage)
}
computeWorktreePathMock.mockReturnValue(created.path)
ensurePathWithinWorkspaceMock.mockImplementation((pathValue: string) => pathValue)
vi.mocked(listWorktrees).mockResolvedValueOnce([created])
const runtime = new OrcaRuntimeService(runtimeStore as never)
const result = await runtime.createManagedWorktree({
repoSelector: TEST_REPO_ID,
name: 'manual-child',
baseBranch: 'origin/main',
lineage: {
parentWorkspace: TEST_FOLDER_WORKSPACE_KEY,
parentWorkspaceOrigin: 'manual'
}
})
expect(result.workspaceLineage).toMatchObject({
childWorkspaceKey: `worktree:${childId}`,
parentWorkspaceKey: TEST_FOLDER_WORKSPACE_KEY,
origin: 'manual',
capture: { source: 'active-workspace', confidence: 'explicit' }
})
})
it('records folder workspace lineage inferred from environment context', async () => {
vi.mocked(addWorktree).mockClear()
const created = {
+14 -3
View File
@@ -2885,6 +2885,8 @@ type ResolvedTerminalWorkspaceLaunchTarget = {
type WorktreeLineageInput = {
parentWorkspace?: string
/** Set by in-app parent pickers so the row is not recorded as a CLI flag. */
parentWorkspaceOrigin?: 'manual'
envParentWorkspace?: string
parentWorktree?: string
cwdParentWorktree?: string
@@ -32501,11 +32503,20 @@ export class OrcaRuntimeService {
if (input.parentWorkspace) {
try {
const parent = await this.resolveWorkspaceParentSelector(input.parentWorkspace)
// Why: a picker in the app must record the same provenance as a local create, or the same
// user action would carry different cleanup semantics depending on where the repo lives.
return {
kind: 'lineage',
parent: await this.resolveWorkspaceParentSelector(input.parentWorkspace),
origin: 'cli',
capture: { source: 'explicit-cli-flag', confidence: 'explicit' }
parent,
origin: input.parentWorkspaceOrigin === 'manual' ? 'manual' : 'cli',
capture:
input.parentWorkspaceOrigin === 'manual'
? {
source: parent.type === 'worktree' ? 'manual-action' : 'active-workspace',
confidence: 'explicit'
}
: { source: 'explicit-cli-flag', confidence: 'explicit' }
}
} catch (err) {
throw new RuntimeLineageError(
@@ -26,4 +26,19 @@ describe('buildManagedWorktreeCreateArgs', () => {
nameWasGenerated: true
})
})
it('carries the parent-pick provenance only when the client marked it manual', () => {
// Why: older clients never send it, and those creates really are CLI-flag equivalents.
expect(
build({ repo: 'id:repo-1', name: 'child', parentWorkspace: 'folder:f1' }).lineage
).not.toHaveProperty('parentWorkspaceOrigin')
expect(
build({
repo: 'id:repo-1',
name: 'child',
parentWorkspace: 'folder:f1',
parentWorkspaceOrigin: 'manual'
}).lineage
).toMatchObject({ parentWorkspaceOrigin: 'manual' })
})
})
@@ -80,6 +80,7 @@ export function buildManagedWorktreeCreateArgs(
startupDraft: params.startupDraft,
lineage: {
parentWorkspace: params.parentWorkspace,
...(params.parentWorkspaceOrigin ? { parentWorkspaceOrigin: 'manual' as const } : {}),
envParentWorkspace: params.envParentWorkspace,
parentWorktree: params.parentWorktree,
...(params.cwdParentWorktree ? { cwdParentWorktree: params.cwdParentWorktree } : {}),
@@ -75,6 +75,9 @@ export const WorktreeCreate = z
// startup and defaultTabs. Pass an explicit target to opt into an all-surface reveal.
navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional(),
parentWorkspace: OptionalString,
// Why: an app-selected parent is a manual action, not the CLI's `--parent-workspace` flag.
// Absent keeps the CLI provenance older clients rely on.
parentWorkspaceOrigin: z.literal('manual').optional(),
envParentWorkspace: OptionalString,
parentWorktree: OptionalString,
cwdParentWorktree: OptionalString,
+7 -4
View File
@@ -1145,9 +1145,11 @@ html.native-shell .app-layout {
/* ── Jump palette (Cmd+J) ─────────────────────────────── */
/* Why the data-slot qualifier: CommandItem also sets data-[selected=true]:bg-accent;
this selector must beat that utility so light-mode selection is actually visible. */
[data-slot='command-item'].jump-palette-item[data-selected='true'] {
/* Why the extra qualifier: CommandItem also sets data-[selected=true]:bg-accent;
this selector must beat that utility so light-mode selection is actually visible.
The role='option' arm covers virtualized rows that render outside cmdk. */
[data-slot='command-item'].jump-palette-item[data-selected='true'],
[role='option'].jump-palette-item[data-selected='true'] {
/* Why: flat --accent (#f5f5f5) on the palette surface (#fff) is nearly invisible
in light mode — the keyboard cursor has to read as a solid selected row.
Mix foreground into background and draw an inset ring; expose the surface
@@ -1159,7 +1161,8 @@ html.native-shell .app-layout {
color: var(--foreground);
}
.dark [data-slot='command-item'].jump-palette-item[data-selected='true'] {
.dark [data-slot='command-item'].jump-palette-item[data-selected='true'],
.dark [role='option'].jump-palette-item[data-selected='true'] {
/* Why accent, not the light-mode mix: --accent (#404040) already reads clearly on
dark surfaces; a low-%% foreground mix was a visible step down from the old row. */
--jump-palette-selection-surface: var(--accent);
@@ -371,10 +371,8 @@ function ParentWorktreeCandidateList({
role="option"
aria-selected={(candidate?.id ?? null) === value}
data-selected={isHighlighted || undefined}
className={cn(
'absolute left-0 top-0 flex w-full cursor-default select-none items-center gap-2 overflow-hidden rounded-sm px-2 py-2 text-sm outline-none',
isHighlighted && 'bg-accent text-accent-foreground'
)}
// Why `jump-palette-item`: selection chrome lives in main.css — flat accent is invisible on light popovers.
className="jump-palette-item absolute left-0 top-0 flex w-full cursor-default select-none items-center gap-2 overflow-hidden rounded-sm px-2 py-2 text-sm outline-none"
style={{
height: virtualRow.size,
transform: `translateY(${virtualRow.start}px)`
@@ -8,6 +8,7 @@ import {
export type RuntimeEnvironmentCallRequest = {
method: string
params?: Record<string, unknown>
}
export function createCompatibleRuntimeStatusResponse(
@@ -1,3 +1,4 @@
import type { CreateWorktreeCallOptions } from './worktrees/create/worktree-create-payload'
import type { WorkspaceKey } from '../../../../shared/folder-workspace-types'
import type { TuiAgent } from '../../../../shared/tui-agent'
import type { WorkspaceSource as WorkspaceCreateTelemetrySource } from '../../../../shared/workspace-source'
@@ -7,7 +8,6 @@ import type {
} from '../../../../shared/worktree/base-ref-drift-types'
import type {
CreateSparseCheckoutRequest,
CreateWorktreeArgs,
CreateWorktreeResult,
ForceDeleteWorktreeBranchResult,
SetupDecision
@@ -19,11 +19,9 @@ import type {
DetectedWorktree,
DetectedWorktreeListResult,
GitPushTarget,
WorkspaceLinkedItem,
WorkspaceStatus,
Worktree
} from '../../../../shared/worktree/types'
import type { TaskSourceContext } from '../../../../shared/task-source-context'
import type { WorktreeRemovalTarget } from '../../../../shared/worktree/removal'
import type { TerminalGitHubPRLink } from '../../../../shared/terminal-github-pr-link-detector'
import type { ExecutionHostId } from '../../../../shared/execution-host'
@@ -198,22 +196,7 @@ export type WorktreeSlice = {
linkedAzureDevOpsPR?: number | null,
linkedGiteaPR?: number | null,
compareBaseRef?: string,
options?: {
automationProvenanceRequest?: CreateWorktreeArgs['automationProvenanceRequest']
linkedWorkItem?: WorkspaceLinkedItem | null
linkedTaskSourceContext?: TaskSourceContext | null
/** Lets the owning runtime launch and prefill a task agent without first creating an idle shell. */
startupDraft?: string
/** True only when `name` came from the creature-name generator; gates host-side retirement. */
nameWasGenerated?: boolean
/** Parent picked in the composer. Sets sidebar nesting only; ignored if it no longer exists. */
parentWorktreeId?: string
provisionedRoot?: {
runtimeId: string
executionHostId: ExecutionHostId
expectedPath: string
}
}
options?: CreateWorktreeCallOptions
) => Promise<CreateWorktreeResult>
/** Register an in-flight background creation and make it the active surface. */
beginPendingWorktreeCreation: (entry: PendingWorktreeCreation) => void
@@ -1,11 +1,10 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AppState } from '../types'
import type { LocalBaseRefRefreshResult } from '../../../../shared/worktree/base-ref-drift-types'
import type { Worktree } from '../../../../shared/worktree/types'
import { toast } from 'sonner'
import type { RuntimeEnvironmentCallRequest } from '../../runtime/runtime-compatibility-test-fixture'
import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope'
import { makeLineage, makeWorkspaceLineage, makeWorktree } from './worktrees-slice-test-fixtures'
import { makeWorkspaceLineage, makeWorktree } from './worktrees-slice-test-fixtures'
import {
createTestStore,
mockApi,
@@ -727,131 +726,3 @@ describe('createWorktree base status merge', () => {
})
})
})
describe('createWorktree composer parent pick', () => {
beforeEach(() => {
vi.clearAllMocks()
resetRemoteRuntimeMocks()
})
function createParentPickStore(parent?: Worktree) {
const store = createTestStore()
store.setState({ worktreesByRepo: { repo1: parent ? [parent] : [] } } as Partial<AppState>)
return store
}
function createWithParentPick(
store: ReturnType<typeof createTestStore>,
parentWorktreeId: string
) {
const createWorktree = store.getState().createWorktree
const args: Parameters<typeof createWorktree> = ['repo1', 'feature', 'origin/main']
args[25] = { parentWorktreeId }
return createWorktree(...args)
}
it('nests the new workspace under the picked parent worktree', async () => {
const parent = makeWorktree({
id: 'repo1::/path/parent',
repoId: 'repo1',
path: '/path/parent',
instanceId: 'parent-instance'
})
const store = createParentPickStore(parent)
store.setState({ activeWorkspaceKey: folderWorkspaceKey('folder-1') } as Partial<AppState>)
mockApi.worktrees.create.mockResolvedValue({
worktree: makeWorktree({ id: 'repo1::/path/child', repoId: 'repo1', path: '/path/child' })
})
await createWithParentPick(store, parent.id)
expect(mockApi.worktrees.create).toHaveBeenCalledWith(
expect.objectContaining({ parentWorkspace: worktreeWorkspaceKey(parent.id) })
)
})
it('drops a stale parent pick and falls back to the active folder workspace', async () => {
const store = createParentPickStore()
store.setState({ activeWorkspaceKey: folderWorkspaceKey('folder-1') } as Partial<AppState>)
mockApi.worktrees.create.mockResolvedValue({
worktree: makeWorktree({ id: 'repo1::/path/child', repoId: 'repo1', path: '/path/child' })
})
await createWithParentPick(store, 'repo1::/path/removed')
expect(mockApi.worktrees.create).toHaveBeenCalledWith(
expect.objectContaining({ parentWorkspace: folderWorkspaceKey('folder-1') })
)
})
it('warns when an archived parent pick is dropped before create', async () => {
const parent = makeWorktree({
id: 'repo1::/path/parent',
repoId: 'repo1',
path: '/path/parent',
displayName: 'parent-wt',
isArchived: true
})
const store = createParentPickStore(parent)
mockApi.worktrees.create.mockResolvedValue({
worktree: makeWorktree({ id: 'repo1::/path/child', repoId: 'repo1', path: '/path/child' })
})
await createWithParentPick(store, parent.id)
expect(toast.warning).toHaveBeenCalledWith(
'Created without nesting under "parent-wt"',
expect.objectContaining({ description: expect.any(String) })
)
})
it('warns when the backend rejects an accepted parent pick', async () => {
const parent = makeWorktree({
id: 'repo1::/path/parent',
repoId: 'repo1',
path: '/path/parent',
displayName: 'parent-wt',
instanceId: 'parent-instance'
})
const store = createParentPickStore(parent)
mockApi.worktrees.create.mockResolvedValue({
worktree: makeWorktree({ id: 'repo1::/path/child', repoId: 'repo1', path: '/path/child' }),
lineage: null
})
await createWithParentPick(store, parent.id)
expect(toast.warning).toHaveBeenCalledWith(
'Created without nesting under "parent-wt"',
expect.objectContaining({ description: expect.any(String) })
)
})
it('keeps an accepted parent pick quiet and seeds its lineage', async () => {
const parent = makeWorktree({
id: 'repo1::/path/parent',
repoId: 'repo1',
path: '/path/parent',
instanceId: 'parent-instance'
})
const created = makeWorktree({
id: 'repo1::/path/child',
repoId: 'repo1',
path: '/path/child',
instanceId: 'child-instance'
})
const lineage = makeLineage({
worktreeId: created.id,
worktreeInstanceId: 'child-instance',
parentWorktreeId: parent.id,
parentWorktreeInstanceId: 'parent-instance'
})
const store = createParentPickStore(parent)
mockApi.worktrees.create.mockResolvedValue({ worktree: created, lineage })
await createWithParentPick(store, parent.id)
expect(toast.warning).not.toHaveBeenCalled()
expect(store.getState().worktreeLineageById[created.id]).toEqual(lineage)
})
})
@@ -0,0 +1,388 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AppState } from '../types'
import type { Worktree } from '../../../../shared/worktree/types'
import { toast } from 'sonner'
import type { RuntimeEnvironmentCallRequest } from '../../runtime/runtime-compatibility-test-fixture'
import { worktreeWorkspaceKey, folderWorkspaceKey } from '../../../../shared/workspace-scope'
import { makeLineage, makeWorktree } from './worktrees-slice-test-fixtures'
import {
createTestStore,
mockApi,
resetRemoteRuntimeMocks,
resetWorktreeSliceModuleMemory,
runtimeEnvironmentCall
} from './worktrees-slice-test-harness'
vi.mock('sonner', () => ({
toast: {
warning: vi.fn(),
info: vi.fn(),
success: vi.fn(),
error: vi.fn(),
dismiss: vi.fn()
}
}))
vi.mock('@/components/worktree-base-fallback-notice', () => ({
requestWorktreeBaseFallbackNotice: vi.fn()
}))
beforeEach(resetWorktreeSliceModuleMemory)
describe('createWorktree composer parent pick', () => {
beforeEach(() => {
vi.clearAllMocks()
resetRemoteRuntimeMocks()
})
function createParentPickStore(parent?: Worktree) {
const store = createTestStore()
store.setState({ worktreesByRepo: { repo1: parent ? [parent] : [] } } as Partial<AppState>)
return store
}
function createWithParentPick(
store: ReturnType<typeof createTestStore>,
parentWorktreeId: string
) {
const createWorktree = store.getState().createWorktree
const args: Parameters<typeof createWorktree> = ['repo1', 'feature', 'origin/main']
args[25] = { parentWorktreeId }
return createWorktree(...args)
}
it('nests the new workspace under the picked parent worktree', async () => {
const parent = makeWorktree({
id: 'repo1::/path/parent',
repoId: 'repo1',
path: '/path/parent',
instanceId: 'parent-instance'
})
const store = createParentPickStore(parent)
store.setState({ activeWorkspaceKey: folderWorkspaceKey('folder-1') } as Partial<AppState>)
mockApi.worktrees.create.mockResolvedValue({
worktree: makeWorktree({ id: 'repo1::/path/child', repoId: 'repo1', path: '/path/child' })
})
await createWithParentPick(store, parent.id)
expect(mockApi.worktrees.create).toHaveBeenCalledWith(
expect.objectContaining({ parentWorkspace: worktreeWorkspaceKey(parent.id) })
)
})
it('drops a stale parent pick and falls back to the active folder workspace', async () => {
const store = createParentPickStore()
store.setState({ activeWorkspaceKey: folderWorkspaceKey('folder-1') } as Partial<AppState>)
mockApi.worktrees.create.mockResolvedValue({
worktree: makeWorktree({ id: 'repo1::/path/child', repoId: 'repo1', path: '/path/child' })
})
await createWithParentPick(store, 'repo1::/path/removed')
expect(mockApi.worktrees.create).toHaveBeenCalledWith(
expect.objectContaining({ parentWorkspace: folderWorkspaceKey('folder-1') })
)
})
it('warns when an archived parent pick is dropped before create', async () => {
const parent = makeWorktree({
id: 'repo1::/path/parent',
repoId: 'repo1',
path: '/path/parent',
displayName: 'parent-wt',
isArchived: true
})
const store = createParentPickStore(parent)
mockApi.worktrees.create.mockResolvedValue({
worktree: makeWorktree({ id: 'repo1::/path/child', repoId: 'repo1', path: '/path/child' })
})
await createWithParentPick(store, parent.id)
expect(toast.warning).toHaveBeenCalledWith(
'Created without nesting under "parent-wt"',
expect.objectContaining({ description: expect.any(String) })
)
})
it('warns when the backend rejects an accepted parent pick', async () => {
const parent = makeWorktree({
id: 'repo1::/path/parent',
repoId: 'repo1',
path: '/path/parent',
displayName: 'parent-wt',
instanceId: 'parent-instance'
})
const store = createParentPickStore(parent)
mockApi.worktrees.create.mockResolvedValue({
worktree: makeWorktree({ id: 'repo1::/path/child', repoId: 'repo1', path: '/path/child' }),
lineage: null
})
await createWithParentPick(store, parent.id)
expect(toast.warning).toHaveBeenCalledWith(
'Created without nesting under "parent-wt"',
expect.objectContaining({ description: expect.any(String) })
)
})
it('keeps an accepted parent pick quiet and seeds its lineage', async () => {
const parent = makeWorktree({
id: 'repo1::/path/parent',
repoId: 'repo1',
path: '/path/parent',
instanceId: 'parent-instance'
})
const created = makeWorktree({
id: 'repo1::/path/child',
repoId: 'repo1',
path: '/path/child',
instanceId: 'child-instance'
})
const lineage = makeLineage({
worktreeId: created.id,
worktreeInstanceId: 'child-instance',
parentWorktreeId: parent.id,
parentWorktreeInstanceId: 'parent-instance'
})
const store = createParentPickStore(parent)
mockApi.worktrees.create.mockResolvedValue({ worktree: created, lineage })
await createWithParentPick(store, parent.id)
expect(toast.warning).not.toHaveBeenCalled()
expect(store.getState().worktreeLineageById[created.id]).toEqual(lineage)
})
// Why: paired web clients route window.api.worktrees.create to their host, which still rejects.
it('retries without the parent when the create API reports the parent is gone', async () => {
const parent = makeWorktree({
id: 'repo1::/path/parent',
repoId: 'repo1',
path: '/path/parent',
displayName: 'parent-wt',
instanceId: 'parent-instance'
})
const store = createParentPickStore(parent)
mockApi.worktrees.create
.mockRejectedValueOnce(
Object.assign(new Error('Parent selector was not found.'), {
code: 'LINEAGE_PARENT_NOT_FOUND'
})
)
.mockResolvedValue({
worktree: makeWorktree({ id: 'repo1::/path/child', repoId: 'repo1', path: '/path/child' })
})
await createWithParentPick(store, parent.id)
expect(mockApi.worktrees.create).toHaveBeenCalledTimes(2)
expect(mockApi.worktrees.create.mock.calls[1][0]).not.toHaveProperty('parentWorkspace')
expect(toast.warning).toHaveBeenCalledTimes(1)
})
it('warns once when a dropped pick is followed by branch-conflict retries', async () => {
const store = createParentPickStore()
const created = makeWorktree({
id: 'repo1::/path/child',
repoId: 'repo1',
path: '/path/child'
})
mockApi.worktrees.create
.mockRejectedValueOnce(new Error('Branch "feature" already exists locally.'))
.mockResolvedValue({ worktree: created })
await createWithParentPick(store, 'repo1::/path/removed')
expect(mockApi.worktrees.create).toHaveBeenCalledTimes(2)
expect(toast.warning).toHaveBeenCalledTimes(1)
})
})
describe('createWorktree parent pick on a remote runtime', () => {
const REMOTE_REPO = 'repo-remote'
const PARENT_ID = `${REMOTE_REPO}::/remote/parent`
beforeEach(() => {
vi.clearAllMocks()
resetRemoteRuntimeMocks()
})
function createRemoteStore() {
const store = createTestStore()
store.setState({
repos: [
{
id: REMOTE_REPO,
path: '/home/dev/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
executionHostId: 'runtime:env-1'
}
],
worktreesByRepo: {
[REMOTE_REPO]: [
makeWorktree({
id: PARENT_ID,
repoId: REMOTE_REPO,
path: '/remote/parent',
displayName: 'parent-wt',
instanceId: 'parent-instance'
})
]
}
} as Partial<AppState>)
return store
}
function createOnRemote(store: ReturnType<typeof createTestStore>) {
const createWorktree = store.getState().createWorktree
const args: Parameters<typeof createWorktree> = [REMOTE_REPO, 'feature', 'origin/main']
args[25] = { parentWorktreeId: PARENT_ID }
return createWorktree(...args)
}
function createCalls() {
return runtimeEnvironmentCall.mock.calls
.map(([request]: [RuntimeEnvironmentCallRequest]) => request)
.filter((request) => request.method === 'worktree.create')
}
function mockRuntimeCreate(firstCreate: (id: string) => unknown) {
let createCount = 0
runtimeEnvironmentCall.mockImplementation(({ method }: RuntimeEnvironmentCallRequest) => {
if (method !== 'worktree.create') {
return Promise.resolve({ id: method, ok: true, result: null })
}
createCount += 1
return createCount === 1
? Promise.resolve(firstCreate('rpc-create'))
: Promise.resolve({
id: 'rpc-create',
ok: true,
result: {
worktree: makeWorktree({
id: `${REMOTE_REPO}::/remote/child`,
repoId: REMOTE_REPO,
path: '/remote/child'
})
}
})
})
}
it('marks an app-selected parent as manual so the host does not record a CLI flag', async () => {
mockRuntimeCreate((id) => ({
id,
ok: true,
result: {
worktree: makeWorktree({
id: `${REMOTE_REPO}::/remote/child`,
repoId: REMOTE_REPO,
path: '/remote/child'
}),
lineage: makeLineage({
worktreeId: `${REMOTE_REPO}::/remote/child`,
parentWorktreeId: PARENT_ID
})
}
}))
await createOnRemote(createRemoteStore())
expect(createCalls()[0]?.params).toMatchObject({
parentWorkspace: worktreeWorkspaceKey(PARENT_ID),
parentWorkspaceOrigin: 'manual'
})
expect(toast.warning).not.toHaveBeenCalled()
})
it('retries without the parent and warns when the host no longer has it', async () => {
mockRuntimeCreate((id) => ({
id,
ok: false,
error: { code: 'LINEAGE_PARENT_NOT_FOUND', message: 'Parent selector was not found.' }
}))
await createOnRemote(createRemoteStore())
const calls = createCalls()
expect(calls).toHaveLength(2)
expect(calls[1]?.params).not.toHaveProperty('parentWorkspace')
expect(toast.warning).toHaveBeenCalledWith(
'Created without nesting under "parent-wt"',
expect.objectContaining({ description: expect.any(String) })
)
})
it('retries when a transport re-wraps the parent-missing code into a plain error', async () => {
let createCount = 0
runtimeEnvironmentCall.mockImplementation(({ method }: RuntimeEnvironmentCallRequest) => {
if (method !== 'worktree.create') {
return Promise.resolve({ id: method, ok: true, result: null })
}
createCount += 1
// Why: the relay envelope re-throws as a plain Error, so only the trailing code survives.
return createCount === 1
? Promise.reject(new Error('Error invoking remote method: LINEAGE_PARENT_NOT_FOUND'))
: Promise.resolve({
id: 'rpc-create',
ok: true,
result: {
worktree: makeWorktree({
id: `${REMOTE_REPO}::/remote/child`,
repoId: REMOTE_REPO,
path: '/remote/child'
})
}
})
})
await createOnRemote(createRemoteStore())
expect(createCalls()).toHaveLength(2)
expect(toast.warning).toHaveBeenCalledTimes(1)
})
// Why: the folder parent comes from the active scope, not a pick, so nothing else would surface it.
it('warns when the retry drops an active folder-workspace parent', async () => {
const store = createRemoteStore()
store.setState({
activeWorkspaceKey: folderWorkspaceKey('folder-1'),
folderWorkspaces: [{ id: 'folder-1', name: 'Design docs' }]
} as unknown as Partial<AppState>)
mockRuntimeCreate((id) => ({
id,
ok: false,
error: { code: 'LINEAGE_PARENT_NOT_FOUND', message: 'Parent selector was not found.' }
}))
const createWorktree = store.getState().createWorktree
await createWorktree(REMOTE_REPO, 'feature', 'origin/main')
const calls = createCalls()
expect(calls).toHaveLength(2)
expect(calls[0]?.params).toMatchObject({ parentWorkspace: folderWorkspaceKey('folder-1') })
expect(calls[1]?.params).not.toHaveProperty('parentWorkspace')
expect(toast.warning).toHaveBeenCalledWith(
'Created without nesting under "Design docs"',
expect.objectContaining({ description: expect.any(String) })
)
})
it('never double-creates when the host fails for an unrelated reason', async () => {
mockRuntimeCreate((id) => ({
id,
ok: false,
error: { code: 'internal_error', message: 'disk on fire' }
}))
await expect(createOnRemote(createRemoteStore())).rejects.toThrow('disk on fire')
expect(createCalls()).toHaveLength(1)
expect(toast.warning).not.toHaveBeenCalled()
})
})
@@ -1,19 +1,12 @@
import type { WorktreeSlice } from '../../worktree-helpers'
import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types'
import type { CreateWorktreeResult } from '../../../../../../shared/worktree/create-types'
import {
CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS,
getClientWorktreeCreateCandidate,
getGeneratedWorktreeCreateRetryCandidate,
isRetryableWorktreeCreateConflict
} from '../../../../../../shared/new-workspace/worktree-create-retry-policy'
import {
parseWorkspaceKey,
folderWorkspaceKey,
worktreeWorkspaceKey
} from '../../../../../../shared/workspace-scope'
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { resolveWorktreeDisplayName } from '@/lib/worktree-default-display-name'
import {
assertRuntimeEnvironmentCapability,
callRuntimeRpc,
@@ -23,33 +16,84 @@ import { WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY } from '../../../.
import { showLocalBaseRefUpdateSuggestionToast } from '@/components/sidebar/local-base-ref-suggestion-toast'
import { requestWorktreeBaseFallbackNotice } from '@/components/worktree-base-fallback-notice'
import { showLocalBaseRefRefreshToast } from './local-base-ref-refresh-toast'
import {
getProjectHostSetupForRepoHost,
repoHostId,
withRepoHostOwnership
} from '../listing/worktree-host-ownership'
import { settingsForRepoOwner } from '../listing/worktree-owner-settings'
import { getIndexedWorktreeById } from '@/store/worktree-repo-index'
import { applyCreatedWorktree } from './created-worktree-state-merge'
import { isRuntimeLineageParentMissingError } from '../listing/runtime-worktree-rpc-errors'
import {
buildLocalWorktreeCreateArgs,
buildRuntimeWorktreeCreateParams,
type WorktreeCreateAttempt,
type WorktreeCreateRequest
} from './worktree-create-payload'
import {
notifyWorktreeParentDropped,
resolveWorktreeCreateParent,
type WorktreeCreateParentPick
} from './worktree-create-parent-pick'
function notifyPickedParentDropped(parentDisplayName: string | null): void {
toast.warning(
parentDisplayName
? translate(
'auto.store.slices.worktrees.createdWithoutParentNesting',
'Created without nesting under "{{value0}}"',
{ value0: parentDisplayName }
)
: translate(
'auto.store.slices.worktrees.createdWithoutParentNestingUnnamed',
'Created without nesting under the selected parent'
),
{
description: translate(
'auto.store.slices.worktrees.createdWithoutParentNestingDetail',
'The parent workspace was no longer available. You can set it from the workspace menu.'
)
type RuntimeTarget = ReturnType<typeof getActiveRuntimeTarget>
type CreateAttemptOutcome = {
result: CreateWorktreeResult
/** The host had no such parent, so this attempt was retried unattached. */
droppedParent: boolean
}
async function runCreateAttempt(
request: WorktreeCreateRequest,
attempt: WorktreeCreateAttempt,
target: RuntimeTarget
): Promise<CreateAttemptOutcome> {
const provisionedRoot = request.options?.provisionedRoot
if (provisionedRoot) {
return {
result: await window.api.worktrees.adoptProvisionedRoot({
...buildLocalWorktreeCreateArgs(request, attempt),
...provisionedRoot
}),
droppedParent: false
}
}
const create = async (
parentWorkspace: WorktreeCreateAttempt['parentWorkspace']
): Promise<CreateWorktreeResult> =>
target.kind === 'local'
? // Why local can still reject on the parent: paired web clients route this API to their host.
await window.api.worktrees.create(
buildLocalWorktreeCreateArgs(request, { ...attempt, parentWorkspace })
)
: await callRuntimeRpc<CreateWorktreeResult>(
target,
'worktree.create',
buildRuntimeWorktreeCreateParams(request, { ...attempt, parentWorkspace }),
{ timeoutMs: 10 * 60_000 }
)
try {
return { result: await create(attempt.parentWorkspace), droppedParent: false }
} catch (error) {
if (!attempt.parentWorkspace || !isRuntimeLineageParentMissingError(error)) {
throw error
}
return { result: await create(undefined), droppedParent: true }
}
}
/** True when the create landed without the nesting the caller asked for. */
function lostRequestedParent(
outcome: CreateAttemptOutcome,
parent: WorktreeCreateParentPick,
target: RuntimeTarget
): boolean {
if (outcome.droppedParent) {
return true
}
if (parent.pickedParentWorktreeId) {
return !outcome.result.lineage
}
// Why local-only: older hosts predate the top-level workspace lineage field, so its absence
// over RPC would warn about a nesting that actually landed.
return (
target.kind === 'local' && Boolean(parent.parentWorkspace) && !outcome.result.workspaceLineage
)
}
@@ -85,223 +129,87 @@ export function createCreateWorktree(
compareBaseRef,
options
) => {
const automationProvenanceRequest = options?.automationProvenanceRequest
const linkedWorkItem = options?.linkedWorkItem
const linkedTaskSourceContext = options?.linkedTaskSourceContext
const startupDraft = options?.startupDraft
const provisionedRoot = options?.provisionedRoot
const request: WorktreeCreateRequest = {
repoId,
name,
baseBranch,
setupDecision,
sparseCheckout,
telemetrySource,
displayName,
linkedIssue,
linkedPR,
pushTarget,
createdWithAgent,
linkedLinearIssue,
branchNameOverride,
workspaceStatus,
linkedGitLabMR,
linkedGitLabIssue,
startup,
pendingFirstAgentMessageRename,
creationId,
linkedLinearIssueWorkspaceId,
linkedLinearIssueOrganizationUrlKey,
linkedBitbucketPR,
linkedAzureDevOpsPR,
linkedGiteaPR,
compareBaseRef,
options
}
try {
// Why outside the retry loop: a branch-name conflict retry must not re-warn about the same dropped pick.
const parent = resolveWorktreeCreateParent(get(), repoId, options?.parentWorktreeId)
let warnedParentDropped = false
const warnParentDroppedOnce = (): void => {
if (warnedParentDropped) {
return
}
warnedParentDropped = true
notifyWorktreeParentDropped(get(), parent)
}
if (parent.staleBeforeCreate) {
warnParentDroppedOnce()
}
// Why: manual sort is user-authored order; stamp new workspaces at the top rather than relying on sortOrder fallback.
const manualOrder = get().sortBy === 'manual' ? Date.now() : undefined
const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), repoId))
if (
target.kind === 'environment' &&
(options?.linkedWorkItem?.provider === 'jira' ||
options?.linkedTaskSourceContext?.provider === 'jira')
) {
await assertRuntimeEnvironmentCapability(
target.environmentId,
WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY,
'Update the remote runtime to link Jira'
)
}
if (options?.provisionedRoot && target.kind !== 'local') {
throw new Error('Provisioned-root recipes currently require a direct SSH connection.')
}
for (let attempt = 0; attempt < CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS; attempt += 1) {
const candidateName = options?.nameWasGenerated
? getGeneratedWorktreeCreateRetryCandidate(name, attempt)
: getClientWorktreeCreateCandidate(name, attempt)
// Why: older runtimes reject exact PR branch overrides on collision, so retry both branch and worktree names.
const candidateBranchNameOverride = branchNameOverride
? getClientWorktreeCreateCandidate(branchNameOverride, attempt)
: undefined
try {
// Why: manual sort is user-authored order; stamp new workspaces at the top rather than relying on sortOrder fallback.
const manualOrder = get().sortBy === 'manual' ? Date.now() : undefined
const activeScope = parseWorkspaceKey(get().activeWorkspaceKey ?? '')
const pickedParent = options?.parentWorktreeId
? getIndexedWorktreeById(get().worktreesByRepo, options.parentWorktreeId)
: undefined
const pickedParentWorktreeId =
pickedParent && !pickedParent.isArchived && pickedParent.repoId === repoId
? pickedParent.id
: undefined
if (options?.parentWorktreeId && !pickedParentWorktreeId) {
notifyPickedParentDropped(
pickedParent ? resolveWorktreeDisplayName(pickedParent).trim() : null
)
const outcome = await runCreateAttempt(
request,
{
name: options?.nameWasGenerated
? getGeneratedWorktreeCreateRetryCandidate(name, attempt)
: getClientWorktreeCreateCandidate(name, attempt),
// Why: older runtimes reject exact PR branch overrides on collision, so retry both branch and worktree names.
branchNameOverride: branchNameOverride
? getClientWorktreeCreateCandidate(branchNameOverride, attempt)
: undefined,
parentWorkspace: parent.parentWorkspace,
manualOrder
},
target
)
if (lostRequestedParent(outcome, parent, target)) {
warnParentDroppedOnce()
}
const parentWorkspace = pickedParentWorktreeId
? worktreeWorkspaceKey(pickedParentWorktreeId)
: activeScope?.type === 'folder'
? folderWorkspaceKey(activeScope.folderWorkspaceId)
: undefined
const createArgs = {
repoId,
name: candidateName,
...(options?.nameWasGenerated ? { nameWasGenerated: true } : {}),
baseBranch,
...(compareBaseRef ? { compareBaseRef } : {}),
...(candidateBranchNameOverride
? { branchNameOverride: candidateBranchNameOverride }
: {}),
setupDecision,
sparseCheckout,
...(displayName ? { displayName } : {}),
...(telemetrySource ? { telemetrySource } : {}),
...(linkedIssue !== undefined ? { linkedIssue } : {}),
...(linkedPR !== undefined ? { linkedPR } : {}),
...(pushTarget ? { pushTarget } : {}),
...(createdWithAgent ? { createdWithAgent } : {}),
...(pendingFirstAgentMessageRename === true && createdWithAgent
? { pendingFirstAgentMessageRename: true }
: {}),
...(linkedLinearIssue !== undefined ? { linkedLinearIssue } : {}),
...(linkedLinearIssueWorkspaceId !== undefined ? { linkedLinearIssueWorkspaceId } : {}),
...(linkedLinearIssueOrganizationUrlKey !== undefined
? { linkedLinearIssueOrganizationUrlKey }
: {}),
...(manualOrder !== undefined ? { manualOrder } : {}),
...(parentWorkspace ? { parentWorkspace } : {}),
...(workspaceStatus !== undefined ? { workspaceStatus } : {}),
...(linkedGitLabMR !== undefined ? { linkedGitLabMR } : {}),
...(linkedGitLabIssue !== undefined ? { linkedGitLabIssue } : {}),
...(linkedBitbucketPR !== undefined ? { linkedBitbucketPR } : {}),
...(linkedAzureDevOpsPR !== undefined ? { linkedAzureDevOpsPR } : {}),
...(linkedGiteaPR !== undefined ? { linkedGiteaPR } : {}),
...(linkedWorkItem !== undefined ? { linkedWorkItem } : {}),
...(linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext } : {}),
...(startup ? { startup } : {}),
...(creationId ? { creationId } : {}),
...(automationProvenanceRequest ? { automationProvenanceRequest } : {})
}
const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), repoId))
if (
target.kind === 'environment' &&
(linkedWorkItem?.provider === 'jira' || linkedTaskSourceContext?.provider === 'jira')
) {
await assertRuntimeEnvironmentCapability(
target.environmentId,
WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY,
'Update the remote runtime to link Jira'
)
}
if (provisionedRoot && target.kind !== 'local') {
throw new Error('Provisioned-root recipes currently require a direct SSH connection.')
}
const createOnRuntimeTarget = async (
parentWorkspaceForCall: typeof parentWorkspace
): Promise<Awaited<ReturnType<typeof window.api.worktrees.create>>> =>
await callRuntimeRpc<Awaited<ReturnType<typeof window.api.worktrees.create>>>(
target,
'worktree.create',
{
repo: repoId,
name: candidateName,
...(options?.nameWasGenerated ? { nameWasGenerated: true } : {}),
baseBranch,
...(compareBaseRef ? { compareBaseRef } : {}),
...(candidateBranchNameOverride
? { branchNameOverride: candidateBranchNameOverride }
: {}),
setupDecision,
sparseCheckout,
...(displayName ? { displayName } : {}),
...(telemetrySource ? { telemetrySource } : {}),
...(linkedIssue !== undefined ? { linkedIssue } : {}),
...(linkedPR !== undefined ? { linkedPR } : {}),
...(pushTarget ? { pushTarget } : {}),
...(createdWithAgent ? { createdWithAgent } : {}),
...(pendingFirstAgentMessageRename === true && createdWithAgent
? { pendingFirstAgentMessageRename: true }
: {}),
...(linkedLinearIssue !== undefined ? { linkedLinearIssue } : {}),
...(linkedLinearIssueWorkspaceId !== undefined
? { linkedLinearIssueWorkspaceId }
: {}),
...(linkedLinearIssueOrganizationUrlKey !== undefined
? { linkedLinearIssueOrganizationUrlKey }
: {}),
...(manualOrder !== undefined ? { manualOrder } : {}),
...(parentWorkspaceForCall ? { parentWorkspace: parentWorkspaceForCall } : {}),
...(workspaceStatus !== undefined ? { workspaceStatus } : {}),
...(linkedGitLabMR !== undefined ? { linkedGitLabMR } : {}),
...(linkedGitLabIssue !== undefined ? { linkedGitLabIssue } : {}),
...(linkedBitbucketPR !== undefined ? { linkedBitbucketPR } : {}),
...(linkedAzureDevOpsPR !== undefined ? { linkedAzureDevOpsPR } : {}),
...(linkedGiteaPR !== undefined ? { linkedGiteaPR } : {}),
...(linkedWorkItem !== undefined ? { linkedWorkItem } : {}),
...(linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext } : {}),
...(startupDraft ? { startupDraft } : {}),
...(automationProvenanceRequest ? { automationProvenanceRequest } : {}),
...(startup
? {
startupCommand: startup.command,
...(startup.env ? { startupEnv: startup.env } : {}),
...(startup.launchConfig
? { startupLaunchConfig: startup.launchConfig }
: {}),
...(startup.startupCommandDelivery
? { startupCommandDelivery: startup.startupCommandDelivery }
: {}),
activate: true
}
: {})
},
{ timeoutMs: 10 * 60_000 }
)
const result = provisionedRoot
? await window.api.worktrees.adoptProvisionedRoot({
...createArgs,
...provisionedRoot
})
: target.kind === 'local'
? await window.api.worktrees.create(createArgs)
: await createOnRuntimeTarget(parentWorkspace).catch(async (error: unknown) => {
if (!parentWorkspace || !isRuntimeLineageParentMissingError(error)) {
throw error
}
return await createOnRuntimeTarget(undefined)
})
if (pickedParentWorktreeId && !result.lineage) {
notifyPickedParentDropped(
pickedParent ? resolveWorktreeDisplayName(pickedParent).trim() : null
)
}
// Why: worktrees.onChanged can add this worktree before this callback runs; appending blindly would duplicate it (React key clash).
set((s) => {
const hostId = repoHostId(s, repoId)
const createdWorktree = withRepoHostOwnership(
result.worktree,
hostId,
getProjectHostSetupForRepoHost(s, repoId, hostId)
)
const current = s.worktreesByRepo[repoId] ?? []
const alreadyPresent = current.some((w) => w.id === createdWorktree.id)
const nextWorktrees = alreadyPresent
? current.map((worktree) =>
worktree.id === createdWorktree.id
? { ...worktree, ...createdWorktree }
: worktree
)
: [...current, createdWorktree]
return {
worktreesByRepo: {
...s.worktreesByRepo,
[repoId]: nextWorktrees
},
...(result.workspaceLineage
? {
workspaceLineageByChildKey: {
...s.workspaceLineageByChildKey,
[result.workspaceLineage.childWorkspaceKey]: result.workspaceLineage
}
}
: {}),
...(result.lineage
? {
worktreeLineageById: {
...s.worktreeLineageById,
[result.lineage.worktreeId]: result.lineage
}
}
: {}),
...(result.initialBaseStatus
? {
baseStatusByWorktreeId: {
...s.baseStatusByWorktreeId,
[result.worktree.id]:
s.baseStatusByWorktreeId[result.worktree.id] ?? result.initialBaseStatus
}
}
: {}),
sortEpoch: s.sortEpoch + 1
}
})
applyCreatedWorktree(set, repoId, outcome.result)
const { result } = outcome
showLocalBaseRefRefreshToast(result.localBaseRefRefresh, result.worktree)
if (result.baseFallback) {
requestWorktreeBaseFallbackNotice(result.baseFallback)
@@ -0,0 +1,63 @@
import type { WorktreeSliceSet } from '../listing/worktree-slice-types'
import type { CreateWorktreeResult } from '../../../../../../shared/worktree/create-types'
import {
getProjectHostSetupForRepoHost,
repoHostId,
withRepoHostOwnership
} from '../listing/worktree-host-ownership'
/** Folds a fresh create result into the worktree, lineage and base-status maps. */
export function applyCreatedWorktree(
set: WorktreeSliceSet,
repoId: string,
result: CreateWorktreeResult
) {
// Why: worktrees.onChanged can add this worktree before this callback runs; appending blindly would duplicate it (React key clash).
set((s) => {
const hostId = repoHostId(s, repoId)
const createdWorktree = withRepoHostOwnership(
result.worktree,
hostId,
getProjectHostSetupForRepoHost(s, repoId, hostId)
)
const current = s.worktreesByRepo[repoId] ?? []
const alreadyPresent = current.some((w) => w.id === createdWorktree.id)
const nextWorktrees = alreadyPresent
? current.map((worktree) =>
worktree.id === createdWorktree.id ? { ...worktree, ...createdWorktree } : worktree
)
: [...current, createdWorktree]
return {
worktreesByRepo: {
...s.worktreesByRepo,
[repoId]: nextWorktrees
},
...(result.workspaceLineage
? {
workspaceLineageByChildKey: {
...s.workspaceLineageByChildKey,
[result.workspaceLineage.childWorkspaceKey]: result.workspaceLineage
}
}
: {}),
...(result.lineage
? {
worktreeLineageById: {
...s.worktreeLineageById,
[result.lineage.worktreeId]: result.lineage
}
}
: {}),
...(result.initialBaseStatus
? {
baseStatusByWorktreeId: {
...s.baseStatusByWorktreeId,
[result.worktree.id]:
s.baseStatusByWorktreeId[result.worktree.id] ?? result.initialBaseStatus
}
}
: {}),
sortEpoch: s.sortEpoch + 1
}
})
}
@@ -0,0 +1,93 @@
import type { AppState } from '../../../types'
import type { WorkspaceKey } from '../../../../../../shared/folder-workspace-types'
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { resolveWorktreeDisplayName } from '@/lib/worktree-default-display-name'
import {
folderWorkspaceKey,
parseWorkspaceKey,
worktreeWorkspaceKey
} from '../../../../../../shared/workspace-scope'
import { getIndexedWorktreeById } from '@/store/worktree-repo-index'
export type WorktreeCreateParentPick = {
/** Workspace the create attaches to. Undefined once a stale pick is dropped. */
parentWorkspace?: WorkspaceKey
/** Set only for an accepted user pick, whose lineage row must come back from the host. */
pickedParentWorktreeId?: string
/** Label for the drop warning; null when the parent record is already gone. */
pickedDisplayName: string | null
/** The pick no longer qualifies, so the create runs unattached. */
staleBeforeCreate: boolean
}
/** Resolves the composer pick against the current store, falling back to the active folder scope. */
export function resolveWorktreeCreateParent(
state: AppState,
repoId: string,
requestedParentWorktreeId: string | undefined
): WorktreeCreateParentPick {
const picked = requestedParentWorktreeId
? getIndexedWorktreeById(state.worktreesByRepo, requestedParentWorktreeId)
: undefined
const usable = picked && !picked.isArchived && picked.repoId === repoId ? picked.id : undefined
const pickedDisplayName = picked ? resolveWorktreeDisplayName(picked).trim() : null
if (usable) {
return {
parentWorkspace: worktreeWorkspaceKey(usable),
pickedParentWorktreeId: usable,
pickedDisplayName,
staleBeforeCreate: false
}
}
const activeScope = parseWorkspaceKey(state.activeWorkspaceKey ?? '')
return {
parentWorkspace:
activeScope?.type === 'folder'
? folderWorkspaceKey(activeScope.folderWorkspaceId)
: undefined,
pickedDisplayName,
staleBeforeCreate: Boolean(requestedParentWorktreeId)
}
}
/** Resolved late so the common create path never walks the folder-workspace list, and defensively
* so a missing label can never fail a create that already succeeded. */
function parentLabel(state: AppState, parent: WorktreeCreateParentPick): string | null {
if (parent.pickedDisplayName) {
return parent.pickedDisplayName
}
const scope = parent.parentWorkspace ? parseWorkspaceKey(parent.parentWorkspace) : null
if (scope?.type !== 'folder') {
return null
}
const workspace = state.folderWorkspaces?.find(
(candidate) => candidate.id === scope.folderWorkspaceId
)
return workspace?.name.trim() || null
}
export function notifyWorktreeParentDropped(
state: AppState,
parent: WorktreeCreateParentPick
): void {
const parentDisplayName = parentLabel(state, parent)
toast.warning(
parentDisplayName
? translate(
'auto.store.slices.worktrees.createdWithoutParentNesting',
'Created without nesting under "{{value0}}"',
{ value0: parentDisplayName }
)
: translate(
'auto.store.slices.worktrees.createdWithoutParentNestingUnnamed',
'Created without nesting under the selected parent'
),
{
description: translate(
'auto.store.slices.worktrees.createdWithoutParentNestingDetail',
'The parent workspace was no longer available. You can set it from the workspace menu.'
)
}
)
}
@@ -0,0 +1,132 @@
import type { CreateWorktreeArgs } from '../../../../../../shared/worktree/create-types'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import type { WorkspaceKey } from '../../../../../../shared/folder-workspace-types'
import type { TaskSourceContext } from '../../../../../../shared/task-source-context'
import type { WorkspaceLinkedItem } from '../../../../../../shared/worktree/types'
/** Trailing bag for `createWorktree` args that outgrew its positional list. */
export type CreateWorktreeCallOptions = {
automationProvenanceRequest?: CreateWorktreeArgs['automationProvenanceRequest']
linkedWorkItem?: WorkspaceLinkedItem | null
linkedTaskSourceContext?: TaskSourceContext | null
/** Lets the owning runtime launch and prefill a task agent without first creating an idle shell. */
startupDraft?: string
/** True only when `name` came from the creature-name generator; gates host-side retirement. */
nameWasGenerated?: boolean
/** Parent picked in the composer. Sets sidebar nesting only; ignored if it no longer exists. */
parentWorktreeId?: string
provisionedRoot?: {
runtimeId: string
executionHostId: ExecutionHostId
expectedPath: string
}
}
/** Everything `createWorktree` received, packed once so both transports read from the same record.
* The per-attempt fields live on `WorktreeCreateAttempt` instead. */
export type WorktreeCreateRequest = Omit<CreateWorktreeArgs, 'parentWorkspace' | 'manualOrder'> & {
options?: CreateWorktreeCallOptions
}
/** Per-attempt values: names carry the conflict-retry suffix, the parent can be dropped on retry. */
export type WorktreeCreateAttempt = {
name: string
branchNameOverride?: string
parentWorkspace?: WorkspaceKey
manualOrder?: number
}
/** Fields both transports spell the same way; one builder keeps IPC and RPC payloads from drifting. */
function sharedCreateFields(
request: WorktreeCreateRequest,
attempt: WorktreeCreateAttempt
): Omit<CreateWorktreeArgs, 'repoId' | 'startup' | 'creationId'> {
const { options } = request
return {
name: attempt.name,
...(options?.nameWasGenerated ? { nameWasGenerated: true } : {}),
baseBranch: request.baseBranch,
...(request.compareBaseRef ? { compareBaseRef: request.compareBaseRef } : {}),
...(attempt.branchNameOverride ? { branchNameOverride: attempt.branchNameOverride } : {}),
setupDecision: request.setupDecision,
sparseCheckout: request.sparseCheckout,
...(request.displayName ? { displayName: request.displayName } : {}),
...(request.telemetrySource ? { telemetrySource: request.telemetrySource } : {}),
...(request.linkedIssue !== undefined ? { linkedIssue: request.linkedIssue } : {}),
...(request.linkedPR !== undefined ? { linkedPR: request.linkedPR } : {}),
...(request.pushTarget ? { pushTarget: request.pushTarget } : {}),
...(request.createdWithAgent ? { createdWithAgent: request.createdWithAgent } : {}),
...(request.pendingFirstAgentMessageRename === true && request.createdWithAgent
? { pendingFirstAgentMessageRename: true }
: {}),
...(request.linkedLinearIssue !== undefined
? { linkedLinearIssue: request.linkedLinearIssue }
: {}),
...(request.linkedLinearIssueWorkspaceId !== undefined
? { linkedLinearIssueWorkspaceId: request.linkedLinearIssueWorkspaceId }
: {}),
...(request.linkedLinearIssueOrganizationUrlKey !== undefined
? {
linkedLinearIssueOrganizationUrlKey: request.linkedLinearIssueOrganizationUrlKey
}
: {}),
...(attempt.manualOrder !== undefined ? { manualOrder: attempt.manualOrder } : {}),
...(attempt.parentWorkspace ? { parentWorkspace: attempt.parentWorkspace } : {}),
...(request.workspaceStatus !== undefined ? { workspaceStatus: request.workspaceStatus } : {}),
...(request.linkedGitLabMR !== undefined ? { linkedGitLabMR: request.linkedGitLabMR } : {}),
...(request.linkedGitLabIssue !== undefined
? { linkedGitLabIssue: request.linkedGitLabIssue }
: {}),
...(request.linkedBitbucketPR !== undefined
? { linkedBitbucketPR: request.linkedBitbucketPR }
: {}),
...(request.linkedAzureDevOpsPR !== undefined
? { linkedAzureDevOpsPR: request.linkedAzureDevOpsPR }
: {}),
...(request.linkedGiteaPR !== undefined ? { linkedGiteaPR: request.linkedGiteaPR } : {}),
...(options?.linkedWorkItem !== undefined ? { linkedWorkItem: options.linkedWorkItem } : {}),
...(options?.linkedTaskSourceContext !== undefined
? { linkedTaskSourceContext: options.linkedTaskSourceContext }
: {}),
...(options?.automationProvenanceRequest
? { automationProvenanceRequest: options.automationProvenanceRequest }
: {})
}
}
export function buildLocalWorktreeCreateArgs(
request: WorktreeCreateRequest,
attempt: WorktreeCreateAttempt
): CreateWorktreeArgs {
return {
repoId: request.repoId,
...sharedCreateFields(request, attempt),
...(request.startup ? { startup: request.startup } : {}),
...(request.creationId ? { creationId: request.creationId } : {})
}
}
export function buildRuntimeWorktreeCreateParams(
request: WorktreeCreateRequest,
attempt: WorktreeCreateAttempt
): Record<string, unknown> {
const { startup, options } = request
return {
repo: request.repoId,
...sharedCreateFields(request, attempt),
// Why: the host defaults a bare `parentWorkspace` to CLI provenance; app picks are manual.
...(attempt.parentWorkspace ? { parentWorkspaceOrigin: 'manual' } : {}),
...(options?.startupDraft ? { startupDraft: options.startupDraft } : {}),
...(startup
? {
startupCommand: startup.command,
...(startup.env ? { startupEnv: startup.env } : {}),
...(startup.launchConfig ? { startupLaunchConfig: startup.launchConfig } : {}),
...(startup.startupCommandDelivery
? { startupCommandDelivery: startup.startupCommandDelivery }
: {}),
activate: true
}
: {})
}
}
@@ -12,7 +12,8 @@ export function isRuntimeRepoNotFoundError(error: unknown): boolean {
return hasRuntimeRpcErrorCode(error, 'repo_not_found')
}
/** Thrown before the worktree exists, so the caller can safely retry without the parent. */
/** Thrown before the worktree exists, so the caller can safely retry without the parent.
* Matches re-wrapped errors too: relay and environment transports lose the RuntimeRpcCallError. */
export function isRuntimeLineageParentMissingError(error: unknown): boolean {
return error instanceof RuntimeRpcCallError && error.code === 'LINEAGE_PARENT_NOT_FOUND'
return hasRuntimeRpcErrorCode(error, 'LINEAGE_PARENT_NOT_FOUND')
}
@@ -78,7 +78,9 @@ export async function callRuntimeResult<TResult>(
): Promise<TResult> {
const response = await callRuntimeEnvelope(method, params, timeoutMs)
if (!response.ok) {
throw new Error(response.error.message)
// Why keep the code: callers classify recoverable host failures by token, and the message alone
// (e.g. "Parent selector was not found.") carries none.
throw Object.assign(new Error(response.error.message), { code: response.error.code })
}
return response.result as TResult
}
@@ -86,6 +86,8 @@ export function createWorktreesApi(): NonNullable<Partial<PreloadApi>['worktrees
}
: {}),
parentWorkspace: args.parentWorkspace,
// Why: every create through this API is an in-app action, never the CLI's parent flag.
...(args.parentWorkspace ? { parentWorkspaceOrigin: 'manual' } : {}),
workspaceStatus: args.workspaceStatus,
manualOrder: args.manualOrder,
automationProvenanceRequest: args.automationProvenanceRequest
@@ -677,6 +677,49 @@ describe('web worktree preload API', () => {
expect(runtimeCalls[0]?.params).not.toHaveProperty('nameWasGenerated')
})
// Why: without it the host records the app's parent pick as a CLI flag, which cleans up differently.
it('marks a parent-picked create as a manual action', async () => {
const runtimeCalls: { method: string; params: unknown }[] = []
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
call(method: string, params?: unknown): Promise<RuntimeRpcResponse<unknown>> {
runtimeCalls.push({ method, params })
return Promise.resolve({
id: `call-${runtimeCalls.length}`,
ok: true,
result: { worktree: { id: 'repo-1::/workspace/child', path: '/workspace/child' } },
_meta: { runtimeId: 'runtime-1' }
})
}
close(): void {}
}
}))
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage)
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
await globals.window.api.worktrees.create({
repoId: 'repo-1',
name: 'child',
setupDecision: 'inherit'
})
await globals.window.api.worktrees.create({
repoId: 'repo-1',
name: 'child',
setupDecision: 'inherit',
parentWorkspace: 'folder:folder-1'
})
expect(runtimeCalls[0]?.params).not.toHaveProperty('parentWorkspaceOrigin')
expect(runtimeCalls[1]?.params).toMatchObject({
parentWorkspace: 'folder:folder-1',
parentWorkspaceOrigin: 'manual'
})
})
it('encodes explicit push target clears for runtime worktree updates', async () => {
const runtimeCalls: { method: string; params: unknown }[] = []
vi.doMock('./web-runtime-client', () => ({