fix(sidebar): stabilize downward worktree card dragging

Fix downward worktree card dragging with virtualization-safe global indices and stable preview geometry. Add unit and Electron regression coverage.
This commit is contained in:
Neil
2026-08-12 23:08:37 -07:00
committed by GitHub
parent 9a10561258
commit 602f0cbe63
9 changed files with 468 additions and 33 deletions
@@ -104,6 +104,7 @@ import {
getVirtualRowTransform,
pruneStaleVirtualRowElementCache,
shouldUseHeaderTopSpacing,
WORKTREE_SIDEBAR_VIRTUAL_ROW_GAP,
type RenderRow
} from './worktree-list-virtual-rows'
import {
@@ -1625,6 +1626,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
groupIds: group.worktreeIds,
draggedIds: args.draggedIds,
draggingWorktreeId: args.draggingWorktreeId,
fallbackGap: WORKTREE_SIDEBAR_VIRTUAL_ROW_GAP,
grab: args.grab,
anchor: args.anchor
})
@@ -2109,7 +2111,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
[stickyHeaderIndexes]
),
overscan: 10,
gap: 6,
gap: WORKTREE_SIDEBAR_VIRTUAL_ROW_GAP,
// Why: the sticky group header lives inside the virtual list, so scroll math needs the same top inset as the DOM reveal.
scrollPaddingStart: WORKTREE_SIDEBAR_REVEAL_TOP_INSET,
isScrollingResetDelay: USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS,
@@ -11,28 +11,37 @@ function arraysEqual(a: readonly string[], b: readonly string[]): boolean {
return a.length === b.length && a.every((value, index) => value === b[index])
}
function getFallbackStride(rects: readonly WorktreeDragPreviewRect[]): number {
function getFallbackStride(rects: readonly WorktreeDragPreviewRect[], defaultGap: number): number {
const sortedRects = [...rects].sort((a, b) => a.groupIndex - b.groupIndex)
const strides: number[] = []
for (let index = 1; index < sortedRects.length; index++) {
strides.push(sortedRects[index]!.top - sortedRects[index - 1]!.top)
const previous = sortedRects[index - 1]!
const current = sortedRects[index]!
const indexDelta = current.groupIndex - previous.groupIndex
if (indexDelta > 0) {
strides.push((current.top - previous.top) / indexDelta)
}
}
if (strides.length > 0) {
strides.sort((a, b) => a - b)
return strides[Math.floor(strides.length / 2)]!
}
const firstRect = sortedRects[0]
return firstRect ? firstRect.bottom - firstRect.top : 0
return firstRect ? firstRect.bottom - firstRect.top + defaultGap : 0
}
function getFallbackGap(rects: readonly WorktreeDragPreviewRect[]): number {
function getFallbackGap(rects: readonly WorktreeDragPreviewRect[], defaultGap: number): number {
const sortedRects = [...rects].sort((a, b) => a.groupIndex - b.groupIndex)
const gaps: number[] = []
for (let index = 1; index < sortedRects.length; index++) {
gaps.push(Math.max(0, sortedRects[index]!.top - sortedRects[index - 1]!.bottom))
const previous = sortedRects[index - 1]!
const current = sortedRects[index]!
if (current.groupIndex === previous.groupIndex + 1) {
gaps.push(Math.max(0, current.top - previous.bottom))
}
}
if (gaps.length === 0) {
return 0
return defaultGap
}
gaps.sort((a, b) => a - b)
return gaps[Math.floor(gaps.length / 2)]!
@@ -70,6 +79,8 @@ export function buildWorktreeDragPreviewOffsets(args: {
groupIds: readonly string[]
draggedIds: readonly string[]
draggingWorktreeId?: string | null
draggedPreviewHeight?: number | null
fallbackGap?: number
dropIndex: number
rects: readonly WorktreeDragPreviewRect[]
}): WorktreeDragPreviewLayout {
@@ -106,13 +117,18 @@ export function buildWorktreeDragPreviewOffsets(args: {
}
}
const fallbackStride = getFallbackStride(args.rects)
const fallbackGap = getFallbackGap(args.rects)
const defaultGap =
typeof args.fallbackGap === 'number' &&
Number.isFinite(args.fallbackGap) &&
args.fallbackGap >= 0
? args.fallbackGap
: 0
const fallbackStride = getFallbackStride(args.rects, defaultGap)
const fallbackGap = getFallbackGap(args.rects, defaultGap)
const groupRects = args.groupIds.flatMap((id) => {
const rect = rectById.get(id)
return rect ? [rect] : []
})
const baseTop = groupRects[0]?.top ?? 0
const fallbackHeight = Math.max(0, fallbackStride - fallbackGap)
const gapAfterById = new Map<string, number>()
for (let index = 0; index < groupRects.length; index++) {
@@ -120,19 +136,51 @@ export function buildWorktreeDragPreviewOffsets(args: {
const nextRect = groupRects[index + 1]
gapAfterById.set(
rect.worktreeId,
nextRect ? Math.max(0, nextRect.top - rect.bottom) : fallbackGap
nextRect?.groupIndex === rect.groupIndex + 1
? Math.max(0, nextRect.top - rect.bottom)
: fallbackGap
)
}
const previewDraggedId = layoutDraggedIds[0] ?? null
const draggedPreviewHeight =
previewDraggedId === args.draggingWorktreeId &&
typeof args.draggedPreviewHeight === 'number' &&
Number.isFinite(args.draggedPreviewHeight) &&
args.draggedPreviewHeight > 0
? args.draggedPreviewHeight
: null
const getHeight = (id: string): number => {
const rect = rectById.get(id)
if (rect) {
return rect.bottom - rect.top
}
return id === previewDraggedId && draggedPreviewHeight !== null
? draggedPreviewHeight
: fallbackHeight
}
const getStride = (id: string): number => getHeight(id) + (gapAfterById.get(id) ?? fallbackGap)
const firstGroupRect = groupRects[0]
let baseTop = firstGroupRect?.top ?? 0
if (firstGroupRect) {
// Why: virtualized leading rows are absent from rects, so derive the full
// list origin from the first mounted row before replaying its order.
for (let index = 0; index < firstGroupRect.groupIndex; index++) {
const id = args.groupIds[index]
if (id) {
baseTop -= getStride(id)
}
}
}
const targetTopById = new Map<string, number>()
let nextTop = baseTop
// Why: lineage drag units can be much taller than ordinary cards, so replay
// layout with measured heights instead of mapping indexes to old slot tops.
for (const id of nextIds) {
targetTopById.set(id, nextTop)
const rect = rectById.get(id)
const height = rect ? rect.bottom - rect.top : fallbackHeight
nextTop += height + (gapAfterById.get(id) ?? fallbackGap)
nextTop += getStride(id)
}
const offsets = new Map<string, number>()
@@ -5,6 +5,7 @@ import { getLineageGroupKey, PINNED_GROUP_KEY } from './worktree-list-groups'
export const GROUP_HEADER_ROW_HEIGHT = 28
export const HOST_HEADER_ROW_HEIGHT = 32
export const WORKTREE_SIDEBAR_VIRTUAL_ROW_GAP = 6
const SECONDARY_GROUP_HEADER_TOP_MARGIN = 4
const IMPORTED_WORKTREES_LINE_ROW_HEIGHT = 36
const PENDING_CREATION_ROW_HEIGHT = 56
@@ -87,6 +87,28 @@ describe('buildWorktreeDragPreviewOffsets', () => {
])
})
it('keeps downward offsets stable after virtualization unmounts the leading rows', () => {
const { offsets, placeholderTop } = buildWorktreeDragPreviewOffsets({
groupIds: ['a', 'b', 'c', 'd', 'e', 'f'],
draggedIds: ['a'],
draggingWorktreeId: 'a',
draggedPreviewHeight: 50,
dropIndex: 6,
rects: [
{ worktreeId: 'd', groupIndex: 3, top: 168, bottom: 218 },
{ worktreeId: 'e', groupIndex: 4, top: 224, bottom: 274 },
{ worktreeId: 'f', groupIndex: 5, top: 280, bottom: 330 }
]
})
expect(Array.from(offsets)).toEqual([
['d', -56],
['e', -56],
['f', -56]
])
expect(placeholderTop).toBe(280)
})
it('slides intervening rows down while dragging a row up', () => {
const { offsets } = buildWorktreeDragPreviewOffsets({
groupIds: ['a', 'b', 'c'],
@@ -159,20 +159,20 @@ describe('worktree sidebar drag geometry under mid-drag card growth', () => {
scrollTop: 0
}
expect(resolveWorktreeSidebarDropAnchorIndex({ anchor, rects: COLLAPSED })).toBeNull()
expect(resolveWorktreeSidebarDropAnchorIndex({ anchor, groupIds: GROUP_IDS })).toBeNull()
expect(
resolveWorktreeSidebarDropAnchorIndex({
anchor: { beforeWorktreeId: 'c', pointerY: 0, scrollTop: 0 },
rects: COLLAPSED
groupIds: GROUP_IDS
})
).toBe(2)
// A null anchor id means end-of-group, which survives any row count change.
expect(
resolveWorktreeSidebarDropAnchorIndex({
anchor: { beforeWorktreeId: null, pointerY: 0, scrollTop: 0 },
rects: COLLAPSED
groupIds: GROUP_IDS
})
).toBe(COLLAPSED.length)
).toBe(GROUP_IDS.length)
})
it('keeps one live coordinate space across a session refresh', () => {
@@ -70,25 +70,25 @@ export function shouldReevaluateWorktreeSidebarDropAnchor(args: {
/**
* Resolve a held anchor back to a drop index in the current layout. Returns null
* when the anchored card is gone (deleted, filtered, or unmounted by
* virtualization), so the caller falls back to a fresh geometric decision.
* when the anchored card is gone (deleted or filtered), so the caller falls
* back to a fresh geometric decision.
*/
export function resolveWorktreeSidebarDropAnchorIndex(args: {
anchor: WorktreeSidebarDropAnchor
rects: readonly WorktreeSidebarDragRect[]
groupIds: readonly string[]
}): number | null {
if (args.anchor.beforeWorktreeId === null) {
return args.rects.length
return args.groupIds.length
}
const target = args.rects.find((rect) => rect.worktreeId === args.anchor.beforeWorktreeId)
return target ? target.groupIndex : null
const targetIndex = args.groupIds.indexOf(args.anchor.beforeWorktreeId)
return targetIndex !== -1 ? targetIndex : null
}
export function getWorktreeSidebarDropAnchorId(args: {
rects: readonly WorktreeSidebarDragRect[]
groupIds: readonly string[]
dropIndex: number
}): string | null {
return args.rects.find((rect) => rect.groupIndex === args.dropIndex)?.worktreeId ?? null
return args.groupIds[args.dropIndex] ?? null
}
/**
@@ -174,6 +174,80 @@ describe('computeWorktreeSidebarDropPreview', () => {
expect(new Set(dropIndexes).size).toBe(1)
})
it('keeps a downward end drop stable when leading rows are virtualized', () => {
const groupIds = ['a', 'b', 'c', 'd', 'e', 'f']
const mountedRects = [
{ worktreeId: 'd', groupIndex: 3, top: 168, bottom: 218 },
{ worktreeId: 'e', groupIndex: 4, top: 224, bottom: 274 },
{ worktreeId: 'f', groupIndex: 5, top: 280, bottom: 330 }
]
const input = {
pointerY: 320,
containerTop: 0,
scrollTop: 0,
rects: mountedRects,
groupIds,
draggedIds: ['a'],
draggingWorktreeId: 'a',
grab: { offsetY: 25, height: 50 }
}
const first = computeWorktreeSidebarDropPreview(input)!
const held = computeWorktreeSidebarDropPreview({
...input,
anchor: { beforeWorktreeId: first.dropAnchorId, pointerY: 320, scrollTop: 0 }
})!
for (const preview of [first, held]) {
expect(preview).toMatchObject({
dropIndex: 6,
dropIndicatorY: 277,
dropAnchorId: null
})
expect(Array.from(preview.previewOffsetsByWorktreeId)).toEqual([
['d', -56],
['e', -56],
['f', -56]
])
}
})
it('uses the full group index when the dragged row is outside the mounted window', () => {
const preview = computeWorktreeSidebarDropPreview({
pointerY: 193,
containerTop: 0,
scrollTop: 0,
rects: [
{ worktreeId: 'd', groupIndex: 3, top: 168, bottom: 218 },
{ worktreeId: 'e', groupIndex: 4, top: 224, bottom: 274 },
{ worktreeId: 'f', groupIndex: 5, top: 280, bottom: 330 }
],
groupIds: ['a', 'b', 'c', 'd', 'e', 'f'],
draggedIds: ['e'],
draggingWorktreeId: 'e',
grab: { offsetY: 25, height: 50 }
})
expect(preview?.dropIndex).toBe(3)
})
it('preserves the virtual row gap when only one row remains mounted', () => {
const preview = computeWorktreeSidebarDropPreview({
pointerY: 305,
containerTop: 0,
scrollTop: 0,
rects: [{ worktreeId: 'f', groupIndex: 5, top: 280, bottom: 330 }],
groupIds: ['a', 'b', 'c', 'd', 'e', 'f'],
draggedIds: ['a'],
draggingWorktreeId: 'a',
fallbackGap: 6,
grab: { offsetY: 25, height: 50 }
})
expect(preview).toMatchObject({ dropIndex: 6, dropIndicatorY: 277 })
expect(Array.from(preview?.previewOffsetsByWorktreeId ?? [])).toEqual([['f', -56]])
})
})
describe('resolveWorktreeSidebarStatusDropCommitTarget', () => {
@@ -178,6 +178,7 @@ export function computeWorktreeSidebarDropPreview(args: {
groupIds: readonly string[]
draggedIds: readonly string[]
draggingWorktreeId?: string | null
fallbackGap?: number
// Where the card was grabbed, so the drop follows the card rather than the bare
// pointer. Omitted for native HTML5 drags, which have no reliable grab offset.
grab?: WorktreeSidebarDragGrab | null
@@ -194,10 +195,13 @@ export function computeWorktreeSidebarDropPreview(args: {
}
const localY = args.pointerY - args.containerTop + args.scrollTop
const activeIndex = args.draggingWorktreeId
? rects.findIndex((rect) => rect.worktreeId === args.draggingWorktreeId)
const activeGroupIndex = args.draggingWorktreeId
? args.groupIds.indexOf(args.draggingWorktreeId)
: -1
const activeRect = activeIndex >= 0 ? rects[activeIndex]! : null
const activeRect =
activeGroupIndex >= 0
? (rects.find((rect) => rect.worktreeId === args.draggingWorktreeId) ?? null)
: null
const referenceY = getWorktreeSidebarDragReferenceY({
localY,
grab: args.grab ?? null,
@@ -217,15 +221,19 @@ export function computeWorktreeSidebarDropPreview(args: {
}
const heldIndex = args.anchor
? resolveWorktreeSidebarDropAnchorIndex({ anchor: args.anchor, rects })
? resolveWorktreeSidebarDropAnchorIndex({ anchor: args.anchor, groupIds: args.groupIds })
: null
let dropIndex: number
if (heldIndex !== null) {
dropIndex = heldIndex
} else if (boundaryDrop.kind === 'drop') {
dropIndex = boundaryDrop.dropIndex
} else if (activeRect) {
dropIndex = getWorktreeSidebarClosestCenterDropIndex({ referenceY, rects, activeIndex })
} else if (activeGroupIndex >= 0) {
dropIndex = getWorktreeSidebarClosestCenterDropIndex({
referenceY,
rects,
activeIndex: activeGroupIndex
})
} else {
dropIndex = getWorktreeSidebarPointerDropIndex({ referenceY, rects })
}
@@ -234,6 +242,8 @@ export function computeWorktreeSidebarDropPreview(args: {
groupIds: args.groupIds,
draggedIds: args.draggedIds,
draggingWorktreeId: args.draggingWorktreeId,
draggedPreviewHeight: args.grab?.height,
fallbackGap: args.fallbackGap,
dropIndex,
rects
})
@@ -246,6 +256,6 @@ export function computeWorktreeSidebarDropPreview(args: {
activeRect
}),
previewOffsetsByWorktreeId: offsets,
dropAnchorId: getWorktreeSidebarDropAnchorId({ rects, dropIndex })
dropAnchorId: getWorktreeSidebarDropAnchorId({ groupIds: args.groupIds, dropIndex })
}
}
@@ -0,0 +1,278 @@
import path from 'node:path'
import type { Page } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { waitForSessionReady } from './helpers/store'
const SYNTHETIC_COUNT = 60
type PreviewOffsetSample = {
worktreeId: string
targetOffset: number
renderedOffset: number
}
async function seedVirtualizedManualWorktrees(page: Page): Promise<{
sourceId: string
nextId: string
idPrefix: string
}> {
const repo = await page.evaluate(() => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
const state = store.getState()
const repo = state.repos[0]
if (!repo) {
throw new Error('Expected a seeded e2e repo')
}
return { id: repo.id, path: repo.path }
})
const now = Date.now()
const syntheticWorktrees = Array.from({ length: SYNTHETIC_COUNT }, (_, index) => {
const suffix = String(index).padStart(2, '0')
const worktreePath = path.join(repo.path, '..', `downward-drag-${suffix}`)
return {
id: `${repo.id}::downward-drag-${suffix}`,
instanceId: `downward-drag-${suffix}`,
repoId: repo.id,
path: worktreePath,
displayName: `Downward drag ${suffix}`,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 100_000 - index,
manualOrder: 100_000 - index,
lastActivityAt: now - index,
head: '0000000000000000000000000000000000000000',
branch: `downward-drag-${suffix}`,
isBare: false,
isMainWorktree: false
}
})
await page.evaluate(
({ repoId, worktrees }) => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
const state = store.getState()
const seededWorktrees = (state.worktreesByRepo[repoId] ?? []).map((worktree, index) => ({
...worktree,
manualOrder: -1_000 - index
}))
store.setState({
groupBy: 'none',
sortBy: 'manual',
showActiveOnly: false,
showSleepingWorkspaces: true,
hideDefaultBranchWorkspace: false,
filterRepoIds: [],
sidebarOpen: true,
worktreesByRepo: {
...state.worktreesByRepo,
[repoId]: [...worktrees, ...seededWorktrees]
},
updateWorktreesMeta: async (updatesByWorktreeId) => {
store.setState((current) => ({
sortEpoch: current.sortEpoch + 1,
worktreesByRepo: Object.fromEntries(
Object.entries(current.worktreesByRepo).map(([repoId, worktrees]) => [
repoId,
worktrees.map((worktree) => ({
...worktree,
...updatesByWorktreeId.get(worktree.id)
}))
])
)
}))
}
})
},
{ repoId: repo.id, worktrees: syntheticWorktrees }
)
const sourceId = syntheticWorktrees[0]!.id
return {
sourceId,
nextId: syntheticWorktrees[1]!.id,
idPrefix: sourceId.slice(0, -2)
}
}
async function sampleMountedPreviewOffsets(
page: Page,
sourceId: string
): Promise<PreviewOffsetSample[][]> {
return page.evaluate(async (draggedId) => {
const scroller = document.querySelector<HTMLElement>('[data-worktree-sidebar]')
if (!scroller) {
throw new Error('Worktree sidebar is not available')
}
const samples: PreviewOffsetSample[][] = []
for (let frame = 0; frame < 20; frame++) {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
if (document.querySelector(`[data-worktree-drag-id=${JSON.stringify(draggedId)}]`)) {
continue
}
const frameSamples = [
...document.querySelectorAll<HTMLElement>('[data-worktree-virtual-row]')
].flatMap((row) => {
const rowRect = row.getBoundingClientRect()
const scrollerRect = scroller.getBoundingClientRect()
if (rowRect.bottom <= scrollerRect.top || rowRect.top >= scrollerRect.bottom) {
return []
}
const worktree = row.querySelector<HTMLElement>('[data-worktree-drag-id]')
const worktreeId = worktree?.getAttribute('data-worktree-drag-id')
const rowStart = Number(row.dataset.worktreeVirtualRowStart)
const matches = [...row.style.transform.matchAll(/translateY\((-?[\d.]+)px\)/g)]
const targetOffset = Number(matches[1]?.[1] ?? 0)
const renderedTop = new DOMMatrixReadOnly(getComputedStyle(row).transform).m42
const renderedOffset = renderedTop - rowStart
return worktreeId &&
Number.isFinite(rowStart) &&
Number.isFinite(targetOffset) &&
Number.isFinite(renderedOffset)
? [{ worktreeId, targetOffset, renderedOffset }]
: []
})
if (frameSamples.length > 0) {
samples.push(frameSamples)
}
}
return samples
}, sourceId)
}
test('dragging a virtualized worktree downward keeps rows stable', async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
await orcaPage.setViewportSize({ width: 1_000, height: 620 })
const { sourceId, nextId, idPrefix } = await seedVirtualizedManualWorktrees(orcaPage)
const scroller = orcaPage.locator('[data-worktree-sidebar]')
const source = orcaPage.locator(
`[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(sourceId)}]`
)
await scroller.evaluate((element) => {
element.scrollTop = 0
element.dispatchEvent(new Event('scroll', { bubbles: true }))
})
await expect(source).toBeVisible()
const sourceBox = await source.evaluate((element) => {
const rect = element.getBoundingClientRect()
return { x: rect.left, y: rect.top, width: rect.width, height: rect.height }
})
const nextSource = orcaPage.locator(
`[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(nextId)}]`
)
const sourceStride = await nextSource.evaluate(
(element, sourceTop) => element.getBoundingClientRect().top - sourceTop,
sourceBox.y
)
expect(Number.isFinite(sourceStride)).toBe(true)
expect(sourceStride).toBeGreaterThan(1)
const scrollerBox = await scroller.evaluate((element) => {
const rect = element.getBoundingClientRect()
return { x: rect.left, y: rect.top, width: rect.width, height: rect.height }
})
await orcaPage.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2)
await orcaPage.mouse.down()
try {
const edgeX = scrollerBox.x + 2
const edgeY = scrollerBox.y + scrollerBox.height - 8
// Keep the pointer in the edge zone while the renderer advances autoscroll.
for (let step = 0; step < 12; step++) {
await orcaPage.mouse.move(edgeX, edgeY, { steps: 2 })
if ((await source.count()) === 0) {
break
}
await orcaPage.waitForTimeout(100)
}
if ((await source.count()) > 0) {
for (let step = 0; step < 8 && (await source.count()) > 0; step++) {
await scroller.evaluate((element) => {
element.scrollTop = Math.min(
element.scrollHeight,
element.scrollTop + element.clientHeight
)
element.dispatchEvent(new Event('scroll', { bubbles: true }))
})
await orcaPage.waitForTimeout(100)
}
}
await expect
.poll(() => source.count(), {
timeout: 10_000,
message: 'Downward autoscroll did not virtualize the dragged source row'
})
.toBe(0)
const samples = await sampleMountedPreviewOffsets(orcaPage, sourceId)
expect(samples.length).toBeGreaterThan(0)
const observationsById = new Map<string, PreviewOffsetSample[]>()
for (const sample of samples.flat()) {
const distanceFromValidTarget = Math.min(
Math.abs(sample.targetOffset),
Math.abs(sample.targetOffset + sourceStride)
)
expect(distanceFromValidTarget).toBeLessThanOrEqual(1)
expect(sample.renderedOffset).toBeGreaterThanOrEqual(-sourceStride - 2)
expect(sample.renderedOffset).toBeLessThanOrEqual(2)
const observations = observationsById.get(sample.worktreeId) ?? []
observations.push(sample)
observationsById.set(sample.worktreeId, observations)
}
const recurrentRows = [...observationsById.values()].filter(
(observations) => observations.length > 1
)
expect(recurrentRows.length).toBeGreaterThan(0)
expect(
recurrentRows.some((observations) =>
observations.some((sample) => sample.renderedOffset <= -sourceStride * 0.8)
)
).toBe(true)
for (const observations of recurrentRows) {
let renderedReversal = 0
for (let index = 1; index < observations.length; index++) {
renderedReversal += Math.max(
0,
observations[index]!.renderedOffset - observations[index - 1]!.renderedOffset
)
}
expect(renderedReversal).toBeLessThanOrEqual(sourceStride)
}
} finally {
await orcaPage.mouse.up()
}
await expect(orcaPage.locator('[data-worktree-sidebar-drag-preview="true"]')).toHaveCount(0)
await expect(orcaPage.locator('html')).not.toHaveAttribute(
'data-worktree-sidebar-pointer-dragging'
)
await scroller.evaluate((element) => {
element.scrollTop = 0
element.dispatchEvent(new Event('scroll', { bubbles: true }))
})
await expect
.poll(
() =>
scroller
.locator('[role="option"]')
.evaluateAll(
(options, prefix) =>
options
.map((option) => option.getAttribute('data-worktree-id'))
.find((worktreeId) => worktreeId?.startsWith(prefix)) ?? null,
idPrefix
),
{ message: 'The downward drop did not move the source away from the first slot' }
)
.toBe(nextId)
})