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