Virtualize workspace board lanes and defer card render for instant open

This commit is contained in:
Jinjing
2026-07-28 20:41:48 -07:00
parent 6d4e335001
commit 6d63bcac04
15 changed files with 1126 additions and 108 deletions
@@ -7,6 +7,9 @@ import { translate } from '@/i18n/i18n'
type WorkspaceKanbanCardProps = {
worktree: Worktree
// Why: the lane virtualizes, so DOM order no longer implies list position.
// Drop-index math reads this instead of counting rendered siblings.
laneIndex: number
repo: Repo | undefined
isActive: boolean
isSelected: boolean
@@ -23,6 +26,7 @@ type WorkspaceKanbanCardProps = {
function WorkspaceKanbanCard({
worktree,
laneIndex,
repo,
isActive,
isSelected,
@@ -40,6 +44,7 @@ function WorkspaceKanbanCard({
<div
className="relative rounded-lg data-[workspace-board-card-area-selected=true]:ring-1 data-[workspace-board-card-area-selected=true]:ring-worktree-sidebar-ring/40"
data-workspace-board-card-id={worktree.id}
data-workspace-board-card-index={laneIndex}
data-workspace-board-card-mode="detailed"
data-workspace-board-card-selected={isSelected ? 'true' : 'false'}
data-workspace-board-pointer-draggable={nativeDragEnabled ? undefined : 'true'}
@@ -1,5 +1,13 @@
/* eslint-disable max-lines -- Why: the board drawer owns shared board state, drag/drop, and settings callbacks that need one coordinated surface. */
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import React, {
startTransition,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState
} from 'react'
import { useAppStore } from '@/store'
import { useAllWorktrees, useRepoMap } from '@/store/selectors'
import { Sheet, SheetContent } from '@/components/ui/sheet'
@@ -43,6 +51,7 @@ import { makeWorkspaceStatusId } from '../../../../shared/workspace-statuses'
import { STATUS_BAR_RESERVE_HEIGHT, WORKSPACE_TOP_CHROME_HEIGHT } from './workspace-chrome-metrics'
import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour'
import { translate } from '@/i18n/i18n'
import { registerWorkspaceKanbanSidebarDropGroups } from './workspace-kanban-sidebar-drop'
type WorkspaceKanbanDrawerProps = {
leftSidebarStyle?: React.CSSProperties
@@ -159,6 +168,7 @@ export default function WorkspaceKanbanDrawer({
const areaSelectionOverlayRef = useRef<HTMLDivElement>(null)
const [dragOverStatus, setDragOverStatus] = useState<WorkspaceStatus | null>(null)
const [pinDragOver, setPinDragOver] = useState(false)
const [renderCards, setRenderCards] = useState(false)
const { canCreateWorktree, createWorktreeForStatus } = useWorkspaceKanbanCreateWorktree()
const visibleWorktreeIdSet = useVisibleWorkspaceKanbanWorktreeIds({
allWorktrees,
@@ -188,6 +198,12 @@ export default function WorkspaceKanbanDrawer({
})),
[worktreesByStatus, workspaceStatuses]
)
useLayoutEffect(() => {
if (!open) {
return
}
return registerWorkspaceKanbanSidebarDropGroups(boardDragGroups)
}, [boardDragGroups, open])
const {
selectedWorktreeIds,
selectedWorktrees,
@@ -628,6 +644,20 @@ export default function WorkspaceKanbanDrawer({
}
)
// Why: mounting every lane's cards in the same commit that opens the sheet
// blocks the slide-in for the whole card render. Paint the board chrome
// first, then fill the lanes in a non-blocking transition.
useEffect(() => {
if (!open) {
setRenderCards(false)
return
}
const frameId = window.requestAnimationFrame(() => {
startTransition(() => setRenderCards(true))
})
return () => window.cancelAnimationFrame(frameId)
}, [open])
useWorkspaceKanbanShiftWheelScroll(boardRef, laneScrollerRef, open, isPointerDragActiveRef)
useWorkspaceKanbanOutsideDismiss({ open, boardRef, preserveOpenForMenu, onOpenChange })
useContextualTour('workspace-board', open && !dragPreview, 'workspace_board_visible')
@@ -789,6 +819,7 @@ export default function WorkspaceKanbanDrawer({
isResizingColumn={isResizingColumn}
dragOverStatus={dragOverStatus}
canCreateWorktree={canCreateWorktree}
renderCards={renderCards}
selectedWorktreeIds={selectedWorktreeIds}
selectedWorktrees={selectedWorktrees}
onDragOver={handleDragOver}
@@ -0,0 +1,105 @@
// @vitest-environment happy-dom
/**
* Why a render test: the board's open cost was mounting every card in every
* lane at once. These assert the lane renders only the virtual window, and that
* each rendered card still advertises its true lane index — drop-index math
* reads that attribute now that DOM order no longer implies list order.
*
* The virtualizer itself is mocked (as the sidebar list tests do): it needs real
* layout, which the test DOM has none of, and the contract under test is ours.
*/
import { cleanup, render } from '@testing-library/react'
import { createRef } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Repo, Worktree } from '../../../../shared/types'
const WINDOW_START = 20
const WINDOW_END = 25
const TOTAL_SIZE = 21_992
vi.mock('@tanstack/react-virtual', () => ({
useVirtualizer: ({ count }: { count: number }) => ({
getTotalSize: () => TOTAL_SIZE,
measurementsCache: Array.from({ length: count }, (_, index) => ({
index,
start: index * 44,
end: index * 44 + 36
})),
getVirtualItems: () =>
Array.from({ length: WINDOW_END - WINDOW_START + 1 }, (_, offset) => {
const index = WINDOW_START + offset
return { index, key: `w${index}`, start: index * 44 }
}),
measureElement: () => {}
})
}))
vi.mock('./WorkspaceKanbanCard', () => ({
default: ({ worktree, laneIndex }: { worktree: Worktree; laneIndex: number }) => (
<div data-workspace-board-card-id={worktree.id} data-workspace-board-card-index={laneIndex} />
)
}))
const { default: WorkspaceKanbanLaneCardList } = await import('./WorkspaceKanbanLaneCardList')
const REPO_MAP = new Map<string, Repo>()
function renderLane(count: number): HTMLElement {
const scrollRef = createRef<HTMLDivElement>()
const { container } = render(
<div ref={scrollRef}>
<WorkspaceKanbanLaneCardList
items={Array.from({ length: count }, (_, index) => ({ id: `w${index}` }) as Worktree)}
repoMap={REPO_MAP}
activeWorktreeId={null}
scrollRef={scrollRef}
selectedWorktreeIds={new Set()}
selectedWorktrees={[]}
nativeDragEnabled={false}
onActivate={() => {}}
onSelectionGesture={() => false}
onContextMenuSelect={() => []}
/>
</div>
)
return container
}
afterEach(cleanup)
describe('WorkspaceKanbanLaneCardList', () => {
it('mounts only the virtual window, not the whole lane', () => {
const container = renderLane(500)
const ids = Array.from(container.querySelectorAll('[data-workspace-board-card-id]')).map(
(card) => card.getAttribute('data-workspace-board-card-id')
)
expect(ids).toEqual(['w20', 'w21', 'w22', 'w23', 'w24', 'w25'])
})
it('gives every rendered card its lane index, not its rendered position', () => {
const container = renderLane(500)
const indexes = Array.from(container.querySelectorAll('[data-workspace-board-card-index]')).map(
(card) => Number(card.getAttribute('data-workspace-board-card-index'))
)
expect(indexes).toEqual([20, 21, 22, 23, 24, 25])
})
it('reserves the full lane height so the scrollbar spans the whole list', () => {
const container = renderLane(500)
const spacer = container.querySelector<HTMLElement>('[data-workspace-board-card-id]')
?.parentElement?.parentElement
expect(spacer?.style.height).toBe(`${TOTAL_SIZE}px`)
})
it('positions each card at its virtual offset', () => {
const container = renderLane(500)
const first = container.querySelector<HTMLElement>(
'[data-workspace-board-card-id]'
)?.parentElement
expect(first?.style.transform).toBe(`translateY(${WINDOW_START * 44}px)`)
})
})
@@ -0,0 +1,115 @@
import React, { useCallback, useLayoutEffect, useRef } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import type { Repo, WorkspaceStatus, Worktree } from '../../../../shared/types'
import WorkspaceKanbanCard from './WorkspaceKanbanCard'
import { registerWorkspaceKanbanVirtualLaneLayout } from './workspace-kanban-virtual-lane-layout'
// Why: board cards are uniform one-line rows; a close estimate keeps the first
// virtual window right so the lane does not reflow once cards measure.
const WORKSPACE_BOARD_CARD_ESTIMATED_HEIGHT = 36
// Matches the `space-y-2` rhythm the lane used before virtualization.
const WORKSPACE_BOARD_CARD_GAP = 8
const WORKSPACE_BOARD_CARD_OVERSCAN = 6
type WorkspaceKanbanLaneCardListProps = {
items: readonly Worktree[]
repoMap: Map<string, Repo>
activeWorktreeId: string | null
scrollRef: React.RefObject<HTMLDivElement | null>
selectedWorktreeIds: ReadonlySet<string>
selectedWorktrees: readonly Worktree[]
nativeDragEnabled: boolean
onActivate: () => void
onSelectionGesture: (event: React.MouseEvent<HTMLElement>, worktreeId: string) => boolean
onContextMenuSelect: (
event: React.MouseEvent<HTMLElement>,
worktree: Worktree
) => readonly Worktree[]
onAssignWorkspaceStatus?: (worktreeIds: readonly string[], status: WorkspaceStatus) => void
}
function WorkspaceKanbanLaneCardList({
items,
repoMap,
activeWorktreeId,
scrollRef,
selectedWorktreeIds,
selectedWorktrees,
nativeDragEnabled,
onActivate,
onSelectionGesture,
onContextMenuSelect,
onAssignWorkspaceStatus
}: WorkspaceKanbanLaneCardListProps): React.JSX.Element {
const spacerRef = useRef<HTMLDivElement | null>(null)
const itemIdsRef = useRef<readonly string[]>([])
itemIdsRef.current = items.map((item) => item.id)
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => WORKSPACE_BOARD_CARD_ESTIMATED_HEIGHT,
getItemKey: useCallback((index: number) => items[index]?.id ?? index, [items]),
overscan: WORKSPACE_BOARD_CARD_OVERSCAN,
gap: WORKSPACE_BOARD_CARD_GAP,
// Why: sync-flushing rich card renders inside the scroll listener stalls the
// wheel; async + overscan keeps the lane filled without blocking input.
useFlushSync: false
})
useLayoutEffect(() => {
const scrollElement = scrollRef.current
const spacerElement = spacerRef.current
if (!scrollElement || !spacerElement) {
return
}
return registerWorkspaceKanbanVirtualLaneLayout({
scrollElement,
spacerElement,
getItemIds: () => itemIdsRef.current,
getMeasurements: () => virtualizer.measurementsCache
})
}, [scrollRef, virtualizer])
return (
<div
ref={spacerRef}
className="relative w-full"
style={{ height: `${virtualizer.getTotalSize()}px` }}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const worktree = items[virtualItem.index]
if (!worktree) {
return null
}
const isSelected = selectedWorktreeIds.has(worktree.id)
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
className="absolute left-0 top-0 w-full"
style={{ transform: `translateY(${virtualItem.start}px)` }}
>
<WorkspaceKanbanCard
worktree={worktree}
laneIndex={virtualItem.index}
repo={repoMap.get(worktree.repoId)}
isActive={activeWorktreeId === worktree.id}
isSelected={isSelected}
nativeDragEnabled={nativeDragEnabled}
selectedWorktrees={
isSelected && selectedWorktrees.length > 0 ? selectedWorktrees : undefined
}
onActivate={onActivate}
onSelectionGesture={onSelectionGesture}
onContextMenuSelect={onContextMenuSelect}
onAssignWorkspaceStatus={onAssignWorkspaceStatus}
/>
</div>
)
})}
</div>
)
}
export default React.memo(WorkspaceKanbanLaneCardList)
@@ -7,6 +7,9 @@ import type {
} from '../../../../shared/types'
import WorkspaceKanbanStatusLane from './WorkspaceKanbanStatusLane'
// Why: a fresh [] per render would defeat the memoized lane on empty lanes.
const EMPTY_LANE_ITEMS: readonly Worktree[] = []
type WorkspaceKanbanLaneGridProps = {
statuses: readonly WorkspaceStatusDefinition[]
worktreesByStatus: ReadonlyMap<WorkspaceStatus, readonly Worktree[]>
@@ -16,6 +19,7 @@ type WorkspaceKanbanLaneGridProps = {
isResizingColumn: boolean
dragOverStatus: WorkspaceStatus | null
canCreateWorktree: boolean
renderCards: boolean
selectedWorktreeIds: ReadonlySet<string>
selectedWorktrees: readonly Worktree[]
onDragOver: (event: React.DragEvent, statusId: string) => void
@@ -42,6 +46,7 @@ export default function WorkspaceKanbanLaneGrid({
isResizingColumn,
dragOverStatus,
canCreateWorktree,
renderCards,
selectedWorktreeIds,
selectedWorktrees,
onDragOver,
@@ -67,13 +72,14 @@ export default function WorkspaceKanbanLaneGrid({
<WorkspaceKanbanStatusLane
key={status.id}
status={status}
items={worktreesByStatus.get(status.id) ?? []}
items={worktreesByStatus.get(status.id) ?? EMPTY_LANE_ITEMS}
repoMap={repoMap}
activeWorktreeId={activeWorktreeId}
columnWidth={columnWidth}
isResizingColumn={isResizingColumn}
isDragTarget={dragOverStatus === status.id}
canCreateWorktree={canCreateWorktree}
renderCards={renderCards}
selectedWorktreeIds={selectedWorktreeIds}
selectedWorktrees={selectedWorktrees}
nativeDragEnabled={false}
@@ -1,4 +1,4 @@
import React from 'react'
import React, { useRef } from 'react'
import { Plus } from 'lucide-react'
import type {
Repo,
@@ -13,7 +13,7 @@ import {
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import WorkspaceKanbanCard from './WorkspaceKanbanCard'
import WorkspaceKanbanLaneCardList from './WorkspaceKanbanLaneCardList'
import { getWorkspaceStatusVisualMeta } from './workspace-status'
import { translate } from '@/i18n/i18n'
@@ -27,6 +27,7 @@ type WorkspaceKanbanStatusLaneProps = {
isDragTarget: boolean
canCreateWorktree: boolean
nativeDragEnabled?: boolean
renderCards: boolean
selectedWorktreeIds: ReadonlySet<string>
selectedWorktrees: readonly Worktree[]
onDragOver: (event: React.DragEvent, statusId: string) => void
@@ -44,7 +45,7 @@ type WorkspaceKanbanStatusLaneProps = {
onColumnResizeKeyDown: (event: React.KeyboardEvent<HTMLElement>) => void
}
export default function WorkspaceKanbanStatusLane({
function WorkspaceKanbanStatusLane({
status,
items,
repoMap,
@@ -54,6 +55,7 @@ export default function WorkspaceKanbanStatusLane({
isDragTarget,
canCreateWorktree,
nativeDragEnabled = true,
renderCards,
selectedWorktreeIds,
selectedWorktrees,
onDragOver,
@@ -67,6 +69,7 @@ export default function WorkspaceKanbanStatusLane({
onColumnResizeStart,
onColumnResizeKeyDown
}: WorkspaceKanbanStatusLaneProps): React.JSX.Element {
const laneScrollRef = useRef<HTMLDivElement | null>(null)
const meta = getWorkspaceStatusVisualMeta(status)
const createTooltip = canCreateWorktree
? `New workspace in ${status.label}`
@@ -152,32 +155,26 @@ export default function WorkspaceKanbanStatusLane({
</div>
<div
ref={laneScrollRef}
data-workspace-board-lane-scroll=""
className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-1.5 py-2 scrollbar-sleek"
>
{items.length > 0 ? (
<div className="space-y-2">
{items.map((worktree) => {
const isSelected = selectedWorktreeIds.has(worktree.id)
return (
<WorkspaceKanbanCard
key={worktree.id}
worktree={worktree}
repo={repoMap.get(worktree.repoId)}
isActive={activeWorktreeId === worktree.id}
isSelected={isSelected}
nativeDragEnabled={nativeDragEnabled}
selectedWorktrees={
isSelected && selectedWorktrees.length > 0 ? selectedWorktrees : undefined
}
onActivate={onActivate}
onSelectionGesture={onSelectionGesture}
onContextMenuSelect={onContextMenuSelect}
onAssignWorkspaceStatus={onAssignWorkspaceStatus}
/>
)
})}
</div>
renderCards ? (
<WorkspaceKanbanLaneCardList
items={items}
repoMap={repoMap}
activeWorktreeId={activeWorktreeId}
scrollRef={laneScrollRef}
selectedWorktreeIds={selectedWorktreeIds}
selectedWorktrees={selectedWorktrees}
nativeDragEnabled={nativeDragEnabled}
onActivate={onActivate}
onSelectionGesture={onSelectionGesture}
onContextMenuSelect={onContextMenuSelect}
onAssignWorkspaceStatus={onAssignWorkspaceStatus}
/>
) : null
) : (
<div className="flex h-20 items-center justify-center rounded-md border border-dashed border-border/70 text-[11px] text-muted-foreground">
{translate('auto.components.sidebar.WorkspaceKanbanStatusLane.8ad104642b', 'Empty')}
@@ -208,3 +205,5 @@ export default function WorkspaceKanbanStatusLane({
</section>
)
}
export default React.memo(WorkspaceKanbanStatusLane)
@@ -0,0 +1,96 @@
import { getWorkspaceKanbanVirtualLaneItemRects } from './workspace-kanban-virtual-lane-layout'
type AreaSelectionCardContentRect = {
top: number
bottom: number
containerTop: number
scrollTop: number
}
export type AreaSelectionCardRect = {
id: string
element: HTMLElement | null
rect: DOMRect
scrollContainer: HTMLElement | null
contentRect: AreaSelectionCardContentRect | null
}
export const AREA_SELECTION_SCROLL_CONTAINER_SELECTOR = '[data-workspace-board-lane-scroll]'
export function getAreaSelectionCardRects(board: HTMLElement): AreaSelectionCardRect[] {
const cardRects = new Map<string, AreaSelectionCardRect>()
const scrollMetrics = new Map<HTMLElement, { containerTop: number; scrollTop: number }>()
const scrollContainers = board.querySelectorAll<HTMLElement>(
AREA_SELECTION_SCROLL_CONTAINER_SELECTOR
)
for (const scrollContainer of scrollContainers) {
const virtualRects = getWorkspaceKanbanVirtualLaneItemRects(scrollContainer)
if (!virtualRects) {
continue
}
const containerTop = scrollContainer.getBoundingClientRect().top
const scrollTop = scrollContainer.scrollTop
scrollMetrics.set(scrollContainer, { containerTop, scrollTop })
for (const virtualRect of virtualRects) {
cardRects.set(virtualRect.id, {
id: virtualRect.id,
element: null,
rect: makeDomRect(virtualRect),
scrollContainer,
contentRect: {
top: virtualRect.contentTop,
bottom: virtualRect.contentBottom,
containerTop,
scrollTop
}
})
}
}
const cards = board.querySelectorAll<HTMLElement>('[data-workspace-board-card-id]')
for (const card of cards) {
const id = card.dataset.workspaceBoardCardId
if (!id) {
continue
}
const rect = card.getBoundingClientRect()
const scrollContainer = card.closest<HTMLElement>(AREA_SELECTION_SCROLL_CONTAINER_SELECTOR)
let metrics = scrollContainer ? scrollMetrics.get(scrollContainer) : undefined
if (scrollContainer && !metrics) {
metrics = {
containerTop: scrollContainer.getBoundingClientRect().top,
scrollTop: scrollContainer.scrollTop
}
scrollMetrics.set(scrollContainer, metrics)
}
cardRects.set(id, {
id,
element: card,
rect,
scrollContainer,
contentRect: metrics
? {
top: rect.top - metrics.containerTop + metrics.scrollTop,
bottom: rect.bottom - metrics.containerTop + metrics.scrollTop,
containerTop: metrics.containerTop,
scrollTop: metrics.scrollTop
}
: null
})
}
return Array.from(cardRects.values())
}
function makeDomRect(rect: { left: number; top: number; right: number; bottom: number }): DOMRect {
return {
x: rect.left,
y: rect.top,
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
width: rect.right - rect.left,
height: rect.bottom - rect.top,
toJSON: () => ({})
} as DOMRect
}
@@ -1,3 +1,14 @@
import {
AREA_SELECTION_SCROLL_CONTAINER_SELECTOR,
type AreaSelectionCardRect
} from './workspace-kanban-area-selection-card-rects'
export {
AREA_SELECTION_SCROLL_CONTAINER_SELECTOR,
getAreaSelectionCardRects,
type AreaSelectionCardRect
} from './workspace-kanban-area-selection-card-rects'
export type AreaSelectionRect = {
left: number
top: number
@@ -5,21 +16,6 @@ export type AreaSelectionRect = {
height: number
}
export type AreaSelectionCardRect = {
id: string
element: HTMLElement
rect: DOMRect
scrollContainer: HTMLElement | null
contentRect: AreaSelectionCardContentRect | null
}
type AreaSelectionCardContentRect = {
top: number
bottom: number
containerTop: number
scrollTop: number
}
type AreaSelectionAutoScrollParams = {
pointerY: number
containerTop: number
@@ -37,7 +33,6 @@ type AreaSelectionCardIdOptions = {
}
const AREA_SELECTED_ATTR = 'data-workspace-board-card-area-selected'
export const AREA_SELECTION_SCROLL_CONTAINER_SELECTOR = '[data-workspace-board-lane-scroll]'
export const AREA_SELECTION_AUTO_SCROLL_EDGE_SIZE = 48
export const AREA_SELECTION_AUTO_SCROLL_MAX_DELTA = 22
@@ -93,45 +88,6 @@ export function isScrollbarPointerDown(
return hitsVerticalScrollbar || hitsHorizontalScrollbar
}
export function getAreaSelectionCardRects(board: HTMLElement): AreaSelectionCardRect[] {
const cardRects: AreaSelectionCardRect[] = []
const seen = new Set<string>()
const scrollMetrics = new Map<HTMLElement, { containerTop: number; scrollTop: number }>()
const cards = board.querySelectorAll<HTMLElement>('[data-workspace-board-card-id]')
for (const card of cards) {
const id = card.dataset.workspaceBoardCardId
if (!id || seen.has(id)) {
continue
}
const rect = card.getBoundingClientRect()
const scrollContainer = card.closest<HTMLElement>(AREA_SELECTION_SCROLL_CONTAINER_SELECTOR)
let metrics = scrollContainer ? scrollMetrics.get(scrollContainer) : undefined
if (scrollContainer && !metrics) {
metrics = {
containerTop: scrollContainer.getBoundingClientRect().top,
scrollTop: scrollContainer.scrollTop
}
scrollMetrics.set(scrollContainer, metrics)
}
cardRects.push({
id,
element: card,
rect,
scrollContainer,
contentRect: metrics
? {
top: rect.top - metrics.containerTop + metrics.scrollTop,
bottom: rect.bottom - metrics.containerTop + metrics.scrollTop,
containerTop: metrics.containerTop,
scrollTop: metrics.scrollTop
}
: null
})
seen.add(id)
}
return cardRects
}
export function getAreaSelectionCardIds(
cardRects: readonly AreaSelectionCardRect[],
selectionRect: AreaSelectionRect,
@@ -256,7 +212,7 @@ export function clearPreviewSelection(
previewIds: Set<string>
): void {
for (const card of cardRects) {
if (previewIds.has(card.id)) {
if (card.element && previewIds.has(card.id)) {
card.element.removeAttribute(AREA_SELECTED_ATTR)
}
}
@@ -276,6 +232,9 @@ export function updatePreviewSelection(
}
for (const card of cardRects) {
if (!card.element) {
continue
}
const shouldPreview = nextIds.has(card.id)
const isPreviewed = previewIds.has(card.id)
if (shouldPreview === isPreviewed) {
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
resolveWorkspaceKanbanCardDropCommitTarget,
resolveWorkspaceCardDropIndexFromRects,
resolveWorkspaceCardDropIndicatorY,
resolveWorkspaceStatusDropTargetFromRects
} from './workspace-kanban-card-pointer-drag-dom'
@@ -41,6 +42,47 @@ describe('workspace kanban pointer drag card drop index', () => {
it('inserts at the end when the pointer is below every card midpoint', () => {
expect(resolveWorkspaceCardDropIndexFromRects(cardRects, 140)).toBe(3)
})
// Why: a virtualized lane only renders its window, so rendered position is
// not lane position. Without the carried index a drop into a scrolled lane
// would land near the top of the list.
it('uses the lane index of a rendered card rather than its rendered position', () => {
const virtualized = [
{ top: 0, bottom: 40, index: 20 },
{ top: 48, bottom: 88, index: 21 },
{ top: 96, bottom: 136, index: 22 }
]
expect(resolveWorkspaceCardDropIndexFromRects(virtualized, 10)).toBe(20)
expect(resolveWorkspaceCardDropIndexFromRects(virtualized, 70)).toBe(22)
expect(resolveWorkspaceCardDropIndexFromRects(virtualized, 140)).toBe(23)
})
})
describe('workspace kanban drop indicator placement', () => {
const cardRects = [
{ top: 100, bottom: 140 },
{ top: 148, bottom: 188 }
]
it('places the line above, between, and below rendered cards', () => {
expect(resolveWorkspaceCardDropIndicatorY(cardRects, 0, 90)).toBe(95)
expect(resolveWorkspaceCardDropIndicatorY(cardRects, 1, 90)).toBe(144)
expect(resolveWorkspaceCardDropIndicatorY(cardRects, 2, 90)).toBe(193)
})
it('falls back to the lane top when the lane renders no cards', () => {
expect(resolveWorkspaceCardDropIndicatorY([], 0, 90)).toBe(104)
})
it('anchors to the rendered card that owns the lane index', () => {
const virtualized = [
{ top: 100, bottom: 140, index: 20 },
{ top: 148, bottom: 188, index: 21 }
]
expect(resolveWorkspaceCardDropIndicatorY(virtualized, 20, 90)).toBe(95)
expect(resolveWorkspaceCardDropIndicatorY(virtualized, 21, 90)).toBe(144)
expect(resolveWorkspaceCardDropIndicatorY(virtualized, 22, 90)).toBe(193)
})
})
describe('workspace kanban pointer drag commit target', () => {
@@ -1,4 +1,8 @@
import type { WorkspaceStatus } from '../../../../shared/types'
import {
resolveWorkspaceKanbanVirtualLaneDropIndex,
resolveWorkspaceKanbanVirtualLaneDropIndicatorY
} from './workspace-kanban-virtual-lane-layout'
export const CARD_SELECTOR = '[data-workspace-board-card-id]'
export const STATUS_DROP_TARGET = '[data-workspace-status-drop-target]'
@@ -19,6 +23,9 @@ export type WorkspaceKanbanStatusDropRect = {
export type WorkspaceKanbanCardDropRect = {
top: number
bottom: number
// Why: lanes virtualize, so a rendered card's DOM position is not its index
// in the lane. Carry the lane index when the card advertises one.
index?: number
}
export type WorkspaceKanbanLaneDropRect = {
@@ -31,6 +38,7 @@ export type WorkspaceKanbanCardDropTarget = {
status: WorkspaceStatus | null
isPinDrop: boolean
dropIndex: number
dropIndicatorY?: number
laneRect?: WorkspaceKanbanLaneDropRect
cardRects?: readonly WorkspaceKanbanCardDropRect[]
}
@@ -75,10 +83,51 @@ export function resolveWorkspaceCardDropIndexFromRects(
for (let index = 0; index < rects.length; index++) {
const rect = rects[index]!
if (y < (rect.top + rect.bottom) / 2) {
return index
return rect.index ?? index
}
}
return rects.length
const last = rects.at(-1)
return last?.index !== undefined ? last.index + 1 : rects.length
}
function getRenderedCardLanePosition(rect: WorkspaceKanbanCardDropRect, fallback: number): number {
return rect.index ?? fallback
}
export function resolveWorkspaceCardDropIndicatorY(
rects: readonly WorkspaceKanbanCardDropRect[],
dropIndex: number,
laneTop: number
): number {
if (rects.length === 0) {
return laneTop + 14
}
// Why: with a virtualized lane the drop index counts unrendered cards too,
// so place the line relative to the rendered card that owns that slot.
const slot = rects.findIndex(
(rect, index) => getRenderedCardLanePosition(rect, index) >= dropIndex
)
if (slot === -1) {
return rects.at(-1)!.bottom + 5
}
if (slot === 0) {
return rects[0]!.top - 5
}
return (rects[slot - 1]!.bottom + rects[slot]!.top) / 2
}
function getLaneCardDropRects(lane: HTMLElement): WorkspaceKanbanCardDropRect[] {
return Array.from(lane.querySelectorAll<HTMLElement>(CARD_SELECTOR))
.filter((card) => card.offsetParent !== null)
.map((card) => {
const rect = card.getBoundingClientRect()
const index = Number.parseInt(card.dataset.workspaceBoardCardIndex ?? '', 10)
return {
top: rect.top,
bottom: rect.bottom,
...(Number.isInteger(index) ? { index } : {})
}
})
}
function hasWorkspaceKanbanCardDropTarget(target: WorkspaceKanbanCardDropTarget): boolean {
@@ -176,16 +225,18 @@ export function getCardDropTarget(
const laneContent = lane.querySelector<HTMLElement>('[data-workspace-board-lane-scroll]')
const laneClientRect = (laneContent ?? lane).getBoundingClientRect()
const cardRects = Array.from(lane.querySelectorAll<HTMLElement>(CARD_SELECTOR))
.filter((card) => card.offsetParent !== null)
.map((card) => {
const rect = card.getBoundingClientRect()
return { top: rect.top, bottom: rect.bottom }
})
const dropIndex = resolveWorkspaceCardDropIndexFromRects(cardRects, y)
const cardRects = getLaneCardDropRects(lane)
const virtualDropIndex = laneContent
? resolveWorkspaceKanbanVirtualLaneDropIndex(laneContent, y)
: null
const dropIndex = virtualDropIndex ?? resolveWorkspaceCardDropIndexFromRects(cardRects, y)
const dropIndicatorY = laneContent
? resolveWorkspaceKanbanVirtualLaneDropIndicatorY(laneContent, dropIndex)
: null
return {
...target,
dropIndex,
...(dropIndicatorY !== null ? { dropIndicatorY } : {}),
laneRect: {
left: laneClientRect.left,
top: laneClientRect.top,
@@ -245,23 +296,10 @@ export function updateCardDropIndicator(
width: fallbackLaneRect.width
}
: { left: 0, top: 0, width: 0 })
const cardRects =
target.cardRects ??
Array.from(lane.querySelectorAll<HTMLElement>(CARD_SELECTOR))
.filter((card) => card.offsetParent !== null)
.map((card) => {
const rect = card.getBoundingClientRect()
return { top: rect.top, bottom: rect.bottom }
})
const boundedDropIndex = Math.max(0, Math.min(cardRects.length, target.dropIndex))
const cardRects = target.cardRects ?? getLaneCardDropRects(lane)
const y =
cardRects.length === 0
? laneRect.top + 14
: boundedDropIndex === 0
? cardRects[0]!.top - 5
: boundedDropIndex >= cardRects.length
? cardRects.at(-1)!.bottom + 5
: (cardRects[boundedDropIndex - 1]!.bottom + cardRects[boundedDropIndex]!.top) / 2
target.dropIndicatorY ??
resolveWorkspaceCardDropIndicatorY(cardRects, Math.max(0, target.dropIndex), laneRect.top)
const horizontalInset = 8
const indicator = getOrCreateDropIndicator()
indicator.dataset.workspaceStatus = target.status
@@ -6,8 +6,10 @@ import {
getWorkspaceKanbanSidebarDropGroups,
getWorkspaceKanbanSidebarDropTarget,
isWorkspaceKanbanSidebarDropPointInBoard,
registerWorkspaceKanbanSidebarDropGroups,
updateWorkspaceKanbanSidebarDropTargetVisual
} from './workspace-kanban-sidebar-drop'
import { registerWorkspaceKanbanVirtualLaneLayout } from './workspace-kanban-virtual-lane-layout'
const workspaceStatuses: WorkspaceStatusDefinition[] = [
{ id: 'todo', label: 'Todo' },
@@ -27,6 +29,7 @@ class FakeElement extends FakeNode {
}
}
offsetParent: FakeElement | null = null
scrollTop = 0
private readonly attributes = new Map<string, string>()
private rect: Pick<DOMRect, 'left' | 'top' | 'right' | 'bottom' | 'width' | 'height'> = {
left: 0,
@@ -157,6 +160,7 @@ class FakeDocument {
}
let fakeDocument: FakeDocument
let unregisterSidebarDropGroups: (() => void) | null = null
function setRect(
element: HTMLElement,
@@ -219,6 +223,8 @@ function worktree(args: {
}
afterEach(() => {
unregisterSidebarDropGroups?.()
unregisterSidebarDropGroups = null
clearWorkspaceKanbanSidebarDropTargetVisual()
vi.unstubAllGlobals()
vi.restoreAllMocks()
@@ -247,6 +253,71 @@ describe('workspace kanban sidebar drop DOM bridge', () => {
])
})
it('prefers complete registered groups and restores the DOM fallback on cleanup', () => {
appendBoard()
unregisterSidebarDropGroups = registerWorkspaceKanbanSidebarDropGroups([
{ key: 'doing', worktreeIds: ['doing-a', 'doing-b', 'doing-c', 'doing-d'] }
])
expect(getWorkspaceKanbanSidebarDropGroups()).toEqual([
{ key: 'doing', worktreeIds: ['doing-a', 'doing-b', 'doing-c', 'doing-d'] }
])
unregisterSidebarDropGroups()
unregisterSidebarDropGroups = null
expect(getWorkspaceKanbanSidebarDropGroups()).toEqual([
{ key: 'doing', worktreeIds: ['doing-a', 'doing-b'] }
])
})
it('uses the full virtual layout when the mounted card window is stale', () => {
const { lane } = appendBoard()
const laneScroll = document.createElement('div')
laneScroll.setAttribute('data-workspace-board-lane-scroll', '')
setRect(laneScroll, {
left: 0,
top: 0,
right: 200,
bottom: 220,
width: 200,
height: 220
})
const spacer = document.createElement('div')
setRect(spacer, {
left: 8,
top: -4260,
right: 192,
bottom: 220,
width: 184,
height: 4480
})
laneScroll.append(spacer)
lane.append(laneScroll)
setElementFromPoint(lane)
const unregister = registerWorkspaceKanbanVirtualLaneLayout({
scrollElement: laneScroll,
spacerElement: spacer,
getItemIds: () => Array.from({ length: 102 }, (_, index) => `doing-${index}`),
getMeasurements: () =>
Array.from({ length: 102 }, (_, index) => ({
index,
start: index * 44,
end: index * 44 + 36
}))
})
expect(getWorkspaceKanbanSidebarDropTarget(24, 210)).toMatchObject({
status: 'doing',
dropIndex: 102,
dropIndicatorY: 225,
cardRects: [
{ top: 8, bottom: 48 },
{ top: 56, bottom: 96 }
]
})
unregister()
})
it('marks and clears the external board hover target', () => {
const { lane } = appendBoard()
setElementFromPoint(lane)
@@ -25,6 +25,7 @@ const BOARD_SHEET_SELECTOR = '[data-workspace-board-sheet]'
const EXTERNAL_DRAG_TARGET_ATTR = 'data-workspace-board-external-drag-target'
let externalDragTargetElement: HTMLElement | null = null
let logicalDropGroupsRegistration: { groups: readonly WorktreeDragGroup[] } | null = null
function getWorkspaceKanbanBoardElement(): HTMLElement | null {
return document.querySelector<HTMLElement>(BOARD_SELECTOR)
@@ -69,7 +70,26 @@ export function clearWorkspaceKanbanSidebarDropTargetVisual(): void {
removeCardDropIndicator()
}
export function registerWorkspaceKanbanSidebarDropGroups(
groups: readonly WorktreeDragGroup[]
): () => void {
const registration = { groups }
logicalDropGroupsRegistration = registration
return () => {
if (logicalDropGroupsRegistration === registration) {
logicalDropGroupsRegistration = null
}
}
}
export function getWorkspaceKanbanSidebarDropGroups(): WorktreeDragGroup[] {
if (logicalDropGroupsRegistration) {
return logicalDropGroupsRegistration.groups.map((group) => ({
key: group.key,
worktreeIds: [...group.worktreeIds]
}))
}
const board = getWorkspaceKanbanBoardElement()
if (!board) {
return []
@@ -0,0 +1,134 @@
import { describe, expect, it } from 'vitest'
import {
getAreaSelectionCardIds,
getAreaSelectionCardRects
} from './workspace-kanban-area-selection-dom'
import {
getWorkspaceKanbanVirtualLaneItemRects,
registerWorkspaceKanbanVirtualLaneLayout,
resolveWorkspaceKanbanVirtualLaneDropIndex,
resolveWorkspaceKanbanVirtualLaneDropIndicatorY
} from './workspace-kanban-virtual-lane-layout'
const ITEM_COUNT = 102
const ITEM_STRIDE = 44
const ITEM_HEIGHT = 36
function createLayoutElements(): {
scrollElement: HTMLElement
spacerElement: HTMLElement
} {
const scrollElement = {
scrollTop: 0,
getBoundingClientRect: () => ({
left: 20,
top: 100,
right: 220,
bottom: 600,
width: 200,
height: 500
})
} as unknown as HTMLElement
const spacerElement = {
getBoundingClientRect: () => ({
left: 26,
top: 108 - scrollElement.scrollTop,
right: 214,
bottom: 108 - scrollElement.scrollTop + ITEM_COUNT * ITEM_STRIDE,
width: 188,
height: ITEM_COUNT * ITEM_STRIDE
})
} as unknown as HTMLElement
return { scrollElement, spacerElement }
}
function registerLayout(scrollElement: HTMLElement, spacerElement: HTMLElement): () => void {
return registerWorkspaceKanbanVirtualLaneLayout({
scrollElement,
spacerElement,
getItemIds: () => Array.from({ length: ITEM_COUNT }, (_, index) => `w${index}`),
getMeasurements: () =>
Array.from({ length: ITEM_COUNT }, (_, index) => ({
index,
start: index * ITEM_STRIDE,
end: index * ITEM_STRIDE + ITEM_HEIGHT
}))
})
}
describe('workspace kanban virtual lane layout', () => {
it('resolves mid-lane and bottom drops immediately after a large scroll jump', () => {
const { scrollElement, spacerElement } = createLayoutElements()
const unregister = registerLayout(scrollElement, spacerElement)
scrollElement.scrollTop = 3_980
expect(resolveWorkspaceKanbanVirtualLaneDropIndex(scrollElement, 370)).toBe(97)
expect(resolveWorkspaceKanbanVirtualLaneDropIndex(scrollElement, 590)).toBe(102)
expect(resolveWorkspaceKanbanVirtualLaneDropIndicatorY(scrollElement, 102)).toBe(613)
unregister()
})
it('exposes every logical item even when no card is mounted', () => {
const { scrollElement, spacerElement } = createLayoutElements()
const unregister = registerLayout(scrollElement, spacerElement)
scrollElement.scrollTop = 3_980
const rects = getWorkspaceKanbanVirtualLaneItemRects(scrollElement)
expect(rects).toHaveLength(ITEM_COUNT)
expect(rects?.[50]).toMatchObject({
id: 'w50',
index: 50,
contentTop: 2_208,
contentBottom: 2_244
})
unregister()
})
it('selects intermediate never-mounted cards across a jumped lane range', () => {
const { scrollElement, spacerElement } = createLayoutElements()
const unregister = registerLayout(scrollElement, spacerElement)
scrollElement.scrollTop = 3_980
const board = {
querySelectorAll: (selector: string) =>
selector === '[data-workspace-board-lane-scroll]' ? [scrollElement] : []
} as unknown as HTMLElement
const cardRects = getAreaSelectionCardRects(board)
const selectedIds = getAreaSelectionCardIds(
cardRects,
{ left: 0, top: 110, width: 300, height: 480 },
{
scrollStartContentYByElement: new Map([[scrollElement, 18]]),
currentY: 590
}
)
expect(cardRects.every((card) => card.element === null)).toBe(true)
expect(selectedIds).toHaveLength(ITEM_COUNT)
expect(selectedIds).toContain('w50')
unregister()
})
it('does not let stale cleanup remove a newer lane registration', () => {
const { scrollElement, spacerElement } = createLayoutElements()
const unregisterFirst = registerWorkspaceKanbanVirtualLaneLayout({
scrollElement,
spacerElement,
getItemIds: () => ['old'],
getMeasurements: () => [{ index: 0, start: 0, end: ITEM_HEIGHT }]
})
const unregisterSecond = registerWorkspaceKanbanVirtualLaneLayout({
scrollElement,
spacerElement,
getItemIds: () => ['new'],
getMeasurements: () => [{ index: 0, start: 0, end: ITEM_HEIGHT }]
})
unregisterFirst()
expect(getWorkspaceKanbanVirtualLaneItemRects(scrollElement)?.map((item) => item.id)).toEqual([
'new'
])
unregisterSecond()
expect(getWorkspaceKanbanVirtualLaneItemRects(scrollElement)).toBeNull()
})
})
@@ -0,0 +1,159 @@
import type { VirtualItem } from '@tanstack/react-virtual'
type VirtualLaneLayoutRegistration = {
spacerElement: HTMLElement
getItemIds: () => readonly string[]
getMeasurements: () => readonly Pick<VirtualItem, 'index' | 'start' | 'end'>[]
}
type VirtualLaneLayoutSnapshot = {
registration: VirtualLaneLayoutRegistration
itemIds: readonly string[]
measurements: readonly Pick<VirtualItem, 'index' | 'start' | 'end'>[]
}
export type WorkspaceKanbanVirtualLaneItemRect = {
id: string
index: number
left: number
top: number
right: number
bottom: number
contentTop: number
contentBottom: number
}
const virtualLaneLayouts = new WeakMap<HTMLElement, VirtualLaneLayoutRegistration>()
export function registerWorkspaceKanbanVirtualLaneLayout(args: {
scrollElement: HTMLElement
spacerElement: HTMLElement
getItemIds: () => readonly string[]
getMeasurements: () => readonly Pick<VirtualItem, 'index' | 'start' | 'end'>[]
}): () => void {
const registration = {
spacerElement: args.spacerElement,
getItemIds: args.getItemIds,
getMeasurements: args.getMeasurements
}
virtualLaneLayouts.set(args.scrollElement, registration)
return () => {
if (virtualLaneLayouts.get(args.scrollElement) === registration) {
virtualLaneLayouts.delete(args.scrollElement)
}
}
}
export function getWorkspaceKanbanVirtualLaneItemRects(
scrollElement: HTMLElement
): WorkspaceKanbanVirtualLaneItemRect[] | null {
const snapshot = getVirtualLaneLayoutSnapshot(scrollElement)
if (!snapshot) {
return null
}
const spacerRect = snapshot.registration.spacerElement.getBoundingClientRect()
const containerRect = scrollElement.getBoundingClientRect()
const contentOffset = spacerRect.top - containerRect.top + scrollElement.scrollTop
const rects: WorkspaceKanbanVirtualLaneItemRect[] = []
for (let index = 0; index < snapshot.itemIds.length; index++) {
const measurement = snapshot.measurements[index]
if (!isValidMeasurement(measurement, index)) {
return null
}
rects.push({
id: snapshot.itemIds[index]!,
index,
left: spacerRect.left,
top: spacerRect.top + measurement.start,
right: spacerRect.right,
bottom: spacerRect.top + measurement.end,
contentTop: contentOffset + measurement.start,
contentBottom: contentOffset + measurement.end
})
}
return rects
}
export function resolveWorkspaceKanbanVirtualLaneDropIndex(
scrollElement: HTMLElement,
pointerY: number
): number | null {
const snapshot = getVirtualLaneLayoutSnapshot(scrollElement)
if (!snapshot) {
return null
}
const spacerTop = snapshot.registration.spacerElement.getBoundingClientRect().top
let low = 0
let high = snapshot.itemIds.length
while (low < high) {
const middle = Math.floor((low + high) / 2)
const measurement = snapshot.measurements[middle]
if (!isValidMeasurement(measurement, middle)) {
return null
}
if (pointerY < spacerTop + (measurement.start + measurement.end) / 2) {
high = middle
} else {
low = middle + 1
}
}
return low
}
export function resolveWorkspaceKanbanVirtualLaneDropIndicatorY(
scrollElement: HTMLElement,
dropIndex: number
): number | null {
const snapshot = getVirtualLaneLayoutSnapshot(scrollElement)
if (!snapshot) {
return null
}
if (snapshot.itemIds.length === 0) {
return scrollElement.getBoundingClientRect().top + 14
}
const spacerTop = snapshot.registration.spacerElement.getBoundingClientRect().top
const boundedIndex = Math.max(0, Math.min(snapshot.itemIds.length, dropIndex))
if (boundedIndex === 0) {
const first = snapshot.measurements[0]
return isValidMeasurement(first, 0) ? spacerTop + first.start - 5 : null
}
if (boundedIndex === snapshot.itemIds.length) {
const lastIndex = snapshot.itemIds.length - 1
const last = snapshot.measurements[lastIndex]
return isValidMeasurement(last, lastIndex) ? spacerTop + last.end + 5 : null
}
const previous = snapshot.measurements[boundedIndex - 1]
const next = snapshot.measurements[boundedIndex]
if (!isValidMeasurement(previous, boundedIndex - 1) || !isValidMeasurement(next, boundedIndex)) {
return null
}
return spacerTop + (previous.end + next.start) / 2
}
function getVirtualLaneLayoutSnapshot(
scrollElement: HTMLElement
): VirtualLaneLayoutSnapshot | null {
const registration = virtualLaneLayouts.get(scrollElement)
if (!registration) {
return null
}
const itemIds = registration.getItemIds()
const measurements = registration.getMeasurements()
return measurements.length >= itemIds.length ? { registration, itemIds, measurements } : null
}
function isValidMeasurement(
measurement: Pick<VirtualItem, 'index' | 'start' | 'end'> | undefined,
index: number
): measurement is Pick<VirtualItem, 'index' | 'start' | 'end'> {
return Boolean(
measurement &&
measurement.index === index &&
Number.isFinite(measurement.start) &&
Number.isFinite(measurement.end) &&
measurement.end >= measurement.start
)
}
@@ -0,0 +1,238 @@
import { test, expect } from './helpers/orca-app'
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
const SEEDED_WORKSPACE_COUNT = 300
const MARQUEE_WORKSPACE_COUNT = 102
/**
* Why: the board used to mount every workspace card in every lane in one
* commit, which blocked the sheet open for seconds on a large workspace set.
* These assert the lane renders a window instead, and that each rendered card
* still carries its true lane index so drop targeting stays correct.
*/
test.describe('Workspace board lane virtualization', () => {
test.beforeEach(async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
})
test('mounts a window of cards for a large lane and keeps lane indexes', async ({ orcaPage }) => {
await orcaPage.evaluate((count) => {
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')
}
const now = Date.now()
const seeded = state.worktreesByRepo[repo.id] ?? []
const synthetic = Array.from({ length: count }, (_, index) => {
const suffix = String(index).padStart(3, '0')
return {
id: `${repo.id}::/virtual-board-${suffix}`,
instanceId: `virtual-board-${suffix}`,
repoId: repo.id,
path: `${repo.path}/../virtual-board-${suffix}`,
displayName: `Virtual board ${suffix}`,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 10_000 - index,
lastActivityAt: now - index - 100,
head: '0000000000000000000000000000000000000000',
branch: `virtual-board-${suffix}`,
isBare: false,
isMainWorktree: false,
workspaceStatus: 'in-progress'
}
})
state.setSidebarOpen(true)
state.setShowSleepingWorkspaces(true)
state.setHideDefaultBranchWorkspace(false)
state.setFilterRepoIds([])
store.setState({
sortBy: 'manual',
worktreesByRepo: { ...state.worktreesByRepo, [repo.id]: [...seeded, ...synthetic] }
})
}, SEEDED_WORKSPACE_COUNT)
await orcaPage.getByRole('button', { name: 'Workspace board' }).click()
const cards = orcaPage.locator('[data-workspace-board-card-id]')
// Why: an empty window would also satisfy "fewer than seeded"; the point of
// the change is a filled window, not a blank board.
await expect.poll(() => cards.count(), { timeout: 15_000 }).toBeGreaterThan(3)
expect(await cards.count()).toBeLessThan(SEEDED_WORKSPACE_COUNT / 2)
const indexes = await cards.evaluateAll((elements) =>
elements.map((element) =>
Number((element as HTMLElement).dataset.workspaceBoardCardIndex ?? -1)
)
)
expect(indexes.every((index) => Number.isInteger(index) && index >= 0)).toBe(true)
expect(new Set(indexes).size).toBe(indexes.length)
})
test('renders later lane indexes after the lane scrolls', async ({ orcaPage }) => {
await orcaPage.evaluate((count) => {
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')
}
const now = Date.now()
const seeded = state.worktreesByRepo[repo.id] ?? []
const synthetic = Array.from({ length: count }, (_, index) => ({
id: `${repo.id}::/virtual-scroll-${index}`,
instanceId: `virtual-scroll-${index}`,
repoId: repo.id,
path: `${repo.path}/../virtual-scroll-${index}`,
displayName: `Virtual scroll ${index}`,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 10_000 - index,
lastActivityAt: now - index - 100,
head: '0000000000000000000000000000000000000000',
branch: `virtual-scroll-${index}`,
isBare: false,
isMainWorktree: false,
workspaceStatus: 'in-progress'
}))
state.setSidebarOpen(true)
state.setShowSleepingWorkspaces(true)
state.setFilterRepoIds([])
store.setState({
sortBy: 'manual',
worktreesByRepo: { ...state.worktreesByRepo, [repo.id]: [...seeded, ...synthetic] }
})
}, SEEDED_WORKSPACE_COUNT)
await orcaPage.getByRole('button', { name: 'Workspace board' }).click()
const cards = orcaPage.locator('[data-workspace-board-card-id]')
await expect.poll(() => cards.count(), { timeout: 15_000 }).toBeGreaterThan(3)
const readMaxIndex = (): Promise<number> =>
cards.evaluateAll((elements) =>
Math.max(
...elements.map((element) =>
Number((element as HTMLElement).dataset.workspaceBoardCardIndex ?? -1)
)
)
)
const before = await readMaxIndex()
await orcaPage
.locator('[data-workspace-status="in-progress"] [data-workspace-board-lane-scroll]')
.first()
.evaluate((element) => {
element.scrollTop = element.scrollHeight
element.dispatchEvent(new Event('scroll', { bubbles: true }))
})
await expect.poll(readMaxIndex, { timeout: 15_000 }).toBeGreaterThan(before)
})
test('selects the full lane across a single large marquee scroll jump', async ({ orcaPage }) => {
const statusId = 'virtual-marquee'
await orcaPage.evaluate(
({ count, status }) => {
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')
}
const now = Date.now()
const seeded = state.worktreesByRepo[repo.id] ?? []
const synthetic = Array.from({ length: count }, (_, index) => ({
id: `${repo.id}::/virtual-marquee-${index}`,
instanceId: `virtual-marquee-${index}`,
repoId: repo.id,
path: `${repo.path}/../virtual-marquee-${index}`,
displayName: `Virtual marquee ${index}`,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 10_000 - index,
manualOrder: 10_000 - index,
lastActivityAt: now - index - 100,
head: '0000000000000000000000000000000000000000',
branch: `virtual-marquee-${index}`,
isBare: false,
isMainWorktree: false,
workspaceStatus: status
}))
state.setSidebarOpen(true)
state.setShowSleepingWorkspaces(true)
state.setFilterRepoIds([])
store.setState({
sortBy: 'manual',
worktreesByRepo: { ...state.worktreesByRepo, [repo.id]: [...seeded, ...synthetic] }
})
state.setWorkspaceStatuses([
{ id: status, label: 'Virtual marquee' },
...state.workspaceStatuses.filter((entry) => entry.id !== status)
])
},
{ count: MARQUEE_WORKSPACE_COUNT, status: statusId }
)
await orcaPage.getByRole('button', { name: 'Workspace board' }).click()
const lane = orcaPage.locator(`[data-workspace-status="${statusId}"]`)
await expect(lane.getByText(String(MARQUEE_WORKSPACE_COUNT), { exact: true })).toBeVisible()
const laneCards = lane.locator('[data-workspace-board-card-id]')
await expect.poll(() => laneCards.count(), { timeout: 15_000 }).toBeGreaterThan(3)
const laneScroll = lane.locator('[data-workspace-board-lane-scroll]')
const box = await laneScroll.boundingBox()
if (!box) {
throw new Error('Expected the marquee lane to have a bounding box')
}
await orcaPage.mouse.move(box.x + 2, box.y + 12)
await orcaPage.mouse.down()
await orcaPage.mouse.move(box.x + box.width - 18, box.y + 80)
await laneScroll.evaluate(async (element) => {
for (let pass = 0; pass < 4; pass++) {
element.scrollTop = element.scrollHeight
element.dispatchEvent(new Event('scroll', { bubbles: true }))
await new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
})
}
})
await orcaPage.mouse.move(box.x + box.width - 18, box.y + box.height - 12)
await orcaPage.mouse.up()
await expect(
orcaPage.getByText(`${MARQUEE_WORKSPACE_COUNT} selected`, { exact: true })
).toBeVisible()
})
})