From bdb8fd35da4048877cf40df97c4d39d102446332 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 12 Nov 2026 12:45:16 -0800 Subject: [PATCH] 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. --- .../details/AutomationDetailHealth.tsx | 4 +- .../listing/AutomationListSearchField.tsx | 3 +- .../listing/automation-list-last-run.ts | 26 ++-- .../listing/automation-run-listed-at.test.ts | 56 +++++++ .../runs/AutomationRunDetailsPage.tsx | 3 +- .../runs/AutomationRunsDashboard.tsx | 5 +- .../runs/automation-run-identity-label.ts | 14 ++ .../use-automations-page-history.test.tsx | 147 ++++++++++++++++-- .../use-automations-page-history.ts | 41 +++-- src/renderer/src/i18n/locales/en.json | 3 +- .../slices/ui/automations-page-history.ts | 11 +- .../slices/ui/automations-page-location.ts | 4 +- .../store/slices/ui/ui-slice-contract-core.ts | 7 +- .../store/slices/ui/ui-slice-view-actions.ts | 4 +- .../automations/history/automation-run.ts | 14 ++ .../history/automation-usage-summary.ts | 9 +- 16 files changed, 289 insertions(+), 62 deletions(-) create mode 100644 src/renderer/src/components/automations/listing/automation-run-listed-at.test.ts diff --git a/src/renderer/src/components/automations/details/AutomationDetailHealth.tsx b/src/renderer/src/components/automations/details/AutomationDetailHealth.tsx index 4450fa4b41c..5d4af984368 100644 --- a/src/renderer/src/components/automations/details/AutomationDetailHealth.tsx +++ b/src/renderer/src/components/automations/details/AutomationDetailHealth.tsx @@ -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 = diff --git a/src/renderer/src/components/automations/listing/AutomationListSearchField.tsx b/src/renderer/src/components/automations/listing/AutomationListSearchField.tsx index b393a320673..be045836689 100644 --- a/src/renderer/src/components/automations/listing/AutomationListSearchField.tsx +++ b/src/renderer/src/components/automations/listing/AutomationListSearchField.tsx @@ -46,7 +46,8 @@ export function AutomationListSearchField({ { + 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) + }) +}) diff --git a/src/renderer/src/components/automations/runs/AutomationRunDetailsPage.tsx b/src/renderer/src/components/automations/runs/AutomationRunDetailsPage.tsx index 77557a46652..991b70ea82e 100644 --- a/src/renderer/src/components/automations/runs/AutomationRunDetailsPage.tsx +++ b/src/renderer/src/components/automations/runs/AutomationRunDetailsPage.tsx @@ -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 diff --git a/src/renderer/src/components/automations/runs/AutomationRunsDashboard.tsx b/src/renderer/src/components/automations/runs/AutomationRunsDashboard.tsx index 93ce86d7c86..df2c5794530 100644 --- a/src/renderer/src/components/automations/runs/AutomationRunsDashboard.tsx +++ b/src/renderer/src/components/automations/runs/AutomationRunsDashboard.tsx @@ -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" /> diff --git a/src/renderer/src/components/automations/runs/automation-run-identity-label.ts b/src/renderer/src/components/automations/runs/automation-run-identity-label.ts index c2005631a20..15382dd8af2 100644 --- a/src/renderer/src/components/automations/runs/automation-run-identity-label.ts +++ b/src/renderer/src/components/automations/runs/automation-run-identity-label.ts @@ -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 +): number | null { + if (run.dispatchedAt !== null) { + return run.dispatchedAt + } + return run.status === 'completed' || run.status === 'dispatched' ? run.startedAt : null +} diff --git a/src/renderer/src/components/automations/use-automations-page-history.test.tsx b/src/renderer/src/components/automations/use-automations-page-history.test.tsx index ad06cffda94..53e40433fb3 100644 --- a/src/renderer/src/components/automations/use-automations-page-history.test.tsx +++ b/src/renderer/src/components/automations/use-automations-page-history.test.tsx @@ -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 = '' + 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 = '' + 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') +}) diff --git a/src/renderer/src/components/automations/use-automations-page-history.ts b/src/renderer/src/components/automations/use-automations-page-history.ts index 1ffc42a7c5a..fa36c0cb121 100644 --- a/src/renderer/src/components/automations/use-automations-page-history.ts +++ b/src/renderer/src/components/automations/use-automations-page-history.ts @@ -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(selector) : null) ?? (selector && performance.now() < giveUpAt ? null - : container.querySelector('[data-automations-page-title]')) + : container.querySelector(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 diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e72d20a141b..b2a357399df 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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" diff --git a/src/renderer/src/store/slices/ui/automations-page-history.ts b/src/renderer/src/store/slices/ui/automations-page-history.ts index 68d4b857a62..c900268a9a4 100644 --- a/src/renderer/src/store/slices/ui/automations-page-history.ts +++ b/src/renderer/src/store/slices/ui/automations-page-history.ts @@ -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() diff --git a/src/renderer/src/store/slices/ui/automations-page-location.ts b/src/renderer/src/store/slices/ui/automations-page-location.ts index 85d5eec72ac..31556812339 100644 --- a/src/renderer/src/store/slices/ui/automations-page-location.ts +++ b/src/renderer/src/store/slices/ui/automations-page-location.ts @@ -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 { diff --git a/src/renderer/src/store/slices/ui/ui-slice-contract-core.ts b/src/renderer/src/store/slices/ui/ui-slice-contract-core.ts index 9acafe05af7..d50c1e07bbd 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-contract-core.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-contract-core.ts @@ -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. */ diff --git a/src/renderer/src/store/slices/ui/ui-slice-view-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-view-actions.ts index 0d2f5dd856b..b17f3faf34a 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-view-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-view-actions.ts @@ -24,14 +24,14 @@ export function createUiViewActions(set: UISliceSet, get: UISliceGet): Partial 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' })) }, diff --git a/src/shared/automations/history/automation-run.ts b/src/shared/automations/history/automation-run.ts index ef44d208a2f..a2a3a5f985e 100644 --- a/src/shared/automations/history/automation-run.ts +++ b/src/shared/automations/history/automation-run.ts @@ -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 +): 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' || diff --git a/src/shared/automations/history/automation-usage-summary.ts b/src/shared/automations/history/automation-usage-summary.ts index 1c29ddbd544..ea6258d2420 100644 --- a/src/shared/automations/history/automation-usage-summary.ts +++ b/src/shared/automations/history/automation-usage-summary.ts @@ -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,