mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user