diff --git a/src/renderer/src/components/automations/AutomationDeleteDialogs.tsx b/src/renderer/src/components/automations/AutomationDeleteDialogs.tsx index 0dcba7d0635..9921ad0b614 100644 --- a/src/renderer/src/components/automations/AutomationDeleteDialogs.tsx +++ b/src/renderer/src/components/automations/AutomationDeleteDialogs.tsx @@ -152,7 +152,7 @@ export function ExternalAutomationDeleteDialog({ 'auto.components.automations.AutomationsPage.8500baacb4', 'external source' )}{' '} - {translate('auto.components.automations.AutomationsPage.1b586f0e2b', 'on')} + {translate('auto.components.automations.AutomationsPage.1b586f0e2b', 'on')}{' '} {externalDeleteTarget?.manager.targetLabel}. diff --git a/src/renderer/src/components/automations/AutomationListExternalRows.tsx b/src/renderer/src/components/automations/AutomationListExternalRows.tsx index eeb246110e2..b5269b65593 100644 --- a/src/renderer/src/components/automations/AutomationListExternalRows.tsx +++ b/src/renderer/src/components/automations/AutomationListExternalRows.tsx @@ -104,7 +104,7 @@ export function AutomationListExternalRows({ } const nextRunLabel = entry.job.enabled ? formatExternalDate(entry.job.nextRunAt, relativeNow) - : 'Paused' + : translate('auto.components.automations.AutomationsPage.paused', 'Paused') const entrySshStatus = entry.manager.target.type === 'ssh' ? sshConnectionStates.get(entry.manager.target.connectionId)?.status @@ -151,7 +151,11 @@ export function AutomationListExternalRows({ · {entry.manager.provider === 'hermes' - ? `${entry.job.runCount} ${entry.job.runCount === 1 ? 'run' : 'runs'}` + ? translate( + 'auto.components.automations.AutomationsPage.runCount', + '{{count}} runs', + { count: entry.job.runCount } + ) : entry.manager.canManage ? translate( 'auto.components.automations.AutomationsPage.aecdc3681f', diff --git a/src/renderer/src/components/automations/AutomationListLocalRows.tsx b/src/renderer/src/components/automations/AutomationListLocalRows.tsx index 0dd2d6c1c2f..1a5a4994aac 100644 --- a/src/renderer/src/components/automations/AutomationListLocalRows.tsx +++ b/src/renderer/src/components/automations/AutomationListLocalRows.tsx @@ -67,6 +67,20 @@ export function AutomationListLocalRows({ onToggle: (automation: Automation) => void onDelete: (automation: Automation) => void }): React.JSX.Element { + // Why: one pass over runs instead of a full scan per rendered automation — + // this list re-renders on the relativeNow timer. + const runsByAutomationId = React.useMemo(() => { + const grouped = new Map() + for (const run of runs) { + const existing = grouped.get(run.automationId) + if (existing) { + existing.push(run) + } else { + grouped.set(run.automationId, [run]) + } + } + return grouped + }, [runs]) return ( <> {automations.map((automation) => { @@ -84,24 +98,50 @@ export function AutomationListLocalRows({ automationHostTarget, sourceHostAvailability: automationSourceHostAvailabilityById.get(automation.id) }) + const baseRefLabel = + automation.baseBranch ?? + automationRepo?.worktreeBaseRef ?? + translate( + 'auto.components.automations.AutomationsPage.projectDefaultBaseRef', + 'project default' + ) const workspaceLabel = automation.workspaceMode === 'new_per_run' - ? `Create from ${automation.baseBranch ?? automationRepo?.worktreeBaseRef ?? 'project default'}` - : (automationWorktree?.displayName ?? 'Missing workspace') + ? translate( + 'auto.components.automations.AutomationsPage.createFromBaseRef', + 'Create from {{baseRef}}', + { baseRef: baseRefLabel } + ) + : (automationWorktree?.displayName ?? + translate( + 'auto.components.automations.AutomationsPage.missingWorkspace', + 'Missing workspace' + )) const usageSummary = summarizeAutomationRunUsage( - runs.filter((run) => run.automationId === automation.id) + runsByAutomationId.get(automation.id) ?? [] ) const usageText = usageSummary.knownRuns > 0 - ? `${formatAutomationCost( - usageSummary.estimatedCostUsd - )} est. · ${formatAutomationTokens(usageSummary.totalTokens)} tokens` + ? translate( + 'auto.components.automations.AutomationsPage.runUsageSummary', + '{{cost}} est. · {{tokens}} tokens', + { + cost: formatAutomationCost(usageSummary.estimatedCostUsd), + tokens: formatAutomationTokens(usageSummary.totalTokens) + } + ) : usageSummary.unavailableRuns > 0 - ? 'Usage unavailable' - : 'No run usage yet' + ? translate( + 'auto.components.automations.AutomationsPage.usageUnavailable', + 'Usage unavailable' + ) + : translate( + 'auto.components.automations.AutomationsPage.noRunUsageYet', + 'No run usage yet' + ) const nextRunLabel = automation.enabled ? formatAutomationDateTimeWithRelative(automation.nextRunAt, relativeNow) - : 'Paused' + : translate('auto.components.automations.AutomationsPage.paused', 'Paused') const scheduleLabel = formatAutomationSchedule(automation.rrule) return ( diff --git a/src/renderer/src/components/automations/AutomationListSearchField.tsx b/src/renderer/src/components/automations/AutomationListSearchField.tsx index 4700891eeaa..3a2bceb7c48 100644 --- a/src/renderer/src/components/automations/AutomationListSearchField.tsx +++ b/src/renderer/src/components/automations/AutomationListSearchField.tsx @@ -45,8 +45,13 @@ export function AutomationListSearchField({ )} aria-invalid={isTooLarge || undefined} aria-describedby={isTooLarge ? 'automations-list-search-too-large' : undefined} - className="h-8 border-border/60 bg-background pl-8 text-xs" - style={hasText ? { paddingRight: isTooLarge ? 72 : 28 } : undefined} + // Why: the page-level capture Escape handler blurs inputs; this opts out + // so the first Escape clears the query without also losing focus. + data-escape-clears-value={hasText ? 'true' : undefined} + className={cn( + 'h-8 border-border/60 bg-background pl-8 text-xs', + hasText && (isTooLarge ? 'pr-20' : 'pr-7') + )} onChange={(event) => onQueryChange(event.target.value)} onKeyDown={(event) => { if (event.key !== 'Escape' || event.nativeEvent.isComposing) { diff --git a/src/renderer/src/components/automations/AutomationRunHistory.tsx b/src/renderer/src/components/automations/AutomationRunHistory.tsx index be8a76dcd5e..777eaff3fa2 100644 --- a/src/renderer/src/components/automations/AutomationRunHistory.tsx +++ b/src/renderer/src/components/automations/AutomationRunHistory.tsx @@ -19,7 +19,7 @@ import { translate } from '@/i18n/i18n' type AutomationRunHistoryProps = { runs: AutomationRun[] automationId: string - worktreeMap: Map + worktreeMap: ReadonlyMap onOpenRun: (run: AutomationRun) => void } diff --git a/src/renderer/src/components/automations/AutomationsDetailPane.tsx b/src/renderer/src/components/automations/AutomationsDetailPane.tsx index d7c3fd3347d..2d290f26ac6 100644 --- a/src/renderer/src/components/automations/AutomationsDetailPane.tsx +++ b/src/renderer/src/components/automations/AutomationsDetailPane.tsx @@ -37,16 +37,9 @@ import type { AutomationTargetAvailability } from './automation-target-availabil import type { AutomationRunViewState } from './automation-run-view-state' import type { AutomationRunWorkspaceDisplay } from './automation-run-workspace-display' import type { ExternalAutomationSourceAvailability } from './external-automation-source-availability' +import type { AutomationPaneTab, SelectedExternalRunPage } from './automation-page-state' import { translate } from '@/i18n/i18n' -type AutomationPaneTab = 'overview' | 'runs' - -type SelectedExternalRunPage = { - manager: ExternalAutomationManager - job: ExternalAutomationJob - run: ExternalAutomationRun -} - type AutomationsDetailPaneProps = { selected: Automation | null selectedExternal: ExternalAutomationListEntry | null @@ -70,7 +63,7 @@ type AutomationsDetailPaneProps = { selectedAutomationRunPageViewState: AutomationRunViewState | null canRerunSelectedAutomationRunPage: boolean isSelectedAutomationRunPageRerunPending: boolean - worktreeMap: Map + worktreeMap: ReadonlyMap fetchExternalAutomationRuns: FetchExternalAutomationRuns onActivePaneTabChange: (tab: AutomationPaneTab) => void onClearExternalRunPage: () => void @@ -263,10 +256,19 @@ export function AutomationsDetailPane({ relativeNow ), 'Orca', - selectedAutomationRunPageWorkspaceDisplay?.detailLabel ?? 'No workspace' + selectedAutomationRunPageWorkspaceDisplay?.detailLabel ?? + translate( + 'auto.components.automations.AutomationsPage.noWorkspace', + 'No workspace' + ) ]} detail={ - selectedAutomationRunPage.outputSnapshot?.truncated ? 'Latest saved output' : null + selectedAutomationRunPage.outputSnapshot?.truncated + ? translate( + 'auto.components.automations.AutomationsPage.latestSavedOutput', + 'Latest saved output' + ) + : null } statusLabel={getAutomationRunStatusLabel(selectedAutomationRunPage.status)} statusVariant={getAutomationRunStatusVariant(selectedAutomationRunPage.status)} diff --git a/src/renderer/src/components/automations/AutomationsListPanel.tsx b/src/renderer/src/components/automations/AutomationsListPanel.tsx index a40fd9426f6..31d9cc9d6d9 100644 --- a/src/renderer/src/components/automations/AutomationsListPanel.tsx +++ b/src/renderer/src/components/automations/AutomationsListPanel.tsx @@ -14,6 +14,7 @@ import type { RuntimeStatus } from '../../../../shared/runtime-types' import type { TaskSourceHostAvailability } from '../task-source-context-summary' import type { AutomationHostTarget } from './automation-host-client' import { clampAutomationListSearchQueryInput } from './automation-list-search' +import type { AutomationPaneTab } from './automation-page-state' import { AutomationListSearchField } from './AutomationListSearchField' import { getAutomationTemplates, type AutomationTemplate } from './automation-templates' import type { ExternalAutomationListEntry } from './external-automation-list-entries' @@ -47,7 +48,7 @@ type AutomationsListPanelProps = { externalActionKey: string | null selectAutomationId: (automationId: string | null) => void selectExternalKey: (externalKey: string | null) => void - setActivePaneTab: (tab: 'overview' | 'runs') => void + setActivePaneTab: (tab: AutomationPaneTab) => void runNow: (automation: Automation) => void openEditDialog: (automation: Automation) => void toggleAutomation: (automation: Automation) => void diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index 0487379d2f6..97f15d844a6 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -105,6 +105,7 @@ import { getRuntimeSourceHostAvailability, type RepoBackedAutomationSourceContext } from './automation-source-context' +import type { AutomationPaneTab, SelectedExternalRunPage } from './automation-page-state' import { getExternalAutomationKey, getExternalAutomationSourceKey, @@ -122,14 +123,6 @@ import { translate } from '@/i18n/i18n' const AGENTS = getAgentCatalog().map((agent) => agent.id) const AUTOMATIONS_CHANGED_EVENT = 'orca:automations-changed' -type AutomationPaneTab = 'overview' | 'runs' - -type SelectedExternalRunPage = { - manager: ExternalAutomationManager - job: ExternalAutomationJob - run: ExternalAutomationRun -} - export default function AutomationsPage(): React.JSX.Element { const repos = useAppStore((s) => s.repos) const projectHostSetups = useAppStore((s) => s.projectHostSetups) @@ -236,6 +229,9 @@ export default function AutomationsPage(): React.JSX.Element { const [dontAskDeleteAgain, setDontAskDeleteAgain] = useState(false) const editRequestRef = useRef(0) const deleteConfirmButtonRef = useRef(null) + // Why: both dialogs stay mounted, so a shared ref would let one dialog's + // unmount clear the focus target the other still needs. + const externalDeleteConfirmButtonRef = useRef(null) const completionInFlightRef = useRef>(new Set()) const rerunRunIdsInFlightRef = useRef>(new Set()) const workspaceNameCacheRef = useRef>(new Map()) @@ -1880,6 +1876,12 @@ export default function AutomationsPage(): React.JSX.Element { return } + // Why: fields that clear their own value on Escape consume this press; + // blurring here would drop focus and let the next Escape close the page. + if (target.dataset.escapeClearsValue === 'true') { + return + } + // Why: match Tasks page behavior: Esc first exits field focus, then exits // the page once focus is back on page chrome. if ( @@ -2026,7 +2028,7 @@ export default function AutomationsPage(): React.JSX.Element { { if (!open) { setExternalDeleteTarget(null) @@ -2079,12 +2081,22 @@ export default function AutomationsPage(): React.JSX.Element { activePaneTab={activePaneTab} relativeNow={relativeNow} externalActionKey={externalActionKey} - selectedRepoDisplayName={selectedRepo?.displayName ?? 'Unknown project'} + selectedRepoDisplayName={ + selectedRepo?.displayName ?? + translate('auto.components.automations.AutomationsPage.13118faadf', 'Unknown project') + } selectedRepoDefaultBaseRef={selectedRepo?.worktreeBaseRef ?? null} selectedWorkspaceName={ selected?.workspaceMode === 'new_per_run' - ? 'New workspace each run' - : (selectedWorktree?.displayName ?? 'Missing workspace') + ? translate( + 'auto.components.automations.AutomationsPage.cd8397cc32', + 'New workspace each run' + ) + : (selectedWorktree?.displayName ?? + translate( + 'auto.components.automations.AutomationsPage.missingWorkspace', + 'Missing workspace' + )) } hostLabelById={hostLabelById} selectedRunNowAvailability={selectedRunNowAvailability} diff --git a/src/renderer/src/components/automations/automation-list-search.test.ts b/src/renderer/src/components/automations/automation-list-search.test.ts index f9325490200..47f59faaba6 100644 --- a/src/renderer/src/components/automations/automation-list-search.test.ts +++ b/src/renderer/src/components/automations/automation-list-search.test.ts @@ -13,7 +13,6 @@ import { filterByAutomationListSearch, filterByAutomationListSearchIndex, getActiveAutomationListSearchQuery, - getAutomationListSearchQuery, isAutomationListSearchQueryTooLarge, normalizeAutomationListSearchField, resolveAutomationListSearchQuery, @@ -22,7 +21,6 @@ import { describe('automation-list-search', () => { it('normalizes query casing and whitespace', () => { - expect(getAutomationListSearchQuery(' Auto PR ')).toBe('auto pr') expect(getActiveAutomationListSearchQuery(' Auto PR ')).toBe('auto pr') expect(resolveAutomationListSearchQuery(' Auto PR ')).toEqual({ status: 'active', @@ -33,7 +31,6 @@ describe('automation-list-search', () => { it('rejects oversized queries without searching', () => { const oversized = 'a'.repeat(AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES + 1) expect(isAutomationListSearchQueryTooLarge(oversized)).toBe(true) - expect(getAutomationListSearchQuery(oversized)).toBeNull() expect(getActiveAutomationListSearchQuery(oversized)).toBeNull() expect(resolveAutomationListSearchQuery(oversized)).toEqual({ status: 'too_large' }) expect( @@ -51,6 +48,14 @@ describe('automation-list-search', () => { expect(filterByAutomationListSearch(items, oversized, (item) => item)).toBe(items) }) + it('rejects queries over the byte limit but under the code-unit limit', () => { + // 3 UTF-8 bytes per character, so half the cap in code units is over it in bytes. + const multiByte = '한'.repeat(AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES / 2) + expect(multiByte.length).toBeLessThanOrEqual(AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES) + expect(isAutomationListSearchQueryTooLarge(multiByte)).toBe(true) + expect(resolveAutomationListSearchQuery(multiByte)).toEqual({ status: 'too_large' }) + }) + it('treats whitespace-only queries as inactive (no search work)', () => { expect(getActiveAutomationListSearchQuery(' \t ')).toBeNull() expect(resolveAutomationListSearchQuery(' ')).toEqual({ status: 'inactive' }) @@ -152,6 +157,12 @@ describe('automation-list-search', () => { 'a'.repeat(AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES + 1) ) ).toBe(items) + // Why: a desynchronized index must leave the list unfiltered, not blank it. + expect( + filterByActiveAutomationListSearchQuery(items, indexes.slice(0, 1), 'apk').map( + (item) => item.id + ) + ).toEqual(['1', '2', '3']) }) it('builds a stable fingerprint from search sources only', () => { diff --git a/src/renderer/src/components/automations/automation-list-search.ts b/src/renderer/src/components/automations/automation-list-search.ts index 2d0fa7f6e1b..b27da8fe221 100644 --- a/src/renderer/src/components/automations/automation-list-search.ts +++ b/src/renderer/src/components/automations/automation-list-search.ts @@ -77,21 +77,6 @@ export function getActiveAutomationListSearchQuery( return resolved.status === 'active' ? resolved.query : null } -/** @deprecated Prefer resolveAutomationListSearchQuery. null means too large. */ -export function getAutomationListSearchQuery( - rawQuery: string, - maxBytes = AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES -): string | null { - const resolved = resolveAutomationListSearchQuery(rawQuery, maxBytes) - if (resolved.status === 'too_large') { - return null - } - if (resolved.status === 'inactive') { - return '' - } - return resolved.query -} - /** Avoid splitting a surrogate pair at the cap boundary. */ export function truncateAutomationListSearchField(value: string, maxCodeUnits: number): string { if (value.length <= maxCodeUnits) { diff --git a/src/renderer/src/components/automations/automation-page-state.ts b/src/renderer/src/components/automations/automation-page-state.ts new file mode 100644 index 00000000000..b4eb2a31cc0 --- /dev/null +++ b/src/renderer/src/components/automations/automation-page-state.ts @@ -0,0 +1,15 @@ +import type { + ExternalAutomationJob, + ExternalAutomationManager, + ExternalAutomationRun +} from '../../../../shared/automations-types' + +/** Detail-pane tab shared by the page, its list panel, and the detail pane. */ +export type AutomationPaneTab = 'overview' | 'runs' + +/** External run opened as a full page inside the detail pane. */ +export type SelectedExternalRunPage = { + manager: ExternalAutomationManager + job: ExternalAutomationJob + run: ExternalAutomationRun +} diff --git a/src/renderer/src/components/automations/use-automation-list-search.ts b/src/renderer/src/components/automations/use-automation-list-search.ts index 17a3014bd2a..ce16d98c06a 100644 --- a/src/renderer/src/components/automations/use-automation-list-search.ts +++ b/src/renderer/src/components/automations/use-automation-list-search.ts @@ -7,6 +7,8 @@ import { buildAutomationListSearchIndex, buildAutomationProjectSearchText, resolveAutomationListSearchQuery, + truncateAutomationListSearchField, + AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS, type AutomationListSearchFields, type AutomationListSearchIndex } from './automation-list-search' @@ -59,7 +61,8 @@ export function useAutomationListSearch({ const isListSearchActive = activeListSearchQuery !== null // Why: fingerprint includes id + search fields so refresh ticks that only - // change nextRunAt / usage do not rebuild indexes or re-run matching. + // change nextRunAt / usage do not rebuild indexes or re-run matching. Prompts + // are truncated to the indexed prefix so each tick stays O(bound) per row. const automationSearchFingerprint = useMemo( () => automations @@ -69,7 +72,11 @@ export function useAutomationListSearch({ displayName: repo?.displayName, path: repo?.path }) - return `${automation.id}\u0001${automation.name}\u0001${project}\u0001${automation.prompt}` + const prompt = truncateAutomationListSearchField( + automation.prompt, + AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS + ) + return `${automation.id}\u0001${automation.name}\u0001${project}\u0001${prompt}` }) .join('\u0000'), [automations, repoMap] @@ -102,7 +109,11 @@ export function useAutomationListSearch({ if (entry.kind === 'source') { return `${entry.key}\u0001${entry.manager.targetLabel}\u0001${getExternalProviderLabel(entry.manager)}\u0001` } - return `${entry.key}\u0001${entry.job.name}\u0001${getExternalProviderLabel(entry.manager)}\u0001${entry.manager.targetLabel}\u0001${entry.job.workdir ?? ''}\u0001${entry.job.prompt ?? entry.job.promptPreview ?? ''}` + const prompt = truncateAutomationListSearchField( + entry.job.prompt ?? entry.job.promptPreview ?? '', + AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS + ) + return `${entry.key}\u0001${entry.job.name}\u0001${getExternalProviderLabel(entry.manager)}\u0001${entry.manager.targetLabel}\u0001${entry.job.workdir ?? ''}\u0001${prompt}` }) .join('\u0000'), [externalAutomationEntries] diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 8ade568b2f9..f7c6ccc6406 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -13919,7 +13919,19 @@ "53f06f0ad5": "Retry source", "pendingAutomationMissing": "Automation no longer available.", "pendingAutomationRunMissing": "Run history no longer available.", - "noSearchMatches": "No automations match your search." + "noSearchMatches": "No automations match your search.", + "paused": "Paused", + "runCount": "{{count}} runs", + "runCount_one": "{{count}} run", + "runCount_other": "{{count}} runs", + "projectDefaultBaseRef": "project default", + "createFromBaseRef": "Create from {{baseRef}}", + "missingWorkspace": "Missing workspace", + "runUsageSummary": "{{cost}} est. · {{tokens}} tokens", + "usageUnavailable": "Usage unavailable", + "noRunUsageYet": "No run usage yet", + "noWorkspace": "No workspace", + "latestSavedOutput": "Latest saved output" }, "CreateFromPicker": { "f061f49e3f": "Search repo branches...",