From ef9e6ab9a8ec37e6ecc4d64b568cd8b7e8df082d Mon Sep 17 00:00:00 2001 From: Eugenio Jesus Jose Valeiras Date: Fri, 31 Jul 2026 03:58:42 -0300 Subject: [PATCH] fix(board): stop truncating workflows longer than 12 columns (#11605) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- src/main/persistence.test.ts | 40 +++ .../sidebar/WorkspaceKanbanDrawer.tsx | 1 + .../sidebar/WorkspaceKanbanLaneGrid.test.tsx | 229 ++++++++++++++++++ .../sidebar/WorkspaceKanbanLaneGrid.tsx | 194 ++++++++++++--- .../WorkspaceKanbanSettingsMenu.test.tsx | 35 ++- .../sidebar/workspace-kanban-lane-range.ts | 17 ++ src/shared/workspace-statuses.test.ts | 23 ++ src/shared/workspace-statuses.ts | 3 +- ...orkspace-board-lane-virtualization.spec.ts | 155 ++++++++++++ 9 files changed, 657 insertions(+), 40 deletions(-) create mode 100644 src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.test.tsx create mode 100644 src/renderer/src/components/sidebar/workspace-kanban-lane-range.ts diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index ba69a92c5..d8e011669 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -6840,6 +6840,46 @@ describe('Store', () => { expect(store.getUI().syncTaskStatusFromWorkspaceBoard).toBe(true) }) + it('preserves workflows above 20 statuses across load, write, and restart', async () => { + const imported = Array.from({ length: 21 }, (_, index) => ({ + id: `state-${index + 1}`, + label: `State ${index + 1}` + })).toReversed() + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: { workspaceStatuses: imported }, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + expect(store.getUI().workspaceStatuses?.map((status) => status.id)).toEqual( + imported.map((status) => status.id) + ) + + const authored = Array.from({ length: 64 }, (_, index) => ({ + id: `final-${String(index + 1).padStart(3, '0')}`, + label: `Final ${index + 1}` + })).toReversed() + store.updateUI({ workspaceStatuses: authored }) + store.flush() + + expect(store.getUI().workspaceStatuses?.map((status) => status.id)).toEqual( + authored.map((status) => status.id) + ) + expect( + (readDataFile() as PersistedState).ui.workspaceStatuses?.map((status) => status.id) + ).toEqual(authored.map((status) => status.id)) + + const restarted = await createStore() + expect(restarted.getUI().workspaceStatuses?.map((status) => status.id)).toEqual( + authored.map((status) => status.id) + ) + }) + it('repairs the known-bad reordered default workspace statuses once on load', async () => { writeDataFile({ schemaVersion: 1, diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx index e67fd44dd..d3998031d 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx @@ -899,6 +899,7 @@ export default function WorkspaceKanbanDrawer({ className="min-h-0 flex-1 overflow-x-auto overflow-y-hidden scrollbar-sleek" > ({ startIndex: 0, visibleCount: 4 })) +const animationFrames = new Map() +let nextAnimationFrameId = 1 + +vi.mock('@tanstack/react-virtual', () => { + const defaultRangeExtractor = (range: { + startIndex: number + endIndex: number + overscan: number + count: number + }): number[] => { + const start = Math.max(0, range.startIndex - range.overscan) + const end = Math.min(range.count - 1, range.endIndex + range.overscan) + return Array.from({ length: Math.max(0, end - start + 1) }, (_, index) => start + index) + } + return { + defaultRangeExtractor, + useVirtualizer: (options: { + count: number + estimateSize: (index: number) => number + getItemKey: (index: number) => string | number + gap: number + rangeExtractor: (range: { + startIndex: number + endIndex: number + overscan: number + count: number + }) => number[] + }) => { + const endIndex = Math.min( + options.count - 1, + virtualWindow.startIndex + virtualWindow.visibleCount - 1 + ) + const indexes = + options.count === 0 + ? [] + : options.rangeExtractor({ + startIndex: virtualWindow.startIndex, + endIndex, + overscan: 1, + count: options.count + }) + const size = options.estimateSize(0) + return { + getTotalSize: () => Math.max(0, options.count * size + (options.count - 1) * options.gap), + getVirtualItems: () => + indexes.map((index) => ({ + index, + key: options.getItemKey(index), + start: index * (size + options.gap) + })), + measureElement: () => {}, + measure: () => {} + } + } + } +}) + +vi.mock('./WorkspaceKanbanStatusLane', () => ({ + default: ({ + status, + items, + renderCards + }: { + status: WorkspaceStatusDefinition + items: readonly Worktree[] + renderCards: boolean + }) => ( +
+ +
+ ) +})) + +const { default: WorkspaceKanbanLaneGrid } = await import('./WorkspaceKanbanLaneGrid') +const { extractWorkspaceKanbanLaneRange } = await import('./workspace-kanban-lane-range') + +const STATUSES = Array.from({ length: 21 }, (_, index) => ({ + id: `state-${String(index + 1).padStart(2, '0')}`, + label: `State ${index + 1}` +})) +const REPO_MAP = new Map() + +function makeGrid(): React.JSX.Element { + return ( + {}} + onDragLeave={() => {}} + onDrop={() => {}} + onActivate={() => {}} + onSelectionGesture={() => false} + onContextMenuSelect={() => []} + onCreateWorktree={() => {}} + onColumnResizeStart={() => {}} + onColumnResizeKeyDown={() => {}} + /> + ) +} + +function renderGrid(): ReturnType { + return render(makeGrid()) +} + +function mountedStatusIds(container: HTMLElement): string[] { + return Array.from(container.querySelectorAll('[data-workspace-status]')).map( + (lane) => lane.dataset.workspaceStatus ?? '' + ) +} + +function flushNextAnimationFrame(): void { + const next = animationFrames.entries().next().value as [number, FrameRequestCallback] | undefined + expect(next).toBeDefined() + if (!next) { + return + } + animationFrames.delete(next[0]) + act(() => next[1](performance.now())) +} + +beforeEach(() => { + animationFrames.clear() + nextAnimationFrameId = 1 + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + const id = nextAnimationFrameId + nextAnimationFrameId += 1 + animationFrames.set(id, callback) + return id + }) + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation((id) => { + animationFrames.delete(id) + }) +}) + +afterEach(() => { + virtualWindow.startIndex = 0 + cleanup() + vi.restoreAllMocks() +}) + +describe('WorkspaceKanbanLaneGrid', () => { + it('reserves the full workflow width while mounting only the horizontal window', () => { + const { container } = renderGrid() + + expect(mountedStatusIds(container)).toEqual([ + 'state-01', + 'state-02', + 'state-03', + 'state-04', + 'state-05' + ]) + expect( + container.querySelector('[data-workspace-board-lane-grid]')?.style.width + ).toBe(`${21 * 308 + 20 * 12}px`) + }) + + it('mounts later ordered lanes and releases distant lanes after horizontal scroll', () => { + const rendered = renderGrid() + virtualWindow.startIndex = 17 + rendered.rerender(makeGrid()) + + expect(mountedStatusIds(rendered.container)).toEqual([ + 'state-17', + 'state-18', + 'state-19', + 'state-20', + 'state-21' + ]) + expect(rendered.container.querySelector('[data-workspace-status="state-01"]')).toBeNull() + }) + + it('keeps one focused lane mounted without unbounding the virtual window', () => { + const rendered = renderGrid() + fireEvent.focus(rendered.getByRole('button', { name: 'State 1' })) + + virtualWindow.startIndex = 17 + rendered.rerender(makeGrid()) + + expect(mountedStatusIds(rendered.container)).toEqual([ + 'state-01', + 'state-17', + 'state-18', + 'state-19', + 'state-20', + 'state-21' + ]) + }) + + it('adds only the focused lane to the normal overscanned range', () => { + expect( + extractWorkspaceKanbanLaneRange({ startIndex: 4, endIndex: 7, overscan: 1, count: 21 }, 18) + ).toEqual([3, 4, 5, 6, 7, 8, 18]) + }) + + it('hydrates at most one mounted lane per animation frame', () => { + const { container } = renderGrid() + const renderedLaneCount = (): number => + container.querySelectorAll('[data-render-cards="true"]').length + + expect(renderedLaneCount()).toBe(0) + flushNextAnimationFrame() + expect(renderedLaneCount()).toBe(1) + flushNextAnimationFrame() + expect(renderedLaneCount()).toBe(2) + }) +}) diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.tsx index 12506678b..48d77c9b7 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.tsx @@ -1,4 +1,13 @@ -import React from 'react' +import React, { + startTransition, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState +} from 'react' +import { useVirtualizer, type Range } from '@tanstack/react-virtual' import type { Repo, WorkspaceStatus, @@ -6,12 +15,17 @@ import type { Worktree } from '../../../../shared/types' import type { WorkspaceKanbanLaneView } from './workspace-kanban-search' +import { extractWorkspaceKanbanLaneRange } from './workspace-kanban-lane-range' import WorkspaceKanbanStatusLane from './WorkspaceKanbanStatusLane' // Why: a fresh [] per render would defeat the memoized lane on empty lanes. const EMPTY_LANE_ITEMS: readonly Worktree[] = [] +const EMPTY_RENDERED_LANE_IDS: ReadonlySet = new Set() +const WORKSPACE_BOARD_LANE_GAP = 12 +const WORKSPACE_BOARD_LANE_OVERSCAN = 1 type WorkspaceKanbanLaneGridProps = { + laneScrollerRef: React.RefObject statuses: readonly WorkspaceStatusDefinition[] laneViews: ReadonlyMap laneFullWorktreeIds: ReadonlyMap @@ -41,6 +55,7 @@ type WorkspaceKanbanLaneGridProps = { } export default function WorkspaceKanbanLaneGrid({ + laneScrollerRef, statuses, laneViews, laneFullWorktreeIds, @@ -65,44 +80,155 @@ export default function WorkspaceKanbanLaneGrid({ onColumnResizeStart, onColumnResizeKeyDown }: WorkspaceKanbanLaneGridProps): React.JSX.Element { + const [focusedStatusId, setFocusedStatusId] = useState(null) + const [renderedLaneIds, setRenderedLaneIds] = + useState>(EMPTY_RENDERED_LANE_IDS) + const renderedLaneIdsRef = useRef(renderedLaneIds) + const renderCardsRef = useRef(renderCards) + useLayoutEffect(() => { + renderedLaneIdsRef.current = renderedLaneIds + renderCardsRef.current = renderCards + }, [renderCards, renderedLaneIds]) + const focusedIndex = useMemo( + () => + focusedStatusId === null + ? null + : statuses.findIndex((status) => status.id === focusedStatusId), + [focusedStatusId, statuses] + ) + const estimateLaneSize = useCallback(() => columnWidth, [columnWidth]) + const getLaneKey = useCallback((index: number) => statuses[index]?.id ?? index, [statuses]) + const rangeExtractor = useCallback( + (range: Range) => extractWorkspaceKanbanLaneRange(range, focusedIndex), + [focusedIndex] + ) + const laneVirtualizer = useVirtualizer({ + count: statuses.length, + getScrollElement: () => laneScrollerRef.current, + estimateSize: estimateLaneSize, + getItemKey: getLaneKey, + horizontal: true, + overscan: WORKSPACE_BOARD_LANE_OVERSCAN, + gap: WORKSPACE_BOARD_LANE_GAP, + rangeExtractor, + useFlushSync: false + }) + useLayoutEffect(() => { + laneVirtualizer.measure() + }, [columnWidth, laneVirtualizer]) + const virtualLanes = laneVirtualizer.getVirtualItems() + const virtualStatusIds = useMemo( + () => + virtualLanes.flatMap((virtualLane) => { + const status = statuses[virtualLane.index] + return status ? [status.id] : [] + }), + [statuses, virtualLanes] + ) + const mountedLaneIds = useMemo(() => new Set(virtualStatusIds), [virtualStatusIds]) + const mountedLaneIdsRef = useRef>(mountedLaneIds) + useLayoutEffect(() => { + mountedLaneIdsRef.current = mountedLaneIds + }, [mountedLaneIds]) + useEffect(() => { + if (!renderCards) { + setRenderedLaneIds(EMPTY_RENDERED_LANE_IDS) + return + } + const missingIds = virtualStatusIds.filter((id) => !renderedLaneIdsRef.current.has(id)) + setRenderedLaneIds((current) => { + const retained = new Set(Array.from(current).filter((id) => mountedLaneIds.has(id))) + return retained.size === current.size ? current : retained + }) + let nextIndex = 0 + let frameId = 0 + const renderNextLane = (): void => { + const statusId = missingIds[nextIndex] + nextIndex += 1 + if (!statusId) { + return + } + startTransition(() => { + setRenderedLaneIds((current) => { + if (!renderCardsRef.current || !mountedLaneIdsRef.current.has(statusId)) { + return current + } + return new Set(current).add(statusId) + }) + }) + if (nextIndex < missingIds.length) { + frameId = window.requestAnimationFrame(renderNextLane) + } + } + if (missingIds.length > 0) { + frameId = window.requestAnimationFrame(renderNextLane) + } + return () => window.cancelAnimationFrame(frameId) + }, [mountedLaneIds, renderCards, virtualStatusIds]) + return (
{ + const lane = (event.target as Element).closest('[data-workspace-status]') + setFocusedStatusId(lane?.dataset.workspaceStatus ?? null) + }} + onBlurCapture={(event) => { + const nextTarget = event.relatedTarget + if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) { + setFocusedStatusId(null) + } }} > - {statuses.map((status) => ( - - ))} + {virtualLanes.map((virtualLane) => { + const status = statuses[virtualLane.index] + if (!status) { + return null + } + return ( +
+ +
+ ) + })}
) } diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.test.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.test.tsx index 35e9f8a0c..bba46379d 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.test.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.test.tsx @@ -25,14 +25,22 @@ import WorkspaceKanbanSettingsMenu from './WorkspaceKanbanSettingsMenu' let root: Root | null = null let container: HTMLDivElement | null = null -function renderMenu(onSyncTaskStatusFromWorkspaceBoardChange = vi.fn()): void { +function renderMenu({ + workspaceStatuses = statuses, + onSyncTaskStatusFromWorkspaceBoardChange = vi.fn<(enabled: boolean) => void>(), + onAddStatus = vi.fn<() => void>() +}: { + workspaceStatuses?: WorkspaceStatusDefinition[] + onSyncTaskStatusFromWorkspaceBoardChange?: (enabled: boolean) => void + onAddStatus?: () => void +} = {}): void { container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) act(() => { root?.render( ) }) @@ -59,7 +67,7 @@ afterEach(() => { describe('WorkspaceKanbanSettingsMenu', () => { it('renders the task status sync switch and forwards changes', async () => { const onChange = vi.fn() - renderMenu(onChange) + renderMenu({ onSyncTaskStatusFromWorkspaceBoardChange: onChange }) const toggle = document.querySelector( 'button[role="switch"][aria-label="Sync board and issue status"]' @@ -74,4 +82,23 @@ describe('WorkspaceKanbanSettingsMenu', () => { expect(onChange).toHaveBeenCalledWith(true) }) + + it('keeps adding available for workflows above the former board limit', () => { + const onAddStatus = vi.fn() + renderMenu({ + workspaceStatuses: Array.from({ length: 21 }, (_, index) => ({ + id: `state-${index + 1}`, + label: `State ${index + 1}` + })), + onAddStatus + }) + + const addStatus = Array.from(document.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Add status' + ) + + expect(addStatus?.disabled).toBe(false) + addStatus?.click() + expect(onAddStatus).toHaveBeenCalledOnce() + }) }) diff --git a/src/renderer/src/components/sidebar/workspace-kanban-lane-range.ts b/src/renderer/src/components/sidebar/workspace-kanban-lane-range.ts new file mode 100644 index 000000000..84acef4a4 --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-kanban-lane-range.ts @@ -0,0 +1,17 @@ +import { defaultRangeExtractor, type Range } from '@tanstack/react-virtual' + +export function extractWorkspaceKanbanLaneRange( + range: Range, + focusedIndex: number | null +): number[] { + const indexes = defaultRangeExtractor(range) + if ( + focusedIndex === null || + focusedIndex < 0 || + focusedIndex >= range.count || + indexes.includes(focusedIndex) + ) { + return indexes + } + return [...indexes, focusedIndex].sort((left, right) => left - right) +} diff --git a/src/shared/workspace-statuses.test.ts b/src/shared/workspace-statuses.test.ts index 9d3e88134..bd12a4100 100644 --- a/src/shared/workspace-statuses.test.ts +++ b/src/shared/workspace-statuses.test.ts @@ -10,6 +10,29 @@ import { } from './workspace-statuses' describe('workspace status visuals', () => { + it.each([13, 20, 21, 64])('keeps all %i authored columns in order', (count) => { + const authored = Array.from({ length: count }, (_, index) => ({ + id: `state-${index + 1}`, + label: `State ${index + 1}` + })) + + const statuses = normalizeWorkspaceStatuses(authored) + + expect(statuses).toHaveLength(count) + expect(statuses.map((status) => status.id)).toEqual(authored.map((status) => status.id)) + }) + + it('normalizes every valid status without truncating the workflow', () => { + const authored = Array.from({ length: 500 }, (_, index) => ({ + id: `state-${index}`, + label: `State ${index}` + })) + + expect(normalizeWorkspaceStatuses(authored).map((status) => status.id)).toEqual( + authored.map((status) => status.id) + ) + }) + it('keeps the default workflow order', () => { expect(cloneDefaultWorkspaceStatuses().map((status) => status.id)).toEqual([ 'todo', diff --git a/src/shared/workspace-statuses.ts b/src/shared/workspace-statuses.ts index b3aa58704..430595733 100644 --- a/src/shared/workspace-statuses.ts +++ b/src/shared/workspace-statuses.ts @@ -9,7 +9,6 @@ export { DEFAULT_WORKSPACE_STATUSES } from './workspace-status-defaults' const WORKSPACE_STATUS_GROUP_PREFIX = 'workspace-status:' const MAX_STATUS_LABEL_LENGTH = 32 -const MAX_WORKSPACE_STATUSES = 12 type WorkspaceStatusNormalizationOptions = { migrateDefaultWorkflowStatuses?: boolean migrateLegacyDefaultStatusVisuals?: boolean @@ -169,7 +168,7 @@ function normalizeWorkspaceStatusesInternal( const statuses: WorkspaceStatusDefinition[] = [] const usedIds = new Set() - for (const rawStatus of value.slice(0, MAX_WORKSPACE_STATUSES)) { + for (const rawStatus of value) { if (!rawStatus || typeof rawStatus !== 'object' || Array.isArray(rawStatus)) { continue } diff --git a/tests/e2e/workspace-board-lane-virtualization.spec.ts b/tests/e2e/workspace-board-lane-virtualization.spec.ts index 850a83024..58db14bcd 100644 --- a/tests/e2e/workspace-board-lane-virtualization.spec.ts +++ b/tests/e2e/workspace-board-lane-virtualization.spec.ts @@ -3,6 +3,8 @@ import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' const SEEDED_WORKSPACE_COUNT = 300 const MARQUEE_WORKSPACE_COUNT = 102 +const MANY_LANE_COUNT = 21 +const CARDS_PER_LANE = 100 /** * Why: the board used to mount every workspace card in every lane in one @@ -151,6 +153,159 @@ test.describe('Workspace board lane virtualization', () => { await expect.poll(readMaxIndex, { timeout: 15_000 }).toBeGreaterThan(before) }) + test('bounds mounted lanes and cards while preserving a 21-status workflow', async ({ + orcaPage + }) => { + const statusIds = Array.from( + { length: MANY_LANE_COUNT }, + (_, index) => `state-${String(index + 1).padStart(2, '0')}` + ) + await orcaPage.evaluate( + ({ cardsPerLane, ids }) => { + 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 synthetic = ids.flatMap((status, statusIndex) => + Array.from({ length: cardsPerLane }, (_, cardIndex) => { + const suffix = `${String(statusIndex + 1).padStart(2, '0')}-${String( + cardIndex + 1 + ).padStart(3, '0')}` + return { + id: `${repo.id}::/virtual-lane-${suffix}`, + instanceId: `virtual-lane-${suffix}`, + repoId: repo.id, + path: `${repo.path}/../virtual-lane-${suffix}`, + displayName: `Virtual lane ${suffix}`, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 20_000 - statusIndex * cardsPerLane - cardIndex, + manualOrder: 20_000 - statusIndex * cardsPerLane - cardIndex, + lastActivityAt: now - statusIndex * cardsPerLane - cardIndex, + head: '0000000000000000000000000000000000000000', + branch: `virtual-lane-${suffix}`, + isBare: false, + isMainWorktree: false, + workspaceStatus: status + } + }) + ) + + state.setSidebarOpen(true) + state.setShowSleepingWorkspaces(true) + state.setHideDefaultBranchWorkspace(false) + state.setFilterRepoIds([]) + state.setWorkspaceBoardColumnWidth(308) + state.setWorkspaceStatuses( + ids.map((id, index) => ({ + id, + label: `State ${index + 1}` + })) + ) + store.setState({ + sortBy: 'manual', + worktreesByRepo: { ...state.worktreesByRepo, [repo.id]: synthetic } + }) + }, + { cardsPerLane: CARDS_PER_LANE, ids: statusIds } + ) + + await orcaPage.getByRole('button', { name: 'Workspace board' }).click() + + const board = orcaPage.locator('[data-workspace-board-selection-surface]') + const scroller = board.locator('[data-workspace-board-lane-grid]').locator('..') + const lanes = board.locator('[data-workspace-status]') + const cards = board.locator('[data-workspace-board-card-id]') + await expect.poll(() => cards.count(), { timeout: 15_000 }).toBeGreaterThan(3) + + const laneBudget = await scroller.evaluate( + (element) => Math.ceil(element.clientWidth / 320) + 3 + ) + const initialLaneCount = await lanes.count() + expect(initialLaneCount).toBeLessThanOrEqual(laneBudget) + expect(await cards.count()).toBeLessThan(initialLaneCount * 40) + expect(await board.locator('*').count()).toBeLessThan(initialLaneCount * 550 + 200) + await expect(board.locator('[data-workspace-status="state-01"]')).toBeVisible() + expect(await board.locator('[data-workspace-status="state-21"]').count()).toBe(0) + + await scroller.evaluate((element) => { + element.scrollLeft = element.scrollWidth + element.dispatchEvent(new Event('scroll', { bubbles: true })) + }) + + await expect(board.locator('[data-workspace-status="state-21"]')).toBeVisible() + await expect.poll(() => board.locator('[data-workspace-status="state-01"]').count()).toBe(0) + const finalIds = await lanes.evaluateAll((elements) => + elements.map((element) => (element as HTMLElement).dataset.workspaceStatus ?? '') + ) + expect(finalIds).toEqual([...finalIds].sort()) + expect(finalIds).toContain('state-21') + expect(await lanes.count()).toBeLessThanOrEqual(laneBudget) + expect(await cards.count()).toBeLessThan((await lanes.count()) * 40) + expect( + await orcaPage.evaluate(() => + window.__store?.getState().workspaceStatuses.map((status) => status.id) + ) + ).toEqual(statusIds) + + const finalLane = board.locator('[data-workspace-status="state-21"]') + const resizeHandle = finalLane.getByRole('separator', { + name: 'Resize workspace board columns' + }) + await resizeHandle.focus() + await resizeHandle.press('ArrowRight') + await expect + .poll(() => orcaPage.evaluate(() => window.__store?.getState().workspaceBoardColumnWidth)) + .toBe(328) + await expect(resizeHandle).toHaveAttribute('aria-valuenow', '328') + await scroller.evaluate((element) => { + element.scrollLeft = element.scrollWidth + element.dispatchEvent(new Event('scroll', { bubbles: true })) + }) + await expect(finalLane).toBeVisible() + + const sourceCard = board + .locator('[data-workspace-status="state-20"] [data-workspace-board-card-id]') + .first() + const sourceId = await sourceCard.getAttribute('data-workspace-board-card-id') + const sourceBox = await sourceCard.boundingBox() + const targetBox = await finalLane + .locator('[data-workspace-board-lane-scroll]') + .first() + .boundingBox() + if (!sourceId || !sourceBox || !targetBox) { + throw new Error('Expected visible source card and final lane drop target') + } + await orcaPage.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2) + await orcaPage.mouse.down() + await orcaPage.mouse.move( + targetBox.x + targetBox.width / 2, + targetBox.y + Math.min(80, targetBox.height / 2), + { steps: 8 } + ) + await orcaPage.mouse.up() + await expect + .poll(() => + orcaPage.evaluate( + (worktreeId) => + window.__store?.getState().getKnownWorktreeById(worktreeId)?.workspaceStatus, + sourceId + ) + ) + .toBe('state-21') + }) + test('selects the full lane across a single large marquee scroll jump', async ({ orcaPage }) => { const statusId = 'virtual-marquee' await orcaPage.evaluate(