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,