From dab6b37e855fbbb55cf339dda7509bd02edeb057 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:27:48 -0700 Subject: [PATCH] fix(sidebar): make nested workspace drops reliable --- .../src/components/sidebar/WorktreeCard.tsx | 26 +- .../sidebar/WorktreeCardAgents.test.tsx | 14 +- .../components/sidebar/WorktreeCardAgents.tsx | 33 +- .../src/components/sidebar/WorktreeList.tsx | 167 ++++++-- ...orkspace-board-drag-preview-intent.test.ts | 44 ++ .../workspace-board-drag-preview-intent.ts | 13 + .../worktree-lineage-drag-drop.test.ts | 265 ++++++++++-- .../sidebar/worktree-lineage-drag-drop.ts | 270 ++++++++++++- .../worktree-lineage-static-hit-test.test.ts | 380 ++++++++++++++++++ .../worktree-sidebar-drag-autoscroll.test.ts | 59 +++ .../worktree-sidebar-drag-autoscroll.ts | 58 +-- .../worktree-sidebar-drop-preview.test.ts | 46 +++ .../sidebar/worktree-sidebar-drop-preview.ts | 10 +- .../worktree-sidebar-pointer-drag-dom.test.ts | 37 +- .../worktree-sidebar-pointer-drag-dom.ts | 28 +- .../worktree-sidebar-static-geometry.ts | 56 +++ tests/e2e/worktree-lineage-state.ts | 96 +++++ tests/e2e/worktree-lineage.spec.ts | 149 ++++++- 18 files changed, 1619 insertions(+), 132 deletions(-) create mode 100644 src/renderer/src/components/sidebar/workspace-board-drag-preview-intent.test.ts create mode 100644 src/renderer/src/components/sidebar/workspace-board-drag-preview-intent.ts create mode 100644 src/renderer/src/components/sidebar/worktree-lineage-static-hit-test.test.ts create mode 100644 src/renderer/src/components/sidebar/worktree-sidebar-static-geometry.ts diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index b53a03ec833..c97e5e0fa44 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -1422,7 +1422,12 @@ const WorktreeCard = React.memo(function WorktreeCard({ )} > {/* Header row: Title */} -
+
{showPinnedRepoIcon && ( @@ -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 && ( -
+
{lineageChildren}
)} @@ -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} diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx index d5591b9e9d5..11298974fc0 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx @@ -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() + const targetMarkup = renderToStaticMarkup( + + ) + const markup = + targetMarkup + renderToStaticMarkup() - expect(markup).toContain('role="group"') - expect(markup).toContain('aria-label="Agents"') - expect(markup).toContain('data-testid="agent-row"') expect(markup).not.toContain(' { diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx index 87ab769d138..a234572d6e2 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx @@ -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 + return ( + + ) }) 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 (
{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.
{rootAgents.map((rootAgent) => renderAgentBranch(rootAgent))}
diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 422b765f082..1f79b0d8c4d 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -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('[data-workspace-status-drop-target]') @@ -1373,6 +1379,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const directScrollInputUntilRef = useRef(0) const [dragOverStatus, setDragOverStatus] = useState(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(null) const [nativeLineageDropTargetId, setNativeLineageDropTargetId] = useState(null) const [worktreeDragState, setWorktreeDragState] = useState( 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('[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} diff --git a/src/renderer/src/components/sidebar/workspace-board-drag-preview-intent.test.ts b/src/renderer/src/components/sidebar/workspace-board-drag-preview-intent.test.ts new file mode 100644 index 00000000000..38047cad362 --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-board-drag-preview-intent.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/components/sidebar/workspace-board-drag-preview-intent.ts b/src/renderer/src/components/sidebar/workspace-board-drag-preview-intent.ts new file mode 100644 index 00000000000..1494b95bce0 --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-board-drag-preview-intent.ts @@ -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 + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts index 9241dd2cf11..029b6ec8bb7 100644 --- a/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts +++ b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts @@ -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 } diff --git a/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts index f171ce5d2a6..e40d6c425c3 100644 --- a/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts +++ b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts @@ -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 +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(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(WORKTREE_STICKY_HEADER_SELECTOR) ) { return null } - - const rowTarget = contentTarget.closest(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( + WORKTREE_CARD_SURFACE_SELECTOR + ) + return cardSurface ? getLineageTargetFromSurface(args, cardSurface, geometry) : null + } + + const cardSurface = args.target.closest(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(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(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( + 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(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 +): 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> + worktreeMap: ReadonlyMap + cyclicLineageIds: ReadonlySet +}): string[] { + const selectedIds = new Set(args.draggedIds) + const included = new Set() + 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> + worktreeMap: ReadonlyMap + cyclicLineageIds: ReadonlySet +}): 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: { diff --git a/src/renderer/src/components/sidebar/worktree-lineage-static-hit-test.test.ts b/src/renderer/src/components/sidebar/worktree-lineage-static-hit-test.test.ts new file mode 100644 index 00000000000..7a5530bb635 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-lineage-static-hit-test.test.ts @@ -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 + } +} diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.test.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.test.ts index bf0bd66b95d..ecb1cada728 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.test.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.test.ts @@ -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() }) diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.ts index 83bfbb29209..3e50d18b251 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.ts @@ -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 + 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 @@ -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('[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 + containerRect: Pick, + 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 diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.test.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.test.ts index ef6147458a1..7fcca92714a 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.test.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.test.ts @@ -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, diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.ts index e25f2dd2b37..41b99b9cbc1 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.ts @@ -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 } diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.test.ts b/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.test.ts index 1da451ff024..0a70fa63c9f 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.test.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.test.ts @@ -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() + }) }) diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.ts b/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.ts index 54f302f872b..2dbc9e9ec58 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.ts @@ -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('[id],[aria-describedby]').forEach((element) => { element.removeAttribute('id') element.removeAttribute('aria-describedby') }) - preview.querySelectorAll('[data-worktree-drag-id]').forEach((element) => { - element.removeAttribute('data-worktree-drag-id') + for (const attribute of PREVIEW_IDENTITY_ATTRIBUTES) { + preview.removeAttribute(attribute) + preview.querySelectorAll(`[${attribute}]`).forEach((element) => { + element.removeAttribute(attribute) + }) + } + preview.querySelectorAll(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 } } diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-static-geometry.ts b/src/renderer/src/components/sidebar/worktree-sidebar-static-geometry.ts new file mode 100644 index 00000000000..bd4dcbc1d57 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-sidebar-static-geometry.ts @@ -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('[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 +} diff --git a/tests/e2e/worktree-lineage-state.ts b/tests/e2e/worktree-lineage-state.ts index 928eba66964..579b4733c99 100644 --- a/tests/e2e/worktree-lineage-state.ts +++ b/tests/e2e/worktree-lineage-state.ts @@ -5,6 +5,102 @@ export type LineageScenario = { childId: string } +export type LongSidebarDragScenario = { + sourceId: string + targetId: string +} + +export async function seedLongSidebarDragScenario(page: Page): Promise { + 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 } = {} diff --git a/tests/e2e/worktree-lineage.spec.ts b/tests/e2e/worktree-lineage.spec.ts index 0f9ddb33496..4ba6f618007 100644 --- a/tests/e2e/worktree-lineage.spec.ts +++ b/tests/e2e/worktree-lineage.spec.ts @@ -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 { await captureEvidence(page, name, page.locator('[data-worktree-sidebar]').first()) } +async function pauseRecordedProof(page: Page, durationMs = 600): Promise { + 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('[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)