fix(automations): address C1 review and UX-I1 findings

C1 (code review):
- C1-01: a Back/Forward replay that settles on another page (its run
  history could not be read) replaces its history entry instead of
  appending, so Forward history survives.
- C1-02: Run now's busy state is request-specific; a late failure or
  acknowledgement for an earlier click no longer clears a later one.
- C1-03: the page title takes focus when the user opens Automations
  (sidebar, deep link, Back/Forward); a page restored at launch or on
  reload leaves focus alone.
- C1-04: returning to an off-screen run scrolls the virtualized table to
  that row before focusing it.
- C1-05: a deleted-run deep link records the run it asked for, so
  Back/Forward return to the same not-found page.

UX-I1:
- One "Open workspace" label on the run page.
- Run status is a dot and label everywhere; the run header uses a soft
  tinted pill.
- Every failed run gets one plain-language banner. Blocked-agent reasons
  are translated (trust prompt: "<Agent> is waiting for you to trust this
  folder", with Open workspace), never shown as internal codes.
- A run that started off its slot shows "Scheduled … · Started …"; the
  detail's Last run says "Started …" in that case.
- The three stats stay in one row down to a 480px container.
- "—" instead of "n/a" for spend and tokens.
- An automation's Runs page drops the "Run history" header and shows the
  run count under the title.
- The not-found button reads "Open automation".
This commit is contained in:
Jinjing
2026-11-12 11:36:44 -08:00
parent 9049dcd8ff
commit 78948ddaa3
40 changed files with 635 additions and 185 deletions
@@ -141,21 +141,27 @@ export function Field({
export function AutomationRunErrorNotice({
label,
error,
action,
className
}: {
label: string
error: string
/** The one step that fixes this failure, when there is one. */
action?: React.ReactNode
className?: string
}): React.JSX.Element {
return (
<div
role="alert"
className={cn(
'rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive',
'flex flex-wrap items-center gap-x-3 gap-y-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive',
className
)}
>
<span className="font-medium">{label}</span> {error}
<span className="min-w-0 flex-1">
<span className="font-medium">{label}</span> {error}
</span>
{action}
</div>
)
}
@@ -4,6 +4,9 @@ export type AutomationPaneTab = 'overview' | 'runs'
/** Top-level surface within Automations. */
export type AutomationsPageView = 'automations' | 'runs' | 'run'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import type { AutomationRunPageOrigin } from '../../store/slices/ui/automations-page-location'
export type {
AutomationRunPageOrigin,
AutomationsPageLocation
@@ -13,10 +16,16 @@ export type {
export type AutomationRunNotFound = {
kind: 'run' | 'automation'
automationId: string
/** The requested run, so Back/Forward can return to this same (missing) destination. */
runId: string | null
hostId?: ExecutionHostId
origin?: AutomationRunPageOrigin
}
/** Run now was requested here; the button stays busy until the new run is listed. */
export type AutomationRunNowPending = {
/** This click's request; a late answer to an earlier click must not touch it. */
requestId: string
rowKey: string
requestedAt: number
acknowledgedAt: number | null
@@ -280,6 +280,8 @@ export function makeStoreState(): AutomationsPageStoreFixtures {
recordAutomationsPageVisit: noop,
automationsPageNextVisitMode: 'push',
setAutomationsPageNextVisitMode: noop,
automationsPageFocusTitleOnOpen: false,
setAutomationsPageFocusTitleOnOpen: noop,
hydratePersistedUI: noop,
recordFeatureInteraction: noop,
allWorktrees: () => [worktree],
@@ -20,6 +20,7 @@ import { translate } from '@/i18n/i18n'
import { useIntlLocale, useTranslate, type Translate } from '@/i18n/use-translate'
import { AutomationPromptDisclosure } from './AutomationPromptDisclosure'
import { automationRunErrorText } from '../runs/automation-run-error-copy'
import { getAgentLabel } from '../authoring/automation-draft-model'
import { AutomationDetailHealth } from './AutomationDetailHealth'
import { AutomationRecentRunsRail } from './AutomationRecentRunsRail'
import { AutomationsPageTitle } from '../AutomationsPageFrame'
@@ -159,7 +160,9 @@ export function AutomationDetail({
const needsMigration = Boolean(hostEntry?.servedBy)
const latestRun = indexLatestAutomationRuns(runs).get(automation.id)
const latestRunError =
latestRun?.status === 'dispatch_failed' ? automationRunErrorText(latestRun) : null
latestRun?.status === 'dispatch_failed'
? automationRunErrorText(latestRun, getAgentLabel(automation.agentId))
: null
const scheduleLabel = formatUiAutomationSchedule(automation.rrule, t, intlLocale)
const statusLabel = !automation.enabled
@@ -9,6 +9,7 @@ import {
type AutomationLastRunTone
} from '../listing/automation-list-last-run'
import { formatAutomationCost, formatAutomationTokens } from '../runs/automation-usage-model'
import { RUN_START_DRIFT_LABEL_MS } from '../runs/automation-run-identity-label'
import { useIntlLocale, useTranslate } from '@/i18n/use-translate'
function HealthCell({
@@ -55,7 +56,9 @@ function HealthCell({
<span className="truncate tabular-nums">{value}</span>
</div>
{detail ? (
<div className="mt-0.5 truncate text-xs text-muted-foreground tabular-nums">{detail}</div>
<div className="mt-0.5 truncate text-xs text-muted-foreground tabular-nums" title={detail}>
{detail}
</div>
) : null}
</>
)
@@ -95,6 +98,17 @@ export function AutomationDetailHealth({
const lastRun = getLocalAutomationLastRunSnapshot(automation, latestRun, t)
const lastRunRelative = formatAutomationRelativeTime(lastRun.at, now, t, intlLocale)
const lastRunAbsolute = lastRun.at ? formatAutomationDateTime(lastRun.at, t, intlLocale) : null
// Why: run pages and lists name a run by its scheduled slot; a late start says it started then.
const startedOffSchedule =
latestRun !== undefined &&
lastRun.at !== null &&
Math.abs(lastRun.at - latestRun.scheduledFor) >= RUN_START_DRIFT_LABEL_MS
const lastRunWhen =
lastRunAbsolute && startedOffSchedule
? t('auto.components.automations.runIdentity.startedAt', 'Started {{time}}', {
time: lastRunAbsolute
})
: lastRunAbsolute
const lastRunValue =
lastRun.tone === 'never'
? t('auto.components.automations.AutomationDetail.notRunYet', 'Not run yet')
@@ -102,9 +116,9 @@ export function AutomationDetailHealth({
const lastRunDetail =
lastRun.tone === 'never'
? null
: lastRun.statusLabel && lastRunRelative && lastRunAbsolute
? `${lastRunRelative} · ${lastRunAbsolute}`
: lastRunAbsolute
: lastRun.statusLabel && lastRunRelative && lastRunWhen
? `${lastRunRelative} · ${lastRunWhen}`
: lastRunWhen
const nextRunRelative = automation.enabled
? formatAutomationRelativeTime(automation.nextRunAt, now, t, intlLocale)
@@ -138,7 +152,7 @@ export function AutomationDetailHealth({
return (
<div className="@container overflow-hidden rounded-lg border border-border/60">
<div className="grid grid-cols-1 gap-px bg-border/60 @lg:grid-cols-3">
<div className="grid grid-cols-1 gap-px bg-border/60 @[30rem]:grid-cols-3">
<HealthCell
label={t('auto.components.automations.AutomationDetail.a7c312430d', 'Last run')}
value={lastRunValue}
@@ -9,7 +9,7 @@ import type { AutomationRun } from '../../../../../shared/automations/history/au
import type { Worktree } from '../../../../../shared/worktree/types'
import { AutomationDetail } from './AutomationDetail'
import { AutomationDetailActions } from './AutomationDetailActions'
import { AutomationRunHistory } from '../runs/AutomationRunHistory'
import { AutomationRunHistory, formatAutomationRunCountSummary } from '../runs/AutomationRunHistory'
import { ExternalAutomationManagers } from '../external/ExternalAutomationManagers'
import type { ExternalAutomationListEntry } from '../external/external-automation-list-entries'
import type { ExternalAutomationScope } from '../external/external-automation-scope-client'
@@ -124,9 +124,12 @@ export function AutomationsDetailPane({
<AutomationsPageTitle>
{t('auto.components.automations.AutomationsPage.0e110a3469', 'Runs')}
</AutomationsPageTitle>
<p className="truncate text-sm text-muted-foreground" title={selected.name}>
{selected.name}
</p>
{/* A failed read knows no counts; "0 runs" would answer a question nobody asked the host. */}
{selectedRunsNotice ? null : (
<p className="text-sm text-muted-foreground">
{formatAutomationRunCountSummary(selectedRuns, t)}
</p>
)}
</div>
<AutomationRunHistory
runs={selectedRuns}
@@ -1,6 +1,5 @@
import React, { useEffect, useRef } from 'react'
import { ChevronDown, ChevronUp, ExternalLink, KeyRound, RefreshCw } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
@@ -11,7 +10,7 @@ import type { Automation } from '../../../../../shared/automations/automation-de
import type { AutomationRun } from '../../../../../shared/automations/history/automation-run'
import { getAgentLabel } from '../authoring/automation-draft-model'
import { getAutomationRunContent } from './automation-run-content'
import { automationRunErrorText } from './automation-run-error-copy'
import { automationRunErrorText, isAutomationRunTrustBlocked } from './automation-run-error-copy'
import type { AutomationRunViewState } from './automation-run-view-state'
import type { AutomationRunWorkspaceDisplay } from './automation-run-workspace-display'
import { useFullAutomationRun } from './use-full-automation-run'
@@ -22,12 +21,13 @@ import {
AUTOMATION_EDITOR_SECTION_LABEL_CLASS,
AutomationRunErrorNotice,
AutomationRunSetupSkippedNotice,
formatAutomationDateTimeWithRelative,
getAutomationRunStatusLabel,
getAutomationRunStatusVariant
formatAutomationDateTime,
formatAutomationDateTimeWithRelative
} from '../automation-page-parts'
import { AutomationRunStatusLabel } from './AutomationRunStatusDot'
import { formatAutomationCost, formatAutomationTokens } from './automation-usage-model'
import {
RUN_START_DRIFT_LABEL_MS,
formatAutomationRunLabel,
getAutomationRunNeighbors,
getAutomationRunStepKey
@@ -101,8 +101,17 @@ export function AutomationRunDetailsPage({
const intlLocale = useIntlLocale()
const full = useFullAutomationRun(listedRun, history)
const run = full.run
const content = getAutomationRunContent(run, t)
const errorText = automationRunErrorText(run)
const failed = run.status === 'dispatch_failed'
// A trust prompt is answered in the workspace, so the banner carries that one action.
const openFromBanner = failed && isAutomationRunTrustBlocked(run) && Boolean(viewState?.canOpen)
// 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 startedLate =
startedAt !== null && Math.abs(startedAt - run.scheduledFor) >= RUN_START_DRIFT_LABEL_MS
const agentLabel = automation ? getAgentLabel(automation.agentId) : undefined
const errorText = automationRunErrorText(run, agentLabel)
// Why: a failed run's reason has its own banner; Output shows only what the agent printed.
const content = getAutomationRunContent(failed ? { ...run, error: null } : run, t)
// Without the automation, the run can't be checked against its approval.
const setupReview = automation
? pendingAutomationSetupReview(automation, {
@@ -161,7 +170,7 @@ export function AutomationRunDetailsPage({
value:
usageKnown && run.usage
? formatAutomationTokens(run.usage.totalTokens, intlLocale)
: t('auto.components.automations.AutomationRunHistory.a00e38d1a3', 'n/a'),
: t('auto.components.automations.AutomationRunHistory.a00e38d1a3', '—'),
muted: !usageKnown
},
{
@@ -186,9 +195,7 @@ export function AutomationRunDetailsPage({
<AutomationsPageTitle>
{formatAutomationRunLabel(run, t, intlLocale)}
</AutomationsPageTitle>
<Badge variant={getAutomationRunStatusVariant(run.status)} className="shrink-0">
{getAutomationRunStatusLabel(run.status, t)}
</Badge>
<AutomationRunStatusLabel status={run.status} pill />
<span className="flex-1" />
{(['newer', 'older'] as const).map((step) => {
const target = step === 'newer' ? newer : older
@@ -218,9 +225,30 @@ export function AutomationRunDetailsPage({
})}
</div>
<p className="mt-1 flex min-w-0 flex-wrap items-center gap-x-2 text-sm text-muted-foreground">
<span>
{formatAutomationDateTimeWithRelative(run.scheduledFor, relativeNow, t, intlLocale)}
</span>
{startedLate ? (
<>
<span>
{t('auto.components.automations.runIdentity.scheduledAt', 'Scheduled {{time}}', {
time: formatAutomationDateTime(run.scheduledFor, t, intlLocale)
})}
</span>
<span aria-hidden="true">·</span>
<span>
{t('auto.components.automations.runIdentity.startedAt', 'Started {{time}}', {
time: formatAutomationDateTimeWithRelative(
startedAt,
relativeNow,
t,
intlLocale
)
})}
</span>
</>
) : (
<span>
{formatAutomationDateTimeWithRelative(run.scheduledFor, relativeNow, t, intlLocale)}
</span>
)}
{automation ? (
<>
<span aria-hidden="true">·</span>
@@ -269,21 +297,28 @@ export function AutomationRunDetailsPage({
</Button>
) : null}
</div>
) : run.status === 'dispatch_failed' && errorText && content !== errorText ? (
// Why: saved terminal output otherwise hides why the run failed.
) : failed && errorText ? (
<AutomationRunErrorNotice
label={t('auto.components.automations.AutomationsPage.runError', 'Error:')}
label={t('auto.components.automations.runIdentity.runFailed', 'Run failed.')}
error={errorText}
action={
openFromBanner ? (
<Button type="button" variant="outline" size="xs" onClick={onOpenWorkspace}>
<ExternalLink className="size-3.5" />
{t('auto.components.automations.runViewState.openWorkspace', 'Open workspace')}
</Button>
) : null
}
/>
) : null}
{viewState?.canOpen || (canRerun && automation) ? (
{(viewState?.canOpen && !openFromBanner) || (canRerun && automation) ? (
<div className="flex flex-wrap items-center gap-2">
{/* Why: a disabled button with nothing to open reads as broken; Details says the workspace is gone. */}
{viewState?.canOpen ? (
{viewState?.canOpen && !openFromBanner ? (
<Button type="button" size="sm" onClick={onOpenWorkspace}>
<ExternalLink className="size-3.5" />
{viewState.actionLabel}
{t('auto.components.automations.runViewState.openWorkspace', 'Open workspace')}
</Button>
) : null}
{canRerun && automation ? (
@@ -1,4 +1,4 @@
import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
import React, { useCallback, useLayoutEffect, useRef, useState } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { cn } from '@/lib/class-names'
import type { AutomationRun } from '../../../../../shared/automations/history/automation-run'
@@ -14,6 +14,7 @@ import {
} from './AutomationRunTableCells'
import { automationRunOccurrenceLabel, isAutomationRunFolded } from './automation-run-occurrences'
import { getAutomationRunWorkspaceDisplay } from './automation-run-workspace-display'
import { useRevealAutomationRun } from './use-reveal-automation-run'
import { AutomationOwnerConflictNotice } from '../hosts/AutomationOwnerConflictNotice'
import type { AutomationActionNotice } from '../hosts/automation-row-action-dispatch'
import type { AutomationHostRecoveryAction } from '../hosts/automation-host-status-descriptors'
@@ -46,6 +47,19 @@ type AutomationRunHistoryProps = {
onOpenRun: (run: AutomationRun) => void
}
/** "16 runs · 12 completed": the summary under an automation's Runs title. */
export function formatAutomationRunCountSummary(
runs: readonly Pick<AutomationRun, 'status'>[],
t: ReturnType<typeof useTranslate>
): string {
const completed = runs.filter((run) => run.status === 'completed').length
return t(
'auto.components.automations.runHistory.runCountSummary',
'{{count}} runs · {{completed}} completed',
{ count: runs.length, completed, defaultValue_one: '{{count}} run · {{completed}} completed' }
)
}
export function AutomationRunHistory({
runs,
automationId,
@@ -69,14 +83,6 @@ export function AutomationRunHistory({
automationId,
runId: null
}))
const runCountLabel = useMemo(() => {
const completed = runs.filter((run) => run.status === 'completed').length
return t(
'auto.components.automations.runHistory.runCountSummary',
'{{count}} runs · {{completed}} completed',
{ count: runs.length, completed, defaultValue_one: '{{count}} run · {{completed}} completed' }
)
}, [runs, t])
const selectedRunId =
selectedRunState.automationId === automationId ? selectedRunState.runId : null
@@ -111,6 +117,11 @@ export function AutomationRunHistory({
scrollPaddingStart: scrollMargin
})
useRevealAutomationRun(
(runId) => runs.findIndex((run) => run.id === runId),
(index) => virtualizer.scrollToIndex(index, { align: 'center' })
)
// Track the rows’ header offset across zoom/font changes.
useLayoutEffect(() => {
const rows = rowsRef.current
@@ -216,13 +227,6 @@ export function AutomationRunHistory({
ref={containerRef}
className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-border/50 bg-muted/20 shadow-sm"
>
<div className="flex shrink-0 items-center justify-between border-b border-border/50 px-3 py-2">
<div className="text-sm font-medium">
{t('auto.components.automations.AutomationRunHistory.53fc5f07ab', 'Run history')}
</div>
{/* A failed read knows no counts; "0 runs" would answer a question nobody asked the host. */}
{notice ? null : <div className="text-xs text-muted-foreground">{runCountLabel}</div>}
</div>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<div ref={scrollRef} className="scrollbar-sleek min-h-0 flex-1 overflow-auto">
<div
@@ -45,18 +45,8 @@ export function AutomationRunNotFound({
</p>
<div className="mt-3 flex flex-wrap justify-center gap-2">
{runGone && automationName ? (
<Button
type="button"
size="sm"
className="max-w-[min(100%,24rem)]"
title={automationName}
onClick={onShowAutomation}
>
<span className="truncate">
{t('auto.components.automations.runNotFound.openAutomation', 'Open {{name}}', {
name: automationName
})}
</span>
<Button type="button" size="sm" title={automationName} onClick={onShowAutomation}>
{t('auto.components.automations.runNotFound.openThisAutomation', 'Open automation')}
</Button>
) : null}
<Button
@@ -1,7 +1,9 @@
import React from 'react'
import { cn } from '@/lib/class-names'
import type { AutomationRunStatus } from '../../../../../shared/automations/history/automation-run'
import { useTranslate } from '@/i18n/use-translate'
import { getToneForAutomationRunStatus } from '../listing/automation-list-last-run'
import { getAutomationRunStatusLabel } from '../automation-page-parts'
/** A run's outcome at a glance; the status word next to it carries the meaning. */
export function AutomationRunStatusDot({
@@ -27,3 +29,32 @@ export function AutomationRunStatusDot({
/>
)
}
/** Dot + status word, the one way run status is drawn; a failure reads in red. */
export function AutomationRunStatusLabel({
status,
pill = false,
className
}: {
status: AutomationRunStatus
/** A soft-tinted pill for the run page header. */
pill?: boolean
className?: string
}): React.JSX.Element {
const t = useTranslate()
const tone = getToneForAutomationRunStatus(status)
return (
<span
className={cn(
'inline-flex min-w-0 shrink-0 items-center gap-1.5 whitespace-nowrap',
tone === 'failed' ? 'text-destructive' : pill ? 'text-foreground' : 'text-muted-foreground',
pill && 'rounded-full px-2 py-0.5 text-xs font-medium',
pill && (tone === 'failed' ? 'bg-destructive/10' : 'bg-muted'),
className
)}
>
<AutomationRunStatusDot status={status} />
{getAutomationRunStatusLabel(status, t)}
</span>
)
}
@@ -1,14 +1,10 @@
import React from 'react'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/class-names'
import { useIntlLocale, useTranslate } from '@/i18n/use-translate'
import type { AutomationRunStatus } from '../../../../../shared/automations/history/automation-run'
import {
formatAutomationDateTime,
getAutomationRunStatusLabel,
getAutomationRunStatusVariant
} from '../automation-page-parts'
import { formatAutomationDateTime } from '../automation-page-parts'
import { formatAutomationCost } from './automation-usage-model'
import { AutomationRunStatusLabel } from './AutomationRunStatusDot'
import { formatAutomationRunLabel } from './automation-run-identity-label'
import type { AutomationRunWorkspaceDisplay } from './automation-run-workspace-display'
@@ -71,12 +67,9 @@ export function AutomationRunResultCell({
}: {
status: AutomationRunStatus
}): React.JSX.Element {
const t = useTranslate()
return (
<div className="flex min-w-0 justify-start">
<Badge variant={getAutomationRunStatusVariant(status)}>
{getAutomationRunStatusLabel(status, t)}
</Badge>
<AutomationRunStatusLabel status={status} />
</div>
)
}
@@ -14,6 +14,7 @@ import {
AutomationRunTableHeaderCells,
AutomationRunWorkspaceCell
} from './AutomationRunTableCells'
import { useRevealAutomationRun } from './use-reveal-automation-run'
const RUN_ROW_HEIGHT_PX = 59
const RUN_ROW_OVERSCAN = 10
@@ -51,6 +52,10 @@ export function AutomationRunsTable({
initialRect: RUNS_VIEWPORT_INITIAL_RECT,
getItemKey: (index) => entries[index]?.key ?? index
})
useRevealAutomationRun(
(runId) => entries.findIndex((entry) => entry.run.id === runId),
(index) => virtualizer.scrollToIndex(index, { align: 'center' })
)
return (
<div className="flex min-h-[15rem] flex-1 flex-col overflow-hidden rounded-lg border border-border/60 bg-card">
@@ -3,12 +3,15 @@ import { toast } from 'sonner'
import type { AutomationRun } from '../../../../../shared/automations/history/automation-run'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store/index'
import { createNonSecureContextUuid } from '../../../../../shared/non-secure-context-uuid'
import { getAutomationTargetAvailability } from '../hosts/automation-target-availability'
import { runAutomationNowForTarget } from '../hosts/automation-host-client'
import { dispatchAutomationRunNow } from '../hosts/automation-row-action-dispatch'
import { waitForAutomationRerunPendingVisibility } from './automation-run-view-state'
import type { AutomationListRow } from '../listing/automation-list-row-identity'
import type { AutomationsPageActionContext } from '../automations-page-action-context'
import type { AutomationRunNowPending } from '../automation-page-state'
import { settleAutomationRunNowPending } from './automation-run-now-pending'
/** Run-now/rerun handlers keyed by the selected row's captured host. */
export function createAutomationRunActions({
@@ -81,7 +84,13 @@ export function createAutomationRunActions({
}
// Why: the detail's Run now stays busy until the run it started is listed, so a
// second click can't queue a duplicate; a scheduled run in progress never blocks it.
const requestId = createNonSecureContextUuid()
const settle = (
update: (current: AutomationRunNowPending) => AutomationRunNowPending | null
): void =>
setRunNowPending((current) => settleAutomationRunNowPending(current, requestId, update))
setRunNowPending({
requestId,
rowKey: row.key,
requestedAt: Date.now(),
acknowledgedAt: null,
@@ -95,20 +104,16 @@ export function createAutomationRunActions({
() => runAutomationNowForTarget(row.automation, rowHostTarget)
)
} catch (error) {
setRunNowPending(null)
settle(() => null)
throw error
}
reportOwnerAction(row.key, result.ok ? null : result.notice)
if (!result.ok) {
setRunNowPending(null)
settle(() => null)
return
}
const startedRunId = result.value?.id ?? null
setRunNowPending((current) =>
current?.rowKey === row.key
? { ...current, runId: startedRunId, acknowledgedAt: Date.now() }
: current
)
settle((current) => ({ ...current, runId: startedRunId, acknowledgedAt: Date.now() }))
useAppStore.getState().recordFeatureInteraction('automation-run')
invalidateRowHost(row.key, 'run')
await pageRefresh.hydratePersistedUIState()
@@ -101,12 +101,77 @@ function isAutomationRunFailureCode(code: unknown): code is AutomationRunFailure
return typeof code === 'string' && Object.hasOwn(COPY, code)
}
// Main records a blocked agent as "Automation agent is blocked: <reason>." with no code.
const BLOCKED_AGENT_ERROR = /^Automation agent is blocked: ([a-z0-9-]+)\.?$/
// Why: the reason is an internal detector id; people need to know what the agent is waiting for.
function blockedAgentText(reason: string, agentLabel: string | undefined): string {
const kind = reason.replace(/^(agent|codex)-/, '')
switch (kind) {
case 'trust-workspace':
return agentLabel
? translate(
'automations.runFailures.blocked.trustWorkspaceNamed',
'{{agent}} is waiting for you to trust this folder.',
{ agent: agentLabel }
)
: translate(
'automations.runFailures.blocked.trustWorkspace',
'The agent is waiting for you to trust this folder.'
)
case 'update-prompt':
return translate(
'automations.runFailures.blocked.update',
'The agent is waiting for you to answer an update prompt.'
)
case 'cwd-prompt':
return translate(
'automations.runFailures.blocked.folder',
'The agent is asking which folder to work in.'
)
case 'model-migration-prompt':
return translate(
'automations.runFailures.blocked.model',
'The agent is asking you to choose a model.'
)
case 'hooks-review-prompt':
return translate('automations.runFailures.hooksReviewBlocked', EN.hooksReviewBlocked)
case 'approval-prompt':
return translate(
'automations.runFailures.blocked.approval',
'The agent is waiting for you to approve an action.'
)
case 'sign-in-prompt':
return translate(
'automations.runFailures.blocked.signIn',
'The agent is waiting for you to sign in.'
)
default:
return translate(
'automations.runFailures.blocked.prompt',
'The agent stopped at a prompt that needs your answer.'
)
}
}
/** Old records and older hosts omit the code; newer hosts may send unknown codes. Both show `error`. */
export function automationRunErrorText(
run: Pick<AutomationRun, 'error' | 'errorCode' | 'errorParams'>
run: Pick<AutomationRun, 'error' | 'errorCode' | 'errorParams'>,
/** Names the agent in prompts it is waiting on, e.g. "Claude". */
agentLabel?: string
): string | null {
if (run.error === null || !isAutomationRunFailureCode(run.errorCode)) {
return run.error
if (run.error === null) {
return null
}
if (!isAutomationRunFailureCode(run.errorCode)) {
const blocked = BLOCKED_AGENT_ERROR.exec(run.error)
return blocked ? blockedAgentText(blocked[1], agentLabel) : run.error
}
return COPY[run.errorCode](run.errorParams) ?? run.error
}
/** A run stopped at an agent trust prompt; opening its workspace is how to answer it. */
export function isAutomationRunTrustBlocked(run: Pick<AutomationRun, 'error'>): boolean {
const blocked = run.error ? BLOCKED_AGENT_ERROR.exec(run.error) : null
return blocked?.[1].endsWith('-trust-workspace') ?? false
}
@@ -61,3 +61,6 @@ export function getAutomationRunStepKey(event: {
}
return step
}
/** Runs normally start within seconds of their slot; past this, start and slot are labeled. */
export const RUN_START_DRIFT_LABEL_MS = 60_000
@@ -1,18 +1,33 @@
import { describe, expect, it } from 'vitest'
import { makeRun } from '../automations-page-fixtures'
import { isAutomationRunNowPending } from './automation-run-now-pending'
import {
isAutomationRunNowPending,
settleAutomationRunNowPending
} from './automation-run-now-pending'
const requestedAt = 1_000_000
describe('isAutomationRunNowPending', () => {
it('stays busy until the run the host returned is listed', () => {
const pending = { rowKey: 'row', requestedAt, acknowledgedAt: requestedAt, runId: 'new-run' }
const pending = {
requestId: 'req',
rowKey: 'row',
requestedAt,
acknowledgedAt: requestedAt,
runId: 'new-run'
}
expect(isAutomationRunNowPending(pending, 'row', [makeRun({ id: 'old-run' })])).toBe(true)
expect(isAutomationRunNowPending(pending, 'row', [makeRun({ id: 'new-run' })])).toBe(false)
})
it('keeps a dispatch in flight busy even if a recent manual run is listed', () => {
const pending = { rowKey: 'row', requestedAt, acknowledgedAt: null, runId: null }
const pending = {
requestId: 'req',
rowKey: 'row',
requestedAt,
acknowledgedAt: null,
runId: null
}
expect(
isAutomationRunNowPending(pending, 'row', [
makeRun({ id: 'previous', trigger: 'manual', createdAt: 999_999 })
@@ -26,7 +41,13 @@ describe('isAutomationRunNowPending', () => {
})
it('without a returned id, a manual run created after the click ends the wait', () => {
const pending = { rowKey: 'row', requestedAt, acknowledgedAt: requestedAt, runId: null }
const pending = {
requestId: 'req',
rowKey: 'row',
requestedAt,
acknowledgedAt: requestedAt,
runId: null
}
const scheduled = makeRun({ id: 's', trigger: 'scheduled', createdAt: requestedAt + 10 })
const manual = makeRun({ id: 'm', trigger: 'manual', createdAt: requestedAt + 10 })
expect(isAutomationRunNowPending(pending, 'row', [scheduled])).toBe(true)
@@ -34,7 +55,32 @@ describe('isAutomationRunNowPending', () => {
})
it('only the row that asked shows the busy state', () => {
const pending = { rowKey: 'row', requestedAt, acknowledgedAt: requestedAt, runId: 'new-run' }
const pending = {
requestId: 'req',
rowKey: 'row',
requestedAt,
acknowledgedAt: requestedAt,
runId: 'new-run'
}
expect(isAutomationRunNowPending(pending, 'other-row', [])).toBe(false)
})
it('ignores a late answer to an earlier click that another click replaced', () => {
const a = { requestId: 'a', rowKey: 'row-a', requestedAt, acknowledgedAt: null, runId: null }
const b = { requestId: 'b', rowKey: 'row-b', requestedAt, acknowledgedAt: null, runId: null }
// A fails after B started: B stays busy.
expect(settleAutomationRunNowPending(b, a.requestId, () => null)).toBe(b)
// B's own failure clears it.
expect(settleAutomationRunNowPending(b, b.requestId, () => null)).toBeNull()
})
it('ignores an earlier same-automation request once a newer one owns the slot', () => {
const older = { requestId: 'r1', rowKey: 'row', requestedAt, acknowledgedAt: null, runId: null }
const newer = { ...older, requestId: 'r2' }
const acknowledged = settleAutomationRunNowPending(newer, 'r1', (p) => ({
...p,
acknowledgedAt: requestedAt + 1
}))
expect(acknowledged).toBe(newer)
})
})
@@ -30,3 +30,16 @@ export function isAutomationRunNowPending(
): boolean {
return pending !== null && pending.rowKey === rowKey && !isAutomationRunNowListed(pending, runs)
}
/**
* Apply an answer (failure, acknowledgement, timeout) to the busy state only if it still
* belongs to the request that got it. A later click, here or on another automation,
* replaced the slot, and its own answer settles it.
*/
export function settleAutomationRunNowPending(
current: AutomationRunNowPending | null,
requestId: string,
update: (pending: AutomationRunNowPending) => AutomationRunNowPending | null
): AutomationRunNowPending | null {
return current?.requestId === requestId ? update(current) : current
}
@@ -26,7 +26,7 @@ export function formatAutomationCost(
t: Translate = translate
): string {
if (value === null || value === undefined) {
return t('auto.components.automations.usage.costUnavailable', 'n/a')
return t('auto.components.automations.usage.costUnavailable', '—')
}
if (value > 0 && value < 0.01) {
return `$${value.toFixed(4)}`
@@ -0,0 +1,10 @@
/** Asks the mounted virtualized run table to scroll a run row into view so it can take focus. */
export const AUTOMATIONS_REVEAL_RUN_EVENT = 'automations-reveal-run'
export type AutomationsRevealRunDetail = { runId: string }
export function requestAutomationRunReveal(runId: string): void {
window.dispatchEvent(
new CustomEvent<AutomationsRevealRunDetail>(AUTOMATIONS_REVEAL_RUN_EVENT, { detail: { runId } })
)
}
@@ -153,7 +153,13 @@ export function useAutomationRunPageState({
setSelectedId(pending.automationId)
setSelectedAutomationRunPageId(null)
setPendingAutomationRunNavigation(null)
setRunNotFound({ kind: 'automation', automationId: pending.automationId })
setRunNotFound({
kind: 'automation',
automationId: pending.automationId,
runId: pending.runId,
hostId: pending.hostId,
origin: pending.origin
})
setPageView('run')
return
}
@@ -207,7 +213,13 @@ export function useAutomationRunPageState({
}
setSelectedAutomationRunPageId(null)
setPendingAutomationRunNavigation(null)
setRunNotFound({ kind: 'run', automationId: pending.automationId })
setRunNotFound({
kind: 'run',
automationId: pending.automationId,
runId: pending.runId,
hostId: pending.hostId,
origin: pending.origin
})
setPageView('run')
}, [
automations,
@@ -0,0 +1,36 @@
import { useEffect, useRef } from 'react'
import {
AUTOMATIONS_REVEAL_RUN_EVENT,
type AutomationsRevealRunDetail
} from './automations-reveal-run'
/**
* Scrolls a virtualized run table to a run when the page asks, so returning from an
* off-screen run can focus its row: an unmounted row has no element to focus.
*/
export function useRevealAutomationRun(
indexOfRun: (runId: string) => number,
scrollToIndex: (index: number) => void
): void {
const latestRef = useRef({ indexOfRun, scrollToIndex })
useEffect(() => {
latestRef.current = { indexOfRun, scrollToIndex }
})
useEffect(() => {
const onReveal = (event: Event): void => {
if (!(event instanceof CustomEvent)) {
return
}
const detail: Partial<AutomationsRevealRunDetail> | null = event.detail
if (typeof detail?.runId !== 'string') {
return
}
const index = latestRef.current.indexOfRun(detail.runId)
if (index >= 0) {
latestRef.current.scrollToIndex(index)
}
}
window.addEventListener(AUTOMATIONS_REVEAL_RUN_EVENT, onReveal)
return () => window.removeEventListener(AUTOMATIONS_REVEAL_RUN_EVENT, onReveal)
}, [])
}
@@ -4,13 +4,27 @@ import { afterEach, beforeEach, 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'
import { useAutomationsPageHistory } from './use-automations-page-history'
import {
useAutomationsPageFocus,
useAutomationsPageHistory,
type AutomationsPageFocusTarget
} from './use-automations-page-history'
import { AUTOMATIONS_REVEAL_RUN_EVENT } from './runs/automations-reveal-run'
import { automationsPageNotFoundLocation } from './use-automations-page-routing'
const list: AutomationsPageLocation = { page: 'list' }
const detail: AutomationsPageLocation = { page: 'detail', automationId: 'a1' }
const run1: AutomationsPageLocation = {
page: 'run',
automationId: 'a1',
runId: 'r1',
origin: 'automation'
}
const run2: AutomationsPageLocation = { ...run1, runId: 'r2' }
type Props = { location: AutomationsPageLocation | null }
const onDetail: Props = { location: detail }
const onList: Props = { location: list }
const onRun2: Props = { location: run2 }
const entry = (location: AutomationsPageLocation): WorktreeNavHistoryEntry => ({
kind: 'automations-page',
@@ -24,12 +38,14 @@ beforeEach(() => {
worktreeNavHistoryIndex: 2,
automationsPageLocationRequest: null,
automationsPageNextVisitMode: 'push',
automationsPageFocusTitleOnOpen: false,
isNavigatingHistory: false
})
})
afterEach(() => {
cleanup()
document.body.innerHTML = ''
})
it('does not record the page Back left while the replay is still applying', () => {
@@ -67,3 +83,97 @@ it('records a sub-page the user opens', () => {
expect(state.worktreeNavHistory).toEqual(['skills', entry(list), entry(detail)])
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
})
const goToLocation = vi.fn()
const { rerender } = renderHook(
({ location }: Props) => useAutomationsPageHistory({ location, navigation: { goToLocation } }),
{ initialProps: onRun2 }
)
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()
expect(state.worktreeNavHistory).toEqual(['skills', entry(detail), entry(detail), entry(run2)])
expect(state.worktreeNavHistoryIndex).toBe(2)
})
function mountPage(): HTMLElement {
const container = document.createElement('main')
container.innerHTML =
'<h1 tabindex="-1" data-automations-page-title>Title</h1><button data-automation-run-id="r9">Run</button>'
document.body.appendChild(container)
return container
}
const detailTarget: AutomationsPageFocusTarget = {
key: 'detail:a1',
page: 'detail',
parents: ['list'],
returnSelector: null
}
it('focuses the title when the user opens the page', () => {
const container = mountPage()
useAppStore.setState({ automationsPageFocusTitleOnOpen: true })
renderHook(() => useAutomationsPageFocus(detailTarget, { current: container }))
expect(document.activeElement?.hasAttribute('data-automations-page-title')).toBe(true)
expect(useAppStore.getState().automationsPageFocusTitleOnOpen).toBe(false)
})
it('leaves focus alone when the page is restored without a user action', () => {
const container = mountPage()
renderHook(() => useAutomationsPageFocus(detailTarget, { current: container }))
expect(document.activeElement).toBe(document.body)
})
it('asks the run table to reveal an off-screen run row on the way back', () => {
const container = mountPage()
container.querySelector('[data-automation-run-id]')?.remove()
const reveal = vi.fn()
window.addEventListener(AUTOMATIONS_REVEAL_RUN_EVENT, reveal)
const runTarget: AutomationsPageFocusTarget = {
key: 'run:r9',
page: 'run',
parents: ['detail', 'automationRuns', 'runs'],
returnSelector: '[data-automation-run-id="r9"]',
returnRunId: 'r9'
}
const runsTarget: AutomationsPageFocusTarget = {
key: 'automationRuns:a1',
page: 'automationRuns',
parents: ['detail', 'list'],
returnSelector: null
}
const { rerender } = renderHook(
({ target }: { target: AutomationsPageFocusTarget }) =>
useAutomationsPageFocus(target, { current: container }),
{ initialProps: { target: runTarget } }
)
rerender({ target: runsTarget })
window.removeEventListener(AUTOMATIONS_REVEAL_RUN_EVENT, reveal)
expect(reveal).toHaveBeenCalled()
expect(reveal.mock.calls[0]?.[0]).toMatchObject({ detail: { runId: 'r9' } })
})
it('records a deleted-run deep link as the run it asked for, so Back/Forward return there', () => {
expect(
automationsPageNotFoundLocation({
kind: 'run',
automationId: 'a1',
runId: 'gone',
origin: 'runs'
})
).toEqual({ page: 'run', automationId: 'a1', runId: 'gone', origin: 'runs' })
expect(
automationsPageNotFoundLocation({ kind: 'automation', automationId: 'a1', runId: null })
).toEqual({ page: 'detail', automationId: 'a1', hostId: undefined })
})
@@ -3,6 +3,7 @@ import { useAppStore } from '@/store/index'
import { automationsPageLocationKey } from '../../store/slices/ui/automations-page-location'
import type { AutomationsPageLocation } from './automation-page-state'
import type { AutomationsPageNavigation } from './automations-page-navigation'
import { requestAutomationRunReveal } from './runs/automations-reveal-run'
/** Record each settled sub-page in app history and apply back/forward replays. */
export function useAutomationsPageHistory({
@@ -17,8 +18,17 @@ export function useAutomationsPageHistory({
const setRequest = useAppStore((s) => s.setAutomationsPageLocationRequest)
const record = useAppStore((s) => s.recordAutomationsPageVisit)
const key = location ? automationsPageLocationKey(location) : null
const keyRef = useRef(key)
const locationRef = useRef(location)
const requestRef = useRef(request)
const goToLocationRef = useRef(navigation.goToLocation)
// The key of the entry a Back/Forward replay is showing, until the page settles.
const replayKeyRef = useRef<string | null>(null)
useEffect(() => {
keyRef.current = key
locationRef.current = location
requestRef.current = request
goToLocationRef.current = navigation.goToLocation
})
useEffect(() => {
@@ -26,24 +36,29 @@ export function useAutomationsPageHistory({
return
}
setRequest(null)
const requestKey = automationsPageLocationKey(request)
// Why: a replay of the page already shown changes nothing, so nothing will settle.
replayKeyRef.current = requestKey === keyRef.current ? null : requestKey
goToLocationRef.current(request)
}, [request, setRequest])
const key = location ? automationsPageLocationKey(location) : null
const locationRef = useRef(location)
const requestRef = useRef(request)
useEffect(() => {
locationRef.current = location
requestRef.current = request
})
useEffect(() => {
// Why: record only when the page itself changes. Clearing a replay request can render
// once before the page state it asked for, still showing the page Back just left;
// re-running on the request change would record that page and cut Forward history.
// A replay's own location matches the entry it came from, so recording it is a no-op.
if (key === null || requestRef.current || !locationRef.current) {
return
}
const replayKey = replayKeyRef.current
if (replayKey !== null) {
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)
}, [key, record])
}
@@ -55,14 +70,18 @@ export type AutomationsPageFocusTarget = {
parents: readonly string[]
/** This page's entry in its parent (its list row, rail item, or "All runs" link). */
returnSelector: string | null
/** Set when that entry is a run row, which a virtualized table may need to scroll to. */
returnRunId?: string
page: string
}
const FOCUS_RETRY_FRAMES = 12
// Virtualized rows mount after a scroll, and a freshly opened page a frame or two later.
const FOCUS_RETRY_FRAMES = 30
/**
* Opening a page focuses its title; going back focuses the row or link you left from,
* so keyboard and screen-reader users keep their place.
* so keyboard and screen-reader users keep their place. A page that appears without a
* user action (restored at launch or on reload) leaves focus alone.
*/
export function useAutomationsPageFocus(
target: AutomationsPageFocusTarget | null,
@@ -73,18 +92,24 @@ export function useAutomationsPageFocus(
const page = target?.page ?? null
const parents = target?.parents.join(',') ?? ''
const returnSelector = target?.returnSelector ?? null
const returnRunId = target?.returnRunId
useLayoutEffect(() => {
// Why: resolving states (loading, deep link) have no target; keep the last real page.
if (key === null || page === null) {
return
}
const previous = previousRef.current
previousRef.current = { key, page, parents: parents.split(','), returnSelector }
if (!previous || previous.key === key) {
previousRef.current = { key, page, parents: parents.split(','), returnSelector, returnRunId }
const store = useAppStore.getState()
const userOpened = store.automationsPageFocusTitleOnOpen
if (userOpened) {
store.setAutomationsPageFocusTitleOnOpen(false)
}
if (previous ? previous.key === key : !userOpened) {
return
}
const selector =
previous.returnSelector && previous.parents.includes(page) ? previous.returnSelector : null
const returning = previous?.returnSelector && previous.parents.includes(page) ? previous : null
const selector = returning?.returnSelector ?? null
let frame = 0
let handle = 0
const attempt = (): void => {
@@ -92,7 +117,6 @@ export function useAutomationsPageFocus(
if (!container) {
return
}
// Why: virtualized rows and freshly opened pages mount a frame or two later.
const element =
(selector ? container.querySelector<HTMLElement>(selector) : null) ??
(selector && frame < FOCUS_RETRY_FRAMES
@@ -102,6 +126,9 @@ export function useAutomationsPageFocus(
element.focus({ preventScroll: false })
return
}
if (returning?.returnRunId) {
requestAutomationRunReveal(returning.returnRunId)
}
frame += 1
if (frame <= FOCUS_RETRY_FRAMES) {
handle = requestAnimationFrame(attempt)
@@ -109,5 +136,5 @@ export function useAutomationsPageFocus(
}
attempt()
return () => cancelAnimationFrame(handle)
}, [key, page, parents, returnSelector, containerRef])
}, [key, page, parents, returnSelector, returnRunId, containerRef])
}
@@ -1,9 +1,10 @@
import { useEffect } from 'react'
import type { AutomationsPageLocation } from './automation-page-state'
import type { AutomationRunNotFound, AutomationsPageLocation } from './automation-page-state'
import { automationRowHostId } from './automations-page-navigation'
import {
isAutomationRunNowListed,
isAutomationRunNowPending
isAutomationRunNowPending,
settleAutomationRunNowPending
} from './runs/automation-run-now-pending'
import type { AutomationsPageController } from './use-automations-page-controller'
import {
@@ -22,6 +23,21 @@ function attributeSelector(name: string, value: string): string {
return `[${name}=${JSON.stringify(value)}]`
}
/** A missing destination is still where the user went; replaying it shows not-found again. */
export function automationsPageNotFoundLocation(
notFound: AutomationRunNotFound
): AutomationsPageLocation {
return notFound.runId
? {
page: 'run',
automationId: notFound.automationId,
runId: notFound.runId,
hostId: notFound.hostId,
origin: notFound.origin ?? 'automation'
}
: { page: 'detail', automationId: notFound.automationId, hostId: notFound.hostId }
}
/** Page identity for back/forward history and focus, plus the Run now busy state. */
export function useAutomationsPageRouting(
controller: AutomationsPageController,
@@ -56,8 +72,11 @@ export function useAutomationsPageRouting(
if (!runNowPending) {
return
}
const { requestId } = runNowPending
const clearThisRequest = (): void =>
setRunNowPending((current) => settleAutomationRunNowPending(current, requestId, () => null))
if (runNowListed) {
setRunNowPending(null)
clearThisRequest()
return
}
// Why: a dispatch that never answers must not lock the button either; the dispatcher's
@@ -66,7 +85,7 @@ export function useAutomationsPageRouting(
runNowPending.acknowledgedAt === null
? runNowPending.requestedAt + RUN_NOW_DISPATCH_MAX_MS
: runNowPending.acknowledgedAt + RUN_NOW_PENDING_MAX_MS
const timer = setTimeout(() => setRunNowPending(null), Math.max(0, deadline - Date.now()))
const timer = setTimeout(clearThisRequest, Math.max(0, deadline - Date.now()))
return () => clearTimeout(timer)
}, [runNowListed, runNowPending, setRunNowPending])
@@ -77,29 +96,31 @@ export function useAutomationsPageRouting(
(local.selectedExternalKey !== null && list.scopedExternal.loading)
const location: AutomationsPageLocation | null = isResolving
? null
: pageView === 'runs'
? { page: 'runs' }
: pageView === 'run'
? selected && selectedAutomationRunPage && !runNotFound
? {
page: 'run',
automationId: selected.id,
runId: selectedAutomationRunPage.id,
hostId: selectedHostId,
origin: runPageOrigin
}
: null
: isDetailOpen && selectedExternal
? { page: 'external', externalKey: selectedExternal.key }
: isDetailOpen && selected
: runNotFound
? automationsPageNotFoundLocation(runNotFound)
: pageView === 'runs'
? { page: 'runs' }
: pageView === 'run'
? selected && selectedAutomationRunPage
? {
page: activePaneTab === 'runs' ? 'automationRuns' : 'detail',
page: 'run',
automationId: selected.id,
hostId: selectedHostId
runId: selectedAutomationRunPage.id,
hostId: selectedHostId,
origin: runPageOrigin
}
: isDetailOpen
? null
: { page: 'list' }
: null
: isDetailOpen && selectedExternal
? { page: 'external', externalKey: selectedExternal.key }
: isDetailOpen && selected
? {
page: activePaneTab === 'runs' ? 'automationRuns' : 'detail',
automationId: selected.id,
hostId: selectedHostId
}
: isDetailOpen
? null
: { page: 'list' }
useAutomationsPageHistory({ location, navigation })
const focusTarget: AutomationsPageFocusTarget | null = runNotFound
@@ -111,7 +132,8 @@ export function useAutomationsPageRouting(
key: `run:${location.runId}`,
page: 'run',
parents: ['detail', 'automationRuns', 'runs'],
returnSelector: attributeSelector('data-automation-run-id', location.runId)
returnSelector: attributeSelector('data-automation-run-id', location.runId),
returnRunId: location.runId
}
: location.page === 'detail' || location.page === 'automationRuns'
? {
+2 -4
View File
@@ -17659,8 +17659,7 @@
"86a248187e": "Ausgaben",
"149c0b49c7": "Arbeitsbereich",
"8faaa00726": "Ausführung",
"53fc5f07ab": "Ausführungsverlauf",
"a00e38d1a3": "Nicht verfügbar",
"a00e38d1a3": "—",
"historyUnavailable": "Der Ausführungsverlauf ist von diesem Host aus nicht verfügbar. Dies bedeutet nicht, dass die Automatisierung fehlgeschlagen ist oder keine Ausführungen hat."
},
"AutomationSchedulePicker": {
@@ -17745,7 +17744,6 @@
"rowActions": "Automatisierungsaktionen",
"tableLastRun": "Letzte Ausführung",
"ownerChanged": "Der Host dieser Automatisierung hat sich geändert. Aktualisieren und erneut versuchen.",
"runError": "Fehler:",
"deleteAutomationDescription": "<name/> und den Ausführungsverlauf löschen. Arbeitsbereiche aus früheren Ausführungen werden nicht gelöscht.",
"deleteExternalAutomationDescription": "<name/> aus <provider/> auf <host/> löschen.",
"runUsageSummaryTokens_one": "ca. {{cost}} · {{tokens}} Token",
@@ -18077,7 +18075,7 @@
"usage": {
"tokensAndCost_one": "{{tokens}} Token · {{cost}}",
"tokensAndCost_other": "{{tokens}} Tokens · {{cost}}",
"costUnavailable": "k. A."
"costUnavailable": "—"
},
"externalDisplay": {
"sshHost": "SSH-Host",
+18 -7
View File
@@ -18598,8 +18598,7 @@
"86a248187e": "Spend",
"149c0b49c7": "Workspace",
"8faaa00726": "Run",
"53fc5f07ab": "Run history",
"a00e38d1a3": "n/a",
"a00e38d1a3": "—",
"historyUnavailable": "Run history is unavailable from this host. This does not mean the automation failed or has no runs."
},
"AutomationSchedulePicker": {
@@ -18684,7 +18683,6 @@
"rowActions": "Automation actions",
"tableLastRun": "Last run",
"ownerChanged": "This automation changed hosts. Refresh and try again.",
"runError": "Error:",
"setupSkippedChanged": "Setup didn't run: this run's branch has different orca.yaml setup commands than the ones approved when the automation was saved. Save the automation again to review the current commands.",
"deleteAutomationDescription": "Delete <name/> and its run history. Workspaces created by previous runs are not deleted.",
"deleteExternalAutomationDescription": "Delete <name/> from <provider/> on <host/>.",
@@ -19019,7 +19017,7 @@
"usage": {
"tokensAndCost_one": "{{tokens}} token · {{cost}}",
"tokensAndCost_other": "{{tokens}} tokens · {{cost}}",
"costUnavailable": "n/a"
"costUnavailable": "—"
},
"externalDisplay": {
"sshHost": "SSH host",
@@ -19049,8 +19047,8 @@
"automationTitle": "This automation no longer exists",
"runDescription": "It was deleted or dropped from the retained run history.",
"automationDescription": "It was deleted, or its host no longer lists it.",
"openAutomation": "Open {{name}}",
"goToAutomations": "Go to Automations"
"goToAutomations": "Go to Automations",
"openThisAutomation": "Open automation"
},
"recentRuns": {
"title": "Recent runs",
@@ -19067,7 +19065,10 @@
"signIn": "Sign in to {{host}}",
"output": "Output",
"details": "Details",
"runNumber": "Run #{{number}}"
"runNumber": "Run #{{number}}",
"runFailed": "Run failed.",
"scheduledAt": "Scheduled {{time}}",
"startedAt": "Started {{time}}"
},
"runTable": {
"result": "Result"
@@ -24871,6 +24872,16 @@
"settingUp": "The project is still being set up on the selected automation host.",
"error": "Project setup failed on the selected automation host.",
"unsupported": "The selected automation host does not support this project."
},
"blocked": {
"trustWorkspace": "The agent is waiting for you to trust this folder.",
"trustWorkspaceNamed": "{{agent}} is waiting for you to trust this folder.",
"update": "The agent is waiting for you to answer an update prompt.",
"folder": "The agent is asking which folder to work in.",
"model": "The agent is asking you to choose a model.",
"approval": "The agent is waiting for you to approve an action.",
"signIn": "The agent is waiting for you to sign in.",
"prompt": "The agent stopped at a prompt that needs your answer."
}
},
"extraAgentArgs": {
+2 -4
View File
@@ -17391,8 +17391,7 @@
"86a248187e": "Gastar",
"149c0b49c7": "Espacio de trabajo",
"8faaa00726": "Correr",
"53fc5f07ab": "Historial de ejecución",
"a00e38d1a3": "n / A",
"a00e38d1a3": "—",
"historyUnavailable": "El historial de ejecuciones no está disponible desde este host. Esto no significa que la automatización haya fallado ni que no tenga ejecuciones."
},
"AutomationSchedulePicker": {
@@ -17477,7 +17476,6 @@
"rowActions": "Acciones de automatización",
"tableLastRun": "Última ejecución",
"ownerChanged": "Esta automatización cambió de host. Actualiza e inténtalo de nuevo.",
"runError": "Error:",
"deleteAutomationDescription": "Eliminar <name/> y su historial de ejecuciones. No se eliminarán los espacios de trabajo creados por ejecuciones anteriores.",
"deleteExternalAutomationDescription": "Eliminar <name/> de <provider/> en <host/>.",
"runUsageSummaryTokens_one": "{{cost}} aprox. · {{tokens}} token",
@@ -17791,7 +17789,7 @@
"tokensAndCost_one": "{{tokens}} token · {{cost}}",
"tokensAndCost_other": "{{tokens}} tokens · {{cost}}",
"tokensAndCost_many": "{{tokens}} tokens · {{cost}}",
"costUnavailable": "n/d"
"costUnavailable": "—"
},
"externalDisplay": {
"sshHost": "Host SSH",
+2 -4
View File
@@ -17635,8 +17635,7 @@
"86a248187e": "Dépense",
"149c0b49c7": "Espace de travail",
"8faaa00726": "Exécution",
"53fc5f07ab": "Historique des exécutions",
"a00e38d1a3": "n/d",
"a00e38d1a3": "—",
"historyUnavailable": "L'historique des exécutions n'est pas disponible sur cet hôte. Cela ne signifie pas que l’automatisation a échoué ou qu’elle n’a pas été exécutée."
},
"AutomationSchedulePicker": {
@@ -17721,7 +17720,6 @@
"moveOriginalUnverified": "Créé sur {host}, mais la suppression d'origine n'a pas pu être vérifiée. Vérifiez l'ancien hôte avant de réessayer.",
"tableHost": "Hôte",
"ownerChanged": "Cette automatisation a changé d'hôtes. Actualisez et réessayez.",
"runError": "Erreur :",
"deleteAutomationDescription": "Supprimer <name/> et son historique d’exécution. Les espaces de travail créés par les exécutions précédentes ne seront pas supprimés.",
"deleteExternalAutomationDescription": "Supprimer <name/> de <provider/> sur <host/>.",
"runUsageSummaryTokens_one": "{{cost}} (est.) · {{tokens}} token",
@@ -18061,7 +18059,7 @@
"tokensAndCost_one": "{{tokens}} token · {{cost}}",
"tokensAndCost_other": "{{tokens}} tokens · {{cost}}",
"tokensAndCost_many": "{{tokens}} tokens · {{cost}}",
"costUnavailable": "n/d"
"costUnavailable": "—"
},
"externalDisplay": {
"sshHost": "Hôte SSH",
+2 -4
View File
@@ -16980,8 +16980,7 @@
"86a248187e": "費用",
"149c0b49c7": "ワークスペース",
"8faaa00726": "実行",
"53fc5f07ab": "実行履歴",
"a00e38d1a3": "該当なし",
"a00e38d1a3": "—",
"historyUnavailable": "このホストからは実行履歴を利用できません。これは、自動化が失敗したり、実行されなかったりすることを意味するものではありません。"
},
"AutomationSchedulePicker": {
@@ -17066,7 +17065,6 @@
"rowActions": "自動化の操作",
"tableLastRun": "最後の実行",
"ownerChanged": "この自動化によりホストが変更されました。更新して再試行してください。",
"runError": "エラー:",
"deleteAutomationDescription": "<name/> とその実行履歴を削除します。過去の実行で作成されたワークスペースは削除されません。",
"deleteExternalAutomationDescription": "<host/> 上の <provider/> から <name/> を削除します。",
"runUsageSummaryTokens_other": "推定 {{cost}} · {{tokens}} トークン"
@@ -17366,7 +17364,7 @@
},
"usage": {
"tokensAndCost_other": "{{tokens}} トークン · {{cost}}",
"costUnavailable": "不明"
"costUnavailable": "—"
},
"externalDisplay": {
"sshHost": "SSH ホスト",
+2 -4
View File
@@ -16977,8 +16977,7 @@
"86a248187e": "경비",
"149c0b49c7": "워크스페이스",
"8faaa00726": "실행",
"53fc5f07ab": "실행 기록",
"a00e38d1a3": "해당 없음",
"a00e38d1a3": "—",
"historyUnavailable": "이 호스트에서는 실행 기록을 사용할 수 없습니다. 이는 자동화가 실패했거나 실행이 없음을 의미하지 않습니다."
},
"AutomationSchedulePicker": {
@@ -17063,7 +17062,6 @@
"rowActions": "자동화 작업",
"tableLastRun": "마지막 실행",
"ownerChanged": "이 자동화로 인해 호스트가 변경되었습니다. 새로고침하고 다시 시도하세요.",
"runError": "오류:",
"deleteAutomationDescription": "<name/> 및 실행 기록을 삭제합니다. 이전 실행에서 생성된 워크스페이스는 삭제되지 않습니다.",
"deleteExternalAutomationDescription": "<host/>의 <provider/>에서 <name/>을(를) 삭제합니다.",
"runUsageSummaryTokens_other": "예상 {{cost}} · {{tokens}} 토큰"
@@ -17363,7 +17361,7 @@
},
"usage": {
"tokensAndCost_other": "{{tokens}} 토큰 · {{cost}}",
"costUnavailable": "알 수 없음"
"costUnavailable": "—"
},
"externalDisplay": {
"sshHost": "SSH 호스트",
+2 -4
View File
@@ -14650,7 +14650,6 @@
"rowActions": "Ações da automação",
"tableLastRun": "Última execução",
"ownerChanged": "Esta automação mudou de host. Atualize e tente novamente.",
"runError": "Erro:",
"deleteAutomationDescription": "Excluir <name/> e seu histórico de execuções. Os espaços de trabalho criados por execuções anteriores não serão excluídos.",
"deleteExternalAutomationDescription": "Excluir <name/> de <provider/> em <host/>.",
"runUsageSummaryTokens_one": "{{cost}} (est.) · {{tokens}} token",
@@ -14930,8 +14929,7 @@
"86a248187e": "Gasto",
"149c0b49c7": "Espaço de Trabalho",
"8faaa00726": "Execução",
"53fc5f07ab": "Histórico de execução",
"a00e38d1a3": "n/d",
"a00e38d1a3": "—",
"historyUnavailable": "O histórico de execução não está disponível a partir deste host. Isso não significa que a automação falhou ou não tem execuções."
},
"AutomationSchedulePicker": {
@@ -15127,7 +15125,7 @@
"tokensAndCost_one": "{{tokens}} token · {{cost}}",
"tokensAndCost_other": "{{tokens}} tokens · {{cost}}",
"tokensAndCost_many": "{{tokens}} tokens · {{cost}}",
"costUnavailable": "n/d"
"costUnavailable": "—"
},
"externalDisplay": {
"sshHost": "Host SSH",
+2 -4
View File
@@ -18158,8 +18158,7 @@
"86a248187e": "Расходы",
"149c0b49c7": "Рабочее пространство",
"8faaa00726": "Запуск",
"53fc5f07ab": "История запусков",
"a00e38d1a3": "нет данных",
"a00e38d1a3": "—",
"historyUnavailable": "История запусков на этом сервере недоступна. Это не означает, что автоматизация завершилась с ошибкой или не запускалась."
},
"AutomationSchedulePicker": {
@@ -18244,7 +18243,6 @@
"rowActions": "Действия с автоматизацией",
"tableLastRun": "Последний запуск",
"ownerChanged": "Сервер этой автоматизации изменился. Обновите данные и повторите попытку.",
"runError": "Ошибка:",
"deleteAutomationDescription": "Удалить <name/> и историю запусков. Рабочие пространства, созданные предыдущими запусками, не будут удалены.",
"deleteExternalAutomationDescription": "Удалить <name/> из <provider/> на <host/>.",
"runUsageSummaryTokens_one": "Примерно {{cost}} · {{tokens}} токен",
@@ -18592,7 +18590,7 @@
"tokensAndCost_few": "{{tokens}} токена · {{cost}}",
"tokensAndCost_many": "{{tokens}} токенов · {{cost}}",
"tokensAndCost_other": "{{tokens}} токена · {{cost}}",
"costUnavailable": "нет данных"
"costUnavailable": "—"
},
"externalDisplay": {
"sshHost": "SSH-сервер",
+2 -4
View File
@@ -17636,8 +17636,7 @@
"86a248187e": "Harcama",
"149c0b49c7": "Çalışma alanı",
"8faaa00726": "Çalıştırma",
"53fc5f07ab": "Çalıştırma geçmişi",
"a00e38d1a3": "yok",
"a00e38d1a3": "—",
"historyUnavailable": "Çalıştırma geçmişi bu sunucudan alınamıyor. Bu, otomasyonun başarısız olduğu veya hiç çalıştırılmadığı anlamına gelmez."
},
"AutomationSchedulePicker": {
@@ -17722,7 +17721,6 @@
"rowActions": "Otomasyon işlemleri",
"tableLastRun": "Son çalıştırma",
"ownerChanged": "Bu otomasyonun sunucusu değişti. Yenileyin ve tekrar deneyin.",
"runError": "Hata:",
"deleteAutomationDescription": "<name/> ve çalıştırma geçmişini sil. Önceki çalıştırmaların oluşturduğu çalışma alanları silinmez.",
"deleteExternalAutomationDescription": "<host/> üzerindeki <provider/> içinden <name/> otomasyonunu sil.",
"runUsageSummaryTokens_other": "Tahmini {{cost}} · {{tokens}} token",
@@ -18054,7 +18052,7 @@
"usage": {
"tokensAndCost_other": "{{tokens}} token · {{cost}}",
"tokensAndCost_one": "{{tokens}} token · {{cost}}",
"costUnavailable": "bilinmiyor"
"costUnavailable": "—"
},
"externalDisplay": {
"sshHost": "SSH sunucusu",
+2 -4
View File
@@ -17427,8 +17427,7 @@
"86a248187e": "花費",
"149c0b49c7": "工作區",
"8faaa00726": "執行",
"53fc5f07ab": "執行歷史",
"a00e38d1a3": "不適用",
"a00e38d1a3": "—",
"historyUnavailable": "該主機無法取得執行歷史記錄。這並不意味著自動化失敗或沒有執行。"
},
"AutomationSchedulePicker": {
@@ -17513,7 +17512,6 @@
"rowActions": "自動化操作",
"tableLastRun": "最後一次執行",
"ownerChanged": "此自動化已切換主機。請重新整理後再試。",
"runError": "錯誤:",
"deleteAutomationDescription": "刪除 <name/> 及其執行歷程。不會刪除先前執行所建立的工作區。",
"deleteExternalAutomationDescription": "從 <host/> 上的 <provider/> 中刪除 <name/>。",
"runUsageSummaryTokens_other": "預估 {{cost}} · {{tokens}} token"
@@ -17837,7 +17835,7 @@
},
"usage": {
"tokensAndCost_other": "{{tokens}} token · {{cost}}",
"costUnavailable": "無資料"
"costUnavailable": "—"
},
"externalDisplay": {
"sshHost": "SSH 主機",
+2 -4
View File
@@ -17268,8 +17268,7 @@
"86a248187e": "花费",
"149c0b49c7": "工作区",
"8faaa00726": "执行",
"53fc5f07ab": "运行历史",
"a00e38d1a3": "不适用",
"a00e38d1a3": "—",
"historyUnavailable": "无法从此主机获取运行历史,这并不表示自动化运行失败或从未运行"
},
"AutomationSchedulePicker": {
@@ -17354,7 +17353,6 @@
"rowActions": "自动化操作",
"tableLastRun": "最后一次运行",
"ownerChanged": "此自动化已切换主机,请刷新后重试",
"runError": "错误:",
"deleteAutomationDescription": "删除 <name/> 及其运行历史。不会删除之前运行创建的工作区",
"deleteExternalAutomationDescription": "从 <host/> 上的 <provider/> 中删除 <name/>",
"runUsageSummaryTokens_other": "预估 {{cost}} · {{tokens}} token"
@@ -17654,7 +17652,7 @@
},
"usage": {
"tokensAndCost_other": "{{tokens}} token · {{cost}}",
"costUnavailable": "暂无数据"
"costUnavailable": "—"
},
"externalDisplay": {
"sshHost": "SSH 主机",
@@ -11,7 +11,8 @@ export function applyWorktreeNavViewEntry(entry: WorktreeNavHistoryViewEntry): v
// Why: the page owns its sub-page state; it applies this request and clears it.
useAppStore.setState({
activeView: 'automations',
automationsPageLocationRequest: entry.location
automationsPageLocationRequest: entry.location,
automationsPageFocusTitleOnOpen: true
})
return
}
@@ -20,7 +20,8 @@ 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.
* 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.
*/
export function appendAutomationsPageEntry(
s: HistoryState,
@@ -32,6 +33,9 @@ export function appendAutomationsPageEntry(
if (current === 'automations') {
return replaceCurrentEntry(s, entry)
}
if (mode === 'replace' && typeof current === 'object' && current.kind === 'automations-page') {
return replaceCurrentEntry(s, entry)
}
if (mode === 'return') {
const previous = s.worktreeNavHistory[s.worktreeNavHistoryIndex - 1]
if (
@@ -19,8 +19,8 @@ export type AutomationsPageLocation =
}
| { page: 'external'; externalKey: string }
/** How the next recorded sub-page enters history: a new step, or a step back to its parent. */
export type AutomationsPageVisitMode = 'push' | 'return'
/** 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'
/** Identity for de-duplication; the run origin is how the user got there, not where they are. */
export function automationsPageLocationKey(location: AutomationsPageLocation): string {
@@ -203,8 +203,16 @@ export type UISliceCore = {
automationsPageLocationRequest: AutomationsPageLocation | null
setAutomationsPageLocationRequest: (location: AutomationsPageLocation | null) => void
/** Record a settled Automations sub-page in back/forward history. */
recordAutomationsPageVisit: (location: AutomationsPageLocation) => void
/** `mode` overrides the pending visit mode (a replay the page settled elsewhere replaces). */
recordAutomationsPageVisit: (
location: AutomationsPageLocation,
mode?: AutomationsPageVisitMode
) => 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. */
automationsPageFocusTitleOnOpen: boolean
setAutomationsPageFocusTitleOnOpen: (value: boolean) => void
automationsPageNextVisitMode: AutomationsPageVisitMode
setAutomationsPageNextVisitMode: (mode: AutomationsPageVisitMode) => void
openAutomationsPage: () => void
@@ -24,19 +24,21 @@ export function createUiViewActions(set: UISliceSet, get: UISliceGet): Partial<U
automationsPageLocationRequest: null,
setAutomationsPageLocationRequest: (location) =>
set({ automationsPageLocationRequest: location }),
recordAutomationsPageVisit: (location) => {
recordAutomationsPageVisit: (location, mode) => {
// 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, state.automationsPageNextVisitMode),
...appendAutomationsPageEntry(state, location, mode ?? state.automationsPageNextVisitMode),
automationsPageNextVisitMode: 'push'
}))
},
automationsPageNextVisitMode: 'push',
setAutomationsPageNextVisitMode: (mode) => set({ automationsPageNextVisitMode: mode }),
automationsPageFocusTitleOnOpen: false,
setAutomationsPageFocusTitleOnOpen: (value) => set({ automationsPageFocusTitleOnOpen: value }),
openAutomationsPage: () => {
const state = get()
// Why: an open page already recorded its sub-page; a second sentinel would be a dead Back step.
@@ -46,7 +48,7 @@ export function createUiViewActions(set: UISliceSet, get: UISliceGet): Partial<U
) {
state.recordViewVisit('automations')
}
set({ automationsPageNextVisitMode: 'push' })
set({ automationsPageNextVisitMode: 'push', automationsPageFocusTitleOnOpen: true })
set((state) => ({
activeView: 'automations',
previousViewBeforeAutomations: