feat(worktree): allow adopting external worktrees as children via CLI and UI drag-and-drop

- Mints an `instanceId` automatically when a lineage relationship is created with an external worktree.
- Adds drag-and-drop support to the sidebar to nest a worktree directly by dropping it onto a parent card.
- Implements descendant cycle validation and visual drop state indicators during drag.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-06-08 14:27:25 -07:00
co-authored by Orca
parent 8526f02c77
commit df6c8fda7e
5 changed files with 199 additions and 47 deletions
+54
View File
@@ -9924,6 +9924,56 @@ export class OrcaRuntimeService {
return { base, behind: drift.behind, recentSubjects }
}
private async ensureWorktreeInstanceId(worktree: Worktree): Promise<void> {
if (worktree.instanceId) {
return
}
const instanceId = randomUUID()
const now = Date.now()
const metaUpdates: Partial<WorktreeMeta> = {
instanceId,
createdAt: now,
orcaCreatedAt: now
}
try {
const dotOrcaPath = join(worktree.path, '.orca')
const jsonPath = join(dotOrcaPath, 'worktree.json')
const jsonContent = JSON.stringify(metaUpdates, null, 2)
const repo = this.store?.getRepos().find((r) => r.id === worktree.repoId)
if (repo?.connectionId) {
const fsProvider = getSshFilesystemProvider(repo.connectionId)
if (!fsProvider) {
throw new Error('SSH filesystem provider not found')
}
try {
await fsProvider.createDir(dotOrcaPath)
} catch {
// ignore if exists
}
await fsProvider.writeFile(jsonPath, jsonContent)
} else {
const { writeFile } = require('fs/promises')
await mkdir(dotOrcaPath, { recursive: true })
await writeFile(jsonPath, jsonContent, 'utf-8')
}
} catch (err) {
throw new RuntimeLineageError(
'LINEAGE_PARENT_CONTEXT_MISSING',
'Workspace directory is not writable. Cannot manage this external worktree.',
err
)
}
if (this.store) {
this.store.setWorktreeMeta(worktree.id, metaUpdates)
}
worktree.instanceId = instanceId
}
async updateManagedWorktreeMeta(
worktreeSelector: string,
updates: Partial<WorktreeMeta> & {
@@ -9942,6 +9992,10 @@ export class OrcaRuntimeService {
this.store.removeWorktreeLineage?.(worktree.id)
} else if (lineage?.parentWorktree) {
const parent = await this.resolveWorktreeSelector(lineage.parentWorktree)
await this.ensureWorktreeInstanceId(worktree)
await this.ensureWorktreeInstanceId(parent)
this.validateLineageParent(worktree, parent)
if (!worktree.instanceId || !parent.instanceId) {
throw new RuntimeLineageError(
@@ -76,6 +76,7 @@ type WorktreeCardProps = {
lineageCollapsed?: boolean
lineageChildren?: React.ReactNode
onLineageToggle?: (event: React.MouseEvent<HTMLButtonElement>) => void
isLineageDropTarget?: boolean
onActivate?: () => void
onImmediateActivate?: (worktreeId: string) => void
onSelectionGesture?: (event: React.MouseEvent<HTMLElement>, worktreeId: string) => boolean
@@ -152,7 +153,8 @@ const WorktreeCard = React.memo(function WorktreeCard({
lineageChildCount = 0,
lineageCollapsed = false,
lineageChildren,
onLineageToggle
onLineageToggle,
isLineageDropTarget = false
}: WorktreeCardProps) {
const openModal = useAppStore((s) => s.openModal)
const openTaskPage = useAppStore((s) => s.openTaskPage)
@@ -801,11 +803,13 @@ const WorktreeCard = React.memo(function WorktreeCard({
'group relative flex items-start gap-0.5 pl-0 pr-1.5 pt-1.5 pb-2 cursor-pointer transition-[background-color,border-color,opacity,box-shadow] duration-200 outline-none select-none',
flushSurface ? 'ml-1 w-[calc(100%-0.25rem)]' : 'ml-1',
isMultiSelected ? 'rounded-sm' : 'rounded-lg',
isActiveSurface
? 'bg-black/[0.08] shadow-[0_1px_2px_rgba(0,0,0,0.04)] border border-black/[0.015] dark:bg-white/[0.10] dark:border-border/40 dark:shadow-[0_1px_2px_rgba(0,0,0,0.03)]'
: isMultiSelected
? 'border border-worktree-sidebar-ring/35 bg-worktree-sidebar-accent/70 ring-1 ring-worktree-sidebar-ring/30'
: 'border border-transparent worktree-sidebar-card-hover',
isLineageDropTarget
? 'bg-accent/80 border-accent-foreground/20'
: isActiveSurface
? 'bg-black/[0.08] shadow-[0_1px_2px_rgba(0,0,0,0.04)] border border-black/[0.015] dark:bg-white/[0.10] dark:border-border/40 dark:shadow-[0_1px_2px_rgba(0,0,0,0.03)]'
: isMultiSelected
? 'border border-worktree-sidebar-ring/35 bg-worktree-sidebar-accent/70 ring-1 ring-worktree-sidebar-ring/30'
: 'border border-transparent worktree-sidebar-card-hover',
isActiveSurface && isMultiSelected && 'ring-1 ring-worktree-sidebar-ring/35',
revealHighlight && [
'scroll-to-current-workspace-reveal-highlight',
@@ -1,5 +1,6 @@
/* eslint-disable max-lines */
import React, { useMemo, useCallback, useRef, useState, useEffect, useLayoutEffect } from 'react'
import { toast } from 'sonner'
import {
measureElement as measureVirtualElementSize,
useVirtualizer
@@ -521,11 +522,11 @@ function areWorktreeDragPreviewOffsetsEqual(
function updateLatestWorktreeStatusDropTarget(
drag: WorktreePointerDrag,
target: WorktreeSidebarStatusDropTarget,
target: WorktreeSidebarStatusDropTarget & { lineageParentId: string | null },
preview: WorktreeSidebarDropPreview | null
): void {
drag.latestStatusDropTarget =
target.status || target.isPinDrop
target.status || target.isPinDrop || target.lineageParentId
? {
target,
preview,
@@ -544,14 +545,19 @@ function getPointerDropStatusTarget(args: {
container: HTMLElement
x: number
y: number
}): WorktreeSidebarStatusDropTarget {
}): WorktreeSidebarStatusDropTarget & { lineageParentId: string | null } {
const target = document.elementFromPoint(args.x, args.y)
if (!(target instanceof Element) || !args.container.contains(target)) {
return { status: null, isPinDrop: false }
return { status: null, isPinDrop: false, lineageParentId: null }
}
const pinTarget = target.closest<HTMLElement>('[data-workspace-pin-drop-target]')
if (pinTarget && args.container.contains(pinTarget)) {
return { status: null, isPinDrop: true }
return { status: null, isPinDrop: true, lineageParentId: null }
}
const rowTarget = target.closest<HTMLElement>('[data-worktree-drag-id]')
let lineageParentId: string | null = null
if (rowTarget && args.container.contains(rowTarget)) {
lineageParentId = rowTarget.dataset.worktreeDragId ?? null
}
const statusTarget = target.closest<HTMLElement>('[data-workspace-status-drop-target]')
return {
@@ -559,7 +565,8 @@ function getPointerDropStatusTarget(args: {
statusTarget && args.container.contains(statusTarget)
? ((statusTarget.dataset.workspaceStatus as WorkspaceStatus | undefined) ?? null)
: null,
isPinDrop: false
isPinDrop: false,
lineageParentId
}
}
@@ -764,6 +771,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
const renamingWorktreeId = useAppStore((s) => s.renamingWorktreeId)
const setRenamingWorktreeId = useAppStore((s) => s.setRenamingWorktreeId)
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
const updateWorktreeLineage = useAppStore((s) => s.updateWorktreeLineage)
const worktreeDragSessionRef = useRef<WorktreeSidebarDragSession | null>(null)
const worktreePointerDragRef = useRef<WorktreePointerDrag | null>(null)
const worktreePointerAutoscrollFrameIdRef = useRef<number | null>(null)
@@ -1690,7 +1698,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
x: drag.currentX,
y: drag.currentY
})
: { status: null, isPinDrop: false }
: { status: null, isPinDrop: false, lineageParentId: null }
if (
shouldPreferSidebarStatusDropTarget({
sourceGroupKey: drag.sourceGroupKey,
@@ -2036,7 +2044,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
x: event.clientX,
y: event.clientY
})
: { status: null, isPinDrop: false }
: { status: null, isPinDrop: false, lineageParentId: null }
if (
shouldPreferSidebarStatusDropTarget({
sourceGroupKey: drag.sourceGroupKey,
@@ -2315,13 +2323,47 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
clearWorktreeDrag()
return
}
const target = getPointerDropStatusTarget({
container: event.currentTarget,
x: event.clientX,
y: event.clientY
})
if (target.lineageParentId && !session.draggedIds.includes(target.lineageParentId)) {
const parentId = target.lineageParentId
const isDescendantDrop = session.draggedIds.some((draggedId) => {
let current: string | undefined = parentId
while (current) {
if (current === draggedId) {
return true
}
const lineage = worktreeLineageById[current]
current = lineage?.parentWorktreeId
}
return false
})
if (!isDescendantDrop) {
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
setWorktreeDragState((prev) =>
prev.dropIndex === null &&
prev.dropIndicatorY === null &&
prev.previewOffsetsByWorktreeId.size === 0
? prev
: {
...prev,
dropIndex: null,
dropIndicatorY: null,
previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS,
pointerY: event.clientY
}
)
return
}
}
const drop = computeWorktreeDrop(event.clientY)
if (!drop) {
const target = getPointerDropStatusTarget({
container: event.currentTarget,
x: event.clientX,
y: event.clientY
})
const statusDrop = target.status
? computeWorktreeStatusDrop({
pointerY: event.clientY,
@@ -2377,7 +2419,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
computeWorktreeDrop,
computeWorktreeStatusDrop,
refreshWorktreeDragSession,
startWorktreeNativeAutoscroll
startWorktreeNativeAutoscroll,
worktreeLineageById
]
)
@@ -2396,16 +2439,49 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
clearWorktreeDrag()
return
}
const container = scrollRef.current
const target = container
? getPointerDropStatusTarget({
container,
x: event.clientX,
y: event.clientY
})
: { status: null, isPinDrop: false, lineageParentId: null }
if (target.lineageParentId && !session.draggedIds.includes(target.lineageParentId)) {
event.preventDefault()
event.stopPropagation()
const parentId = target.lineageParentId
const isDescendantDrop = session.draggedIds.some((draggedId) => {
let current: string | undefined = parentId
while (current) {
if (current === draggedId) {
return true
}
const lineage = worktreeLineageById[current]
current = lineage?.parentWorktreeId
}
return false
})
if (isDescendantDrop) {
clearWorktreeDrag()
return
}
void Promise.all(
session.draggedIds.map((id) => updateWorktreeLineage(id, { parentWorktreeId: parentId }))
).catch((err) => {
toast.error(err instanceof Error ? err.message : 'Failed to nest workspace')
})
clearWorktreeDrag()
return
}
const drop = computeWorktreeDrop(event.clientY)
if (!drop) {
const container = scrollRef.current
const target = container
? getPointerDropStatusTarget({
container,
x: event.clientX,
y: event.clientY
})
: { status: null, isPinDrop: false }
const statusDrop = target.status
? computeWorktreeStatusDrop({
pointerY: event.clientY,
@@ -2448,8 +2524,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
onMoveWorktreesToStatusAtIndex,
onReorderWorktrees,
refreshWorktreeDragSession,
updateWorktreeLineage,
worktreeDragGroups,
worktreeDragUnitGroups
worktreeDragUnitGroups,
worktreeLineageById
]
)
@@ -2635,7 +2713,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
x: event.clientX,
y: event.clientY
})
: { status: null, isPinDrop: false }
: { status: null, isPinDrop: false, lineageParentId: null }
const statusDrop = target.status
? computeWorktreeStatusDrop({
pointerY: event.clientY,
@@ -3216,6 +3294,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
const worktreeDragGroupIndex = groupIndexByWorktreeId.get(itemRow.worktree.id)
const revealHighlightTone =
agentSendTargetWorktreeId === itemRow.worktree.id ? 'ai' : 'default'
const isLineageDropTarget =
worktreeDragState.draggingWorktreeId &&
worktreePointerDragRef.current?.latestStatusDropTarget?.target.lineageParentId ===
itemRow.worktree.id
return (
<div
key={itemRow.worktree.id}
@@ -3266,6 +3349,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
nativeDragEnabled={false}
contentIndent={nested ? 0 : paddingLeft}
flushSurface={!nested}
isLineageDropTarget={Boolean(isLineageDropTarget)}
onImmediateActivate={onImmediateWorktreeActivate}
onSelectionGesture={onSelectionGesture}
onContextMenuSelect={onContextMenuSelect}
@@ -3334,6 +3418,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
const lineageToggleGroupKey = child.lineageGroupKey
const childCardIndent = Math.max(0, child.depth) * LINEAGE_INDENT
const childContentIndent = Math.max(0, child.depth - 1) * LINEAGE_INDENT
const isLineageDropTarget =
worktreeDragState.draggingWorktreeId &&
worktreePointerDragRef.current?.latestStatusDropTarget?.target.lineageParentId ===
child.worktree.id
return (
<div
key={child.worktree.id}
@@ -3361,9 +3450,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
revealHighlightTone === 'ai' &&
'scroll-to-current-workspace-reveal-highlight--ai'
],
isActive
? 'border-black/[0.015] bg-black/[0.08] shadow-[0_1px_2px_rgba(0,0,0,0.04)] dark:border-border/40 dark:bg-white/[0.10] dark:shadow-[0_1px_2px_rgba(0,0,0,0.03)]'
: 'worktree-sidebar-card-hover',
isLineageDropTarget
? 'bg-accent/80 border-accent-foreground/20'
: isActive
? 'border-black/[0.015] bg-black/[0.08] shadow-[0_1px_2px_rgba(0,0,0,0.04)] dark:border-border/40 dark:bg-white/[0.10] dark:shadow-[0_1px_2px_rgba(0,0,0,0.03)]'
: 'worktree-sidebar-card-hover',
isDeleting && 'cursor-not-allowed opacity-50 grayscale'
)}
data-scroll-reveal-highlight={
@@ -50,10 +50,10 @@ describe('resolveWorktreeSidebarStatusDropCommitTarget', () => {
it('uses the current status target when pointerup hit-testing succeeds', () => {
expect(
resolveWorktreeSidebarStatusDropCommitTarget({
currentTarget: { status: 'completed', isPinDrop: false },
currentTarget: { status: 'completed', isPinDrop: false, lineageParentId: null },
currentPreview: preview,
latestTrackedTarget: {
target: { status: 'in-progress', isPinDrop: false },
target: { status: 'in-progress', isPinDrop: false, lineageParentId: null },
preview: null,
x: 100,
y: 100
@@ -62,7 +62,7 @@ describe('resolveWorktreeSidebarStatusDropCommitTarget', () => {
y: 100
})
).toEqual({
target: { status: 'completed', isPinDrop: false },
target: { status: 'completed', isPinDrop: false, lineageParentId: null },
preview
})
})
@@ -70,10 +70,10 @@ describe('resolveWorktreeSidebarStatusDropCommitTarget', () => {
it('reuses the latest status target when pointerup hit-testing blanks at the same point', () => {
expect(
resolveWorktreeSidebarStatusDropCommitTarget({
currentTarget: { status: null, isPinDrop: false },
currentTarget: { status: null, isPinDrop: false, lineageParentId: null },
currentPreview: null,
latestTrackedTarget: {
target: { status: 'completed', isPinDrop: false },
target: { status: 'completed', isPinDrop: false, lineageParentId: null },
preview,
x: 100,
y: 100
@@ -82,7 +82,7 @@ describe('resolveWorktreeSidebarStatusDropCommitTarget', () => {
y: 101
})
).toEqual({
target: { status: 'completed', isPinDrop: false },
target: { status: 'completed', isPinDrop: false, lineageParentId: null },
preview
})
})
@@ -90,10 +90,10 @@ describe('resolveWorktreeSidebarStatusDropCommitTarget', () => {
it('does not reuse a stale status target after the pointer has moved away', () => {
expect(
resolveWorktreeSidebarStatusDropCommitTarget({
currentTarget: { status: null, isPinDrop: false },
currentTarget: { status: null, isPinDrop: false, lineageParentId: null },
currentPreview: null,
latestTrackedTarget: {
target: { status: 'completed', isPinDrop: false },
target: { status: 'completed', isPinDrop: false, lineageParentId: null },
preview,
x: 100,
y: 100
@@ -102,7 +102,7 @@ describe('resolveWorktreeSidebarStatusDropCommitTarget', () => {
y: 100
})
).toEqual({
target: { status: null, isPinDrop: false },
target: { status: null, isPinDrop: false, lineageParentId: null },
preview: null
})
})
@@ -8,6 +8,7 @@ export type WorktreeSidebarDropPreview = {
dropIndex: number
dropIndicatorY: number
previewOffsetsByWorktreeId: ReadonlyMap<string, number>
lineageParentId?: string
}
export type WorktreeSidebarStatusDropTarget = {
@@ -16,7 +17,7 @@ export type WorktreeSidebarStatusDropTarget = {
}
export type WorktreeSidebarTrackedStatusDropTarget = {
target: WorktreeSidebarStatusDropTarget
target: WorktreeSidebarStatusDropTarget & { lineageParentId: string | null }
preview: WorktreeSidebarDropPreview | null
x: number
y: number
@@ -24,18 +25,20 @@ export type WorktreeSidebarTrackedStatusDropTarget = {
const STATUS_DROP_TARGET_FALLBACK_TOLERANCE_PX = 6
function hasWorktreeSidebarStatusDropTarget(target: WorktreeSidebarStatusDropTarget): boolean {
return target.isPinDrop || target.status !== null
function hasWorktreeSidebarStatusDropTarget(
target: WorktreeSidebarStatusDropTarget & { lineageParentId?: string | null }
): boolean {
return target.isPinDrop || target.status !== null || (target.lineageParentId ?? null) !== null
}
export function resolveWorktreeSidebarStatusDropCommitTarget(args: {
currentTarget: WorktreeSidebarStatusDropTarget
currentTarget: WorktreeSidebarStatusDropTarget & { lineageParentId?: string | null }
currentPreview: WorktreeSidebarDropPreview | null
latestTrackedTarget: WorktreeSidebarTrackedStatusDropTarget | null
x: number
y: number
}): {
target: WorktreeSidebarStatusDropTarget
target: WorktreeSidebarStatusDropTarget & { lineageParentId?: string | null }
preview: WorktreeSidebarDropPreview | null
} {
if (hasWorktreeSidebarStatusDropTarget(args.currentTarget)) {