fix(board): stop truncating workflows longer than 12 columns (#11605)

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
Eugenio Jesus Jose Valeiras 2026-07-31 03:58:42 -03:00 committed by GitHub
parent 94cf2f1422
commit ef9e6ab9a8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 657 additions and 40 deletions

View File

@ -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,

View File

@ -899,6 +899,7 @@ export default function WorkspaceKanbanDrawer({
className="min-h-0 flex-1 overflow-x-auto overflow-y-hidden scrollbar-sleek"
>
<WorkspaceKanbanLaneGrid
laneScrollerRef={laneScrollerRef}
statuses={workspaceStatuses}
laneViews={laneViews}
laneFullWorktreeIds={laneFullWorktreeIds}

View File

@ -0,0 +1,229 @@
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import React, { createRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Repo, WorkspaceStatusDefinition, Worktree } from '../../../../shared/types'
const virtualWindow = vi.hoisted(() => ({ startIndex: 0, visibleCount: 4 }))
const animationFrames = new Map<number, FrameRequestCallback>()
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
}) => (
<section
data-workspace-status={status.id}
data-item-count={items.length}
data-render-cards={renderCards ? 'true' : 'false'}
>
<button type="button">{status.label}</button>
</section>
)
}))
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<string, Repo>()
function makeGrid(): React.JSX.Element {
return (
<WorkspaceKanbanLaneGrid
laneScrollerRef={createRef()}
statuses={STATUSES}
laneViews={new Map()}
laneFullWorktreeIds={new Map()}
hasQuery={false}
repoMap={REPO_MAP}
activeWorktreeId={null}
columnWidth={308}
isResizingColumn={false}
dragOverStatus={null}
canCreateWorktree={true}
renderCards={true}
selectedWorktreeIds={new Set()}
selectedWorktrees={[]}
onDragOver={() => {}}
onDragLeave={() => {}}
onDrop={() => {}}
onActivate={() => {}}
onSelectionGesture={() => false}
onContextMenuSelect={() => []}
onCreateWorktree={() => {}}
onColumnResizeStart={() => {}}
onColumnResizeKeyDown={() => {}}
/>
)
}
function renderGrid(): ReturnType<typeof render> {
return render(makeGrid())
}
function mountedStatusIds(container: HTMLElement): string[] {
return Array.from(container.querySelectorAll<HTMLElement>('[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<HTMLElement>('[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)
})
})

View File

@ -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<WorkspaceStatus> = new Set()
const WORKSPACE_BOARD_LANE_GAP = 12
const WORKSPACE_BOARD_LANE_OVERSCAN = 1
type WorkspaceKanbanLaneGridProps = {
laneScrollerRef: React.RefObject<HTMLDivElement | null>
statuses: readonly WorkspaceStatusDefinition[]
laneViews: ReadonlyMap<WorkspaceStatus, WorkspaceKanbanLaneView>
laneFullWorktreeIds: ReadonlyMap<WorkspaceStatus, readonly string[]>
@ -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<WorkspaceStatus | null>(null)
const [renderedLaneIds, setRenderedLaneIds] =
useState<ReadonlySet<WorkspaceStatus>>(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<ReadonlySet<WorkspaceStatus>>(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 (
<div
className="grid h-full min-h-0 min-w-full grid-rows-[minmax(0,1fr)] gap-3"
className="relative h-full min-h-0 min-w-full"
data-contextual-tour-target="workspace-board-lanes"
style={{
gridTemplateColumns: `repeat(${statuses.length}, minmax(${columnWidth}px, ${columnWidth}px))`
data-workspace-board-lane-grid=""
style={{ width: `${laneVirtualizer.getTotalSize()}px` }}
onFocusCapture={(event) => {
const lane = (event.target as Element).closest<HTMLElement>('[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) => (
<WorkspaceKanbanStatusLane
key={status.id}
status={status}
items={laneViews.get(status.id)?.items ?? EMPTY_LANE_ITEMS}
totalCount={laneViews.get(status.id)?.totalCount ?? 0}
hasQuery={hasQuery}
fullWorktreeIds={laneFullWorktreeIds.get(status.id) ?? []}
repoMap={repoMap}
activeWorktreeId={activeWorktreeId}
columnWidth={columnWidth}
isResizingColumn={isResizingColumn}
isDragTarget={dragOverStatus === status.id}
canCreateWorktree={canCreateWorktree}
renderCards={renderCards}
selectedWorktreeIds={selectedWorktreeIds}
selectedWorktrees={selectedWorktrees}
nativeDragEnabled={false}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
onActivate={onActivate}
onSelectionGesture={onSelectionGesture}
onContextMenuSelect={onContextMenuSelect}
onAssignWorkspaceStatus={onAssignWorkspaceStatus}
onCreateWorktree={onCreateWorktree}
onColumnResizeStart={onColumnResizeStart}
onColumnResizeKeyDown={onColumnResizeKeyDown}
/>
))}
{virtualLanes.map((virtualLane) => {
const status = statuses[virtualLane.index]
if (!status) {
return null
}
return (
<div
key={virtualLane.key}
ref={laneVirtualizer.measureElement}
data-index={virtualLane.index}
className="absolute left-0 top-0 h-full"
style={{
width: `${columnWidth}px`,
transform: `translateX(${virtualLane.start}px)`
}}
>
<WorkspaceKanbanStatusLane
status={status}
items={laneViews.get(status.id)?.items ?? EMPTY_LANE_ITEMS}
totalCount={laneViews.get(status.id)?.totalCount ?? 0}
hasQuery={hasQuery}
fullWorktreeIds={laneFullWorktreeIds.get(status.id) ?? []}
repoMap={repoMap}
activeWorktreeId={activeWorktreeId}
columnWidth={columnWidth}
isResizingColumn={isResizingColumn}
isDragTarget={dragOverStatus === status.id}
canCreateWorktree={canCreateWorktree}
renderCards={renderCards && renderedLaneIds.has(status.id)}
selectedWorktreeIds={selectedWorktreeIds}
selectedWorktrees={selectedWorktrees}
nativeDragEnabled={false}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
onActivate={onActivate}
onSelectionGesture={onSelectionGesture}
onContextMenuSelect={onContextMenuSelect}
onAssignWorkspaceStatus={onAssignWorkspaceStatus}
onCreateWorktree={onCreateWorktree}
onColumnResizeStart={onColumnResizeStart}
onColumnResizeKeyDown={onColumnResizeKeyDown}
/>
</div>
)
})}
</div>
)
}

View File

@ -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(
<WorkspaceKanbanSettingsMenu
workspaceStatuses={statuses}
workspaceStatuses={workspaceStatuses}
syncTaskStatusFromWorkspaceBoard={false}
onSyncTaskStatusFromWorkspaceBoardChange={onSyncTaskStatusFromWorkspaceBoardChange}
onRenameStatus={vi.fn()}
@ -40,7 +48,7 @@ function renderMenu(onSyncTaskStatusFromWorkspaceBoardChange = vi.fn()): void {
onChangeStatusIcon={vi.fn()}
onMoveStatus={vi.fn()}
onRemoveStatus={vi.fn()}
onAddStatus={vi.fn()}
onAddStatus={onAddStatus}
/>
)
})
@ -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<HTMLButtonElement>(
'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()
})
})

View File

@ -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)
}

View File

@ -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',

View File

@ -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<string>()
for (const rawStatus of value.slice(0, MAX_WORKSPACE_STATUSES)) {
for (const rawStatus of value) {
if (!rawStatus || typeof rawStatus !== 'object' || Array.isArray(rawStatus)) {
continue
}

View File

@ -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(