fix(sidebar): make nested workspace drops reliable

This commit is contained in:
Neil
2026-08-09 21:45:15 -07:00
parent ce8c3267b7
commit dab6b37e85
18 changed files with 1619 additions and 132 deletions
@@ -1422,7 +1422,12 @@ const WorktreeCard = React.memo(function WorktreeCard({
)}
>
{/* Header row: Title */}
<div className="flex min-w-0 items-center justify-between gap-2">
<div
className={cn(
'flex min-w-0 items-center justify-between gap-2 rounded-md transition-[background-color,box-shadow]',
isLineageDropTarget && 'bg-worktree-sidebar-accent ring-1 ring-worktree-sidebar-ring/50'
)}
>
<div className="flex min-w-0 flex-1 items-center gap-1.5">
{showPinnedRepoIcon && (
<RepoIdentityChip repo={repo}>
@@ -1759,6 +1764,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
worktreeId={worktree.id}
agents={agentActivityDisplayMode === 'compact' ? compactInlineAgentRows : undefined}
className={hasMetaRow || remoteBranchConflict ? 'mt-0' : '-mt-1'}
isLineageDropTarget={isLineageDropTarget}
/>
)}
@@ -1806,7 +1812,10 @@ const WorktreeCard = React.memo(function WorktreeCard({
)}
{!newCardStyle && lineageChildren && (
<div className="-ml-[1.125rem] mt-1.5 w-[calc(100%+1.125rem)] space-y-1">
<div
className="-ml-[1.125rem] mt-1.5 w-[calc(100%+1.125rem)] space-y-1"
data-worktree-legacy-lineage-children=""
>
{lineageChildren}
</div>
)}
@@ -1873,13 +1882,11 @@ const WorktreeCard = React.memo(function WorktreeCard({
'rounded-lg',
// Why: the live data attribute updates before React state during navigation,
// so it must own the complete active style without stale utility classes.
isLineageDropTarget
? 'border border-accent-foreground/20 bg-accent/80'
: isActiveSurface
? 'border border-transparent'
: 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
? 'border border-transparent'
: 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',
@@ -1893,6 +1900,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
isRuntimeDisconnected && !isDeleting && 'opacity-60'
)}
data-worktree-card-surface="true"
data-worktree-lineage-drop-target={isLineageDropTarget ? 'true' : undefined}
data-worktree-card-active={isActiveSurface ? activeSurfaceVariant : undefined}
onClick={handleClick}
onDoubleClick={affiliateListMode ? undefined : handleDoubleClick}
@@ -197,17 +197,21 @@ describe('WorktreeCardAgents', () => {
capturedRowActivations = []
})
it('renders ordinary rows in full mode without a child disclosure', async () => {
it('renders full rows and scopes lineage feedback to the targeted list', async () => {
mockAgentActivityDisplayMode = 'full'
const { default: WorktreeCardAgents } = await import('./WorktreeCardAgents')
const markup = renderToStaticMarkup(<WorktreeCardAgents worktreeId="wt-1" />)
const targetMarkup = renderToStaticMarkup(
<WorktreeCardAgents worktreeId="target" isLineageDropTarget />
)
const markup =
targetMarkup + renderToStaticMarkup(<WorktreeCardAgents worktreeId="descendant" />)
expect(markup).toContain('role="group"')
expect(markup).toContain('aria-label="Agents"')
expect(markup).toContain('data-testid="agent-row"')
expect(markup).not.toContain('<button')
expect(markup).not.toContain('aria-expanded')
expect(markup.match(/data-worktree-card-agent-list-drop-target="true"/g)).toHaveLength(1)
expect(markup.match(/bg-worktree-sidebar-accent/g)).toHaveLength(1)
expect(markup.match(/ring-worktree-sidebar-ring\/50/g)).toHaveLength(1)
}, 30_000)
it('uses compact mode when the display preference is absent', async () => {
@@ -48,13 +48,15 @@ type Props = {
agents?: DashboardAgentRowData[]
/** Spacing from the card body above; parent decides whether a divider is appropriate. */
className?: string
isLineageDropTarget?: boolean
}
/** Inline agent list rendered inside WorktreeCard when 'inline-agents' is enabled. */
const WorktreeCardAgents = React.memo(function WorktreeCardAgents({
worktreeId,
agents: precomputedAgents,
className
className,
isLineageDropTarget = false
}: Props) {
const selectedAgents = useWorktreeAgentRows(worktreeId, precomputedAgents === undefined)
const agents = precomputedAgents ?? selectedAgents
@@ -62,19 +64,28 @@ const WorktreeCardAgents = React.memo(function WorktreeCardAgents({
return null
}
// Why: mount the inner body (owns the 30s useNow tick) only for non-empty rows, so idle worktrees pay no timer cost.
return <WorktreeCardAgentsBody worktreeId={worktreeId} agents={agents} className={className} />
return (
<WorktreeCardAgentsBody
worktreeId={worktreeId}
agents={agents}
className={className}
isLineageDropTarget={isLineageDropTarget}
/>
)
})
type BodyProps = {
worktreeId: string
agents: DashboardAgentRowData[]
className?: string
isLineageDropTarget: boolean
}
const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
worktreeId,
agents,
className
className,
isLineageDropTarget
}: BodyProps) {
const agentActivityDisplayMode =
useAppStore((s) => s.agentActivityDisplayMode) ?? DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE
@@ -363,13 +374,19 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
return (
<div
ref={compactAgentListRootRef}
className={cn('flex flex-col mt-1 gap-0.5', className)}
className={cn(
'flex flex-col mt-1 gap-0.5 rounded-md transition-[background-color,box-shadow]',
isLineageDropTarget && 'bg-worktree-sidebar-accent ring-1 ring-worktree-sidebar-ring/50',
className
)}
onClick={stopBubble}
onDoubleClick={stopBubble}
onMouseDown={stopBubble}
onPointerDown={stopBubble}
role={hasLineage ? 'tree' : 'group'}
aria-label={translate('auto.components.sidebar.WorktreeCardAgents.1b0a156717', 'Agents')}
data-worktree-card-agent-list=""
data-worktree-card-agent-list-drop-target={isLineageDropTarget ? 'true' : undefined}
data-compact-agent-list="true"
>
{agents.length === 0 ? null : shouldUseSummaryRow ? (
@@ -405,13 +422,19 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
return (
// Why: swallow bubbling so gutter clicks don't reach WorktreeCard's activate / edit-meta handlers.
<div
className={cn('flex flex-col mt-1', className)}
className={cn(
'flex flex-col mt-1 rounded-md transition-[background-color,box-shadow]',
isLineageDropTarget && 'bg-worktree-sidebar-accent ring-1 ring-worktree-sidebar-ring/50',
className
)}
onClick={stopBubble}
onDoubleClick={stopBubble}
onMouseDown={stopBubble}
onPointerDown={stopBubble}
role={hasLineage ? 'tree' : 'group'}
aria-label={translate('auto.components.sidebar.WorktreeCardAgents.1b0a156717', 'Agents')}
data-worktree-card-agent-list=""
data-worktree-card-agent-list-drop-target={isLineageDropTarget ? 'true' : undefined}
>
{rootAgents.map((rootAgent) => renderAgentBranch(rootAgent))}
</div>
@@ -189,10 +189,12 @@ import {
setSidebarPointerDragDocumentStyles,
updateSidebarDragPreviewPosition
} from './worktree-sidebar-pointer-drag-dom'
import { shouldStartWorkspaceBoardDragPreview } from './workspace-board-drag-preview-intent'
import {
getWorktreeSidebarDragAutoscroll,
getWorktreeSidebarDragRectsForGroup,
refreshWorktreeSidebarDragSession,
shouldHoldWorktreeSidebarRowsAtPointerEdge,
type WorktreeSidebarDragRect,
type WorktreeSidebarDragSession,
type WorktreeSidebarDragPoint
@@ -212,6 +214,8 @@ import {
} from './worktree-sidebar-drop-preview'
import {
getReorderedWorktreeIdsToUnnest,
getTopLevelWorktreeLineageDragIds,
getWorktreeLineageDragRootId,
getWorktreeLineageDropTargetId
} from './worktree-lineage-drag-drop'
import { resolveProjectGroupHeaderColor } from './project-header-color'
@@ -945,6 +949,7 @@ type WorktreePointerDrag = {
currentX: number
currentY: number
worktreeId: string
explicitDraggedIds: readonly string[]
draggedIds: readonly string[]
reorderDraggedIds: readonly string[]
reorderUnitDraggedIds: readonly string[]
@@ -1015,6 +1020,7 @@ function getPointerDropStatusTarget(args: {
const lineageParentId = getWorktreeLineageDropTargetId({
container: args.container,
target,
pointerX: args.x,
pointerY: args.y
})
const statusTarget = target.closest<HTMLElement>('[data-workspace-status-drop-target]')
@@ -1373,6 +1379,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
const directScrollInputUntilRef = useRef(0)
const [dragOverStatus, setDragOverStatus] = useState<WorkspaceStatus | null>(null)
const [pinDragOver, setPinDragOver] = useState(false)
// Why: same-Y parent/child crossings change only target identity, so a ref would leave feedback stale.
const [pointerLineageDropTargetId, setPointerLineageDropTargetId] = useState<string | null>(null)
const [nativeLineageDropTargetId, setNativeLineageDropTargetId] = useState<string | null>(null)
const [worktreeDragState, setWorktreeDragState] = useState<WorktreeRowDragState>(
WORKTREE_ROW_DRAG_INITIAL_STATE
@@ -1544,6 +1552,27 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
expandDraggedWorktreeIdsForVisibleLineage(worktreeLineageDragRows, draggedIds),
[worktreeLineageDragRows]
)
const getLineageDragRootIds = useCallback(
(draggedIds: readonly string[]) =>
getTopLevelWorktreeLineageDragIds({
draggedIds,
lineageById: worktreeLineageById,
worktreeMap,
cyclicLineageIds
}),
[cyclicLineageIds, worktreeLineageById, worktreeMap]
)
const getLineageDragRootId = useCallback(
(worktreeId: string, lineageRootIds: readonly string[]) =>
getWorktreeLineageDragRootId({
worktreeId,
lineageRootIds,
lineageById: worktreeLineageById,
worktreeMap,
cyclicLineageIds
}),
[cyclicLineageIds, worktreeLineageById, worktreeMap]
)
const getReorderUnitDraggedIds = useCallback(
(sourceGroupKey: string, reorderDraggedIds: readonly string[]) => {
const group = worktreeDragUnitGroups.find((candidate) => candidate.key === sourceGroupKey)
@@ -1595,7 +1624,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
return null
}
const containerRect = container.getBoundingClientRect()
return computeWorktreeSidebarDropPreview({
const preview = computeWorktreeSidebarDropPreview({
pointerY: args.pointerY,
containerTop: containerRect.top,
scrollTop: container.scrollTop,
@@ -1606,6 +1635,27 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
grab: args.grab,
anchor: args.anchor
})
const pointerDrag = worktreePointerDragRef.current
const point = pointerDrag?.active
? { clientX: pointerDrag.currentX, clientY: pointerDrag.currentY }
: worktreeNativeLatestPointRef.current
if (
!preview ||
!point ||
!shouldHoldWorktreeSidebarRowsAtPointerEdge({
point,
containerRect,
scrollHeight: container.scrollHeight,
clientHeight: container.clientHeight
})
) {
return preview
}
// Why: shifting a tall card while edge-scrolling can move its target surface by a full viewport.
return {
...preview,
previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS
}
},
[worktreeDragUnitGroups]
)
@@ -2617,6 +2667,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
const cleanupWorktreePointerDrag = useCallback(() => {
const drag = worktreePointerDragRef.current
cancelWorktreePointerAutoscroll()
setPointerLineageDropTargetId(null)
setNativeLineageDropTargetId(null)
if (!drag) {
return
@@ -2769,14 +2820,26 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
clearWorktreeDrag()
return
}
// Why: show the board preview as soon as a card drag begins so the drop target is visible up front, not only at the sidebar edge.
const previewSidebarContainer = scrollRef.current
if (
!drag.workspaceBoardDragPreviewRequested &&
!workspaceBoardOpen &&
!hasWorkspaceKanbanSidebarDropBoard()
previewSidebarContainer
) {
drag.workspaceBoardDragPreviewRequested = true
onWorkspaceBoardDragPreviewStart()
const sidebarRect = previewSidebarContainer.getBoundingClientRect()
// Why: mounting the companion board during vertical reorder stalls the
// first drag frame; rightward edge movement signals board intent.
if (
shouldStartWorkspaceBoardDragPreview({
pointerX: drag.currentX,
startX: drag.startX,
sidebarRight: sidebarRect.right
}) &&
!hasWorkspaceKanbanSidebarDropBoard()
) {
drag.workspaceBoardDragPreviewRequested = true
onWorkspaceBoardDragPreviewStart()
}
}
const boardTarget = updateWorkspaceKanbanSidebarDropTargetVisual({
x: drag.currentX,
@@ -2797,6 +2860,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
}
if (boardTarget.status || boardTarget.isPinDrop) {
drag.latestStatusDropTarget = null
setPointerLineageDropTargetId(null)
setDragOverStatus(null)
setPinDragOver(false)
setWorktreeDragState((prev) =>
@@ -2829,6 +2893,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
)
if (preferredStatusTarget.lineageParentId) {
updateLatestWorktreeStatusDropTarget(drag, preferredStatusTarget, null)
setPointerLineageDropTargetId(preferredStatusTarget.lineageParentId)
clearWorkspaceKanbanSidebarDropTargetVisual()
setDragOverStatus(null)
setPinDragOver(false)
@@ -2848,6 +2913,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
)
return
}
setPointerLineageDropTargetId(null)
if (
shouldPreferSidebarStatusDropTarget({
sourceGroupKey: drag.sourceGroupKey,
@@ -3004,7 +3070,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
scrollTop: container.scrollTop,
scrollHeight: container.scrollHeight,
clientHeight: container.clientHeight,
elapsedMs: frameTime - previousFrameTime
elapsedMs: frameTime - previousFrameTime,
lineageTargetActive: Boolean(drag.latestStatusDropTarget?.target.lineageParentId)
})
if (autoscroll) {
markScrollMovement()
@@ -3045,7 +3112,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
sourceRow: drag.sourceRow,
pointerX: drag.currentX,
pointerY: drag.currentY,
draggedCount: drag.draggedIds.length
draggedCount: drag.explicitDraggedIds.length
})
drag.active = true
drag.preview = preview
@@ -3084,13 +3151,42 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
if (event.button !== 0 || event.pointerType === 'touch') {
return
}
const sourceRow = event.currentTarget
if (isSidebarPointerDragBlocked(event.target, sourceRow)) {
const pointerSourceRow = event.currentTarget
if (isSidebarPointerDragBlocked(event.target, pointerSourceRow)) {
return
}
const sourceGroupKey = groupKeyByRowKey.get(rowKey)
const container = scrollRef.current
if (!sourceGroupKey || !container) {
if (!container) {
return
}
const explicitDraggedIds =
selectedWorktreeIds.has(worktreeId) && selectedWorktrees.length > 1
? selectedWorktrees.map((worktree) => worktree.id)
: [worktreeId]
const draggedIds = getLineageDragRootIds(explicitDraggedIds)
const lineageRootWorktreeId = getLineageDragRootId(worktreeId, draggedIds)
const pointerSourceGroupKey = groupKeyByRowKey.get(rowKey)
const lineageRootSourceRow =
lineageRootWorktreeId === worktreeId
? pointerSourceRow
: [...container.querySelectorAll<HTMLElement>('[data-worktree-lineage-drop-id]')].find(
(candidate) =>
candidate.getAttribute('data-worktree-lineage-drop-id') === lineageRootWorktreeId &&
candidate.contains(pointerSourceRow)
)
const lineageRootSourceRowKey = lineageRootSourceRow?.getAttribute('data-worktree-row-key')
const lineageRootSourceGroupKey = lineageRootSourceRowKey
? groupKeyByRowKey.get(lineageRootSourceRowKey)
: null
const useLineageRootGeometry = Boolean(
pointerSourceGroupKey && lineageRootSourceGroupKey === pointerSourceGroupKey
)
const draggingWorktreeId = useLineageRootGeometry ? lineageRootWorktreeId : worktreeId
const sourceRow =
useLineageRootGeometry && lineageRootSourceRow ? lineageRootSourceRow : pointerSourceRow
const sourceRowKey = sourceRow.getAttribute('data-worktree-row-key') ?? rowKey
const sourceGroupKey = groupKeyByRowKey.get(sourceRowKey)
if (!sourceGroupKey) {
return
}
const rects = getWorktreeSidebarDragRectsForGroup(container, sourceGroupKey)
@@ -3104,11 +3200,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
) {
return
}
const draggedIds =
selectedWorktreeIds.has(worktreeId) && selectedWorktrees.length > 1
? selectedWorktrees.map((worktree) => worktree.id)
: [worktreeId]
const reorderDraggedIds = getReorderDraggedIds(draggedIds)
const reorderDraggedIds = getReorderDraggedIds(
useLineageRootGeometry ? draggedIds : explicitDraggedIds
)
const reorderUnitDraggedIds = getReorderUnitDraggedIds(sourceGroupKey, reorderDraggedIds)
worktreePointerDragRef.current = {
pointerId: event.pointerId,
@@ -3117,7 +3211,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
startY: event.clientY,
currentX: event.clientX,
currentY: event.clientY,
worktreeId,
worktreeId: draggingWorktreeId,
explicitDraggedIds,
draggedIds,
reorderDraggedIds,
reorderUnitDraggedIds,
@@ -3136,6 +3231,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
[
getReorderDraggedIds,
getReorderUnitDraggedIds,
getLineageDragRootId,
getLineageDragRootIds,
groupKeyByRowKey,
onWorkspaceBoardDragPreviewStart,
selectedWorktreeIds,
@@ -3199,7 +3296,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
onWorkspaceBoardDragPreviewCommit()
}
if (boardDropTarget.isPinDrop) {
onPinWorktrees(drag.draggedIds)
onPinWorktrees(drag.explicitDraggedIds)
} else if (boardDropTarget.status) {
onDropWorktreesOnWorkspaceBoard({
worktreeIds: drag.reorderDraggedIds,
@@ -3223,8 +3320,20 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
: { status: null, isPinDrop: false, lineageParentId: null },
drag.draggedIds
)
if (preferredStatusTarget.lineageParentId) {
commitWorktreeLineageParentDrop(drag.draggedIds, preferredStatusTarget.lineageParentId)
// Why: a move and release can share a frame; the live gutter must beat last-frame lineage.
const currentReorderDrop = preferredStatusTarget.lineageParentId
? null
: computeWorktreeDrop(event.clientY)
const lineageCommitTarget = resolveWorktreeSidebarStatusDropCommitTarget({
currentTarget: preferredStatusTarget,
currentPreview: null,
currentReorderTargetActive: currentReorderDrop !== null,
latestTrackedTarget: drag.latestStatusDropTarget,
x: event.clientX,
y: event.clientY
}).target.lineageParentId
if (lineageCommitTarget) {
commitWorktreeLineageParentDrop(drag.draggedIds, lineageCommitTarget)
clearWorktreeDrag()
return
}
@@ -3243,7 +3352,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
})
: null
if (preferredStatusTarget.isPinDrop) {
onPinWorktrees(drag.draggedIds)
onPinWorktrees(drag.explicitDraggedIds)
} else if (preferredStatusTarget.status) {
if (statusDrop) {
onMoveWorktreesToStatusAtIndex({
@@ -3259,7 +3368,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
clearWorktreeDrag()
return
}
const drop = computeWorktreeDrop(event.clientY)
const drop = currentReorderDrop
if (drop) {
onReorderWorktrees({
groups: worktreeDragGroups,
@@ -3287,6 +3396,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
const { target, preview: statusDrop } = resolveWorktreeSidebarStatusDropCommitTarget({
currentTarget,
currentPreview,
currentReorderTargetActive: false,
latestTrackedTarget: drag.latestStatusDropTarget,
x: event.clientX,
y: event.clientY
@@ -3294,7 +3404,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
if (target.lineageParentId) {
commitWorktreeLineageParentDrop(drag.draggedIds, target.lineageParentId)
} else if (target.isPinDrop) {
onPinWorktrees(drag.draggedIds)
onPinWorktrees(drag.explicitDraggedIds)
} else if (target.status) {
if (statusDrop) {
onMoveWorktreesToStatusAtIndex({
@@ -3477,7 +3587,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
if (!sourceGroupKey) {
return
}
const reorderDraggedIds = getReorderDraggedIds(draggedIds)
const lineageDragRootIds = getLineageDragRootIds(draggedIds)
const reorderDraggedIds = getReorderDraggedIds(lineageDragRootIds)
const reorderUnitDraggedIds = getReorderUnitDraggedIds(sourceGroupKey, reorderDraggedIds)
const rects = scrollRef.current
? getWorktreeSidebarDragRectsForGroup(scrollRef.current, sourceGroupKey)
@@ -3486,7 +3597,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
worktreeDragSessionRef.current = {
draggingWorktreeId: worktreeId,
sourceGroupKey,
draggedIds,
draggedIds: lineageDragRootIds,
reorderDraggedIds,
reorderUnitDraggedIds,
rects,
@@ -3505,7 +3616,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
pointerY: null
})
},
[getReorderDraggedIds, getReorderUnitDraggedIds, worktreeDragGroups]
[getLineageDragRootIds, getReorderDraggedIds, getReorderUnitDraggedIds, worktreeDragGroups]
)
const handleWorktreeDragOver = useCallback(
@@ -4806,8 +4917,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
agentSendTargetWorktreeId === itemRow.worktree.id ? 'ai' : 'default'
const isLineageDropTarget =
worktreeDragState.draggingWorktreeId &&
(worktreePointerDragRef.current?.latestStatusDropTarget?.target.lineageParentId ===
itemRow.worktree.id ||
(pointerLineageDropTargetId === itemRow.worktree.id ||
nativeLineageDropTargetId === itemRow.worktree.id)
const isPinnedOverlayRow = itemRow.sectionKey === PINNED_GROUP_KEY
const isActiveWorktree = activeWorktreeId === itemRow.worktree.id
@@ -4822,6 +4932,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
data-worktree-id={itemRow.worktree.id}
data-worktree-row-key={itemRow.rowKey}
data-worktree-section-key={itemRow.sectionKey}
data-worktree-lineage-drop-id={itemRow.worktree.id}
data-worktree-drag-id={worktreeDragGroupKey ? itemRow.worktree.id : undefined}
data-worktree-drag-group-key={worktreeDragGroupKey}
data-worktree-drag-group-index={worktreeDragGroupIndex}
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { shouldStartWorkspaceBoardDragPreview } from './workspace-board-drag-preview-intent'
describe('workspace board drag preview intent', () => {
it('keeps vertical sidebar reorders from mounting the board preview', () => {
expect(
shouldStartWorkspaceBoardDragPreview({
pointerX: 132,
startX: 96,
sidebarRight: 285
})
).toBe(false)
})
it('starts the preview after a rightward drag reaches the sidebar edge zone', () => {
expect(
shouldStartWorkspaceBoardDragPreview({
pointerX: 252,
startX: 96,
sidebarRight: 285
})
).toBe(true)
})
it('ignores the edge zone until the drag moves right far enough', () => {
expect(
shouldStartWorkspaceBoardDragPreview({
pointerX: 252,
startX: 242,
sidebarRight: 285
})
).toBe(false)
})
it('accepts a deliberate rightward drag beyond the sidebar edge', () => {
expect(
shouldStartWorkspaceBoardDragPreview({
pointerX: 310,
startX: 260,
sidebarRight: 285
})
).toBe(true)
})
})
@@ -0,0 +1,13 @@
const WORKSPACE_BOARD_DRAG_PREVIEW_EDGE_ZONE_PX = 48
const WORKSPACE_BOARD_DRAG_PREVIEW_MIN_RIGHTWARD_PX = 16
export function shouldStartWorkspaceBoardDragPreview(args: {
pointerX: number
startX: number
sidebarRight: number
}): boolean {
return (
args.pointerX >= args.sidebarRight - WORKSPACE_BOARD_DRAG_PREVIEW_EDGE_ZONE_PX &&
args.pointerX - args.startX >= WORKSPACE_BOARD_DRAG_PREVIEW_MIN_RIGHTWARD_PX
)
}
@@ -1,8 +1,12 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest'
import type { Worktree, WorktreeLineage } from '../../../../shared/types'
import { getCyclicProjectedWorktreeLineageIds } from './worktree-lineage-projection'
import {
getReorderedWorktreeIdsToUnnest,
getTopLevelWorktreeLineageDragIds,
getWorktreeLineageDragRootId,
getWorktreeLineageDropTargetId,
isWorktreeLineageDropZoneHit
} from './worktree-lineage-drag-drop'
@@ -11,26 +15,175 @@ describe('isWorktreeLineageDropZoneHit', () => {
it('keeps the top and bottom of a card available for reorder drops', () => {
const rect = { top: 100, bottom: 200 } as DOMRect
expect(isWorktreeLineageDropZoneHit({ pointerY: 120, rect })).toBe(false)
expect(isWorktreeLineageDropZoneHit({ pointerY: 107, rect })).toBe(false)
expect(isWorktreeLineageDropZoneHit({ pointerY: 150, rect })).toBe(true)
expect(isWorktreeLineageDropZoneHit({ pointerY: 180, rect })).toBe(false)
expect(isWorktreeLineageDropZoneHit({ pointerY: 193, rect })).toBe(false)
})
it('caps the parent-drop band on tall cards', () => {
const rect = { top: 0, bottom: 180 } as DOMRect
it('keeps expanded owned content available without moving the target to its center', () => {
const rect = { top: 0, bottom: 500 } as DOMRect
expect(isWorktreeLineageDropZoneHit({ pointerY: 67, rect })).toBe(false)
expect(isWorktreeLineageDropZoneHit({ pointerY: 90, rect })).toBe(true)
expect(isWorktreeLineageDropZoneHit({ pointerY: 113, rect })).toBe(false)
expect(isWorktreeLineageDropZoneHit({ pointerY: 7, rect })).toBe(false)
expect(isWorktreeLineageDropZoneHit({ pointerY: 24, rect })).toBe(true)
expect(isWorktreeLineageDropZoneHit({ pointerY: 476, rect })).toBe(true)
})
})
describe('getWorktreeLineageDropTargetId', () => {
it('returns the row id only when the pointer is in the card content middle band', () => {
it('targets the owned title and agent surface while preserving edge reorder gutters', () => {
const { container, target } = makeTarget({ worktreeId: 'parent', top: 100, bottom: 200 })
expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 120 })).toBeNull()
expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 150 })).toBe('parent')
expect(
getWorktreeLineageDropTargetId({ container, target, pointerX: 100, pointerY: 106 })
).toBeNull()
expect(
getWorktreeLineageDropTargetId({ container, target, pointerX: 100, pointerY: 120 })
).toBe('parent')
expect(
getWorktreeLineageDropTargetId({ container, target, pointerX: 100, pointerY: 150 })
).toBe('parent')
})
it('keeps a tall parent target stable across every owned agent row', () => {
const { container, surface } = makeTarget({
worktreeId: 'parent',
top: 100,
bottom: 600
})
const agentRow = document.createElement('div')
surface.appendChild(agentRow)
expect(
getWorktreeLineageDropTargetId({
container,
target: agentRow,
pointerX: 100,
pointerY: 124
})
).toBe('parent')
expect(
getWorktreeLineageDropTargetId({
container,
target: agentRow,
pointerX: 100,
pointerY: 480
})
).toBe('parent')
})
it('targets the deepest child surface and leaves lineage-container gaps for reorder', () => {
const { container, surface } = makeTarget({
worktreeId: 'parent',
top: 100,
bottom: 600
})
const children = document.createElement('div')
children.setAttribute('data-worktree-lineage-children', '')
setVerticalRect(children, 260, 600)
surface.appendChild(children)
const childRow = document.createElement('div')
childRow.setAttribute('data-worktree-lineage-drop-id', 'child')
const childSurface = document.createElement('div')
childSurface.setAttribute('data-worktree-card-surface', 'true')
setVerticalRect(childSurface, 300, 360)
childRow.appendChild(childSurface)
children.appendChild(childRow)
expect(
getWorktreeLineageDropTargetId({
container,
target: childSurface,
pointerX: 100,
pointerY: 330
})
).toBe('child')
expect(
getWorktreeLineageDropTargetId({
container,
target: children,
pointerX: 100,
pointerY: 280
})
).toBeNull()
})
it('leaves legacy lineage-container gaps for reorder', () => {
const { container, surface } = makeTarget({
worktreeId: 'parent',
top: 100,
bottom: 600
})
const children = document.createElement('div')
children.setAttribute('data-worktree-legacy-lineage-children', '')
setVerticalRect(children, 260, 600)
surface.appendChild(children)
expect(
getWorktreeLineageDropTargetId({
container,
target: children,
pointerX: 100,
pointerY: 280
})
).toBeNull()
})
it('targets pinned copies that are not reorder sources', () => {
const { container, target, row } = makeTarget({
worktreeId: 'pinned-parent',
top: 100,
bottom: 140
})
expect(row.hasAttribute('data-worktree-drag-id')).toBe(false)
expect(
getWorktreeLineageDropTargetId({ container, target, pointerX: 100, pointerY: 120 })
).toBe('pinned-parent')
})
it('ignores worktree surfaces while deletion is in progress', () => {
const { container, target, surface } = makeTarget({
worktreeId: 'deleting-parent',
top: 100,
bottom: 200
})
surface.setAttribute('aria-busy', 'true')
expect(
getWorktreeLineageDropTargetId({ container, target, pointerX: 100, pointerY: 150 })
).toBeNull()
})
it('ignores child surfaces beneath a deleting ancestor overlay', () => {
const {
container,
target: overlay,
surface
} = makeTarget({
worktreeId: 'deleting-parent',
top: 100,
bottom: 300
})
surface.setAttribute('aria-busy', 'true')
const children = document.createElement('div')
children.setAttribute('data-worktree-lineage-children', '')
const childRow = document.createElement('div')
childRow.setAttribute('data-worktree-lineage-drop-id', 'child')
const childSurface = document.createElement('div')
childSurface.setAttribute('data-worktree-card-surface', 'true')
setVerticalRect(childSurface, 160, 220)
childRow.appendChild(childSurface)
children.appendChild(childRow)
surface.appendChild(children)
expect(
getWorktreeLineageDropTargetId({
container,
target: overlay,
pointerX: 100,
pointerY: 180
})
).toBeNull()
})
it('ignores content targets outside the sidebar container', () => {
@@ -41,7 +194,60 @@ describe('getWorktreeLineageDropTargetId', () => {
contained: false
})
expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 150 })).toBeNull()
expect(
getWorktreeLineageDropTargetId({ container, target, pointerX: 100, pointerY: 150 })
).toBeNull()
})
})
describe('getTopLevelWorktreeLineageDragIds', () => {
it('keeps selected subtrees intact when an ancestor and descendant are selected', () => {
const parent = makeWorktree('parent')
const child = makeWorktree('child')
const grandchild = makeWorktree('grandchild')
const root = makeWorktree('root')
const lineageById = {
[child.id]: makeLineage(child, parent),
[grandchild.id]: makeLineage(grandchild, child)
}
const worktreeMap = new Map([parent, child, grandchild, root].map((item) => [item.id, item]))
expect(
getTopLevelWorktreeLineageDragIds({
draggedIds: ['grandchild', 'root', 'parent', 'child', 'root'],
lineageById,
worktreeMap,
cyclicLineageIds: getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap)
})
).toEqual(['root', 'parent'])
})
it('uses the selected ancestor root as geometry when its child starts the drag', () => {
const parent = makeWorktree('parent')
const child = makeWorktree('child')
const other = makeWorktree('other')
const lineageById = { [child.id]: makeLineage(child, parent) }
const worktreeMap = new Map([parent, child, other].map((item) => [item.id, item]))
const cyclicLineageIds = getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap)
expect(
getWorktreeLineageDragRootId({
worktreeId: child.id,
lineageRootIds: [parent.id, other.id],
lineageById,
worktreeMap,
cyclicLineageIds
})
).toBe(parent.id)
expect(
getWorktreeLineageDragRootId({
worktreeId: other.id,
lineageRootIds: [parent.id, other.id],
lineageById,
worktreeMap,
cyclicLineageIds
})
).toBe(other.id)
})
})
@@ -170,21 +376,26 @@ function makeTarget(args: {
}): {
container: HTMLElement
target: Element
row: HTMLElement
surface: HTMLElement
} {
const row = {
getAttribute: (name: string) => (name === 'data-worktree-drag-id' ? args.worktreeId : null)
} as HTMLElement
const content = {
getBoundingClientRect: () => ({ top: args.top, bottom: args.bottom }),
closest: (selector: string) => (selector === '[data-worktree-drag-id]' ? row : null)
} as HTMLElement
const target = {
closest: (selector: string) =>
selector === '[data-worktree-card-hover-trigger]' ? content : null
} as Element
const contained = args.contained ?? true
const container = {
contains: (element: Element) => contained && (element === content || element === row)
} as HTMLElement
return { container, target }
const container = document.createElement('div')
setVerticalRect(container, 0, 1_000)
const row = document.createElement('div')
row.setAttribute('data-worktree-lineage-drop-id', args.worktreeId)
const surface = document.createElement('div')
surface.setAttribute('data-worktree-card-surface', 'true')
const target = document.createElement('span')
surface.appendChild(target)
row.appendChild(surface)
if (args.contained ?? true) {
container.appendChild(row)
}
setVerticalRect(surface, args.top, args.bottom)
return { container, target, row, surface }
}
function setVerticalRect(element: HTMLElement, top: number, bottom: number): void {
element.getBoundingClientRect = () =>
({ left: 0, right: 200, top, bottom, height: bottom - top }) as DOMRect
}
@@ -1,13 +1,31 @@
import type { Worktree, WorktreeLineage } from '../../../../shared/types'
import { getLineageRenderInfo } from './worktree-lineage-projection'
import {
getWorktreeSidebarStaticRect,
getWorktreeSidebarVirtualRowStart,
type WorktreeSidebarStaticGeometry,
type WorktreeSidebarStaticRect
} from './worktree-sidebar-static-geometry'
const WORKTREE_CARD_CONTENT_TARGET_SELECTOR = '[data-worktree-card-hover-trigger]'
const WORKTREE_DRAG_ROW_SELECTOR = '[data-worktree-drag-id]'
const WORKTREE_CARD_SURFACE_SELECTOR = '[data-worktree-card-surface]'
const WORKTREE_BUSY_CARD_SURFACE_SELECTOR = `${WORKTREE_CARD_SURFACE_SELECTOR}[aria-busy='true']`
const WORKTREE_LINEAGE_CHILDREN_SELECTOR =
'[data-worktree-lineage-children],[data-worktree-legacy-lineage-children]'
const WORKTREE_LINEAGE_DROP_ROW_SELECTOR = '[data-worktree-lineage-drop-id]'
const WORKTREE_STICKY_HEADER_SELECTOR = '[data-worktree-sticky-header-active]'
const WORKTREE_VIRTUAL_ROW_SELECTOR = '[data-worktree-virtual-row]'
const WORKTREE_CARD_PARENT_CONTENT_SELECTOR = '[data-worktree-card-parent-content]'
const LINEAGE_DROP_ZONE_RATIO = 0.4
const LINEAGE_DROP_ZONE_MAX_HEIGHT_PX = 44
const LINEAGE_REORDER_GUTTER_PX = 8
const LINEAGE_REORDER_GUTTER_RATIO = 0.25
type VerticalRect = Pick<DOMRect, 'top' | 'bottom'>
type PointerPosition = { pointerX: number; pointerY: number }
type StaticVirtualRowHit = {
foundVirtualRows: boolean
row: HTMLElement | null
}
export function isWorktreeLineageDropZoneHit(args: {
pointerY: number
@@ -18,38 +36,246 @@ export function isWorktreeLineageDropZoneHit(args: {
return false
}
const zoneHeight = Math.min(height * LINEAGE_DROP_ZONE_RATIO, LINEAGE_DROP_ZONE_MAX_HEIGHT_PX)
const zoneTop = args.rect.top + (height - zoneHeight) / 2
const zoneBottom = args.rect.bottom - (height - zoneHeight) / 2
const gutter = Math.min(LINEAGE_REORDER_GUTTER_PX, height * LINEAGE_REORDER_GUTTER_RATIO)
const zoneTop = args.rect.top + gutter
const zoneBottom = args.rect.bottom - gutter
return args.pointerY >= zoneTop && args.pointerY <= zoneBottom
}
export function getWorktreeLineageDropTargetId(args: {
container: HTMLElement
target: Element
pointerX: number
pointerY: number
}): string | null {
const contentTarget = args.target.closest<HTMLElement>(WORKTREE_CARD_CONTENT_TARGET_SELECTOR)
if (!contentTarget || !args.container.contains(contentTarget)) {
return null
}
// Why: nesting should be deliberate; the top/bottom of a card stays available
// for reorder drops instead of treating the whole card as a parent target.
if (
!isWorktreeLineageDropZoneHit({
pointerY: args.pointerY,
rect: contentTarget.getBoundingClientRect()
})
!args.container.contains(args.target) ||
args.target.closest<HTMLElement>(WORKTREE_STICKY_HEADER_SELECTOR)
) {
return null
}
const rowTarget = contentTarget.closest<HTMLElement>(WORKTREE_DRAG_ROW_SELECTOR)
if (!rowTarget || !args.container.contains(rowTarget)) {
const containerRect = args.container.getBoundingClientRect()
if (!isPointInRect(args, containerRect)) {
return null
}
return rowTarget.getAttribute('data-worktree-drag-id')
const geometry: WorktreeSidebarStaticGeometry = {
containerTop: containerRect.top,
scrollTop: args.container.scrollTop
}
const virtualRowHit = getStaticVirtualRowAtPoint(args, geometry)
if (virtualRowHit.foundVirtualRows) {
const cardSurface = virtualRowHit.row?.querySelector<HTMLElement>(
WORKTREE_CARD_SURFACE_SELECTOR
)
return cardSurface ? getLineageTargetFromSurface(args, cardSurface, geometry) : null
}
const cardSurface = args.target.closest<HTMLElement>(WORKTREE_CARD_SURFACE_SELECTOR)
return cardSurface && args.container.contains(cardSurface)
? getLineageTargetFromSurface(args, cardSurface, geometry)
: null
}
function getLineageTargetFromSurface(
point: PointerPosition & { container: HTMLElement },
cardSurface: HTMLElement,
geometry: WorktreeSidebarStaticGeometry
): string | null {
const rect = getWorktreeSidebarStaticRect(point.container, cardSurface, geometry)
if (cardSurface.closest(WORKTREE_BUSY_CARD_SURFACE_SELECTOR) || !isPointInRect(point, rect)) {
return null
}
const lineageChildren = getOwnedLineageChildren(cardSurface)
if (
lineageChildren &&
isPointInRect(point, getWorktreeSidebarStaticRect(point.container, lineageChildren, geometry))
) {
const childSurface = findChildSurfaceAtPoint(point, lineageChildren, point.container, geometry)
return childSurface ? getLineageTargetFromSurface(point, childSurface, geometry) : null
}
if (!isWorktreeLineageDropZoneHit({ pointerY: point.pointerY, rect })) {
return null
}
const rowTarget = cardSurface.closest<HTMLElement>(WORKTREE_LINEAGE_DROP_ROW_SELECTOR)
return rowTarget && point.container.contains(rowTarget)
? rowTarget.getAttribute('data-worktree-lineage-drop-id')
: null
}
function getStaticVirtualRowAtPoint(
point: PointerPosition & { container: HTMLElement; target: Element },
geometry: WorktreeSidebarStaticGeometry
): StaticVirtualRowHit {
const targetRow = point.target.closest<HTMLElement>(WORKTREE_VIRTUAL_ROW_SELECTOR)
if (
targetRow &&
point.container.contains(targetRow) &&
isPointInRect(point, getWorktreeSidebarStaticRect(point.container, targetRow, geometry))
) {
return { foundVirtualRows: true, row: targetRow }
}
const firstChild = point.container.firstElementChild
const rowsHost =
targetRow?.parentElement ??
(firstChild?.matches(WORKTREE_VIRTUAL_ROW_SELECTOR) ? point.container : firstChild)
if (!(rowsHost instanceof HTMLElement)) {
return { foundVirtualRows: false, row: null }
}
const pointerContentY = point.pointerY - geometry.containerTop + geometry.scrollTop
let foundVirtualRows = false
let candidate: HTMLElement | null = null
for (const child of rowsHost.children) {
if (!(child instanceof HTMLElement) || !child.matches(WORKTREE_VIRTUAL_ROW_SELECTOR)) {
continue
}
foundVirtualRows = true
const start = getWorktreeSidebarVirtualRowStart(child)
if (start === null) {
continue
}
if (start > pointerContentY) {
break
}
candidate = child
}
return { foundVirtualRows, row: candidate }
}
function getOwnedLineageChildren(cardSurface: HTMLElement): HTMLElement | null {
const directCandidate = cardSurface.lastElementChild
if (
directCandidate instanceof HTMLElement &&
directCandidate.matches(WORKTREE_LINEAGE_CHILDREN_SELECTOR)
) {
return directCandidate
}
// Why: legacy lineage is last in the content column; avoid walking long agent subtrees.
const parentContent = cardSurface.querySelector<HTMLElement>(
WORKTREE_CARD_PARENT_CONTENT_SELECTOR
)
const contentColumn = parentContent?.lastElementChild
const legacyCandidate = contentColumn?.lastElementChild
return legacyCandidate instanceof HTMLElement &&
legacyCandidate.matches(WORKTREE_LINEAGE_CHILDREN_SELECTOR)
? legacyCandidate
: null
}
function findChildSurfaceAtPoint(
point: PointerPosition,
lineageChildren: HTMLElement,
container: HTMLElement,
geometry: WorktreeSidebarStaticGeometry
): HTMLElement | null {
const rows = lineageChildren.children
let low = 0
let high = rows.length - 1
let candidate: HTMLElement | null = null
let candidateRect: WorktreeSidebarStaticRect | null = null
while (low <= high) {
const middle = Math.floor((low + high) / 2)
const childRow = rows.item(middle)
const childSurface = childRow?.querySelector<HTMLElement>(WORKTREE_CARD_SURFACE_SELECTOR)
if (!childSurface) {
return null
}
const rect = getWorktreeSidebarStaticRect(container, childSurface, geometry)
if (rect.top <= point.pointerY) {
candidate = childSurface
candidateRect = rect
low = middle + 1
} else {
high = middle - 1
}
}
if (!candidate || !candidateRect) {
return null
}
return isPointInRect(point, candidateRect) ? candidate : null
}
function isPointInRect(
point: PointerPosition,
rect: Pick<WorktreeSidebarStaticRect, 'left' | 'right' | 'top' | 'bottom'>
): boolean {
return (
point.pointerX >= rect.left &&
point.pointerX <= rect.right &&
point.pointerY >= rect.top &&
point.pointerY <= rect.bottom
)
}
export function getTopLevelWorktreeLineageDragIds(args: {
draggedIds: readonly string[]
lineageById: Readonly<Record<string, WorktreeLineage>>
worktreeMap: ReadonlyMap<string, Worktree>
cyclicLineageIds: ReadonlySet<string>
}): string[] {
const selectedIds = new Set(args.draggedIds)
const included = new Set<string>()
const roots: string[] = []
for (const id of args.draggedIds) {
if (included.has(id)) {
continue
}
included.add(id)
let current = args.worktreeMap.get(id)
let selectedAncestor = false
while (current) {
const lineage = getLineageRenderInfo(
current,
args.lineageById,
args.worktreeMap,
args.cyclicLineageIds
)
if (lineage.state !== 'valid') {
break
}
if (selectedIds.has(lineage.parent.id)) {
selectedAncestor = true
break
}
current = lineage.parent
}
if (!selectedAncestor) {
roots.push(id)
}
}
return roots
}
export function getWorktreeLineageDragRootId(args: {
worktreeId: string
lineageRootIds: readonly string[]
lineageById: Readonly<Record<string, WorktreeLineage>>
worktreeMap: ReadonlyMap<string, Worktree>
cyclicLineageIds: ReadonlySet<string>
}): string {
const rootIds = new Set(args.lineageRootIds)
let current = args.worktreeMap.get(args.worktreeId)
while (current) {
if (rootIds.has(current.id)) {
return current.id
}
const lineage = getLineageRenderInfo(
current,
args.lineageById,
args.worktreeMap,
args.cyclicLineageIds
)
if (lineage.state !== 'valid') {
break
}
current = lineage.parent
}
return args.worktreeId
}
export function getReorderedWorktreeIdsToUnnest(args: {
@@ -0,0 +1,380 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest'
import { getWorktreeLineageDropTargetId } from './worktree-lineage-drag-drop'
import { getWorktreeSidebarStaticRect } from './worktree-sidebar-static-geometry'
describe('static worktree lineage hit testing', () => {
it('keeps a tall target under the pointer when reorder preview moves it away', () => {
const container = makeContainer({ scrollTop: 642, top: 200, bottom: 1_000 })
const virtualRow = makeVirtualRow({ start: 1_280, top: 158 })
const { row, surface } = makeWorktreeCard('target', {
left: 56,
right: 204,
top: 158,
bottom: 838
})
virtualRow.appendChild(row)
container.appendChild(virtualRow)
expect(getWorktreeSidebarStaticRect(container, surface)).toMatchObject({
top: 838,
bottom: 1_518
})
expect(
getWorktreeLineageDropTargetId({
container,
target: container,
pointerX: 100,
pointerY: 883
})
).toBe('target')
})
it('uses static intent when a shifted sibling is visually under the pointer', () => {
const container = makeContainer({ scrollTop: 642, top: 200, bottom: 1_000 })
const targetVirtualRow = makeVirtualRow({ start: 1_280, top: 158 })
const target = makeWorktreeCard('target', {
left: 56,
right: 204,
top: 158,
bottom: 838
})
targetVirtualRow.appendChild(target.row)
container.appendChild(targetVirtualRow)
const siblingVirtualRow = makeVirtualRow({ start: 2_000, top: 850 })
const sibling = makeWorktreeCard('shifted-sibling', {
left: 56,
right: 204,
top: 850,
bottom: 930
})
siblingVirtualRow.appendChild(sibling.row)
container.appendChild(siblingVirtualRow)
expect(
getWorktreeLineageDropTargetId({
container,
target: sibling.surface,
pointerX: 100,
pointerY: 883
})
).toBe('target')
})
it('chooses the deepest child and keeps lineage-container gaps reorderable', () => {
const container = makeContainer({ scrollTop: 200, top: 100, bottom: 1_000 })
const virtualRow = makeVirtualRow({ start: 600, top: 120 })
const parent = makeWorktreeCard('parent', {
left: 40,
right: 220,
top: 120,
bottom: 520
})
const children = document.createElement('div')
children.setAttribute('data-worktree-lineage-children', '')
setRect(children, { left: 48, right: 212, top: 250, bottom: 520 })
const child = makeWorktreeCard('child', {
left: 56,
right: 204,
top: 300,
bottom: 380
})
children.appendChild(child.row)
parent.surface.appendChild(children)
virtualRow.appendChild(parent.row)
container.appendChild(virtualRow)
expect(
getWorktreeLineageDropTargetId({
container,
target: container,
pointerX: 100,
pointerY: 720
})
).toBe('child')
expect(
getWorktreeLineageDropTargetId({
container,
target: container,
pointerX: 100,
pointerY: 650
})
).toBeNull()
})
it('preserves reorder gutters and rejects deleting targets', () => {
const container = makeContainer({ scrollTop: 0, top: 100, bottom: 500 })
const { row, surface } = makeWorktreeCard('target', {
left: 40,
right: 220,
top: 160,
bottom: 260
})
container.appendChild(row)
expect(
getWorktreeLineageDropTargetId({
container,
target: surface,
pointerX: 100,
pointerY: 165
})
).toBeNull()
surface.setAttribute('aria-busy', 'true')
expect(
getWorktreeLineageDropTargetId({
container,
target: surface,
pointerX: 100,
pointerY: 210
})
).toBeNull()
})
it('measures only the pointed lineage branch in a wide nested list', () => {
let containerRectReads = 0
let surfaceRectReads = 0
const container = makeContainer({ scrollTop: 0, top: 0, bottom: 500 })
setRect(container, { left: 20, right: 240, top: 0, bottom: 500 }, () => containerRectReads++)
const virtualRow = makeVirtualRow({ start: 0, top: 0 })
const parent = makeWorktreeCard('parent', {
left: 40,
right: 220,
top: 20,
bottom: 21_000
})
setRect(
parent.surface,
{ left: 40, right: 220, top: 20, bottom: 21_000 },
() => surfaceRectReads++
)
const children = document.createElement('div')
children.setAttribute('data-worktree-lineage-children', '')
setRect(children, { left: 48, right: 212, top: 80, bottom: 20_980 })
for (let index = 0; index < 256; index++) {
const top = 100 + index * 80
const child = makeWorktreeCard(`child-${index}`, {
left: 56,
right: 204,
top,
bottom: top + 60
})
setRect(
child.surface,
{ left: 56, right: 204, top, bottom: top + 60 },
() => surfaceRectReads++
)
children.appendChild(child.row)
}
parent.surface.appendChild(children)
virtualRow.appendChild(parent.row)
container.appendChild(virtualRow)
expect(
getWorktreeLineageDropTargetId({
container,
target: container,
pointerX: 100,
pointerY: 130
})
).toBe('child-0')
expect(containerRectReads).toBe(1)
expect(surfaceRectReads).toBeLessThanOrEqual(12)
})
it('targets the deepest node with reads bounded by depth and sibling width', () => {
const depth = 8
const siblingsPerLevel = 16
let surfaceRectReads = 0
const container = makeContainer({ scrollTop: 0, top: 0, bottom: 600 })
const virtualRow = makeVirtualRow({ start: 0, top: 0 })
const root = makeWorktreeCard('root', {
left: 40,
right: 220,
top: 20,
bottom: 3_000
})
setRect(root.surface, { left: 40, right: 220, top: 20, bottom: 3_000 }, () => {
surfaceRectReads++
})
let branchSurface = root.surface
let branchBottom = 3_000
let deepestId = 'root'
for (let level = 1; level <= depth; level++) {
const parentSurface = branchSurface
const children = document.createElement('div')
children.setAttribute('data-worktree-lineage-children', '')
setRect(children, {
left: 48,
right: 212,
top: 60 + level * 4,
bottom: branchBottom - 1
})
const nextBottom = branchBottom - 100
for (let sibling = 0; sibling < siblingsPerLevel; sibling++) {
const worktreeId = sibling === 0 ? `branch-${level}` : `decoy-${level}-${sibling}`
const top = sibling === 0 ? 80 + level * 4 : nextBottom + sibling * 2
const bottom = sibling === 0 ? nextBottom : top + 1
const child = makeWorktreeCard(worktreeId, { left: 56, right: 204, top, bottom })
setRect(child.surface, { left: 56, right: 204, top, bottom }, () => {
surfaceRectReads++
})
children.appendChild(child.row)
if (sibling === 0) {
branchSurface = child.surface
deepestId = worktreeId
}
}
parentSurface.appendChild(children)
branchBottom = nextBottom
}
virtualRow.appendChild(root.row)
container.appendChild(virtualRow)
expect(
getWorktreeLineageDropTargetId({
container,
target: container,
pointerX: 100,
pointerY: 300
})
).toBe(deepestId)
const logarithmicReadsPerLevel = Math.ceil(Math.log2(siblingsPerLevel)) + 2
expect(surfaceRectReads).toBeLessThanOrEqual(1 + depth * logarithmicReadsPerLevel)
})
it('descends legacy lineage after a long agent list', () => {
const container = makeContainer({ scrollTop: 0, top: 0, bottom: 600 })
const virtualRow = makeVirtualRow({ start: 0, top: 0 })
const parent = makeWorktreeCard('parent', {
left: 40,
right: 220,
top: 20,
bottom: 500
})
const parentContent = document.createElement('div')
parentContent.setAttribute('data-worktree-card-parent-content', '')
const statusSlot = document.createElement('div')
const contentColumn = document.createElement('div')
const agentList = document.createElement('div')
for (let index = 0; index < 128; index++) {
agentList.appendChild(document.createElement('div'))
}
const legacyChildren = document.createElement('div')
legacyChildren.setAttribute('data-worktree-legacy-lineage-children', '')
setRect(legacyChildren, { left: 48, right: 212, top: 300, bottom: 500 })
const child = makeWorktreeCard('child', {
left: 56,
right: 204,
top: 340,
bottom: 420
})
legacyChildren.appendChild(child.row)
contentColumn.append(agentList, legacyChildren)
parentContent.append(statusSlot, contentColumn)
parent.surface.appendChild(parentContent)
virtualRow.appendChild(parent.row)
container.appendChild(virtualRow)
expect(
getWorktreeLineageDropTargetId({
container,
target: child.surface,
pointerX: 100,
pointerY: 380
})
).toBe('child')
})
it('does not hit shifted cards through chrome outside the scroller', () => {
const container = makeContainer({ scrollTop: 642, top: 200, bottom: 1_000 })
const virtualRow = makeVirtualRow({ start: 1_280, top: 158 })
const { row } = makeWorktreeCard('target', {
left: 56,
right: 204,
top: 158,
bottom: 838
})
virtualRow.appendChild(row)
container.appendChild(virtualRow)
expect(
getWorktreeLineageDropTargetId({
container,
target: container,
pointerX: 100,
pointerY: 165
})
).toBeNull()
})
it('does not hit static cards through an active sticky header', () => {
const container = makeContainer({ scrollTop: 642, top: 200, bottom: 1_000 })
const virtualRow = makeVirtualRow({ start: 1_280, top: 158 })
const { row } = makeWorktreeCard('target', {
left: 56,
right: 204,
top: 158,
bottom: 838
})
virtualRow.appendChild(row)
container.appendChild(virtualRow)
const stickyHeader = document.createElement('div')
stickyHeader.setAttribute('data-worktree-sticky-header-active', '')
container.appendChild(stickyHeader)
expect(
getWorktreeLineageDropTargetId({
container,
target: stickyHeader,
pointerX: 100,
pointerY: 883
})
).toBeNull()
})
})
function makeContainer(args: { scrollTop: number; top: number; bottom: number }): HTMLElement {
const container = document.createElement('div')
container.scrollTop = args.scrollTop
setRect(container, { left: 20, right: 240, top: args.top, bottom: args.bottom })
return container
}
function makeVirtualRow(args: { start: number; top: number }): HTMLElement {
const row = document.createElement('div')
row.setAttribute('data-worktree-virtual-row', '')
row.setAttribute('data-worktree-virtual-row-start', String(args.start))
setRect(row, { left: 20, right: 240, top: args.top, bottom: args.top + 680 })
return row
}
function makeWorktreeCard(
worktreeId: string,
rect: { left: number; right: number; top: number; bottom: number }
): { row: HTMLElement; surface: HTMLElement } {
const row = document.createElement('div')
row.setAttribute('data-worktree-lineage-drop-id', worktreeId)
const surface = document.createElement('div')
surface.setAttribute('data-worktree-card-surface', 'true')
setRect(surface, rect)
row.appendChild(surface)
return { row, surface }
}
function setRect(
element: HTMLElement,
rect: { left: number; right: number; top: number; bottom: number },
onRead?: () => void
): void {
element.getBoundingClientRect = () => {
onRead?.()
return {
...rect,
height: rect.bottom - rect.top,
width: rect.right - rect.left
} as DOMRect
}
}
@@ -4,6 +4,7 @@ import {
getWorktreeSidebarBoundaryDrop,
getWorktreeSidebarDragRectsForGroup,
refreshWorktreeSidebarDragSession,
shouldHoldWorktreeSidebarRowsAtPointerEdge,
type WorktreeSidebarDragRect,
type WorktreeSidebarDragSession
} from './worktree-sidebar-drag-autoscroll'
@@ -26,6 +27,57 @@ const SESSION: WorktreeSidebarDragSession = {
anchor: null
}
describe('shouldHoldWorktreeSidebarRowsAtPointerEdge', () => {
it('holds preview rows throughout an overflowing edge zone', () => {
expect(
shouldHoldWorktreeSidebarRowsAtPointerEdge({
point: { clientX: 80, clientY: 488 },
containerRect: CONTAINER_RECT,
scrollHeight: 1_000,
clientHeight: 400
})
).toBe(true)
})
it('keeps rows held when autoscroll reaches the list boundary', () => {
const point = { clientX: 80, clientY: 488 }
expect(getAutoscroll(point, { scrollTop: 600 })).toBeNull()
expect(
shouldHoldWorktreeSidebarRowsAtPointerEdge({
point,
containerRect: CONTAINER_RECT,
scrollHeight: 1_000,
clientHeight: 400
})
).toBe(true)
})
it('keeps normal previews in the interior, outside the sidebar, and without overflow', () => {
const base = {
point: { clientX: 80, clientY: 300 },
containerRect: CONTAINER_RECT,
scrollHeight: 1_000,
clientHeight: 400
}
expect(shouldHoldWorktreeSidebarRowsAtPointerEdge(base)).toBe(false)
expect(
shouldHoldWorktreeSidebarRowsAtPointerEdge({
...base,
point: { clientX: 4, clientY: 488 }
})
).toBe(false)
expect(
shouldHoldWorktreeSidebarRowsAtPointerEdge({
...base,
point: { clientX: 80, clientY: 488 },
scrollHeight: 400
})
).toBe(false)
})
})
describe('getWorktreeSidebarDragAutoscroll', () => {
it('scrolls up near the top edge', () => {
expect(getAutoscroll({ clientX: 80, clientY: 112 })?.scrollTop).toBeCloseTo(187.93, 2)
@@ -39,6 +91,13 @@ describe('getWorktreeSidebarDragAutoscroll', () => {
expect(getAutoscroll({ clientX: 80, clientY: 300 })).toBeNull()
})
it('holds an active lineage target unless the pointer reaches the extreme edge', () => {
expect(getAutoscroll({ clientX: 80, clientY: 470 }, { lineageTargetActive: true })).toBeNull()
expect(
getAutoscroll({ clientX: 80, clientY: 496 }, { lineageTargetActive: true })?.scrollTop
).toBeGreaterThan(200)
})
it('does nothing when the pointer is outside horizontally', () => {
expect(getAutoscroll({ clientX: 4, clientY: 488 })).toBeNull()
})
@@ -4,8 +4,10 @@ import type {
WorktreeSidebarDragGrab,
WorktreeSidebarDropAnchor
} from './worktree-sidebar-drag-geometry'
import { getWorktreeSidebarStaticRect } from './worktree-sidebar-static-geometry'
const EDGE_ZONE_PX = 56
const ACTIVE_LINEAGE_TARGET_EDGE_ZONE_PX = 12
const MAX_OUTSIDE_EDGE_PX = 48
const MAX_SCROLL_SPEED_PX_PER_SECOND = 960
const MAX_FRAME_MS = 32
@@ -50,6 +52,23 @@ export type WorktreeSidebarBoundaryDropResult =
| { kind: 'inside' }
| { kind: 'outside' }
export function shouldHoldWorktreeSidebarRowsAtPointerEdge(args: {
point: WorktreeSidebarDragPoint
containerRect: Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom'>
scrollHeight: number
clientHeight: number
}): boolean {
if (
args.scrollHeight <= args.clientHeight ||
args.point.clientX < args.containerRect.left ||
args.point.clientX > args.containerRect.right
) {
return false
}
// Why: edge intent must stay stable at scroll bounds even after autoscroll has no remaining delta.
return Boolean(getVerticalEdgeIntensity(args.point.clientY, args.containerRect, EDGE_ZONE_PX))
}
export function getWorktreeSidebarDragAutoscroll(args: {
point: WorktreeSidebarDragPoint
containerRect: Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom'>
@@ -57,6 +76,7 @@ export function getWorktreeSidebarDragAutoscroll(args: {
scrollHeight: number
clientHeight: number
elapsedMs: number
lineageTargetActive?: boolean
}): WorktreeSidebarAutoscrollResult | null {
const { point, containerRect } = args
if (point.clientX < containerRect.left || point.clientX > containerRect.right) {
@@ -74,7 +94,11 @@ export function getWorktreeSidebarDragAutoscroll(args: {
return null
}
const edge = getVerticalEdgeIntensity(point.clientY, containerRect)
const edge = getVerticalEdgeIntensity(
point.clientY,
containerRect,
args.lineageTargetActive ? ACTIVE_LINEAGE_TARGET_EDGE_ZONE_PX : EDGE_ZONE_PX
)
if (!edge) {
return null
}
@@ -141,13 +165,8 @@ export function getWorktreeSidebarDragRectsForGroup(
if (!worktreeId || !Number.isFinite(groupIndex)) {
return
}
const rect = element.getBoundingClientRect()
const virtualRow = element.closest<HTMLElement>('[data-worktree-virtual-row]')
const virtualRowStart = getWorktreeVirtualRowStart(virtualRow)
const top =
virtualRow && virtualRowStart !== null
? virtualRowStart + rect.top - virtualRow.getBoundingClientRect().top
: rect.top - containerRect.top + container.scrollTop
const rect = getWorktreeSidebarStaticRect(container, element)
const top = rect.top - containerRect.top + container.scrollTop
rects.push({
worktreeId,
groupIndex,
@@ -161,18 +180,6 @@ export function getWorktreeSidebarDragRectsForGroup(
return rects
}
function getWorktreeVirtualRowStart(virtualRow: HTMLElement | null): number | null {
if (!virtualRow) {
return null
}
const rawStart = virtualRow.getAttribute('data-worktree-virtual-row-start')
if (rawStart === null) {
return null
}
const start = Number(rawStart)
return Number.isFinite(start) ? start : null
}
export function refreshWorktreeSidebarDragSession(args: {
session: WorktreeSidebarDragSession
groups: readonly WorktreeDragGroup[]
@@ -204,7 +211,8 @@ export function refreshWorktreeSidebarDragSession(args: {
function getVerticalEdgeIntensity(
clientY: number,
containerRect: Pick<DOMRect, 'top' | 'bottom'>
containerRect: Pick<DOMRect, 'top' | 'bottom'>,
edgeZonePx: number
): { direction: -1 | 1; intensity: number } | null {
if (clientY < containerRect.top - MAX_OUTSIDE_EDGE_PX) {
return null
@@ -212,16 +220,16 @@ function getVerticalEdgeIntensity(
if (clientY > containerRect.bottom + MAX_OUTSIDE_EDGE_PX) {
return null
}
if (clientY <= containerRect.top + EDGE_ZONE_PX) {
if (clientY <= containerRect.top + edgeZonePx) {
return {
direction: -1,
intensity: Math.min(1, (containerRect.top + EDGE_ZONE_PX - clientY) / EDGE_ZONE_PX)
intensity: Math.min(1, (containerRect.top + edgeZonePx - clientY) / edgeZonePx)
}
}
if (clientY >= containerRect.bottom - EDGE_ZONE_PX) {
if (clientY >= containerRect.bottom - edgeZonePx) {
return {
direction: 1,
intensity: Math.min(1, (clientY - (containerRect.bottom - EDGE_ZONE_PX)) / EDGE_ZONE_PX)
intensity: Math.min(1, (clientY - (containerRect.bottom - edgeZonePx)) / edgeZonePx)
}
}
return null
@@ -189,6 +189,7 @@ describe('resolveWorktreeSidebarStatusDropCommitTarget', () => {
resolveWorktreeSidebarStatusDropCommitTarget({
currentTarget: { status: 'completed', isPinDrop: false, lineageParentId: null },
currentPreview: preview,
currentReorderTargetActive: false,
latestTrackedTarget: {
target: { status: 'in-progress', isPinDrop: false, lineageParentId: null },
preview: null,
@@ -209,6 +210,7 @@ describe('resolveWorktreeSidebarStatusDropCommitTarget', () => {
resolveWorktreeSidebarStatusDropCommitTarget({
currentTarget: { status: null, isPinDrop: false, lineageParentId: null },
currentPreview: null,
currentReorderTargetActive: false,
latestTrackedTarget: {
target: { status: 'completed', isPinDrop: false, lineageParentId: null },
preview,
@@ -229,6 +231,7 @@ describe('resolveWorktreeSidebarStatusDropCommitTarget', () => {
resolveWorktreeSidebarStatusDropCommitTarget({
currentTarget: { status: null, isPinDrop: false, lineageParentId: null },
currentPreview: null,
currentReorderTargetActive: false,
latestTrackedTarget: {
target: { status: null, isPinDrop: false, lineageParentId: 'parent-worktree' },
preview: null,
@@ -244,11 +247,54 @@ describe('resolveWorktreeSidebarStatusDropCommitTarget', () => {
})
})
it('keeps the current status intent ahead of a nearby lineage fallback', () => {
expect(
resolveWorktreeSidebarStatusDropCommitTarget({
currentTarget: { status: 'in-progress', isPinDrop: false, lineageParentId: null },
currentPreview: null,
currentReorderTargetActive: false,
latestTrackedTarget: {
target: { status: 'in-progress', isPinDrop: false, lineageParentId: 'parent-worktree' },
preview: null,
x: 100,
y: 100
},
x: 102,
y: 101
})
).toEqual({
target: { status: 'in-progress', isPinDrop: false, lineageParentId: null },
preview: null
})
})
it('keeps a reorder gutter ahead of a nearby lineage fallback', () => {
expect(
resolveWorktreeSidebarStatusDropCommitTarget({
currentTarget: { status: null, isPinDrop: false, lineageParentId: null },
currentPreview: null,
currentReorderTargetActive: true,
latestTrackedTarget: {
target: { status: null, isPinDrop: false, lineageParentId: 'parent-worktree' },
preview: null,
x: 100,
y: 100
},
x: 101,
y: 100
})
).toEqual({
target: { status: null, isPinDrop: false, lineageParentId: null },
preview: null
})
})
it('does not reuse a stale status target after the pointer has moved away', () => {
expect(
resolveWorktreeSidebarStatusDropCommitTarget({
currentTarget: { status: null, isPinDrop: false, lineageParentId: null },
currentPreview: null,
currentReorderTargetActive: false,
latestTrackedTarget: {
target: { status: 'completed', isPinDrop: false, lineageParentId: null },
preview,
@@ -82,6 +82,7 @@ function hasWorktreeSidebarStatusDropTarget(
export function resolveWorktreeSidebarStatusDropCommitTarget(args: {
currentTarget: WorktreeSidebarStatusDropTarget & { lineageParentId?: string | null }
currentPreview: WorktreeSidebarDropPreview | null
currentReorderTargetActive: boolean
latestTrackedTarget: WorktreeSidebarTrackedStatusDropTarget | null
x: number
y: number
@@ -89,14 +90,19 @@ export function resolveWorktreeSidebarStatusDropCommitTarget(args: {
target: WorktreeSidebarStatusDropTarget & { lineageParentId?: string | null }
preview: WorktreeSidebarDropPreview | null
} {
if (hasWorktreeSidebarStatusDropTarget(args.currentTarget)) {
if (hasWorktreeSidebarStatusDropTarget(args.currentTarget) || args.currentReorderTargetActive) {
return { target: args.currentTarget, preview: args.currentPreview }
}
const latest = args.latestTrackedTarget
const distance = latest
? Math.hypot(args.x - latest.x, args.y - latest.y)
: Number.POSITIVE_INFINITY
if (latest?.target.lineageParentId && distance <= STATUS_DROP_TARGET_FALLBACK_TOLERANCE_PX) {
return { target: latest.target, preview: latest.preview }
}
if (!latest || !hasWorktreeSidebarStatusDropTarget(latest.target)) {
return { target: args.currentTarget, preview: args.currentPreview }
}
const distance = Math.hypot(args.x - latest.x, args.y - latest.y)
return distance <= STATUS_DROP_TARGET_FALLBACK_TOLERANCE_PX
? { target: latest.target, preview: latest.preview }
: { target: args.currentTarget, preview: args.currentPreview }
@@ -1,7 +1,10 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest'
import { isSidebarPointerDragBlocked } from './worktree-sidebar-pointer-drag-dom'
import {
createSidebarDragPreview,
isSidebarPointerDragBlocked
} from './worktree-sidebar-pointer-drag-dom'
describe('worktree sidebar pointer drag DOM guards', () => {
it('allows plain row targets to start pointer drags', () => {
@@ -40,4 +43,36 @@ describe('worktree sidebar pointer drag DOM guards', () => {
expect(isSidebarPointerDragBlocked(icon, row)).toBe(true)
})
it('builds a compact preview without agent trees, lineage children, or duplicate row ids', () => {
const row = document.createElement('div')
row.setAttribute('data-worktree-id', 'parent')
row.setAttribute('data-worktree-drag-id', 'parent')
row.setAttribute('data-worktree-lineage-drop-id', 'parent')
row.getBoundingClientRect = () => ({ left: 10, top: 20, width: 220, height: 500 }) as DOMRect
const title = document.createElement('div')
title.textContent = 'Parent workspace'
const agents = document.createElement('div')
agents.setAttribute('data-worktree-card-agent-list', '')
agents.textContent = '12 agents'
const children = document.createElement('div')
children.setAttribute('data-worktree-legacy-lineage-children', '')
children.setAttribute('data-worktree-id', 'child')
children.textContent = 'Child workspace'
row.append(title, agents, children)
const { preview } = createSidebarDragPreview({
sourceRow: row,
pointerX: 80,
pointerY: 40,
draggedCount: 1
})
expect(preview.textContent).toContain('Parent workspace')
expect(preview.textContent).not.toContain('12 agents')
expect(preview.textContent).not.toContain('Child workspace')
expect(preview.querySelector('[data-worktree-id]')).toBeNull()
expect(preview.querySelector('[data-worktree-lineage-drop-id]')).toBeNull()
preview.remove()
})
})
@@ -1,6 +1,14 @@
const POINTER_DRAGGING_ATTR = 'data-worktree-sidebar-pointer-dragging'
const POINTER_DRAG_PREVIEW_ATTR = 'data-worktree-sidebar-drag-preview'
const POINTER_DRAG_COUNT_ATTR = 'data-worktree-sidebar-drag-count'
const PREVIEW_OMITTED_CONTENT_SELECTOR =
'[data-worktree-card-agent-list],[data-worktree-lineage-children],[data-worktree-legacy-lineage-children]'
const PREVIEW_IDENTITY_ATTRIBUTES = [
'data-worktree-drag-id',
'data-worktree-id',
'data-worktree-lineage-drop-id',
'data-worktree-row-key'
] as const
const INTERACTIVE_DRAG_BLOCKER_SELECTOR = [
'button',
@@ -42,13 +50,18 @@ export function setSidebarPointerDragDocumentStyles(enabled: boolean): void {
function stripDuplicatePreviewAttributes(preview: HTMLElement): void {
preview.removeAttribute('id')
preview.removeAttribute('aria-describedby')
preview.removeAttribute('data-worktree-drag-id')
preview.querySelectorAll<HTMLElement>('[id],[aria-describedby]').forEach((element) => {
element.removeAttribute('id')
element.removeAttribute('aria-describedby')
})
preview.querySelectorAll<HTMLElement>('[data-worktree-drag-id]').forEach((element) => {
element.removeAttribute('data-worktree-drag-id')
for (const attribute of PREVIEW_IDENTITY_ATTRIBUTES) {
preview.removeAttribute(attribute)
preview.querySelectorAll<HTMLElement>(`[${attribute}]`).forEach((element) => {
element.removeAttribute(attribute)
})
}
preview.querySelectorAll<HTMLElement>(PREVIEW_OMITTED_CONTENT_SELECTOR).forEach((element) => {
element.remove()
})
}
@@ -74,7 +87,7 @@ export function createSidebarDragPreview(args: {
const preview = document.createElement('div')
const clone = args.sourceRow.cloneNode(true) as HTMLElement
const offsetX = Math.min(Math.max(args.pointerX - rect.left, 0), rect.width)
const offsetY = Math.min(Math.max(args.pointerY - rect.top, 0), rect.height)
const sourceOffsetY = Math.min(Math.max(args.pointerY - rect.top, 0), rect.height)
stripDuplicatePreviewAttributes(clone)
preview.setAttribute(POINTER_DRAG_PREVIEW_ATTR, 'true')
@@ -92,9 +105,11 @@ export function createSidebarDragPreview(args: {
preview.style.left = '0'
preview.style.top = '0'
preview.style.width = `${rect.width}px`
preview.style.height = `${rect.height}px`
preview.style.pointerEvents = 'none'
preview.style.transformOrigin = 'top left'
document.body.appendChild(preview)
const height = preview.offsetHeight > 0 ? preview.offsetHeight : rect.height
const offsetY = Math.min(sourceOffsetY, height)
updateSidebarDragPreviewPosition({
preview,
pointerX: args.pointerX,
@@ -102,6 +117,5 @@ export function createSidebarDragPreview(args: {
offsetX,
offsetY
})
document.body.appendChild(preview)
return { preview, offsetX, offsetY, height: rect.height }
return { preview, offsetX, offsetY, height }
}
@@ -0,0 +1,56 @@
export type WorktreeSidebarStaticRect = Pick<
DOMRect,
'left' | 'right' | 'top' | 'bottom' | 'height'
>
export type WorktreeSidebarStaticGeometry = {
containerTop: number
scrollTop: number
}
function getWorktreeSidebarStaticGeometry(container: HTMLElement): WorktreeSidebarStaticGeometry {
return {
containerTop: container.getBoundingClientRect().top,
scrollTop: container.scrollTop
}
}
export function getWorktreeSidebarStaticRect(
container: HTMLElement,
element: HTMLElement,
geometry?: WorktreeSidebarStaticGeometry
): WorktreeSidebarStaticRect {
const rect = element.getBoundingClientRect()
const virtualRow = element.closest<HTMLElement>('[data-worktree-virtual-row]')
const virtualRowStart = getWorktreeSidebarVirtualRowStart(virtualRow)
if (!virtualRow || virtualRowStart === null) {
return rect
}
const staticGeometry = geometry ?? getWorktreeSidebarStaticGeometry(container)
const top =
staticGeometry.containerTop -
staticGeometry.scrollTop +
virtualRowStart +
rect.top -
(virtualRow === element ? rect.top : virtualRow.getBoundingClientRect().top)
return {
left: rect.left,
right: rect.right,
top,
bottom: top + rect.height,
height: rect.height
}
}
export function getWorktreeSidebarVirtualRowStart(virtualRow: HTMLElement | null): number | null {
if (!virtualRow) {
return null
}
const rawStart = virtualRow.getAttribute('data-worktree-virtual-row-start')
if (rawStart === null) {
return null
}
const start = Number(rawStart)
return Number.isFinite(start) ? start : null
}
+96
View File
@@ -5,6 +5,102 @@ export type LineageScenario = {
childId: string
}
export type LongSidebarDragScenario = {
sourceId: string
targetId: string
}
export async function seedLongSidebarDragScenario(page: Page): Promise<LongSidebarDragScenario> {
return page.evaluate(async () => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
const initial = store.getState()
initial.setActiveView('terminal')
initial.setSidebarOpen(true)
initial.setGroupBy('none')
initial.setSortBy('manual')
initial.setShowActiveOnly(false)
initial.setShowSleepingWorkspaces(true)
initial.setHideDefaultBranchWorkspace(false)
initial.setFilterRepoIds([])
initial.setAgentActivityDisplayMode('full')
if (!initial.worktreeCardProperties.includes('inline-agents')) {
initial.toggleWorktreeCardProperty('inline-agents')
}
const repo = initial.repos[0]
const repoWorktrees = repo
? (initial.worktreesByRepo[repo.id] ?? []).filter((worktree) => !worktree.isArchived)
: []
const target = repoWorktrees.find((worktree) => worktree.isMainWorktree) ?? repoWorktrees[0]
const source = repoWorktrees.find((worktree) => worktree.id !== target?.id)
if (!repo || !target?.instanceId || !source?.instanceId) {
throw new Error('Long sidebar drag E2E needs two instance-stamped worktrees in one repo')
}
await Promise.all([
initial.updateWorktreeLineage(source.id, { noParent: true }),
initial.updateWorktreeLineage(target.id, { noParent: true })
])
const clearedWorktrees = (store.getState().worktreesByRepo[repo.id] ?? []).filter(
(worktree) => !worktree.isArchived
)
store.setState((current) => ({
worktreesByRepo: {
...current.worktreesByRepo,
[repo.id]: clearedWorktrees.map((worktree) =>
worktree.id === source.id
? {
...worktree,
displayName: 'Drag this workspace',
manualOrder: 2,
sortOrder: 2
}
: worktree.id === target.id
? {
...worktree,
displayName: 'Long agent target',
manualOrder: 1,
sortOrder: 1
}
: { ...worktree, manualOrder: 0, sortOrder: 0 }
)
}
}))
const seedAgents = (worktreeId: string, count: number, label: string): void => {
if ((store.getState().tabsByWorktree[worktreeId] ?? []).length === 0) {
store.getState().createTab(worktreeId)
}
const tab = store.getState().tabsByWorktree[worktreeId]?.[0]
if (!tab) {
throw new Error(`Could not seed ${label} agents`)
}
const now = Date.now()
for (let index = 0; index < count; index++) {
store
.getState()
.setAgentStatus(
`${tab.id}:${crypto.randomUUID()}`,
{ state: 'working', prompt: `${label} agent ${index + 1}`, agentType: 'codex' },
'codex',
{ updatedAt: now + index, stateStartedAt: now + index }
)
}
}
store.getState().dropAgentStatusByWorktree(source.id)
store.getState().dropAgentStatusByWorktree(target.id)
seedAgents(source.id, 15, 'Source')
seedAgents(target.id, 15, 'Target')
store.getState().setActiveWorktree(source.id)
return { sourceId: source.id, targetId: target.id }
})
}
export async function seedLineageScenario(
page: Page,
options: { inlineOnly?: boolean } = {}
+148 -1
View File
@@ -5,11 +5,12 @@ import { test, expect } from './helpers/orca-app'
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import {
markWorkspaceTerminalSlept,
seedLongSidebarDragScenario,
seedLineageScenario,
seedWorkspaceAgentStatus,
seedWorkspaceLiveTerminal
} from './worktree-lineage-state'
import { worktreeRow } from './worktree-row-locators'
import { worktreeRow, worktreeRowSurface } from './worktree-row-locators'
function worktreeOption(page: Page, worktreeId: string) {
return worktreeRow(page, worktreeId)
@@ -33,6 +34,12 @@ async function captureSidebarEvidence(page: Page, name: string): Promise<void> {
await captureEvidence(page, name, page.locator('[data-worktree-sidebar]').first())
}
async function pauseRecordedProof(page: Page, durationMs = 600): Promise<void> {
if (process.env.ORCA_E2E_RECORD_VIDEO === '1') {
await page.waitForTimeout(durationMs)
}
}
test.describe('Worktree Lineage', () => {
test.describe.configure({ mode: 'serial' })
@@ -41,6 +48,146 @@ test.describe('Worktree Lineage', () => {
await waitForActiveWorktree(orcaPage)
})
test('autoscrolls through nested agent rows and nests a workspace from its title', async ({
orcaPage
}) => {
const scenario = await seedLongSidebarDragScenario(orcaPage)
const scroller = orcaPage.locator('[data-worktree-sidebar]')
const sourceRow = worktreeRow(orcaPage, scenario.sourceId)
const targetRow = worktreeRow(orcaPage, scenario.targetId)
const sourceTitle = sourceRow.locator('[data-worktree-title-inline-rename]').first()
const targetEndAgent = targetRow.getByText('Target agent 15')
const targetDropAgent = targetRow.getByText('Target agent 13')
await expect(sourceRow).toBeVisible()
await expect(sourceRow.getByText('Source agent 15')).toBeVisible()
await expect
.poll(() =>
scroller.evaluate((element) => Math.max(0, element.scrollHeight - element.clientHeight))
)
.toBeGreaterThan(80)
await scroller.evaluate((element) => {
element.scrollTop = 0
})
await pauseRecordedProof(orcaPage)
const [sourceBox, sourceTitleBox, scrollerBox] = await Promise.all([
sourceRow.boundingBox(),
sourceTitle.boundingBox(),
scroller.boundingBox()
])
if (!sourceBox || !sourceTitleBox || !scrollerBox) {
throw new Error('Expected long sidebar drag geometry')
}
const dragX = sourceTitleBox.x + sourceTitleBox.width / 2
await orcaPage.mouse.move(dragX, sourceTitleBox.y + sourceTitleBox.height / 2)
await orcaPage.mouse.down()
try {
await orcaPage.mouse.move(dragX, sourceTitleBox.y + sourceTitleBox.height + 8, { steps: 4 })
await expect(orcaPage.locator('html')).toHaveAttribute(
'data-worktree-sidebar-pointer-dragging',
''
)
const preview = orcaPage.locator('[data-worktree-sidebar-drag-preview="true"]')
await expect(preview).toBeVisible()
const previewBox = await preview.boundingBox()
expect(previewBox?.height ?? Number.POSITIVE_INFINITY).toBeLessThan(sourceBox.height)
const initialScrollTop = await scroller.evaluate((element) => element.scrollTop)
await orcaPage.mouse.move(dragX, scrollerBox.y + scrollerBox.height - 3, { steps: 12 })
await expect
.poll(() => scroller.evaluate((element) => element.scrollTop), { timeout: 15_000 })
.toBeGreaterThan(initialScrollTop + 50)
await expect
.poll(
async () => {
const [agentBox, sidebarBox] = await Promise.all([
targetEndAgent.boundingBox(),
scroller.boundingBox()
])
return Boolean(
agentBox &&
sidebarBox &&
agentBox.y >= sidebarBox.y &&
agentBox.y + agentBox.height <= sidebarBox.y + sidebarBox.height
)
},
{ timeout: 15_000, message: 'pointer autoscroll did not reveal the target agent' }
)
.toBe(true)
const targetDropAgentBox = await targetDropAgent.boundingBox()
if (!targetDropAgentBox) {
throw new Error('Expected the autoscrolled lineage target agent')
}
// Why: the final row borders the reorder gutter; an interior row proves the owned agent surface.
await orcaPage.mouse.move(
targetDropAgentBox.x + targetDropAgentBox.width / 2,
targetDropAgentBox.y + targetDropAgentBox.height / 2
)
await expect(worktreeRowSurface(orcaPage, scenario.targetId)).toHaveAttribute(
'data-worktree-lineage-drop-target',
'true'
)
const targetAgentList = worktreeRowSurface(orcaPage, scenario.targetId)
.locator('[data-worktree-card-agent-list]')
.first()
const sourceAgentList = worktreeRowSurface(orcaPage, scenario.sourceId)
.locator('[data-worktree-card-agent-list]')
.first()
await expect(targetAgentList).toHaveAttribute(
'data-worktree-card-agent-list-drop-target',
'true'
)
await expect
.poll(async () => {
const [targetStyle, sourceStyle] = await Promise.all(
[targetAgentList, sourceAgentList].map((agentList) =>
agentList.evaluate((element) => {
const style = getComputedStyle(element)
return { backgroundColor: style.backgroundColor, boxShadow: style.boxShadow }
})
)
)
return (
targetStyle.backgroundColor !== sourceStyle.backgroundColor &&
targetStyle.boxShadow !== sourceStyle.boxShadow
)
})
.toBe(true)
await pauseRecordedProof(orcaPage)
await orcaPage.mouse.up()
await expect(
targetRow.getByRole('button', {
name: /Hide \d+ child workspaces?/
})
).toBeVisible({ timeout: 15_000 })
await expect
.poll(() =>
targetRow.evaluate(
(element, sourceId) =>
Boolean(
[...element.querySelectorAll<HTMLElement>('[data-worktree-id]')].find(
(candidate) => candidate.dataset.worktreeId === sourceId
)
),
scenario.sourceId
)
)
.toBe(true)
await expect(preview).toHaveCount(0)
await expect(orcaPage.locator('html')).not.toHaveAttribute(
'data-worktree-sidebar-pointer-dragging'
)
await pauseRecordedProof(orcaPage, 900)
} finally {
await orcaPage.mouse.up().catch(() => {})
await orcaPage.evaluate(async (sourceId) => {
await window.__store?.getState().updateWorktreeLineage(sourceId, { noParent: true })
}, scenario.sourceId)
}
})
test('renders existing child lineage in the sidebar', async ({ orcaPage }) => {
const { parentId, childId } = await seedLineageScenario(orcaPage)
const parentRow = worktreeOption(orcaPage, parentId)