From 78948ddaa36e1edb2e8bc4e9e78f22a8807ceed6 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:36:44 -0800 Subject: [PATCH] fix(automations): address C1 review and UX-I1 findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: " 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". --- .../automations/automation-page-parts.tsx | 10 +- .../automations/automation-page-state.ts | 9 ++ .../automations/automations-page-fixtures.ts | 2 + .../automations/details/AutomationDetail.tsx | 5 +- .../details/AutomationDetailHealth.tsx | 24 +++- .../details/AutomationsDetailPane.tsx | 11 +- .../runs/AutomationRunDetailsPage.tsx | 75 ++++++++---- .../automations/runs/AutomationRunHistory.tsx | 36 +++--- .../runs/AutomationRunNotFound.tsx | 14 +-- .../runs/AutomationRunStatusDot.tsx | 31 +++++ .../runs/AutomationRunTableCells.tsx | 13 +- .../automations/runs/AutomationRunsTable.tsx | 5 + .../runs/automation-run-actions.ts | 19 +-- .../runs/automation-run-error-copy.ts | 71 ++++++++++- .../runs/automation-run-identity-label.ts | 3 + .../runs/automation-run-now-pending.test.ts | 56 ++++++++- .../runs/automation-run-now-pending.ts | 13 ++ .../runs/automation-usage-model.ts | 2 +- .../runs/automations-reveal-run.ts | 10 ++ .../runs/use-automation-run-page-state.ts | 16 ++- .../runs/use-reveal-automation-run.ts | 36 ++++++ .../use-automations-page-history.test.tsx | 112 +++++++++++++++++- .../use-automations-page-history.ts | 59 ++++++--- .../use-automations-page-routing.ts | 72 +++++++---- src/renderer/src/i18n/locales/de.json | 6 +- src/renderer/src/i18n/locales/en.json | 25 ++-- src/renderer/src/i18n/locales/es.json | 6 +- src/renderer/src/i18n/locales/fr.json | 6 +- src/renderer/src/i18n/locales/ja.json | 6 +- src/renderer/src/i18n/locales/ko.json | 6 +- src/renderer/src/i18n/locales/pt.json | 6 +- src/renderer/src/i18n/locales/ru.json | 6 +- src/renderer/src/i18n/locales/tr.json | 6 +- src/renderer/src/i18n/locales/zh-Hant.json | 6 +- src/renderer/src/i18n/locales/zh.json | 6 +- .../worktree-nav-view-history-replay.ts | 3 +- .../slices/ui/automations-page-history.ts | 6 +- .../slices/ui/automations-page-location.ts | 4 +- .../store/slices/ui/ui-slice-contract-core.ts | 10 +- .../store/slices/ui/ui-slice-view-actions.ts | 8 +- 40 files changed, 635 insertions(+), 185 deletions(-) create mode 100644 src/renderer/src/components/automations/runs/automations-reveal-run.ts create mode 100644 src/renderer/src/components/automations/runs/use-reveal-automation-run.ts diff --git a/src/renderer/src/components/automations/automation-page-parts.tsx b/src/renderer/src/components/automations/automation-page-parts.tsx index 297865a0052..37d81736aa2 100644 --- a/src/renderer/src/components/automations/automation-page-parts.tsx +++ b/src/renderer/src/components/automations/automation-page-parts.tsx @@ -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 (
- {label} {error} + + {label} {error} + + {action}
) } diff --git a/src/renderer/src/components/automations/automation-page-state.ts b/src/renderer/src/components/automations/automation-page-state.ts index 26af10e0847..9a0eedff534 100644 --- a/src/renderer/src/components/automations/automation-page-state.ts +++ b/src/renderer/src/components/automations/automation-page-state.ts @@ -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 diff --git a/src/renderer/src/components/automations/automations-page-fixtures.ts b/src/renderer/src/components/automations/automations-page-fixtures.ts index 7b2fe5b8c58..f55000450f0 100644 --- a/src/renderer/src/components/automations/automations-page-fixtures.ts +++ b/src/renderer/src/components/automations/automations-page-fixtures.ts @@ -280,6 +280,8 @@ export function makeStoreState(): AutomationsPageStoreFixtures { recordAutomationsPageVisit: noop, automationsPageNextVisitMode: 'push', setAutomationsPageNextVisitMode: noop, + automationsPageFocusTitleOnOpen: false, + setAutomationsPageFocusTitleOnOpen: noop, hydratePersistedUI: noop, recordFeatureInteraction: noop, allWorktrees: () => [worktree], diff --git a/src/renderer/src/components/automations/details/AutomationDetail.tsx b/src/renderer/src/components/automations/details/AutomationDetail.tsx index 7a926f67129..5a4d9330cbd 100644 --- a/src/renderer/src/components/automations/details/AutomationDetail.tsx +++ b/src/renderer/src/components/automations/details/AutomationDetail.tsx @@ -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 diff --git a/src/renderer/src/components/automations/details/AutomationDetailHealth.tsx b/src/renderer/src/components/automations/details/AutomationDetailHealth.tsx index 47d50997751..8bdeff391df 100644 --- a/src/renderer/src/components/automations/details/AutomationDetailHealth.tsx +++ b/src/renderer/src/components/automations/details/AutomationDetailHealth.tsx @@ -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({ {value} {detail ? ( -
{detail}
+
+ {detail} +
) : 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 (
-
+
{t('auto.components.automations.AutomationsPage.0e110a3469', 'Runs')} -

- {selected.name} -

+ {/* A failed read knows no counts; "0 runs" would answer a question nobody asked the host. */} + {selectedRunsNotice ? null : ( +

+ {formatAutomationRunCountSummary(selectedRuns, t)} +

+ )}
= 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({ {formatAutomationRunLabel(run, t, intlLocale)} - - {getAutomationRunStatusLabel(run.status, t)} - + {(['newer', 'older'] as const).map((step) => { const target = step === 'newer' ? newer : older @@ -218,9 +225,30 @@ export function AutomationRunDetailsPage({ })}

- - {formatAutomationDateTimeWithRelative(run.scheduledFor, relativeNow, t, intlLocale)} - + {startedLate ? ( + <> + + {t('auto.components.automations.runIdentity.scheduledAt', 'Scheduled {{time}}', { + time: formatAutomationDateTime(run.scheduledFor, t, intlLocale) + })} + + + + {t('auto.components.automations.runIdentity.startedAt', 'Started {{time}}', { + time: formatAutomationDateTimeWithRelative( + startedAt, + relativeNow, + t, + intlLocale + ) + })} + + + ) : ( + + {formatAutomationDateTimeWithRelative(run.scheduledFor, relativeNow, t, intlLocale)} + + )} {automation ? ( <> @@ -269,21 +297,28 @@ export function AutomationRunDetailsPage({ ) : null}

- ) : run.status === 'dispatch_failed' && errorText && content !== errorText ? ( - // Why: saved terminal output otherwise hides why the run failed. + ) : failed && errorText ? ( + + {t('auto.components.automations.runViewState.openWorkspace', 'Open workspace')} + + ) : null + } /> ) : null} - {viewState?.canOpen || (canRerun && automation) ? ( + {(viewState?.canOpen && !openFromBanner) || (canRerun && automation) ? (
{/* Why: a disabled button with nothing to open reads as broken; Details says the workspace is gone. */} - {viewState?.canOpen ? ( + {viewState?.canOpen && !openFromBanner ? ( ) : null} {canRerun && automation ? ( diff --git a/src/renderer/src/components/automations/runs/AutomationRunHistory.tsx b/src/renderer/src/components/automations/runs/AutomationRunHistory.tsx index 789dc52145c..b5cbbbcb06e 100644 --- a/src/renderer/src/components/automations/runs/AutomationRunHistory.tsx +++ b/src/renderer/src/components/automations/runs/AutomationRunHistory.tsx @@ -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[], + t: ReturnType +): 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" > -
-
- {t('auto.components.automations.AutomationRunHistory.53fc5f07ab', 'Run history')} -
- {/* A failed read knows no counts; "0 runs" would answer a question nobody asked the host. */} - {notice ? null :
{runCountLabel}
} -
{runGone && automationName ? ( - ) : null} ' + 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: