Localize remaining automation strings and optimize search

- Add 12 i18n keys for automation labels, counts, and usage display
- Extract AutomationPaneTab and SelectedExternalRunPage types to shared automation-page-state module
- Optimize search fingerprint by truncating prompts to indexed prefix for bounded performance per tick
- Improve escape-key handling in search field to clear input before blurring
- Remove deprecated getAutomationListSearchQuery function
This commit is contained in:
Jinjing
2026-08-04 12:13:13 -07:00
parent 45b5f8fbbc
commit bc112af54e
13 changed files with 159 additions and 61 deletions
@@ -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}.
</DialogDescription>
</DialogHeader>
@@ -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({
<span className="shrink-0">·</span>
<span className="truncate">
{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',
@@ -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<string, AutomationRun[]>()
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 (
<ContextMenu key={automation.id}>
@@ -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) {
@@ -19,7 +19,7 @@ import { translate } from '@/i18n/i18n'
type AutomationRunHistoryProps = {
runs: AutomationRun[]
automationId: string
worktreeMap: Map<string, Worktree>
worktreeMap: ReadonlyMap<string, Worktree>
onOpenRun: (run: AutomationRun) => void
}
@@ -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<string, Worktree>
worktreeMap: ReadonlyMap<string, Worktree>
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)}
@@ -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
@@ -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<HTMLButtonElement>(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<HTMLButtonElement>(null)
const completionInFlightRef = useRef<Set<string>>(new Set())
const rerunRunIdsInFlightRef = useRef<Set<string>>(new Set())
const workspaceNameCacheRef = useRef<Map<string, string>>(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 {
<ExternalAutomationDeleteDialog
externalDeleteTarget={externalDeleteTarget}
confirmButtonRef={deleteConfirmButtonRef}
confirmButtonRef={externalDeleteConfirmButtonRef}
onOpenChange={(open) => {
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}
@@ -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', () => {
@@ -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) {
@@ -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
}
@@ -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]
+13 -1
View File
@@ -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...",