diff --git a/src/renderer/src/components/automations/runs/automation-run-actions.ts b/src/renderer/src/components/automations/runs/automation-run-actions.ts
index 0b83a305e2f..90edfafb6b2 100644
--- a/src/renderer/src/components/automations/runs/automation-run-actions.ts
+++ b/src/renderer/src/components/automations/runs/automation-run-actions.ts
@@ -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()
diff --git a/src/renderer/src/components/automations/runs/automation-run-error-copy.ts b/src/renderer/src/components/automations/runs/automation-run-error-copy.ts
index c7480ce7376..08df760626c 100644
--- a/src/renderer/src/components/automations/runs/automation-run-error-copy.ts
+++ b/src/renderer/src/components/automations/runs/automation-run-error-copy.ts
@@ -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:
." 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
+ run: Pick,
+ /** 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): boolean {
+ const blocked = run.error ? BLOCKED_AGENT_ERROR.exec(run.error) : null
+ return blocked?.[1].endsWith('-trust-workspace') ?? false
+}
diff --git a/src/renderer/src/components/automations/runs/automation-run-identity-label.ts b/src/renderer/src/components/automations/runs/automation-run-identity-label.ts
index 2fd74bfd9d1..c2005631a20 100644
--- a/src/renderer/src/components/automations/runs/automation-run-identity-label.ts
+++ b/src/renderer/src/components/automations/runs/automation-run-identity-label.ts
@@ -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
diff --git a/src/renderer/src/components/automations/runs/automation-run-now-pending.test.ts b/src/renderer/src/components/automations/runs/automation-run-now-pending.test.ts
index 23897a5b621..c1e9160a825 100644
--- a/src/renderer/src/components/automations/runs/automation-run-now-pending.test.ts
+++ b/src/renderer/src/components/automations/runs/automation-run-now-pending.test.ts
@@ -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)
+ })
})
diff --git a/src/renderer/src/components/automations/runs/automation-run-now-pending.ts b/src/renderer/src/components/automations/runs/automation-run-now-pending.ts
index 718897fe0bb..6544a000ec1 100644
--- a/src/renderer/src/components/automations/runs/automation-run-now-pending.ts
+++ b/src/renderer/src/components/automations/runs/automation-run-now-pending.ts
@@ -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
+}
diff --git a/src/renderer/src/components/automations/runs/automation-usage-model.ts b/src/renderer/src/components/automations/runs/automation-usage-model.ts
index 03449a6643a..ddbe12029b5 100644
--- a/src/renderer/src/components/automations/runs/automation-usage-model.ts
+++ b/src/renderer/src/components/automations/runs/automation-usage-model.ts
@@ -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)}`
diff --git a/src/renderer/src/components/automations/runs/automations-reveal-run.ts b/src/renderer/src/components/automations/runs/automations-reveal-run.ts
new file mode 100644
index 00000000000..e9f6181c10b
--- /dev/null
+++ b/src/renderer/src/components/automations/runs/automations-reveal-run.ts
@@ -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(AUTOMATIONS_REVEAL_RUN_EVENT, { detail: { runId } })
+ )
+}
diff --git a/src/renderer/src/components/automations/runs/use-automation-run-page-state.ts b/src/renderer/src/components/automations/runs/use-automation-run-page-state.ts
index 2c54660d618..b454168d3d8 100644
--- a/src/renderer/src/components/automations/runs/use-automation-run-page-state.ts
+++ b/src/renderer/src/components/automations/runs/use-automation-run-page-state.ts
@@ -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,
diff --git a/src/renderer/src/components/automations/runs/use-reveal-automation-run.ts b/src/renderer/src/components/automations/runs/use-reveal-automation-run.ts
new file mode 100644
index 00000000000..6c1e38e3a9a
--- /dev/null
+++ b/src/renderer/src/components/automations/runs/use-reveal-automation-run.ts
@@ -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 | 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)
+ }, [])
+}
diff --git a/src/renderer/src/components/automations/use-automations-page-history.test.tsx b/src/renderer/src/components/automations/use-automations-page-history.test.tsx
index f63ae92f0d5..b2e6eb39bc4 100644
--- a/src/renderer/src/components/automations/use-automations-page-history.test.tsx
+++ b/src/renderer/src/components/automations/use-automations-page-history.test.tsx
@@ -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 =
+ 'Title
'
+ 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 })
+})
diff --git a/src/renderer/src/components/automations/use-automations-page-history.ts b/src/renderer/src/components/automations/use-automations-page-history.ts
index 6a9a86b2085..8e45e84d189 100644
--- a/src/renderer/src/components/automations/use-automations-page-history.ts
+++ b/src/renderer/src/components/automations/use-automations-page-history.ts
@@ -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(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(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])
}
diff --git a/src/renderer/src/components/automations/use-automations-page-routing.ts b/src/renderer/src/components/automations/use-automations-page-routing.ts
index fec59d437c6..fb159ce4cad 100644
--- a/src/renderer/src/components/automations/use-automations-page-routing.ts
+++ b/src/renderer/src/components/automations/use-automations-page-routing.ts
@@ -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'
? {
diff --git a/src/renderer/src/i18n/locales/de.json b/src/renderer/src/i18n/locales/de.json
index e7b66e5e698..4090e238674 100644
--- a/src/renderer/src/i18n/locales/de.json
+++ b/src/renderer/src/i18n/locales/de.json
@@ -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": " und den Ausführungsverlauf löschen. Arbeitsbereiche aus früheren Ausführungen werden nicht gelöscht.",
"deleteExternalAutomationDescription": " aus auf 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",
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 4ccb7249c5d..9fbd70672dc 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -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 and its run history. Workspaces created by previous runs are not deleted.",
"deleteExternalAutomationDescription": "Delete from on .",
@@ -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": {
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index 242568fe041..e6d3b25c8af 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -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 y su historial de ejecuciones. No se eliminarán los espacios de trabajo creados por ejecuciones anteriores.",
"deleteExternalAutomationDescription": "Eliminar de en .",
"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",
diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json
index dafa45e2378..4613e835ded 100644
--- a/src/renderer/src/i18n/locales/fr.json
+++ b/src/renderer/src/i18n/locales/fr.json
@@ -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 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 de sur .",
"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",
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index ce2fbb415e3..3056c42ff69 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -16980,8 +16980,7 @@
"86a248187e": "費用",
"149c0b49c7": "ワークスペース",
"8faaa00726": "実行",
- "53fc5f07ab": "実行履歴",
- "a00e38d1a3": "該当なし",
+ "a00e38d1a3": "—",
"historyUnavailable": "このホストからは実行履歴を利用できません。これは、自動化が失敗したり、実行されなかったりすることを意味するものではありません。"
},
"AutomationSchedulePicker": {
@@ -17066,7 +17065,6 @@
"rowActions": "自動化の操作",
"tableLastRun": "最後の実行",
"ownerChanged": "この自動化によりホストが変更されました。更新して再試行してください。",
- "runError": "エラー:",
"deleteAutomationDescription": " とその実行履歴を削除します。過去の実行で作成されたワークスペースは削除されません。",
"deleteExternalAutomationDescription": " 上の から を削除します。",
"runUsageSummaryTokens_other": "推定 {{cost}} · {{tokens}} トークン"
@@ -17366,7 +17364,7 @@
},
"usage": {
"tokensAndCost_other": "{{tokens}} トークン · {{cost}}",
- "costUnavailable": "不明"
+ "costUnavailable": "—"
},
"externalDisplay": {
"sshHost": "SSH ホスト",
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 1f5e88b5558..cb0d5f2d13e 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -16977,8 +16977,7 @@
"86a248187e": "경비",
"149c0b49c7": "워크스페이스",
"8faaa00726": "실행",
- "53fc5f07ab": "실행 기록",
- "a00e38d1a3": "해당 없음",
+ "a00e38d1a3": "—",
"historyUnavailable": "이 호스트에서는 실행 기록을 사용할 수 없습니다. 이는 자동화가 실패했거나 실행이 없음을 의미하지 않습니다."
},
"AutomationSchedulePicker": {
@@ -17063,7 +17062,6 @@
"rowActions": "자동화 작업",
"tableLastRun": "마지막 실행",
"ownerChanged": "이 자동화로 인해 호스트가 변경되었습니다. 새로고침하고 다시 시도하세요.",
- "runError": "오류:",
"deleteAutomationDescription": " 및 실행 기록을 삭제합니다. 이전 실행에서 생성된 워크스페이스는 삭제되지 않습니다.",
"deleteExternalAutomationDescription": "의 에서 을(를) 삭제합니다.",
"runUsageSummaryTokens_other": "예상 {{cost}} · {{tokens}} 토큰"
@@ -17363,7 +17361,7 @@
},
"usage": {
"tokensAndCost_other": "{{tokens}} 토큰 · {{cost}}",
- "costUnavailable": "알 수 없음"
+ "costUnavailable": "—"
},
"externalDisplay": {
"sshHost": "SSH 호스트",
diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json
index c534822bb43..8b02bee739f 100644
--- a/src/renderer/src/i18n/locales/pt.json
+++ b/src/renderer/src/i18n/locales/pt.json
@@ -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 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 de em .",
"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",
diff --git a/src/renderer/src/i18n/locales/ru.json b/src/renderer/src/i18n/locales/ru.json
index 86f97186419..49fcb6127a2 100644
--- a/src/renderer/src/i18n/locales/ru.json
+++ b/src/renderer/src/i18n/locales/ru.json
@@ -18158,8 +18158,7 @@
"86a248187e": "Расходы",
"149c0b49c7": "Рабочее пространство",
"8faaa00726": "Запуск",
- "53fc5f07ab": "История запусков",
- "a00e38d1a3": "нет данных",
+ "a00e38d1a3": "—",
"historyUnavailable": "История запусков на этом сервере недоступна. Это не означает, что автоматизация завершилась с ошибкой или не запускалась."
},
"AutomationSchedulePicker": {
@@ -18244,7 +18243,6 @@
"rowActions": "Действия с автоматизацией",
"tableLastRun": "Последний запуск",
"ownerChanged": "Сервер этой автоматизации изменился. Обновите данные и повторите попытку.",
- "runError": "Ошибка:",
"deleteAutomationDescription": "Удалить и историю запусков. Рабочие пространства, созданные предыдущими запусками, не будут удалены.",
"deleteExternalAutomationDescription": "Удалить из на .",
"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-сервер",
diff --git a/src/renderer/src/i18n/locales/tr.json b/src/renderer/src/i18n/locales/tr.json
index 0ba7561526c..7f3879757b1 100644
--- a/src/renderer/src/i18n/locales/tr.json
+++ b/src/renderer/src/i18n/locales/tr.json
@@ -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": " ve çalıştırma geçmişini sil. Önceki çalıştırmaların oluşturduğu çalışma alanları silinmez.",
"deleteExternalAutomationDescription": " üzerindeki içinden 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",
diff --git a/src/renderer/src/i18n/locales/zh-Hant.json b/src/renderer/src/i18n/locales/zh-Hant.json
index e59487a96c0..39e215d520a 100644
--- a/src/renderer/src/i18n/locales/zh-Hant.json
+++ b/src/renderer/src/i18n/locales/zh-Hant.json
@@ -17427,8 +17427,7 @@
"86a248187e": "花費",
"149c0b49c7": "工作區",
"8faaa00726": "執行",
- "53fc5f07ab": "執行歷史",
- "a00e38d1a3": "不適用",
+ "a00e38d1a3": "—",
"historyUnavailable": "該主機無法取得執行歷史記錄。這並不意味著自動化失敗或沒有執行。"
},
"AutomationSchedulePicker": {
@@ -17513,7 +17512,6 @@
"rowActions": "自動化操作",
"tableLastRun": "最後一次執行",
"ownerChanged": "此自動化已切換主機。請重新整理後再試。",
- "runError": "錯誤:",
"deleteAutomationDescription": "刪除 及其執行歷程。不會刪除先前執行所建立的工作區。",
"deleteExternalAutomationDescription": "從 上的 中刪除 。",
"runUsageSummaryTokens_other": "預估 {{cost}} · {{tokens}} token"
@@ -17837,7 +17835,7 @@
},
"usage": {
"tokensAndCost_other": "{{tokens}} token · {{cost}}",
- "costUnavailable": "無資料"
+ "costUnavailable": "—"
},
"externalDisplay": {
"sshHost": "SSH 主機",
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index 0f52a14d033..a5dcf7990bb 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -17268,8 +17268,7 @@
"86a248187e": "花费",
"149c0b49c7": "工作区",
"8faaa00726": "执行",
- "53fc5f07ab": "运行历史",
- "a00e38d1a3": "不适用",
+ "a00e38d1a3": "—",
"historyUnavailable": "无法从此主机获取运行历史,这并不表示自动化运行失败或从未运行"
},
"AutomationSchedulePicker": {
@@ -17354,7 +17353,6 @@
"rowActions": "自动化操作",
"tableLastRun": "最后一次运行",
"ownerChanged": "此自动化已切换主机,请刷新后重试",
- "runError": "错误:",
"deleteAutomationDescription": "删除 及其运行历史。不会删除之前运行创建的工作区",
"deleteExternalAutomationDescription": "从 上的 中删除 ",
"runUsageSummaryTokens_other": "预估 {{cost}} · {{tokens}} token"
@@ -17654,7 +17652,7 @@
},
"usage": {
"tokensAndCost_other": "{{tokens}} token · {{cost}}",
- "costUnavailable": "暂无数据"
+ "costUnavailable": "—"
},
"externalDisplay": {
"sshHost": "SSH 主机",
diff --git a/src/renderer/src/lib/workspace/activation/worktree-nav-view-history-replay.ts b/src/renderer/src/lib/workspace/activation/worktree-nav-view-history-replay.ts
index 750b7ac2190..9fbd0fd9acb 100644
--- a/src/renderer/src/lib/workspace/activation/worktree-nav-view-history-replay.ts
+++ b/src/renderer/src/lib/workspace/activation/worktree-nav-view-history-replay.ts
@@ -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
}
diff --git a/src/renderer/src/store/slices/ui/automations-page-history.ts b/src/renderer/src/store/slices/ui/automations-page-history.ts
index 9ae41e35414..bc952898de1 100644
--- a/src/renderer/src/store/slices/ui/automations-page-history.ts
+++ b/src/renderer/src/store/slices/ui/automations-page-history.ts
@@ -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 (
diff --git a/src/renderer/src/store/slices/ui/automations-page-location.ts b/src/renderer/src/store/slices/ui/automations-page-location.ts
index 31556812339..85d5eec72ac 100644
--- a/src/renderer/src/store/slices/ui/automations-page-location.ts
+++ b/src/renderer/src/store/slices/ui/automations-page-location.ts
@@ -19,8 +19,8 @@ export type AutomationsPageLocation =
}
| { page: 'external'; externalKey: string }
-/** How 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 {
diff --git a/src/renderer/src/store/slices/ui/ui-slice-contract-core.ts b/src/renderer/src/store/slices/ui/ui-slice-contract-core.ts
index 226541857e3..9acafe05af7 100644
--- a/src/renderer/src/store/slices/ui/ui-slice-contract-core.ts
+++ b/src/renderer/src/store/slices/ui/ui-slice-contract-core.ts
@@ -203,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
diff --git a/src/renderer/src/store/slices/ui/ui-slice-view-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-view-actions.ts
index f54b87a0143..0d2f5dd856b 100644
--- a/src/renderer/src/store/slices/ui/ui-slice-view-actions.ts
+++ b/src/renderer/src/store/slices/ui/ui-slice-view-actions.ts
@@ -24,19 +24,21 @@ export function createUiViewActions(set: UISliceSet, get: UISliceGet): Partial
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 ({
activeView: 'automations',
previousViewBeforeAutomations: