From feb04ec254585ba69587699d41f80a72dda2cb27 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 15 Sep 2026 22:21:38 -0700 Subject: [PATCH] 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 --- .../automations/AutomationRunHistory.test.tsx | 280 ++++++++++++- .../automations/AutomationRunHistory.tsx | 382 ++++++++++++------ .../automations/AutomationRunsTable.test.tsx | 256 +++++++++++- .../automations/AutomationRunsTable.tsx | 47 ++- .../automations/AutomationsDetailPane.tsx | 39 +- .../automations/automation-page-parts.tsx | 16 +- .../automations/automation-run-occurrences.ts | 9 +- .../automations/automations-page-fixtures.ts | 42 +- .../automations/virtualizer-test-stub.ts | 72 ++++ 9 files changed, 951 insertions(+), 192 deletions(-) create mode 100644 src/renderer/src/components/automations/virtualizer-test-stub.ts diff --git a/src/renderer/src/components/automations/AutomationRunHistory.test.tsx b/src/renderer/src/components/automations/AutomationRunHistory.test.tsx index d835d530890..1083e90acf0 100644 --- a/src/renderer/src/components/automations/AutomationRunHistory.test.tsx +++ b/src/renderer/src/components/automations/AutomationRunHistory.test.tsx @@ -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 { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + await act(async () => { + root.render( + + ) + }) + return container + } + + async function pressArrow(key: 'ArrowDown' | 'ArrowUp', times: number): Promise { + 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( + `[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('[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> + onOpenRun?: (run: AutomationRun) => void + }): void { + act(() => { + root.render( + + ) + }) + } + + function rows(): NodeListOf { + return container.querySelectorAll('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 { + 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( + + ) + }) + + 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( + + ) + }) + // 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( + + ) + }) + + await pressEnter() + + expect(onOpenRun).toHaveBeenCalledExactlyOnceWith(replacement) + }) +}) diff --git a/src/renderer/src/components/automations/AutomationRunHistory.tsx b/src/renderer/src/components/automations/AutomationRunHistory.tsx index cc36d416f14..3f5a7eb2ee1 100644 --- a/src/renderer/src/components/automations/AutomationRunHistory.tsx +++ b/src/renderer/src/components/automations/AutomationRunHistory.tsx @@ -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(null) + const scrollRef = useRef(null) + const headerRef = useRef(null) + const rowsRef = useRef(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(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 ( -
-
+
+
{translate('auto.components.automations.AutomationRunHistory.53fc5f07ab', 'Run history')}
{/* A failed read knows no counts; "0 runs" would answer a question nobody asked the host. */} {notice ? null :
{runCountLabel}
}
-
-
-
- {translate('auto.components.automations.AutomationRunHistory.8faaa00726', 'Run')} +
+
+
+
+ {translate('auto.components.automations.AutomationRunHistory.8faaa00726', 'Run')} +
+
+ {translate( + 'auto.components.automations.AutomationRunHistory.149c0b49c7', + 'Workspace' + )} +
+
+ {translate('auto.components.automations.AutomationRunHistory.86a248187e', 'Spend')} +
+
+ {translate('auto.components.automations.AutomationRunHistory.13988187b3', 'Tokens')} +
+
+ {translate('auto.components.automations.AutomationRunHistory.9974a2b429', 'Status')} +
-
- {translate('auto.components.automations.AutomationRunHistory.149c0b49c7', 'Workspace')} -
-
- {translate('auto.components.automations.AutomationRunHistory.86a248187e', 'Spend')} -
-
- {translate('auto.components.automations.AutomationRunHistory.13988187b3', 'Tokens')} -
-
- {translate('auto.components.automations.AutomationRunHistory.9974a2b429', 'Status')} -
-
-
- {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 ( -
-
- {workspaceLabel.rowLabel} -
-
- {formatAutomationCost(run.usage?.estimatedCostUsd)} -
-
- {run.usage?.status === 'known' - ? formatAutomationTokens(run.usage.totalTokens) - : translate( - 'auto.components.automations.AutomationRunHistory.a00e38d1a3', - 'n/a' - )} -
-
- - {getAutomationRunStatusLabel(run.status)} - -
- - ) - })} + ) + })} +
{notice ? (

diff --git a/src/renderer/src/components/automations/AutomationRunsTable.test.tsx b/src/renderer/src/components/automations/AutomationRunsTable.test.tsx index b5c8a70aa8b..3ef348977b7 100644 --- a/src/renderer/src/components/automations/AutomationRunsTable.test.tsx +++ b/src/renderer/src/components/automations/AutomationRunsTable.test.tsx @@ -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 { + return container.querySelectorAll('[data-testid="automation-runs-row"]') + } + + it('fills every column of a row from the entry it stands for', () => { + render( + {}} + 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( + {}} + 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( + {}} + onOpenRun={onOpenRun} + /> + ) + + act(() => rows()[3].click()) + + expect(onOpenRun).toHaveBeenCalledExactlyOnceWith(rendered[3]) + }) + + it('shows the spinner only until the first page arrives', () => { + render( + {}} + 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( + {}} + onOpenRun={() => {}} + /> + ) + + expect(container.textContent).not.toContain('Loading runs') + expect(rows()).toHaveLength(3) + }) + + it('distinguishes an empty history from one still loading', () => { + render( + {}} + 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( + {}} + /> + ) + ) + } + + function scroller(): HTMLElement { + const element = container.querySelector('.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) + }) +}) diff --git a/src/renderer/src/components/automations/AutomationRunsTable.tsx b/src/renderer/src/components/automations/AutomationRunsTable.tsx index e4ec4f565eb..eaff918c855 100644 --- a/src/renderer/src/components/automations/AutomationRunsTable.tsx +++ b/src/renderer/src/components/automations/AutomationRunsTable.tsx @@ -45,27 +45,9 @@ export function AutomationRunsTable({ return (

-
-
- {translate( - 'auto.components.automations.AutomationRunsDashboard.automation', - 'Automation' - )} -
-
- {translate('auto.components.automations.AutomationRunsDashboard.triggered', 'Triggered')} -
-
- {translate('auto.components.automations.AutomationRunsDashboard.trigger', 'Trigger')} -
-
{translate('auto.components.automations.AutomationRunsDashboard.host', 'Host')}
-
- {translate('auto.components.automations.AutomationRunsDashboard.status', 'Status')} -
-
{ const { clientHeight, scrollHeight, scrollTop } = event.currentTarget const nearEnd = scrollHeight - scrollTop - clientHeight < RUN_ROW_HEIGHT_PX * 10 @@ -75,8 +57,29 @@ export function AutomationRunsTable({ } }} > +
+
+ {translate( + 'auto.components.automations.AutomationRunsDashboard.automation', + 'Automation' + )} +
+
+ {translate( + 'auto.components.automations.AutomationRunsDashboard.triggered', + 'Triggered' + )} +
+
+ {translate('auto.components.automations.AutomationRunsDashboard.trigger', 'Trigger')} +
+
{translate('auto.components.automations.AutomationRunsDashboard.host', 'Host')}
+
+ {translate('auto.components.automations.AutomationRunsDashboard.status', 'Status')} +
+
{loading && entries.length === 0 ? ( -
+
{translate( 'auto.components.automations.AutomationRunsDashboard.loading', @@ -84,7 +87,7 @@ export function AutomationRunsTable({ )}
) : entries.length === 0 ? ( -
+
{translate( 'auto.components.automations.AutomationRunsDashboard.noRuns', @@ -99,7 +102,7 @@ export function AutomationRunsTable({
) : ( -
+
{virtualizer.getVirtualItems().map((virtualRow) => { const entry = entries[virtualRow.index] if (!entry) { diff --git a/src/renderer/src/components/automations/AutomationsDetailPane.tsx b/src/renderer/src/components/automations/AutomationsDetailPane.tsx index 72c60463ea3..38e9311288c 100644 --- a/src/renderer/src/components/automations/AutomationsDetailPane.tsx +++ b/src/renderer/src/components/automations/AutomationsDetailPane.tsx @@ -258,24 +258,27 @@ export function AutomationsDetailPane({ /> - - {selected ? ( - - ) : ( -
- {translate( - 'auto.components.automations.AutomationsPage.c3a28c9793', - 'Select an automation to view runs.' - )} -
- )} + + {/* The history owns the scrolling, so the padding rides a wrapper it can size against. */} +
+ {selected ? ( + + ) : ( +
+ {translate( + 'auto.components.automations.AutomationsPage.c3a28c9793', + 'Select an automation to view runs.' + )} +
+ )} +
)} diff --git a/src/renderer/src/components/automations/automation-page-parts.tsx b/src/renderer/src/components/automations/automation-page-parts.tsx index 4ffd81001bb..03ba4d5738d 100644 --- a/src/renderer/src/components/automations/automation-page-parts.tsx +++ b/src/renderer/src/components/automations/automation-page-parts.tsx @@ -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( diff --git a/src/renderer/src/components/automations/automation-run-occurrences.ts b/src/renderer/src/components/automations/automation-run-occurrences.ts index 71af9043eb8..fc90937853f 100644 --- a/src/renderer/src/components/automations/automation-run-occurrences.ts +++ b/src/renderer/src/components/automations/automation-run-occurrences.ts @@ -13,12 +13,17 @@ import { translate } from '@/i18n/i18n' type AutomationRunOccurrences = Pick +/** 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. diff --git a/src/renderer/src/components/automations/automations-page-fixtures.ts b/src/renderer/src/components/automations/automations-page-fixtures.ts index 16c28c28145..a5a3175e467 100644 --- a/src/renderer/src/components/automations/automations-page-fixtures.ts +++ b/src/renderer/src/components/automations/automations-page-fixtures.ts @@ -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 { } } +export function makeRunUsage(overrides: Partial = {}): 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 { @@ -179,14 +202,27 @@ function makeProjectHostSetup(): ProjectHostSetup { } } -function makeWorktree(): Worktree { +export function makeWorktree(overrides: Partial = {}): 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 = { diff --git a/src/renderer/src/components/automations/virtualizer-test-stub.ts b/src/renderer/src/components/automations/virtualizer-test-stub.ts new file mode 100644 index 00000000000..43eed088ecd --- /dev/null +++ b/src/renderer/src/components/automations/virtualizer-test-stub.ts @@ -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 + }) + } + } + } +}