diff --git a/src/main/ipc/worktree-create-lineage.test.ts b/src/main/ipc/worktree-create-lineage.test.ts index 2357601ed34..fa7e101ac3f 100644 --- a/src/main/ipc/worktree-create-lineage.test.ts +++ b/src/main/ipc/worktree-create-lineage.test.ts @@ -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, + 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/ + ) + }) +}) diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index b80bed6a3a6..ab91c1f12f7 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -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 @@ -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}`) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 40c9eafd621..0cdf1198ad6 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -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 = {} + const runtimeStore = { + ...createFolderWorkspaceRuntimeStore(), + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + 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 = { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 0b25b3cef46..6223a447102 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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( diff --git a/src/main/runtime/rpc/methods/worktree-create-args.test.ts b/src/main/runtime/rpc/methods/worktree-create-args.test.ts index fa2b773560b..5a55d54460f 100644 --- a/src/main/runtime/rpc/methods/worktree-create-args.test.ts +++ b/src/main/runtime/rpc/methods/worktree-create-args.test.ts @@ -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' }) + }) }) diff --git a/src/main/runtime/rpc/methods/worktree-create-args.ts b/src/main/runtime/rpc/methods/worktree-create-args.ts index 2b86b12ee05..ac674f08d61 100644 --- a/src/main/runtime/rpc/methods/worktree-create-args.ts +++ b/src/main/runtime/rpc/methods/worktree-create-args.ts @@ -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 } : {}), diff --git a/src/main/runtime/rpc/methods/worktree-create-schemas.ts b/src/main/runtime/rpc/methods/worktree-create-schemas.ts index 507f44b325b..e296b9c3ace 100644 --- a/src/main/runtime/rpc/methods/worktree-create-schemas.ts +++ b/src/main/runtime/rpc/methods/worktree-create-schemas.ts @@ -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, diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 9d75836cd11..cd556d6f486 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -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); diff --git a/src/renderer/src/components/new-workspace/ComposerParentWorktreePicker.tsx b/src/renderer/src/components/new-workspace/ComposerParentWorktreePicker.tsx index 68c005239ad..5775cb8ac14 100644 --- a/src/renderer/src/components/new-workspace/ComposerParentWorktreePicker.tsx +++ b/src/renderer/src/components/new-workspace/ComposerParentWorktreePicker.tsx @@ -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)` diff --git a/src/renderer/src/runtime/runtime-compatibility-test-fixture.ts b/src/renderer/src/runtime/runtime-compatibility-test-fixture.ts index ccb20944e58..6fc2cfcd027 100644 --- a/src/renderer/src/runtime/runtime-compatibility-test-fixture.ts +++ b/src/renderer/src/runtime/runtime-compatibility-test-fixture.ts @@ -8,6 +8,7 @@ import { export type RuntimeEnvironmentCallRequest = { method: string + params?: Record } export function createCompatibleRuntimeStatusResponse( diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index a4ede0d82a5..b5eb2904cde 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -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 /** Register an in-flight background creation and make it the active surface. */ beginPendingWorktreeCreation: (entry: PendingWorktreeCreation) => void diff --git a/src/renderer/src/store/slices/worktrees-create-base-status.test.ts b/src/renderer/src/store/slices/worktrees-create-base-status.test.ts index b8820147535..0bfe28c86c8 100644 --- a/src/renderer/src/store/slices/worktrees-create-base-status.test.ts +++ b/src/renderer/src/store/slices/worktrees-create-base-status.test.ts @@ -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) - return store - } - - function createWithParentPick( - store: ReturnType, - parentWorktreeId: string - ) { - const createWorktree = store.getState().createWorktree - const args: Parameters = ['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) - 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) - 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) - }) -}) diff --git a/src/renderer/src/store/slices/worktrees-create-parent-pick.test.ts b/src/renderer/src/store/slices/worktrees-create-parent-pick.test.ts new file mode 100644 index 00000000000..0e16557a1cf --- /dev/null +++ b/src/renderer/src/store/slices/worktrees-create-parent-pick.test.ts @@ -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) + return store + } + + function createWithParentPick( + store: ReturnType, + parentWorktreeId: string + ) { + const createWorktree = store.getState().createWorktree + const args: Parameters = ['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) + 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) + 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) + return store + } + + function createOnRemote(store: ReturnType) { + const createWorktree = store.getState().createWorktree + const args: Parameters = [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) + 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() + }) +}) diff --git a/src/renderer/src/store/slices/worktrees/create/create-worktree.ts b/src/renderer/src/store/slices/worktrees/create/create-worktree.ts index 7d30ead73c6..11949c69572 100644 --- a/src/renderer/src/store/slices/worktrees/create/create-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/create/create-worktree.ts @@ -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 + +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 { + 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 => + 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( + 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>> => - await callRuntimeRpc>>( - 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) diff --git a/src/renderer/src/store/slices/worktrees/create/created-worktree-state-merge.ts b/src/renderer/src/store/slices/worktrees/create/created-worktree-state-merge.ts new file mode 100644 index 00000000000..51ad88b2537 --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/create/created-worktree-state-merge.ts @@ -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 + } + }) +} diff --git a/src/renderer/src/store/slices/worktrees/create/worktree-create-parent-pick.ts b/src/renderer/src/store/slices/worktrees/create/worktree-create-parent-pick.ts new file mode 100644 index 00000000000..51fe772be61 --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/create/worktree-create-parent-pick.ts @@ -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.' + ) + } + ) +} diff --git a/src/renderer/src/store/slices/worktrees/create/worktree-create-payload.ts b/src/renderer/src/store/slices/worktrees/create/worktree-create-payload.ts new file mode 100644 index 00000000000..b018183016d --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/create/worktree-create-payload.ts @@ -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 & { + 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 { + 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 { + 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 + } + : {}) + } +} diff --git a/src/renderer/src/store/slices/worktrees/listing/runtime-worktree-rpc-errors.ts b/src/renderer/src/store/slices/worktrees/listing/runtime-worktree-rpc-errors.ts index de6df00e405..d95393554a1 100644 --- a/src/renderer/src/store/slices/worktrees/listing/runtime-worktree-rpc-errors.ts +++ b/src/renderer/src/store/slices/worktrees/listing/runtime-worktree-rpc-errors.ts @@ -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') } diff --git a/src/renderer/src/web/preload-api/web-runtime-calls.ts b/src/renderer/src/web/preload-api/web-runtime-calls.ts index 4470b0be37a..16bc514074a 100644 --- a/src/renderer/src/web/preload-api/web-runtime-calls.ts +++ b/src/renderer/src/web/preload-api/web-runtime-calls.ts @@ -78,7 +78,9 @@ export async function callRuntimeResult( ): Promise { 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 } diff --git a/src/renderer/src/web/preload-api/web-worktrees-api.ts b/src/renderer/src/web/preload-api/web-worktrees-api.ts index 5d3b9bd1bd9..43e496d3bd0 100644 --- a/src/renderer/src/web/preload-api/web-worktrees-api.ts +++ b/src/renderer/src/web/preload-api/web-worktrees-api.ts @@ -86,6 +86,8 @@ export function createWorktreesApi(): NonNullable['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 diff --git a/src/renderer/src/web/web-preload-api-workspace-catalog.test.ts b/src/renderer/src/web/web-preload-api-workspace-catalog.test.ts index cb6ca0eb3e8..60efde1d826 100644 --- a/src/renderer/src/web/web-preload-api-workspace-catalog.test.ts +++ b/src/renderer/src/web/web-preload-api-workspace-catalog.test.ts @@ -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> { + 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', () => ({