fix(automations): address C2 review and UX-I3 findings

C2:
- C2-01: a Back/Forward replay that cannot show its run (history read
  failed) records nothing, so the entry keeps the requested run and
  Forward history is untouched.
- C2-02: the list and global Runs pages focus their search field
  ("Search automations" / "Search runs") on user navigation; the list
  search no longer autofocuses, so a restored page leaves focus alone.
- C2-03: returning to a run waits up to 5s for the table's data to list
  it (cancelled when the page changes or the user focuses elsewhere),
  instead of a fixed frame budget.
- C2-04: "Started" appears only with launch evidence (dispatch time, or
  the start time of a completed/dispatched older record); skipped and
  missed runs show their slot only.

UX-I3:
- UXI3-1: the list, detail stat, rail and run tables all show a run's
  listed time: its scheduled slot, or a folded run's latest occurrence.
  The usage summary carries it as lastRunListedAt; older hosts fall back.
- UXI3-2: a returned row that is already visible stays put; only an
  off-screen row is centered.
This commit is contained in:
Jinjing
2026-11-12 12:45:16 -08:00
parent 9fc32ec27e
commit bdb8fd35da
16 changed files with 289 additions and 62 deletions
@@ -98,8 +98,8 @@ export function AutomationDetailHealth({
const intlLocale = useIntlLocale()
const lastRun = getLocalAutomationLastRunSnapshot(automation, latestRun, t)
// Why: lists and run rows name a run by its scheduled slot; the stat uses the same time.
const lastRunAt = latestRun ? latestRun.scheduledFor : lastRun.at
// The snapshot uses the run's listed time (its slot), the same time the list and rows show.
const lastRunAt = lastRun.at
const lastRunRelative = formatAutomationRelativeTime(lastRunAt, now, t, intlLocale)
const lastRunWhen = lastRunAt ? formatAutomationDateTime(lastRunAt, t, intlLocale) : null
const lastRunValue =
@@ -46,7 +46,8 @@ export function AutomationListSearchField({
<Input
ref={inputRef}
type="text"
autoFocus
// Why: the page focuses this on user navigation; a restored page must not move focus.
data-automations-page-search=""
value={query}
aria-label={t(
'auto.components.automations.AutomationListSearchField.label',
@@ -1,9 +1,10 @@
import { translate } from '@/i18n/i18n'
import type { Translate } from '@/i18n/use-translate'
import type { Automation } from '../../../../../shared/automations/automation-definition'
import type {
AutomationRun,
AutomationRunStatus
import {
getAutomationRunListedAt,
type AutomationRun,
type AutomationRunStatus
} from '../../../../../shared/automations/history/automation-run'
import type { ExternalAutomationJob } from '../../../../../shared/automations/external/external-automation'
import {
@@ -49,10 +50,6 @@ export function indexLatestAutomationRuns(
return latest
}
export function getAutomationRunLastRunAt(run: AutomationRun): number {
return run.dispatchedAt ?? run.startedAt ?? run.createdAt
}
export function getToneForAutomationRunStatus(status: AutomationRunStatus): AutomationLastRunTone {
if (status === 'dispatch_failed') {
return 'failed'
@@ -73,14 +70,23 @@ export function getToneForAutomationRunStatus(status: AutomationRunStatus): Auto
export function getAutomationRowLastRunSnapshot(
row: {
automation: Automation
usageSummary: { lastRunStatus?: AutomationRunStatus | null; lastRunAt?: number | null } | null
usageSummary: {
lastRunStatus?: AutomationRunStatus | null
lastRunAt?: number | null
lastRunListedAt?: number | null
} | null
},
t?: Translate
): AutomationLastRunSnapshot {
const status = row.usageSummary?.lastRunStatus
if (status) {
return {
at: row.usageSummary?.lastRunAt ?? row.automation.lastRunAt ?? null,
// Why: rows, rail and run pages name a run by its slot; older hosts only send lastRunAt.
at:
row.usageSummary?.lastRunListedAt ??
row.usageSummary?.lastRunAt ??
row.automation.lastRunAt ??
null,
tone: getToneForAutomationRunStatus(status),
statusLabel: getAutomationRunStatusLabel(status, t)
}
@@ -95,7 +101,7 @@ export function getLocalAutomationLastRunSnapshot(
): AutomationLastRunSnapshot {
if (lastRun) {
return {
at: getAutomationRunLastRunAt(lastRun),
at: getAutomationRunListedAt(lastRun),
tone: getToneForAutomationRunStatus(lastRun.status),
statusLabel: getAutomationRunStatusLabel(lastRun.status, t)
}
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'
import { getAutomationRunListedAt } from '../../../../../shared/automations/history/automation-run'
import { summarizeAutomationRunUsage } from '../../../../../shared/automations/history/automation-usage-summary'
import { makeAutomation, makeRun } from '../automations-page-fixtures'
import { getLocalAutomationLastRunSnapshot } from './automation-list-last-run'
import { getAutomationRunLaunchedAt } from '../runs/automation-run-identity-label'
const SLOT = 1_000_000
const LATE_START = SLOT + 95 * 60_000
describe('the time a run is listed under', () => {
it('is the scheduled slot, even when the run started late', () => {
const run = makeRun({ scheduledFor: SLOT, dispatchedAt: LATE_START, createdAt: LATE_START })
expect(getAutomationRunListedAt(run)).toBe(SLOT)
})
it('is the latest occurrence for a folded run', () => {
expect(
getAutomationRunListedAt({
scheduledFor: SLOT,
occurrenceCount: 3,
lastOccurrenceAt: SLOT + 9
})
).toBe(SLOT + 9)
expect(getAutomationRunListedAt({ scheduledFor: SLOT, occurrenceCount: 1 })).toBe(SLOT)
})
it('is what the list (usage summary) and the detail stat both show', () => {
const run = makeRun({ scheduledFor: SLOT, dispatchedAt: LATE_START, createdAt: LATE_START })
expect(summarizeAutomationRunUsage([run]).lastRunListedAt).toBe(SLOT)
expect(getLocalAutomationLastRunSnapshot(makeAutomation(), run).at).toBe(SLOT)
})
})
describe('when a run launched', () => {
it('is its dispatch time', () => {
expect(
getAutomationRunLaunchedAt({ status: 'dispatch_failed', dispatchedAt: 5, startedAt: 3 })
).toBe(5)
})
it('is unknown for a skipped or missed run that only carries an observation time', () => {
expect(
getAutomationRunLaunchedAt({ status: 'skipped_missed', dispatchedAt: null, startedAt: 7 })
).toBeNull()
expect(
getAutomationRunLaunchedAt({ status: 'dispatch_failed', dispatchedAt: null, startedAt: 7 })
).toBeNull()
})
it('keeps the start time of older completed records without a dispatch time', () => {
expect(
getAutomationRunLaunchedAt({ status: 'completed', dispatchedAt: null, startedAt: 7 })
).toBe(7)
})
})
@@ -28,6 +28,7 @@ import { AutomationRunStatusLabel } from './AutomationRunStatusDot'
import { formatAutomationCost, formatAutomationTokens } from './automation-usage-model'
import {
RUN_START_DRIFT_LABEL_MS,
getAutomationRunLaunchedAt,
formatAutomationRunLabel,
getAutomationRunNeighbors,
getAutomationRunStepKey
@@ -103,7 +104,7 @@ export function AutomationRunDetailsPage({
const run = full.run
const failed = run.status === 'dispatch_failed'
// Why: the detail's Last run shows when a run started; when that differs from its slot, say both.
const startedAt = run.dispatchedAt ?? run.startedAt ?? null
const startedAt = getAutomationRunLaunchedAt(run)
const startedLate =
startedAt !== null && Math.abs(startedAt - run.scheduledFor) >= RUN_START_DRIFT_LABEL_MS
const agentLabel = automation ? getAgentLabel(automation.agentId) : undefined
@@ -128,9 +128,10 @@ export function AutomationRunsDashboard({
'Search runs…'
)}
aria-label={t(
'auto.components.automations.AutomationRunsDashboard.search',
'Search runs…'
'auto.components.automations.AutomationRunsDashboard.searchLabel',
'Search runs'
)}
data-automations-page-search=""
className="h-8 pl-8 text-xs"
/>
</div>
@@ -64,3 +64,17 @@ export function getAutomationRunStepKey(event: {
/** Runs normally start within seconds of their slot; past this, start and slot are labeled. */
export const RUN_START_DRIFT_LABEL_MS = 60_000
/**
* When a run actually launched, or null. Only dispatch proves a launch; skipped and missed
* runs can carry an observation `startedAt` from before anything ran. Older records that
* completed or are running without a dispatch time keep their start time.
*/
export function getAutomationRunLaunchedAt(
run: Pick<AutomationRun, 'status' | 'dispatchedAt' | 'startedAt'>
): number | null {
if (run.dispatchedAt !== null) {
return run.dispatchedAt
}
return run.status === 'completed' || run.status === 'dispatched' ? run.startedAt : null
}
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store/index'
import type { WorktreeNavHistoryEntry } from '../../store/slices/ui/workspace-navigation-history'
import type { AutomationsPageLocation } from './automation-page-state'
@@ -84,11 +84,33 @@ it('records a sub-page the user opens', () => {
expect(state.worktreeNavHistoryIndex).toBe(2)
})
it('a replay whose run cannot be shown swaps that entry instead of cutting Forward', () => {
useAppStore.setState({
worktreeNavHistory: ['skills', entry(detail), entry(run1), entry(run2)],
worktreeNavHistoryIndex: 3
it.each([
['from the global Runs page', { page: 'runs' } satisfies AutomationsPageLocation],
['with a detail entry that is not adjacent', detail]
])('a replay whose run cannot be shown keeps that entry and Forward (%s)', (_label, before) => {
const history = ['skills', entry(before), entry(list), entry(run1), entry(run2)]
useAppStore.setState({ worktreeNavHistory: history, worktreeNavHistoryIndex: 4 })
const goToLocation = vi.fn()
const { rerender } = renderHook(
({ location }: Props) => useAutomationsPageHistory({ location, navigation: { goToLocation } }),
{ initialProps: onRun2 }
)
act(() => {
useAppStore.setState({ worktreeNavHistoryIndex: 3, automationsPageLocationRequest: run1 })
})
// The run's history read fails while resolving, so the page falls back to the detail.
rerender({ location: null })
rerender({ location: detail })
const state = useAppStore.getState()
// The entry still names the requested run, so Back/Forward can try it again.
expect(state.worktreeNavHistory).toEqual(history)
expect(state.worktreeNavHistoryIndex).toBe(3)
})
it('a replay that lands where it asked records nothing (control)', () => {
const history = ['skills', entry(detail), entry(run1), entry(run2)]
useAppStore.setState({ worktreeNavHistory: history, worktreeNavHistoryIndex: 3 })
const goToLocation = vi.fn()
const { rerender } = renderHook(
({ location }: Props) => useAutomationsPageHistory({ location, navigation: { goToLocation } }),
@@ -97,15 +119,10 @@ it('a replay whose run cannot be shown swaps that entry instead of cutting Forwa
act(() => {
useAppStore.setState({ worktreeNavHistoryIndex: 2, automationsPageLocationRequest: run1 })
})
// The run's history read fails while resolving, so the page falls back to the detail.
rerender({ location: null })
rerender({ location: detail })
const state = useAppStore.getState()
// The fallback is the page just before the run, so the index steps back to it; the run
// stays as Forward and nothing is deleted.
expect(state.worktreeNavHistory).toEqual(['skills', entry(detail), entry(run1), entry(run2)])
expect(state.worktreeNavHistoryIndex).toBe(1)
rerender({ location: run1 })
expect(useAppStore.getState().worktreeNavHistory).toEqual(history)
expect(useAppStore.getState().worktreeNavHistoryIndex).toBe(2)
})
function mountPage(): HTMLElement {
@@ -179,3 +196,107 @@ it('records a deleted-run deep link as the run it asked for, so Back/Forward ret
automationsPageNotFoundLocation({ kind: 'automation', automationId: 'a1', runId: null })
).toEqual({ page: 'detail', automationId: 'a1', hostId: undefined })
})
describe('returning to a run whose table is still loading', () => {
const runTarget: AutomationsPageFocusTarget = {
key: 'run:r9',
page: 'run',
parents: ['detail', 'automationRuns', 'runs'],
returnSelector: '[data-automation-run-id="r9"]',
returnRunId: 'r9'
}
const runsTarget: AutomationsPageFocusTarget = {
key: 'runs',
page: 'runs',
parents: [],
returnSelector: null
}
function mountRunsPage(): HTMLElement {
const container = document.createElement('main')
container.innerHTML = '<input aria-label="Search runs" data-automations-page-search />'
document.body.appendChild(container)
return container
}
beforeEach(() => {
vi.useFakeTimers({ toFake: ['requestAnimationFrame', 'cancelAnimationFrame', 'performance'] })
})
afterEach(() => {
vi.useRealTimers()
})
it('focuses the row once the reloaded data lists it (1.2s read)', () => {
const container = mountRunsPage()
const { rerender } = renderHook(
({ target }: { target: AutomationsPageFocusTarget }) =>
useAutomationsPageFocus(target, { current: container }),
{ initialProps: { target: runTarget } }
)
rerender({ target: runsTarget })
act(() => {
vi.advanceTimersByTime(1200)
})
expect(document.activeElement).toBe(document.body)
const row = document.createElement('button')
row.setAttribute('data-automation-run-id', 'r9')
container.appendChild(row)
act(() => {
vi.advanceTimersByTime(50)
})
expect(document.activeElement).toBe(row)
})
it('falls back to the page search after 5s when the row never appears', () => {
const container = mountRunsPage()
const { rerender } = renderHook(
({ target }: { target: AutomationsPageFocusTarget }) =>
useAutomationsPageFocus(target, { current: container }),
{ initialProps: { target: runTarget } }
)
rerender({ target: runsTarget })
act(() => {
vi.advanceTimersByTime(4900)
})
expect(document.activeElement).toBe(document.body)
act(() => {
vi.advanceTimersByTime(200)
})
expect(document.activeElement?.getAttribute('aria-label')).toBe('Search runs')
})
it('stops waiting when the user navigates away', () => {
const container = mountRunsPage()
const { rerender, unmount } = renderHook(
({ target }: { target: AutomationsPageFocusTarget }) =>
useAutomationsPageFocus(target, { current: container }),
{ initialProps: { target: runTarget } }
)
rerender({ target: runsTarget })
act(() => {
vi.advanceTimersByTime(500)
})
unmount()
const row = document.createElement('button')
row.setAttribute('data-automation-run-id', 'r9')
container.appendChild(row)
act(() => {
vi.advanceTimersByTime(1000)
})
expect(document.activeElement).toBe(document.body)
})
})
it('focuses the list search when the user opens the list page', () => {
const container = document.createElement('main')
container.innerHTML = '<input aria-label="Search automations" data-automations-page-search />'
document.body.appendChild(container)
useAppStore.setState({ automationsPageFocusTitleOnOpen: true })
renderHook(() =>
useAutomationsPageFocus(
{ key: 'list', page: 'list', parents: [], returnSelector: null },
{ current: container }
)
)
expect(document.activeElement?.getAttribute('aria-label')).toBe('Search automations')
})
@@ -49,14 +49,11 @@ export function useAutomationsPageHistory({
if (key === null || requestRef.current || !locationRef.current) {
return
}
const replayKey = replayKeyRef.current
if (replayKey !== null) {
if (replayKeyRef.current !== null) {
// Why: a replay that lands where it asked is already the current entry. One that settles
// elsewhere (its run history could not be read) records nothing: the entry keeps the
// requested run, so Back/Forward can try it again, and Forward history is untouched.
replayKeyRef.current = null
// A replay that lands where it asked is already the current entry. One that settles
// elsewhere (its run history could not be read) swaps that entry, keeping Forward.
if (key !== replayKey) {
record(locationRef.current, 'replace')
}
return
}
record(locationRef.current)
@@ -75,6 +72,26 @@ export type AutomationsPageFocusTarget = {
page: string
}
/** Leave a visible row where it is; bring an off-screen one to the middle of its scroller. */
function revealReturnedRow(element: HTMLElement): void {
const rect = element.getBoundingClientRect()
let scroller = element.parentElement
while (scroller && !/(auto|scroll)/.test(getComputedStyle(scroller).overflowY)) {
scroller = scroller.parentElement
}
const view = scroller?.getBoundingClientRect() ?? {
top: 0,
bottom: window.innerHeight
}
const visible = rect.top >= view.top && rect.bottom <= view.bottom
if (!visible) {
element.scrollIntoView({ block: 'center' })
}
}
// A page's title, or on the list and Runs pages (which have none) their named search field.
const PAGE_FOCUS_TARGET = '[data-automations-page-title], [data-automations-page-search]'
const RETURN_HIGHLIGHT_MS = 1500
// How long a returned-to row keeps focus through a list that is still re-rendering.
const RETURN_SETTLE_MS = 1000
@@ -119,9 +136,9 @@ function flashReturnedRow(element: HTMLElement): void {
}
}
// Virtualized rows mount after a scroll, and a list that reloads on open (Runs) can take a
// moment to list the row; past this, focus falls back to the page title.
const FOCUS_RETRY_MS = 3000
// Virtualized rows mount after a scroll, and a list that reloads on open (Runs) lists the row
// only once its data arrives; past this, focus falls back to the page's own target.
const FOCUS_RETRY_MS = 5000
/**
* Opening a page focuses its title; going back focuses the row or link you left from,
@@ -193,12 +210,12 @@ export function useAutomationsPageFocus(
(selector ? container.querySelector<HTMLElement>(selector) : null) ??
(selector && performance.now() < giveUpAt
? null
: container.querySelector<HTMLElement>('[data-automations-page-title]'))
: container.querySelector<HTMLElement>(PAGE_FOCUS_TARGET))
if (element) {
if (selector && element.matches(selector)) {
// Why: focus set from code after a pointer click draws no ring, so mark the row
// you came back to; a run row is also brought to the middle of its table.
element.scrollIntoView({ block: returning?.returnRunId ? 'center' : 'nearest' })
revealReturnedRow(element)
element.focus({ preventScroll: true })
flashReturnedRow(element)
keepUntil = performance.now() + RETURN_SETTLE_MS
+2 -1
View File
@@ -18948,7 +18948,8 @@
"local": "Local",
"remote": "Remote",
"historyUnavailable_one": "Run history is unavailable for {{count}} automation. Counts include available history only.",
"historyUnavailable_other": "Run history is unavailable for {{count}} automations. Counts include available history only."
"historyUnavailable_other": "Run history is unavailable for {{count}} automations. Counts include available history only.",
"searchLabel": "Search runs"
},
"AutomationsPageBreadcrumb": {
"ariaLabel": "Automations breadcrumb"
@@ -29,8 +29,7 @@ function replaceCurrentEntry(s: HistoryState, entry: WorktreeNavHistoryEntry): H
* Record an Automations sub-page. The plain 'automations' sentinel that opening the page
* recorded is replaced in place, so Back never lands on a duplicate of the same page.
* 'return' steps the index back when the destination is the previous entry (Escape to the
* parent), so Forward still reaches the page that was left. 'replace' swaps the current
* sub-page in place (a replayed entry the page could not show), keeping Forward intact.
* parent), so Forward still reaches the page that was left.
*/
export function appendAutomationsPageEntry(
s: HistoryState,
@@ -42,14 +41,6 @@ export function appendAutomationsPageEntry(
if (current === 'automations') {
return replaceCurrentEntry(s, entry)
}
if (mode === 'replace' && typeof current === 'object' && current.kind === 'automations-page') {
// Why: landing on the page just before this entry would leave a Back step that goes nowhere;
// step back to it and keep this entry as Forward instead.
if (isPreviousEntry(s, location)) {
return { ...s, worktreeNavHistoryIndex: s.worktreeNavHistoryIndex - 1 }
}
return replaceCurrentEntry(s, entry)
}
if (mode === 'return') {
if (isPreviousEntry(s, location)) {
const next = s.worktreeNavHistory.slice()
@@ -19,8 +19,8 @@ export type AutomationsPageLocation =
}
| { page: 'external'; externalKey: string }
/** How a recorded sub-page enters history: a new step, a step back to its parent, or a swap. */
export type AutomationsPageVisitMode = 'push' | 'return' | 'replace'
/** How the next recorded sub-page enters history: a new step, or a step back to its parent. */
export type AutomationsPageVisitMode = 'push' | 'return'
/** Identity for de-duplication; the run origin is how the user got there, not where they are. */
export function automationsPageLocationKey(location: AutomationsPageLocation): string {
@@ -203,11 +203,8 @@ export type UISliceCore = {
automationsPageLocationRequest: AutomationsPageLocation | null
setAutomationsPageLocationRequest: (location: AutomationsPageLocation | null) => void
/** Record a settled Automations sub-page in back/forward history. */
/** `mode` overrides the pending visit mode (a replay the page settled elsewhere replaces). */
recordAutomationsPageVisit: (
location: AutomationsPageLocation,
mode?: AutomationsPageVisitMode
) => void
/** Record a settled Automations sub-page in back/forward history. */
recordAutomationsPageVisit: (location: AutomationsPageLocation) => void
/** How the next recorded sub-page enters history; consumed by the next record. */
/** Set when the user opens Automations (sidebar, deep link, Back/Forward); the page then
* focuses its title once. A page restored at launch or on reload leaves focus alone. */
@@ -24,14 +24,14 @@ export function createUiViewActions(set: UISliceSet, get: UISliceGet): Partial<U
automationsPageLocationRequest: null,
setAutomationsPageLocationRequest: (location) =>
set({ automationsPageLocationRequest: location }),
recordAutomationsPageVisit: (location, mode) => {
recordAutomationsPageVisit: (location) => {
// Why: replaying an entry must not record it again and truncate the forward stack.
if (get().isNavigatingHistory) {
set({ automationsPageNextVisitMode: 'push' })
return
}
set((state) => ({
...appendAutomationsPageEntry(state, location, mode ?? state.automationsPageNextVisitMode),
...appendAutomationsPageEntry(state, location, state.automationsPageNextVisitMode),
automationsPageNextVisitMode: 'push'
}))
},
@@ -15,6 +15,20 @@ export type AutomationRunStatus =
export type AutomationRunTrigger = 'scheduled' | 'manual'
/** Statuses a run can never leave; only these are safe to evict from history. */
/**
* The time a run is listed under everywhere (list, detail, rail, run tables): its scheduled
* slot. A folded run (repeated identical refusals) is listed under its latest occurrence.
*/
export function getAutomationRunListedAt(
run: Pick<AutomationRun, 'scheduledFor' | 'occurrenceCount' | 'lastOccurrenceAt'>
): number {
return (run.occurrenceCount ?? 1) > 1 &&
run.lastOccurrenceAt !== undefined &&
run.lastOccurrenceAt > run.scheduledFor
? run.lastOccurrenceAt
: run.scheduledFor
}
export function isFinalAutomationRunStatus(status: AutomationRunStatus): boolean {
return (
status === 'completed' ||
@@ -1,4 +1,8 @@
import type { AutomationRun, AutomationRunStatus } from './automation-run'
import {
getAutomationRunListedAt,
type AutomationRun,
type AutomationRunStatus
} from './automation-run'
import type { SetupTrustMismatch } from '../../worktree/create-types'
export type AutomationUsageSummary = {
@@ -13,6 +17,8 @@ export type AutomationUsageSummary = {
/** Newest retained run's status, so list filters never fetch run history. Optional: older projections omit it. */
lastRunStatus?: AutomationRunStatus | null
lastRunAt?: number | null
/** The newest run's listed time (its scheduled slot); older projections omit it. */
lastRunListedAt?: number | null
/** The newest run skipped setup because its branch's setup differs from the approved one. */
lastRunSetupSkipped?: true
/** That skipped content, when the host recorded it. */
@@ -68,6 +74,7 @@ export function summarizeAutomationRunUsage(
lastRunAt: latest
? (latest.dispatchedAt ?? latest.startedAt ?? latest.createdAt ?? null)
: null,
lastRunListedAt: latest ? getAutomationRunListedAt(latest) : null,
...(latest?.setupSkippedReason === 'setup-changed'
? {
lastRunSetupSkipped: true,