Virtualize automations run history table for large histories (#20916)

* refactor(automations): virtualize run history table

- Add virtual scrolling to AutomationRunHistory for efficient rendering of large run lists
- Implement sticky table header that stays visible during scroll
- Update keyboard navigation and focus management for virtualized rows
- Move AutomationRunsTable header inside scroll container for visual consistency
- Add virtualizer-test-stub for testing virtual scroll behavior without DOM measurement
- Cache DateTimeFormat to avoid per-cell allocation overhead

* test(automations): add coverage for virtualized run table

- Tests verify row content renders spend, tokens, and workspace labels correctly
- Keyboard navigation guards prevent operations during failed host reads
- Load-more pagination triggers at scroll end and respects page boundaries
- New fixtures support flexible automation run and usage test scenarios

* test(automations): verify scroll-to-focus path in virtualized runs

- Implement scrollToIndex in virtualizer stub to move viewport window
- Optimize row-size estimation to use predicate instead of labels
- Test validates keyboard navigation scrolls rows into view before focus

* add more tests
This commit is contained in:
Jinjing
2026-09-15 22:21:38 -07:00
committed by GitHub
parent b0d46e2d3d
commit feb04ec254
9 changed files with 951 additions and 192 deletions
@@ -13,7 +13,13 @@ import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AutomationRun } from '../../../../shared/automations-types'
import { AutomationRunHistory } from './AutomationRunHistory'
import { makeRun } from './automations-page-fixtures'
import { WORKSPACE_ID, makeRun, makeRunUsage, makeWorktree } from './automations-page-fixtures'
import { VIRTUALIZER_STUB_WINDOW_SIZE } from './virtualizer-test-stub'
vi.mock('@tanstack/react-virtual', async () => {
const { createVirtualizerStub } = await import('./virtualizer-test-stub')
return { useVirtualizer: createVirtualizerStub() }
})
const roots: Root[] = []
@@ -132,6 +138,81 @@ describe('AutomationRunHistory unanswered history', () => {
})
})
describe('AutomationRunHistory virtualization', () => {
async function renderRuns(runs: AutomationRun[]): Promise<HTMLDivElement> {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
roots.push(root)
await act(async () => {
root.render(
<AutomationRunHistory
runs={runs}
automationId="a-1"
worktreeMap={new Map()}
onOpenRun={vi.fn()}
/>
)
})
return container
}
async function pressArrow(key: 'ArrowDown' | 'ArrowUp', times: number): Promise<void> {
for (let move = 0; move < times; move += 1) {
await act(async () => {
window.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
})
}
}
function makeRuns(count: number): AutomationRun[] {
return Array.from({ length: count }, (_, index) =>
makeRun({ id: `run-${index}`, scheduledFor: FIRST + index })
)
}
it('keeps a long history to a bounded number of mounted rows', async () => {
const container = await renderRuns(makeRuns(5_000))
expect(container.querySelectorAll('button[data-automation-run-id]').length).toBeLessThan(50)
// The count above the table still speaks for the whole history, not the window.
expect(container.textContent).toContain('5000 runs')
})
it('scrolls a selected row below the fold into the window and then focuses it', async () => {
const container = await renderRuns(makeRuns(VIRTUALIZER_STUB_WINDOW_SIZE * 2))
const belowFold = `run-${VIRTUALIZER_STUB_WINDOW_SIZE}`
expect(container.querySelector(`[data-automation-run-id="${belowFold}"]`)).toBeNull()
// Selection starts on the first row, so this many moves lands one row past the
// window — the case where focus has to wait for the scroll to mount the row.
await pressArrow('ArrowDown', VIRTUALIZER_STUB_WINDOW_SIZE)
const selected = container.querySelector<HTMLButtonElement>(
`[data-automation-run-id="${belowFold}"]`
)
expect(selected?.getAttribute('data-current')).toBe('true')
expect(document.activeElement).toBe(selected)
// The window moved rather than grew: the row it scrolled past is unmounted.
expect(container.querySelector('[data-automation-run-id="run-0"]')).toBeNull()
})
it('scrolls a selected row above the fold back into the window and then focuses it', async () => {
const container = await renderRuns(makeRuns(VIRTUALIZER_STUB_WINDOW_SIZE * 2))
await pressArrow('ArrowDown', VIRTUALIZER_STUB_WINDOW_SIZE)
expect(container.querySelector('[data-automation-run-id="run-0"]')).toBeNull()
// Back to the top: the window now has to move the other way before focus can land.
await pressArrow('ArrowUp', VIRTUALIZER_STUB_WINDOW_SIZE)
const selected = container.querySelector<HTMLButtonElement>('[data-automation-run-id="run-0"]')
expect(selected?.getAttribute('data-current')).toBe('true')
expect(document.activeElement).toBe(selected)
})
})
describe('AutomationRunHistory keyboard navigation', () => {
it('navigates runs with ArrowDown and ArrowUp and opens on Enter', async () => {
const onOpenRun = vi.fn()
@@ -237,3 +318,200 @@ describe('AutomationRunHistory keyboard navigation', () => {
expect(onOpenRun).toHaveBeenCalledWith(run2)
})
})
describe('AutomationRunHistory row content', () => {
let container: HTMLDivElement
let root: Root
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
roots.push(root)
})
function renderHistory(props: {
runs: AutomationRun[]
automationId?: string
worktreeMap?: ReadonlyMap<string, ReturnType<typeof makeWorktree>>
onOpenRun?: (run: AutomationRun) => void
}): void {
act(() => {
root.render(
<AutomationRunHistory
runs={props.runs}
automationId={props.automationId ?? 'a-1'}
worktreeMap={props.worktreeMap ?? new Map()}
onOpenRun={props.onOpenRun ?? vi.fn()}
/>
)
})
}
function rows(): NodeListOf<HTMLButtonElement> {
return container.querySelectorAll<HTMLButtonElement>('button[data-automation-run-id]')
}
it('reports spend and tokens a host actually measured', () => {
renderHistory({
runs: [
makeRun({
usage: makeRunUsage({ estimatedCostUsd: 1.5, totalTokens: 12_345 })
})
]
})
expect(rows()[0].textContent).toContain('$1.50')
expect(rows()[0].textContent).toContain('12k')
})
it('says n/a rather than zero when usage is unavailable', () => {
renderHistory({ runs: [makeRun({ usage: null })] })
// A run whose usage nobody could read has not been measured at $0.00.
expect(rows()[0].textContent).toContain('n/a')
expect(rows()[0].textContent).not.toContain('$0.00')
})
it('names the workspace a run is still attached to', () => {
renderHistory({
runs: [makeRun({ workspaceId: WORKSPACE_ID })],
worktreeMap: new Map([[WORKSPACE_ID, makeWorktree({ displayName: 'nightly-check' })]])
})
expect(rows()[0].textContent).toContain('nightly-check')
})
it('keeps the remembered name of a workspace that is gone, and says it is gone', () => {
renderHistory({
runs: [makeRun({ workspaceId: WORKSPACE_ID, workspaceDisplayName: 'nightly-check' })],
worktreeMap: new Map()
})
expect(rows()[0].textContent).toContain('nightly-check')
expect(rows()[0].textContent).toContain('no longer available')
})
it('counts the whole history but only the completed runs as completed', () => {
renderHistory({
runs: [
makeRun({ id: 'run-1', status: 'completed' }),
makeRun({ id: 'run-2', status: 'dispatch_failed' }),
makeRun({ id: 'run-3', status: 'completed' })
]
})
expect(container.textContent).toContain('3 runs · 2 completed')
})
it('says "1 run" rather than "1 runs"', () => {
renderHistory({ runs: [makeRun()] })
expect(container.textContent).toContain('1 run · 1 completed')
})
it('opens and selects the clicked run', () => {
const onOpenRun = vi.fn()
const second = makeRun({ id: 'run-2' })
renderHistory({ runs: [makeRun({ id: 'run-1' }), second], onOpenRun })
act(() => rows()[1].click())
expect(onOpenRun).toHaveBeenCalledExactlyOnceWith(second)
expect(rows()[1].getAttribute('data-current')).toBe('true')
expect(rows()[0].getAttribute('data-current')).toBe('false')
})
it('drops a selection that belonged to the automation before this one', () => {
const runs = [makeRun({ id: 'run-1' }), makeRun({ id: 'run-2' })]
renderHistory({ runs, automationId: 'a-1' })
act(() => rows()[1].click())
expect(rows()[1].getAttribute('data-current')).toBe('true')
// Same row IDs, different automation: carrying the old selection over would
// highlight a row the user never picked.
renderHistory({ runs, automationId: 'a-2' })
expect(rows()[0].getAttribute('data-current')).toBe('true')
expect(rows()[1].getAttribute('data-current')).toBe('false')
})
})
describe('AutomationRunHistory keyboard navigation guards', () => {
let container: HTMLDivElement
let root: Root
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
roots.push(root)
})
async function pressEnter(): Promise<void> {
await act(async () => {
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
)
})
}
it('opens nothing while rows are on screen under an unanswered read', async () => {
const onOpenRun = vi.fn()
await act(async () => {
root.render(
<AutomationRunHistory
runs={[makeRun({ id: 'run-1' })]}
automationId="a-1"
worktreeMap={new Map()}
notice={{
message: 'web-01 is not connected',
recovery: 'reconnect',
severity: 'failure'
}}
onOpenRun={onOpenRun}
/>
)
})
await pressEnter()
// The notice says these rows are not the host's answer, so Enter must not act
// on them however many of them are still painted.
expect(onOpenRun).not.toHaveBeenCalled()
})
it('follows the runs it was last given, not the ones it mounted with', async () => {
const onOpenRun = vi.fn()
const replacement = makeRun({ id: 'run-9', scheduledFor: LATEST })
await act(async () => {
root.render(
<AutomationRunHistory
runs={[makeRun({ id: 'run-1' })]}
automationId="a-1"
worktreeMap={new Map()}
onOpenRun={onOpenRun}
/>
)
})
// The listener subscribes once and reads the current runs through a ref; a
// refreshed history has to reach it without a resubscribe.
await act(async () => {
root.render(
<AutomationRunHistory
runs={[replacement]}
automationId="a-1"
worktreeMap={new Map()}
onOpenRun={onOpenRun}
/>
)
})
await pressEnter()
expect(onOpenRun).toHaveBeenCalledExactlyOnceWith(replacement)
})
})
@@ -1,4 +1,5 @@
import React, { useMemo, useState } from 'react'
import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import type { AutomationRun } from '../../../../shared/automations-types'
@@ -13,7 +14,7 @@ import {
formatAutomationTokens,
getAutomationUsageStatusLabel
} from './automation-usage-model'
import { automationRunOccurrenceLabel } from './automation-run-occurrences'
import { automationRunOccurrenceLabel, isAutomationRunFolded } from './automation-run-occurrences'
import { getAutomationRunWorkspaceDisplay } from './automation-run-workspace-display'
import { AutomationOwnerConflictNotice } from './AutomationOwnerConflictNotice'
import type { AutomationActionNotice } from './automation-row-action-dispatch'
@@ -25,6 +26,22 @@ import {
} from './automation-run-history-keyboard-navigation'
import { translate } from '@/i18n/i18n'
// Date line + workspace detail line inside the row padding; the occurrence line
// is the only optional one, so the estimate can be exact without measuring.
const RUN_ROW_HEIGHT_PX = 57
const RUN_ROW_OCCURRENCE_LINE_PX = 20
const RUN_ROW_OVERSCAN = 10
// happy-dom and the first paint both report a zero-height scroll element; without
// a starting viewport the first render would mount no rows at all.
const RUNS_VIEWPORT_INITIAL_RECT = { width: 1024, height: 600 }
const RUN_ROW_GRID_CLASS =
'grid w-full grid-cols-[minmax(9rem,1fr)_minmax(10rem,1.1fr)_minmax(5rem,.55fr)_minmax(5rem,.55fr)_minmax(6rem,auto)] gap-3'
// Sticky inside the scroller so the header shares the rows' content width when a
// classic scrollbar takes gutter space; opaque so scrolled rows don't bleed through.
const RUN_ROW_HEADER_SURFACE_CLASS =
'[background:color-mix(in_srgb,var(--muted)_20%,var(--background))]'
type AutomationRunHistoryProps = {
runs: AutomationRun[]
automationId: string
@@ -44,6 +61,13 @@ export function AutomationRunHistory({
onOpenRun
}: AutomationRunHistoryProps): React.JSX.Element {
const containerRef = React.useRef<HTMLDivElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const headerRef = useRef<HTMLDivElement>(null)
const rowsRef = useRef<HTMLDivElement>(null)
// The sticky header sits above the virtual rows in the same scroller, so every
// item is offset by the header height; without scrollMargin the virtualizer's
// coordinates (and scrollToIndex) are short by that offset.
const [scrollMargin, setScrollMargin] = useState(0)
const [selectedRunState, setSelectedRunState] = useState<{
automationId: string
runId: string | null
@@ -58,7 +82,67 @@ export function AutomationRunHistory({
const selectedRunId =
selectedRunState.automationId === automationId ? selectedRunState.runId : null
const selectedRun = runs.find((run) => run.id === selectedRunId) ?? runs[0] ?? null
const selectedIndex = selectedRunId ? runs.findIndex((run) => run.id === selectedRunId) : -1
const selectedRun = (selectedIndex >= 0 ? runs[selectedIndex] : undefined) ?? runs[0] ?? null
// Both options must be stable across renders: virtual-core memoizes its
// measurements on measuringOptions, which closes over getItemKey, and an inline
// estimateSize re-walks every uncached index (up to the whole history) per render.
const estimateRunRowSize = useCallback(
(index: number): number => {
const run = runs[index]
// The predicate, not the label: estimateSize is asked for unmounted indexes too,
// and building the label there would translate and format a date per run.
return run && isAutomationRunFolded(run)
? RUN_ROW_HEIGHT_PX + RUN_ROW_OCCURRENCE_LINE_PX
: RUN_ROW_HEIGHT_PX
},
[runs]
)
const getRunRowKey = useCallback(
(index: number): string | number => runs[index]?.id ?? index,
[runs]
)
const virtualizer = useVirtualizer({
count: runs.length,
getScrollElement: () => scrollRef.current,
estimateSize: estimateRunRowSize,
overscan: RUN_ROW_OVERSCAN,
initialRect: RUNS_VIEWPORT_INITIAL_RECT,
getItemKey: getRunRowKey,
scrollMargin,
// The sticky header covers the top of the scrollport, so a row aligned to the
// top must land below it; scrollPaddingStart is that viewport inset.
scrollPaddingStart: scrollMargin
})
// Measure the rows container's offset inside the scroller (its top equals the
// header height) and keep it current across zoom/font changes.
useLayoutEffect(() => {
const rows = rowsRef.current
const scrollElement = scrollRef.current
if (!rows || !scrollElement) {
return
}
const measure = (): void => {
const next = Math.round(
rows.getBoundingClientRect().top -
scrollElement.getBoundingClientRect().top +
scrollElement.scrollTop
)
setScrollMargin((current) => (current === next ? current : next))
}
measure()
if (typeof ResizeObserver === 'undefined' || !headerRef.current) {
return
}
// Only the header can shift the rows container's offset; observing the rows
// container too would fire on every row mount for no offset change.
const observer = new ResizeObserver(measure)
observer.observe(headerRef.current)
return () => observer.disconnect()
}, [])
const findRunRow = React.useCallback(
(runId: string): HTMLElement | null =>
@@ -67,162 +151,222 @@ export function AutomationRunHistory({
[]
)
// The window listener reads the latest runs and selection through this ref so it
// subscribes once, instead of on every render the page above it causes.
const keyboardInputRef = useRef({ runs, selectedRun, automationId, notice, onOpenRun })
React.useEffect(() => {
if (runs.length === 0 || notice) {
return
}
keyboardInputRef.current = { runs, selectedRun, automationId, notice, onOpenRun }
})
const pendingFocusRunIdRef = useRef<string | null>(null)
// A refresh can drop the row a keyboard move was waiting to focus; without this
// the stale id would steal focus if that run ever reappeared.
React.useEffect(() => {
const pendingRunId = pendingFocusRunIdRef.current
if (pendingRunId && !runs.some((run) => run.id === pendingRunId)) {
pendingFocusRunIdRef.current = null
}
}, [runs])
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent): void => {
if (!shouldHandleAutomationRunHistoryKey(event)) {
const input = keyboardInputRef.current
if (input.runs.length === 0 || input.notice || !shouldHandleAutomationRunHistoryKey(event)) {
return
}
if (event.key === 'Enter') {
if (selectedRun) {
if (input.selectedRun) {
event.preventDefault()
onOpenRun(selectedRun)
input.onOpenRun(input.selectedRun)
}
return
}
if (isAutomationRunHistoryArrowKey(event.key)) {
const targetRun = getAutomationRunHistoryArrowTarget({
runs,
selectedRunId: selectedRun?.id ?? null,
runs: input.runs,
selectedRunId: input.selectedRun?.id ?? null,
key: event.key
})
if (targetRun) {
event.preventDefault()
setSelectedRunState({ automationId, runId: targetRun.id })
// Enter is left to the focused control, so focus has to follow the selection.
findRunRow(targetRun.id)?.focus?.({ preventScroll: true })
setSelectedRunState({ automationId: input.automationId, runId: targetRun.id })
// Enter is left to the focused control, so focus has to follow the selection
// but the target row may still be outside the virtual window, so focus waits
// for the scroll below to mount it.
pendingFocusRunIdRef.current = targetRun.id
}
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [automationId, findRunRow, notice, onOpenRun, runs, selectedRun])
}, [])
React.useEffect(() => {
if (!selectedRunId) {
if (selectedIndex >= 0) {
virtualizer.scrollToIndex(selectedIndex, { align: 'auto' })
}
}, [selectedIndex, virtualizer])
// Unconditional: the row a keyboard move selected can take an extra scroll-driven
// render to mount, and only then can it take focus.
React.useEffect(() => {
const pendingRunId = pendingFocusRunIdRef.current
if (!pendingRunId) {
return
}
const element = findRunRow(selectedRunId)
if (element && typeof element.scrollIntoView === 'function') {
element.scrollIntoView({ block: 'nearest' })
const element = findRunRow(pendingRunId)
if (element) {
pendingFocusRunIdRef.current = null
element.focus?.({ preventScroll: true })
}
}, [findRunRow, selectedRunId])
})
return (
<div ref={containerRef} className="rounded-md border border-border/50 bg-muted/20 shadow-sm">
<div className="flex items-center justify-between border-b border-border/50 px-3 py-2">
<div
ref={containerRef}
className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-border/50 bg-muted/20 shadow-sm"
>
<div className="flex shrink-0 items-center justify-between border-b border-border/50 px-3 py-2">
<div className="text-sm font-medium">
{translate('auto.components.automations.AutomationRunHistory.53fc5f07ab', 'Run history')}
</div>
{/* A failed read knows no counts; "0 runs" would answer a question nobody asked the host. */}
{notice ? null : <div className="text-xs text-muted-foreground">{runCountLabel}</div>}
</div>
<div className="min-h-[18rem] min-w-0">
<div className="grid grid-cols-[minmax(9rem,1fr)_minmax(10rem,1.1fr)_minmax(5rem,.55fr)_minmax(5rem,.55fr)_minmax(6rem,auto)] gap-3 border-b border-border/50 px-3 py-1.5 text-[11px] font-medium uppercase text-muted-foreground">
<div>
{translate('auto.components.automations.AutomationRunHistory.8faaa00726', 'Run')}
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<div ref={scrollRef} className="scrollbar-sleek min-h-0 flex-1 overflow-auto">
<div
ref={headerRef}
className={cn(
RUN_ROW_GRID_CLASS,
RUN_ROW_HEADER_SURFACE_CLASS,
'sticky top-0 z-10 border-b border-border/50 px-3 py-1.5 text-[11px] font-medium uppercase text-muted-foreground'
)}
>
<div>
{translate('auto.components.automations.AutomationRunHistory.8faaa00726', 'Run')}
</div>
<div>
{translate(
'auto.components.automations.AutomationRunHistory.149c0b49c7',
'Workspace'
)}
</div>
<div>
{translate('auto.components.automations.AutomationRunHistory.86a248187e', 'Spend')}
</div>
<div>
{translate('auto.components.automations.AutomationRunHistory.13988187b3', 'Tokens')}
</div>
<div>
{translate('auto.components.automations.AutomationRunHistory.9974a2b429', 'Status')}
</div>
</div>
<div>
{translate('auto.components.automations.AutomationRunHistory.149c0b49c7', 'Workspace')}
</div>
<div>
{translate('auto.components.automations.AutomationRunHistory.86a248187e', 'Spend')}
</div>
<div>
{translate('auto.components.automations.AutomationRunHistory.13988187b3', 'Tokens')}
</div>
<div>
{translate('auto.components.automations.AutomationRunHistory.9974a2b429', 'Status')}
</div>
</div>
<div className="divide-y divide-border/50">
{runs.map((run) => {
const runWorktree = run.workspaceId ? (worktreeMap.get(run.workspaceId) ?? null) : null
const workspaceLabel = getAutomationRunWorkspaceDisplay({
run,
worktree: runWorktree
})
const usageLabel = getAutomationUsageStatusLabel(run.usage)
const occurrenceLabel = automationRunOccurrenceLabel(run)
return (
<button
key={run.id}
type="button"
data-automation-run-id={run.id}
data-current={selectedRun?.id === run.id}
className={cn(
'grid w-full grid-cols-[minmax(9rem,1fr)_minmax(10rem,1.1fr)_minmax(5rem,.55fr)_minmax(5rem,.55fr)_minmax(6rem,auto)] items-center gap-3 px-3 py-2 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
selectedRun?.id === run.id && 'bg-accent text-accent-foreground'
)}
onClick={() => {
setSelectedRunState({ automationId, runId: run.id })
onOpenRun(run)
}}
>
<div className="min-w-0">
<div>{formatAutomationDateTime(run.scheduledFor)}</div>
{/* The row's own date is the first occurrence; only this line says it recurred. */}
{occurrenceLabel ? (
<div
data-testid="automation-run-occurrences"
className="mt-1 truncate text-xs text-foreground"
>
{occurrenceLabel}
<div
ref={rowsRef}
className="relative w-full"
style={{ height: virtualizer.getTotalSize() }}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const run = runs[virtualRow.index]
if (!run) {
return null
}
const runWorktree = run.workspaceId
? (worktreeMap.get(run.workspaceId) ?? null)
: null
const workspaceLabel = getAutomationRunWorkspaceDisplay({
run,
worktree: runWorktree
})
const usageLabel = getAutomationUsageStatusLabel(run.usage)
const occurrenceLabel = automationRunOccurrenceLabel(run)
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
className="absolute left-0 top-0 w-full border-b border-border/50"
style={{ transform: `translateY(${virtualRow.start - scrollMargin}px)` }}
>
<button
type="button"
data-automation-run-id={run.id}
data-current={selectedRun?.id === run.id}
aria-current={selectedRun?.id === run.id || undefined}
className={cn(
RUN_ROW_GRID_CLASS,
'items-center px-3 py-2 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
selectedRun?.id === run.id && 'bg-accent text-accent-foreground'
)}
onClick={() => {
setSelectedRunState({ automationId, runId: run.id })
onOpenRun(run)
}}
>
<div className="min-w-0">
<div>{formatAutomationDateTime(run.scheduledFor)}</div>
{/* The row's own date is the first occurrence; only this line says it recurred. */}
{occurrenceLabel ? (
<div
data-testid="automation-run-occurrences"
className="mt-1 truncate text-xs text-foreground"
>
{occurrenceLabel}
</div>
) : null}
<div className="mt-1 truncate text-xs text-muted-foreground">
{workspaceLabel.detailLabel}
</div>
</div>
) : null}
<div className="mt-1 truncate text-xs text-muted-foreground">
{workspaceLabel.detailLabel}
</div>
<div
className={
workspaceLabel.muted
? 'min-w-0 truncate text-muted-foreground'
: 'min-w-0 truncate text-foreground'
}
title={workspaceLabel.title}
>
{workspaceLabel.rowLabel}
</div>
<div
className={
run.usage?.status === 'known'
? 'text-sm tabular-nums'
: 'text-sm text-muted-foreground'
}
title={usageLabel}
>
{formatAutomationCost(run.usage?.estimatedCostUsd)}
</div>
<div
className={
run.usage?.status === 'known'
? 'text-sm tabular-nums'
: 'text-sm text-muted-foreground'
}
title={usageLabel}
>
{run.usage?.status === 'known'
? formatAutomationTokens(run.usage.totalTokens)
: translate(
'auto.components.automations.AutomationRunHistory.a00e38d1a3',
'n/a'
)}
</div>
<div className="flex justify-start">
<Badge variant={getAutomationRunStatusVariant(run.status)}>
{getAutomationRunStatusLabel(run.status)}
</Badge>
</div>
</button>
</div>
<div
className={
workspaceLabel.muted
? 'min-w-0 truncate text-muted-foreground'
: 'min-w-0 truncate text-foreground'
}
title={workspaceLabel.title}
>
{workspaceLabel.rowLabel}
</div>
<div
className={
run.usage?.status === 'known'
? 'text-sm tabular-nums'
: 'text-sm text-muted-foreground'
}
title={usageLabel}
>
{formatAutomationCost(run.usage?.estimatedCostUsd)}
</div>
<div
className={
run.usage?.status === 'known'
? 'text-sm tabular-nums'
: 'text-sm text-muted-foreground'
}
title={usageLabel}
>
{run.usage?.status === 'known'
? formatAutomationTokens(run.usage.totalTokens)
: translate(
'auto.components.automations.AutomationRunHistory.a00e38d1a3',
'n/a'
)}
</div>
<div className="flex justify-start">
<Badge variant={getAutomationRunStatusVariant(run.status)}>
{getAutomationRunStatusLabel(run.status)}
</Badge>
</div>
</button>
)
})}
)
})}
</div>
{notice ? (
<div className="grid gap-2 px-3 py-6" data-testid="automation-run-history-failure">
<p className="text-center text-sm text-foreground">
@@ -7,32 +7,21 @@ import type { Automation, AutomationRun } from '../../../../shared/automations-t
import type { AutomationRunsDashboardEntry } from './automation-runs-dashboard-model'
import { AutomationRunsTable } from './AutomationRunsTable'
vi.mock('@tanstack/react-virtual', () => ({
useVirtualizer: ({
count,
getItemKey
}: {
count: number
getItemKey: (index: number) => string
}) => ({
getTotalSize: () => count * 59,
getVirtualItems: () =>
Array.from({ length: Math.min(count, 21) }, (_, index) => ({
index,
key: getItemKey(index),
start: index * 59
})),
measureElement: () => undefined
})
}))
vi.mock('@tanstack/react-virtual', async () => {
const { createVirtualizerStub } = await import('./virtualizer-test-stub')
return { useVirtualizer: createVirtualizerStub() }
})
function entries(count: number): AutomationRunsDashboardEntry[] {
function entries(
count: number,
overrides: { hostLabel?: string; scope?: AutomationRunsDashboardEntry['scope'] } = {}
): AutomationRunsDashboardEntry[] {
const automation = { id: 'automation', name: 'Daily check' } as Automation
const row = {
key: 'row',
automation,
catalogRef: { authority: { kind: 'desktop' }, selector: { kind: 'self' } },
hostLabel: 'Local Mac',
hostLabel: overrides.hostLabel ?? 'Local Mac',
usageSummary: null
} as const
return Array.from({ length: count }, (_, index) => ({
@@ -48,10 +37,23 @@ function entries(count: number): AutomationRunsDashboardEntry[] {
trigger: 'scheduled',
status: 'completed'
} as AutomationRun,
scope: 'local'
scope: overrides.scope ?? 'local'
}))
}
/** The load-more guard reads the scroller's geometry, which happy-dom leaves at 0. */
function scrollTo(
scroller: HTMLElement,
geometry: { scrollTop: number; scrollHeight: number; clientHeight: number }
): void {
for (const [property, value] of Object.entries(geometry)) {
Object.defineProperty(scroller, property, { value, configurable: true })
}
act(() => {
scroller.dispatchEvent(new Event('scroll', { bubbles: true }))
})
}
describe('AutomationRunsTable virtualization', () => {
let container: HTMLDivElement
let root: Root
@@ -85,3 +87,215 @@ describe('AutomationRunsTable virtualization', () => {
expect(mountedRows).toHaveLength(21)
})
})
describe('AutomationRunsTable rows', () => {
let container: HTMLDivElement
let root: Root
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
})
function render(node: React.JSX.Element): void {
act(() => root.render(node))
}
function rows(): NodeListOf<HTMLButtonElement> {
return container.querySelectorAll<HTMLButtonElement>('[data-testid="automation-runs-row"]')
}
it('fills every column of a row from the entry it stands for', () => {
render(
<AutomationRunsTable
entries={entries(1)}
loading={false}
hasMore={false}
onLoadMore={() => {}}
onOpenRun={() => {}}
/>
)
const row = rows()[0]
expect(row.textContent).toContain('Daily check')
expect(row.textContent).toContain('Run 0')
expect(row.textContent).toContain('Local Mac')
expect(row.textContent).toContain('scheduled')
expect(row.textContent).toContain('Done')
})
it('names the scope when the row carries no host label', () => {
render(
<AutomationRunsTable
entries={entries(1, { hostLabel: '', scope: 'remote' })}
loading={false}
hasMore={false}
onLoadMore={() => {}}
onOpenRun={() => {}}
/>
)
// An unlabeled host still has to say where the run happened.
expect(rows()[0].textContent).toContain('Remote')
})
it('opens the entry belonging to the clicked row, not the first one', () => {
const onOpenRun = vi.fn()
const rendered = entries(5)
render(
<AutomationRunsTable
entries={rendered}
loading={false}
hasMore={false}
onLoadMore={() => {}}
onOpenRun={onOpenRun}
/>
)
act(() => rows()[3].click())
expect(onOpenRun).toHaveBeenCalledExactlyOnceWith(rendered[3])
})
it('shows the spinner only until the first page arrives', () => {
render(
<AutomationRunsTable
entries={[]}
loading={true}
hasMore={false}
onLoadMore={() => {}}
onOpenRun={() => {}}
/>
)
expect(container.textContent).toContain('Loading runs')
expect(rows()).toHaveLength(0)
// A refresh over rows already on screen must not blank them back to a spinner.
render(
<AutomationRunsTable
entries={entries(3)}
loading={true}
hasMore={false}
onLoadMore={() => {}}
onOpenRun={() => {}}
/>
)
expect(container.textContent).not.toContain('Loading runs')
expect(rows()).toHaveLength(3)
})
it('distinguishes an empty history from one still loading', () => {
render(
<AutomationRunsTable
entries={[]}
loading={false}
hasMore={false}
onLoadMore={() => {}}
onOpenRun={() => {}}
/>
)
expect(container.textContent).toContain('No runs yet')
expect(container.textContent).not.toContain('Loading runs')
})
})
describe('AutomationRunsTable load more', () => {
let container: HTMLDivElement
let root: Root
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
})
function renderTable(props: {
loading: boolean
hasMore: boolean
onLoadMore: () => void
}): void {
act(() =>
root.render(
<AutomationRunsTable
entries={entries(40)}
loading={props.loading}
hasMore={props.hasMore}
onLoadMore={props.onLoadMore}
onOpenRun={() => {}}
/>
)
)
}
function scroller(): HTMLElement {
const element = container.querySelector<HTMLElement>('.scrollbar-sleek')
if (!element) {
throw new Error('runs table has no scroll container')
}
return element
}
it('asks for the next page once the scroll reaches the end', () => {
const onLoadMore = vi.fn()
renderTable({ loading: false, hasMore: true, onLoadMore })
scrollTo(scroller(), { scrollTop: 1760, scrollHeight: 2360, clientHeight: 600 })
expect(onLoadMore).toHaveBeenCalledTimes(1)
})
it('stays quiet while the scroll is still far from the end', () => {
const onLoadMore = vi.fn()
renderTable({ loading: false, hasMore: true, onLoadMore })
scrollTo(scroller(), { scrollTop: 0, scrollHeight: 2360, clientHeight: 600 })
expect(onLoadMore).not.toHaveBeenCalled()
})
it('stays quiet when the host has no further pages', () => {
const onLoadMore = vi.fn()
renderTable({ loading: false, hasMore: false, onLoadMore })
scrollTo(scroller(), { scrollTop: 1760, scrollHeight: 2360, clientHeight: 600 })
expect(onLoadMore).not.toHaveBeenCalled()
})
it('asks once per page, not once per scroll event the same page fires', () => {
const onLoadMore = vi.fn()
const geometry = { scrollTop: 1760, scrollHeight: 2360, clientHeight: 600 }
renderTable({ loading: false, hasMore: true, onLoadMore })
scrollTo(scroller(), geometry)
// Scroll momentum keeps firing before the request settles; a second ask would
// fetch the same cursor twice.
renderTable({ loading: true, hasMore: true, onLoadMore })
scrollTo(scroller(), geometry)
scrollTo(scroller(), geometry)
expect(onLoadMore).toHaveBeenCalledTimes(1)
// Once the page settles the next stretch of scrolling may ask again.
renderTable({ loading: false, hasMore: true, onLoadMore })
scrollTo(scroller(), geometry)
expect(onLoadMore).toHaveBeenCalledTimes(2)
})
})
@@ -45,27 +45,9 @@ export function AutomationRunsTable({
return (
<div className="flex min-h-[18rem] flex-col overflow-hidden rounded-lg border border-border/60 bg-card">
<div className="grid shrink-0 grid-cols-[minmax(11rem,1.4fr)_minmax(10rem,1fr)_minmax(5rem,.55fr)_minmax(8rem,.8fr)_minmax(7rem,auto)] gap-3 border-b border-border/60 px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
<div>
{translate(
'auto.components.automations.AutomationRunsDashboard.automation',
'Automation'
)}
</div>
<div>
{translate('auto.components.automations.AutomationRunsDashboard.triggered', 'Triggered')}
</div>
<div>
{translate('auto.components.automations.AutomationRunsDashboard.trigger', 'Trigger')}
</div>
<div>{translate('auto.components.automations.AutomationRunsDashboard.host', 'Host')}</div>
<div>
{translate('auto.components.automations.AutomationRunsDashboard.status', 'Status')}
</div>
</div>
<div
ref={scrollRef}
className="scrollbar-sleek h-[calc(100vh-21rem)] min-h-[15rem] overflow-auto"
className="scrollbar-sleek flex h-[calc(100vh-21rem)] min-h-[15rem] flex-col overflow-auto"
onScroll={(event) => {
const { clientHeight, scrollHeight, scrollTop } = event.currentTarget
const nearEnd = scrollHeight - scrollTop - clientHeight < RUN_ROW_HEIGHT_PX * 10
@@ -75,8 +57,29 @@ export function AutomationRunsTable({
}
}}
>
<div className="sticky top-0 z-10 grid shrink-0 grid-cols-[minmax(11rem,1.4fr)_minmax(10rem,1fr)_minmax(5rem,.55fr)_minmax(8rem,.8fr)_minmax(7rem,auto)] gap-3 border-b border-border/60 bg-card px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
<div>
{translate(
'auto.components.automations.AutomationRunsDashboard.automation',
'Automation'
)}
</div>
<div>
{translate(
'auto.components.automations.AutomationRunsDashboard.triggered',
'Triggered'
)}
</div>
<div>
{translate('auto.components.automations.AutomationRunsDashboard.trigger', 'Trigger')}
</div>
<div>{translate('auto.components.automations.AutomationRunsDashboard.host', 'Host')}</div>
<div>
{translate('auto.components.automations.AutomationRunsDashboard.status', 'Status')}
</div>
</div>
{loading && entries.length === 0 ? (
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
<div className="flex min-h-0 flex-1 items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
{translate(
'auto.components.automations.AutomationRunsDashboard.loading',
@@ -84,7 +87,7 @@ export function AutomationRunsTable({
)}
</div>
) : entries.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-1 px-6 text-center">
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-1 px-6 text-center">
<div className="text-sm font-medium">
{translate(
'auto.components.automations.AutomationRunsDashboard.noRuns',
@@ -99,7 +102,7 @@ export function AutomationRunsTable({
</div>
</div>
) : (
<div className="relative w-full" style={{ height: virtualizer.getTotalSize() }}>
<div className="relative w-full shrink-0" style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const entry = entries[virtualRow.index]
if (!entry) {
@@ -258,24 +258,27 @@ export function AutomationsDetailPane({
/>
</TabsContent>
<TabsContent value="runs" className="scrollbar-sleek min-h-0 overflow-auto p-5">
{selected ? (
<AutomationRunHistory
runs={selectedRuns}
automationId={selected.id}
worktreeMap={worktreeMap}
notice={selectedRunsNotice}
onRecoverHistory={recoverSelectedRuns}
onOpenRun={openAutomationRunPage}
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{translate(
'auto.components.automations.AutomationsPage.c3a28c9793',
'Select an automation to view runs.'
)}
</div>
)}
<TabsContent value="runs" className="flex min-h-0 flex-1 flex-col overflow-hidden">
{/* The history owns the scrolling, so the padding rides a wrapper it can size against. */}
<div className="flex min-h-0 flex-1 flex-col p-5">
{selected ? (
<AutomationRunHistory
runs={selectedRuns}
automationId={selected.id}
worktreeMap={worktreeMap}
notice={selectedRunsNotice}
onRecoverHistory={recoverSelectedRuns}
onOpenRun={openAutomationRunPage}
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{translate(
'auto.components.automations.AutomationsPage.c3a28c9793',
'Select an automation to view runs.'
)}
</div>
)}
</div>
</TabsContent>
</Tabs>
)}
@@ -3,16 +3,20 @@ import type { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import type { AutomationRun } from '../../../../shared/automations-types'
// Frozen at module scope: every run row formats a date, and constructing a
// DateTimeFormat per cell dominates the render of a long runs table.
const automationDateTimeFormatter = new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
})
export function formatAutomationDateTime(value: number | null | undefined): string {
if (!value) {
return 'Never'
}
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
}).format(value)
return automationDateTimeFormatter.format(value)
}
export function formatAutomationRelativeTime(
@@ -13,12 +13,17 @@ import { translate } from '@/i18n/i18n'
type AutomationRunOccurrences = Pick<AutomationRun, 'occurrenceCount' | 'lastOccurrenceAt'>
/** The label's condition without its cost; row-size estimation asks it per history item. */
export function isAutomationRunFolded(run: AutomationRunOccurrences): boolean {
return (run.occurrenceCount ?? 1) > 1
}
/** Null for the single-occurrence rows, which is every row written before folding. */
export function automationRunOccurrenceLabel(run: AutomationRunOccurrences): string | null {
const count = run.occurrenceCount ?? 1
if (count <= 1) {
if (!isAutomationRunFolded(run)) {
return null
}
const count = run.occurrenceCount ?? 1
// Not named `count`: i18next reserves it for plural selection, which would send
// these keys looking for `_one`/`_other` variants the catalog does not carry.
// The label only renders above 1, so the plural is always right.
@@ -10,6 +10,7 @@
import type {
Automation,
AutomationRun,
AutomationRunUsage,
ExternalAutomationManager
} from '../../../../shared/automations-types'
import type { ProjectHostSetup } from '../../../../shared/project-types'
@@ -94,6 +95,28 @@ export function makeRun(overrides: Partial<AutomationRun> = {}): AutomationRun {
}
}
export function makeRunUsage(overrides: Partial<AutomationRunUsage> = {}): AutomationRunUsage {
return {
status: 'known',
provider: 'claude',
model: 'claude-opus-5',
inputTokens: 1_000,
outputTokens: 500,
cacheReadTokens: null,
cacheWriteTokens: null,
reasoningOutputTokens: null,
totalTokens: 1_500,
estimatedCostUsd: 0.25,
estimatedCostSource: 'api_equivalent',
providerSessionId: 'session-1',
attribution: 'provider_session_time_window',
collectedAt: 10,
unavailableReason: null,
unavailableMessage: null,
...overrides
}
}
export function makeExternalManager(
overrides: Partial<ExternalAutomationManager> = {}
): ExternalAutomationManager {
@@ -179,14 +202,27 @@ function makeProjectHostSetup(): ProjectHostSetup {
}
}
function makeWorktree(): Worktree {
export function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
return {
id: WORKSPACE_ID,
repoId: REPO_ID,
displayName: 'main',
path: '/repos/orca',
branch: 'main'
} as Worktree
branch: 'main',
head: 'abc123',
isBare: false,
isMainWorktree: true,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
...overrides
}
}
export type AutomationsPageStoreFixtures = {
@@ -0,0 +1,72 @@
/**
* happy-dom reports a zero-height scroll element, and `observeElementRect` hands
* that measurement straight to the virtualizer — so the real `useVirtualizer`
* renders no rows at all under test. This stub renders a bounded window instead,
* which is what the virtualization assertions are actually about.
*
* The window starts at index 0 and only moves when `scrollToIndex` names an index
* outside it, so a row below the fold stays unmounted until the component scrolls
* to it — the sequence a deferred-focus path depends on.
*/
import { useState } from 'react'
export const VIRTUALIZER_STUB_WINDOW_SIZE = 21
type VirtualizerStubOptions = {
count: number
estimateSize: (index: number) => number
getItemKey?: (index: number) => string | number
}
type VirtualizerStub = {
getTotalSize: () => number
getVirtualItems: () => { index: number; key: string | number; start: number; size: number }[]
measureElement: (element: Element | null) => void
scrollToIndex: (index: number) => void
}
export function createVirtualizerStub(
windowSize = VIRTUALIZER_STUB_WINDOW_SIZE
): (options: VirtualizerStubOptions) => VirtualizerStub {
return ({ count, estimateSize, getItemKey }) => {
const [windowStart, setWindowStart] = useState(0)
const sizes = Array.from({ length: count }, (_, index) => estimateSize(index))
let offset = 0
const starts = sizes.map((size) => {
const start = offset
offset += size
return start
})
return {
getTotalSize: () => sizes.reduce((total, size) => total + size, 0),
getVirtualItems: () =>
Array.from(
{ length: Math.max(0, Math.min(windowSize, count - windowStart)) },
(_, position) => {
const index = windowStart + position
return {
index,
key: getItemKey?.(index) ?? index,
start: starts[index] ?? 0,
size: sizes[index] ?? 0
}
}
),
measureElement: () => undefined,
// Scrolls the least the target allows, like `align: 'auto'`.
scrollToIndex: (index: number) => {
setWindowStart((current) => {
const lastStart = Math.max(0, count - windowSize)
if (index < current) {
return Math.min(index, lastStart)
}
if (index >= current + windowSize) {
return Math.min(index - windowSize + 1, lastStart)
}
return current
})
}
}
}
}