mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user