From 6d63bcac0441ede2428550efcf4e261fabcd493f Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Tue, 28 Jul 2026 20:41:48 -0700
Subject: [PATCH] Virtualize workspace board lanes and defer card render for
instant open
---
.../sidebar/WorkspaceKanbanCard.tsx | 5 +
.../sidebar/WorkspaceKanbanDrawer.tsx | 33 ++-
.../WorkspaceKanbanLaneCardList.test.tsx | 105 ++++++++
.../sidebar/WorkspaceKanbanLaneCardList.tsx | 115 +++++++++
.../sidebar/WorkspaceKanbanLaneGrid.tsx | 8 +-
.../sidebar/WorkspaceKanbanStatusLane.tsx | 49 ++--
...kspace-kanban-area-selection-card-rects.ts | 96 +++++++
.../workspace-kanban-area-selection-dom.ts | 71 ++----
...space-kanban-card-pointer-drag-dom.test.ts | 42 ++++
.../workspace-kanban-card-pointer-drag-dom.ts | 88 +++++--
.../workspace-kanban-sidebar-drop.test.ts | 71 ++++++
.../sidebar/workspace-kanban-sidebar-drop.ts | 20 ++
...rkspace-kanban-virtual-lane-layout.test.ts | 134 ++++++++++
.../workspace-kanban-virtual-lane-layout.ts | 159 ++++++++++++
...orkspace-board-lane-virtualization.spec.ts | 238 ++++++++++++++++++
15 files changed, 1126 insertions(+), 108 deletions(-)
create mode 100644 src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.test.tsx
create mode 100644 src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.tsx
create mode 100644 src/renderer/src/components/sidebar/workspace-kanban-area-selection-card-rects.ts
create mode 100644 src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.test.ts
create mode 100644 src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.ts
create mode 100644 tests/e2e/workspace-board-lane-virtualization.spec.ts
diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx
index 601bef497d6..cd7b94dd725 100644
--- a/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx
+++ b/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx
@@ -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({
{items.length > 0 ? (
-
- {items.map((worktree) => {
- const isSelected = selectedWorktreeIds.has(worktree.id)
- return (
- 0 ? selectedWorktrees : undefined
- }
- onActivate={onActivate}
- onSelectionGesture={onSelectionGesture}
- onContextMenuSelect={onContextMenuSelect}
- onAssignWorkspaceStatus={onAssignWorkspaceStatus}
- />
- )
- })}
-
+ renderCards ? (
+
+ ) : null
) : (
{translate('auto.components.sidebar.WorkspaceKanbanStatusLane.8ad104642b', 'Empty')}
@@ -208,3 +205,5 @@ export default function WorkspaceKanbanStatusLane({
)
}
+
+export default React.memo(WorkspaceKanbanStatusLane)
diff --git a/src/renderer/src/components/sidebar/workspace-kanban-area-selection-card-rects.ts b/src/renderer/src/components/sidebar/workspace-kanban-area-selection-card-rects.ts
new file mode 100644
index 00000000000..89766b4ecde
--- /dev/null
+++ b/src/renderer/src/components/sidebar/workspace-kanban-area-selection-card-rects.ts
@@ -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()
+ const scrollMetrics = new Map()
+ const scrollContainers = board.querySelectorAll(
+ 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('[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(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
+}
diff --git a/src/renderer/src/components/sidebar/workspace-kanban-area-selection-dom.ts b/src/renderer/src/components/sidebar/workspace-kanban-area-selection-dom.ts
index a0a5c9d46b3..55227e4905a 100644
--- a/src/renderer/src/components/sidebar/workspace-kanban-area-selection-dom.ts
+++ b/src/renderer/src/components/sidebar/workspace-kanban-area-selection-dom.ts
@@ -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()
- const scrollMetrics = new Map()
- const cards = board.querySelectorAll('[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(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
): 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) {
diff --git a/src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.test.ts b/src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.test.ts
index 5a8698a1cb8..f8ab1df0f94 100644
--- a/src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.test.ts
+++ b/src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.test.ts
@@ -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', () => {
diff --git a/src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.ts b/src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.ts
index 9114a47a881..dd59d430ab9 100644
--- a/src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.ts
+++ b/src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.ts
@@ -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(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('[data-workspace-board-lane-scroll]')
const laneClientRect = (laneContent ?? lane).getBoundingClientRect()
- const cardRects = Array.from(lane.querySelectorAll(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(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
diff --git a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts
index e9ae00c76f1..8d450a2c600 100644
--- a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts
+++ b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts
@@ -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()
private rect: Pick = {
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)
diff --git a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts
index befa87d9eee..b29fa3e350f 100644
--- a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts
+++ b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts
@@ -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(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 []
diff --git a/src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.test.ts b/src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.test.ts
new file mode 100644
index 00000000000..40f028a2a1a
--- /dev/null
+++ b/src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.test.ts
@@ -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()
+ })
+})
diff --git a/src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.ts b/src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.ts
new file mode 100644
index 00000000000..e142b3617d6
--- /dev/null
+++ b/src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.ts
@@ -0,0 +1,159 @@
+import type { VirtualItem } from '@tanstack/react-virtual'
+
+type VirtualLaneLayoutRegistration = {
+ spacerElement: HTMLElement
+ getItemIds: () => readonly string[]
+ getMeasurements: () => readonly Pick[]
+}
+
+type VirtualLaneLayoutSnapshot = {
+ registration: VirtualLaneLayoutRegistration
+ itemIds: readonly string[]
+ measurements: readonly Pick[]
+}
+
+export type WorkspaceKanbanVirtualLaneItemRect = {
+ id: string
+ index: number
+ left: number
+ top: number
+ right: number
+ bottom: number
+ contentTop: number
+ contentBottom: number
+}
+
+const virtualLaneLayouts = new WeakMap()
+
+export function registerWorkspaceKanbanVirtualLaneLayout(args: {
+ scrollElement: HTMLElement
+ spacerElement: HTMLElement
+ getItemIds: () => readonly string[]
+ getMeasurements: () => readonly Pick[]
+}): () => 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 | undefined,
+ index: number
+): measurement is Pick {
+ return Boolean(
+ measurement &&
+ measurement.index === index &&
+ Number.isFinite(measurement.start) &&
+ Number.isFinite(measurement.end) &&
+ measurement.end >= measurement.start
+ )
+}
diff --git a/tests/e2e/workspace-board-lane-virtualization.spec.ts b/tests/e2e/workspace-board-lane-virtualization.spec.ts
new file mode 100644
index 00000000000..850a83024a7
--- /dev/null
+++ b/tests/e2e/workspace-board-lane-virtualization.spec.ts
@@ -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 =>
+ 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((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()
+ })
+})