From bc1feb5a4f2f2c81add5cf1d95826aa4e77e9962 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:13:40 -0700 Subject: [PATCH] Add search by name, project, and prompt for automations (#12561) * Add search by name, project, and prompt for automations Split the monolithic automations page into focused modules: extract dialog logic, list panel rendering, search functionality, and utility helpers into separate files. Introduce deferred search matching to keep the input responsive, with proper bounds checking to reject oversized pastes. The page stays unfiltered when search is inactive or too large, preserving the original list view in those cases. * fix(i18n): add missing automation search localization keys Sync en.json keys used by AutomationListSearchField and the no-matches empty state so static analysis localization catalog check passes. * 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 --- .../automations/AutomationDeleteDialogs.tsx | 186 +++ .../AutomationListExternalRows.tsx | 223 +++ .../automations/AutomationListLocalRows.tsx | 245 ++++ .../automations/AutomationListSearchField.tsx | 106 ++ .../automations/AutomationRunHistory.tsx | 2 +- .../automations/AutomationsDetailPane.tsx | 339 +++++ .../automations/AutomationsListPanel.tsx | 206 +++ .../automations/AutomationsPage.tsx | 1233 +++-------------- .../ExternalAutomationManagers.tsx | 31 +- .../ExternalAutomationRunTable.tsx | 45 +- .../automations/automation-draft-model.ts | 52 + .../automations/automation-host-client.ts | 14 + .../automation-list-search.test.ts | 186 +++ .../automations/automation-list-search.ts | 244 ++++ .../automations/automation-page-state.ts | 15 + .../automations/automation-run-content.ts | 17 + .../automations/automation-run-view-state.ts | 10 + .../automations/automation-source-context.ts | 56 + .../external-automation-display.ts | 71 + .../external-automation-list-entries.ts | 46 + .../automations/use-automation-list-search.ts | 265 ++++ src/renderer/src/i18n/locales/en.json | 22 +- 22 files changed, 2499 insertions(+), 1115 deletions(-) create mode 100644 src/renderer/src/components/automations/AutomationDeleteDialogs.tsx create mode 100644 src/renderer/src/components/automations/AutomationListExternalRows.tsx create mode 100644 src/renderer/src/components/automations/AutomationListLocalRows.tsx create mode 100644 src/renderer/src/components/automations/AutomationListSearchField.tsx create mode 100644 src/renderer/src/components/automations/AutomationsDetailPane.tsx create mode 100644 src/renderer/src/components/automations/AutomationsListPanel.tsx create mode 100644 src/renderer/src/components/automations/automation-draft-model.ts create mode 100644 src/renderer/src/components/automations/automation-list-search.test.ts create mode 100644 src/renderer/src/components/automations/automation-list-search.ts create mode 100644 src/renderer/src/components/automations/automation-page-state.ts create mode 100644 src/renderer/src/components/automations/automation-run-content.ts create mode 100644 src/renderer/src/components/automations/automation-source-context.ts create mode 100644 src/renderer/src/components/automations/external-automation-display.ts create mode 100644 src/renderer/src/components/automations/external-automation-list-entries.ts create mode 100644 src/renderer/src/components/automations/use-automation-list-search.ts diff --git a/src/renderer/src/components/automations/AutomationDeleteDialogs.tsx b/src/renderer/src/components/automations/AutomationDeleteDialogs.tsx new file mode 100644 index 00000000000..9921ad0b614 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationDeleteDialogs.tsx @@ -0,0 +1,186 @@ +import React from 'react' +import { Check, Trash2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import type { + Automation, + ExternalAutomationJob, + ExternalAutomationManager +} from '../../../../shared/automations-types' +import { getExternalAutomationScheduleDisplay } from './external-automation-schedule-display' +import { getExternalProviderLabel } from './external-automation-display' +import { translate } from '@/i18n/i18n' + +export function AutomationDeleteDialog({ + deleteTarget, + dontAskDeleteAgain, + confirmButtonRef, + onOpenChange, + onDontAskAgainToggle, + onCancel, + onConfirm +}: { + deleteTarget: Automation | null + dontAskDeleteAgain: boolean + confirmButtonRef: React.RefObject + onOpenChange: (open: boolean) => void + onDontAskAgainToggle: () => void + onCancel: () => void + onConfirm: () => void +}): React.JSX.Element { + return ( + + { + event.preventDefault() + confirmButtonRef.current?.focus() + }} + > + + + {translate( + 'auto.components.automations.AutomationsPage.080dcb5fbb', + 'Delete Automation' + )} + + + {translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}{' '} + {deleteTarget?.name}{' '} + {translate( + 'auto.components.automations.AutomationsPage.b264564427', + 'and its run history. Workspaces created by previous runs are not deleted.' + )} + + + {deleteTarget ? ( +
+
{deleteTarget.name}
+
+ {deleteTarget.workspaceMode === 'new_per_run' + ? translate( + 'auto.components.automations.AutomationsPage.cd8397cc32', + 'New workspace each run' + ) + : translate( + 'auto.components.automations.AutomationsPage.36f71740a7', + 'Selected workspace' + )} +
+
+ ) : null} + + + + + +
+
+ ) +} + +export function ExternalAutomationDeleteDialog({ + externalDeleteTarget, + confirmButtonRef, + onOpenChange, + onCancel, + onConfirm +}: { + externalDeleteTarget: { + manager: ExternalAutomationManager + job: ExternalAutomationJob + } | null + confirmButtonRef: React.RefObject + onOpenChange: (open: boolean) => void + onCancel: () => void + onConfirm: () => void +}): React.JSX.Element { + return ( + + { + event.preventDefault() + confirmButtonRef.current?.focus() + }} + > + + + {translate( + 'auto.components.automations.AutomationsPage.9adfab2596', + 'Delete External Automation' + )} + + + {translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}{' '} + + {externalDeleteTarget?.job.name} + {' '} + {translate('auto.components.automations.AutomationsPage.02a33e3204', 'from')}{' '} + {externalDeleteTarget + ? getExternalProviderLabel(externalDeleteTarget.manager) + : translate( + 'auto.components.automations.AutomationsPage.8500baacb4', + 'external source' + )}{' '} + {translate('auto.components.automations.AutomationsPage.1b586f0e2b', 'on')}{' '} + {externalDeleteTarget?.manager.targetLabel}. + + + {externalDeleteTarget ? ( +
+
+ {externalDeleteTarget.job.name} +
+
+ { + getExternalAutomationScheduleDisplay( + externalDeleteTarget.manager, + externalDeleteTarget.job + ).label + } +
+
+ ) : null} + + + + +
+
+ ) +} diff --git a/src/renderer/src/components/automations/AutomationListExternalRows.tsx b/src/renderer/src/components/automations/AutomationListExternalRows.tsx new file mode 100644 index 00000000000..b5269b65593 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationListExternalRows.tsx @@ -0,0 +1,223 @@ +import React from 'react' +import { Clock, Pause, Pencil, Play, Trash2 } from 'lucide-react' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger +} from '@/components/ui/context-menu' +import { cn } from '@/lib/utils' +import type { + ExternalAutomationAction, + ExternalAutomationJob, + ExternalAutomationManager +} from '../../../../shared/automations-types' +import type { SshConnectionState } from '../../../../shared/ssh-types' +import type { ExternalAutomationListEntry } from './external-automation-list-entries' +import { + formatExternalDate, + getExternalProviderLabel, + getExternalTargetKindLabel +} from './external-automation-display' +import { getExternalAutomationScheduleDisplay } from './external-automation-schedule-display' +import { + getExternalAutomationActionDisabledMessage, + getExternalAutomationSourceAvailability +} from './external-automation-source-availability' +import { translate } from '@/i18n/i18n' + +export function AutomationListExternalRows({ + entries, + selectedExternalKey, + relativeNow, + sshConnectionStates, + externalActionKey, + onSelect, + onRequestAction, + onEdit +}: { + entries: readonly ExternalAutomationListEntry[] + selectedExternalKey: string | null | undefined + relativeNow: number + sshConnectionStates: ReadonlyMap> + externalActionKey: string | null + onSelect: (entryKey: string) => void + onRequestAction: ( + manager: ExternalAutomationManager, + job: ExternalAutomationJob, + action: ExternalAutomationAction + ) => void + onEdit: (manager: ExternalAutomationManager, job: ExternalAutomationJob) => void +}): React.JSX.Element { + return ( + <> + {entries.map((entry) => { + const providerLabel = getExternalProviderLabel(entry.manager) + const targetKindLabel = getExternalTargetKindLabel(entry.manager) + if (entry.kind === 'source') { + const sshStatus = + entry.manager.target.type === 'ssh' + ? sshConnectionStates.get(entry.manager.target.connectionId)?.status + : undefined + const sourceAvailability = getExternalAutomationSourceAvailability({ + manager: entry.manager, + providerLabel, + targetKindLabel, + sshStatus + }) + return ( + + ) + } + const nextRunLabel = entry.job.enabled + ? formatExternalDate(entry.job.nextRunAt, relativeNow) + : translate('auto.components.automations.AutomationsPage.paused', 'Paused') + const entrySshStatus = + entry.manager.target.type === 'ssh' + ? sshConnectionStates.get(entry.manager.target.connectionId)?.status + : undefined + const disabledMessage = getExternalAutomationActionDisabledMessage({ + manager: entry.manager, + providerLabel, + targetKindLabel, + sshStatus: entrySshStatus, + actionInProgress: externalActionKey !== null + }) + const actionDisabled = disabledMessage !== null + const scheduleDisplay = getExternalAutomationScheduleDisplay(entry.manager, entry.job) + return ( + + + + + + onRequestAction(entry.manager, entry.job, 'run')} + > + + + {disabledMessage ?? + translate('auto.components.automations.AutomationsPage.2faecab10b', 'Run Now')} + + + {entry.manager.provider === 'hermes' ? ( + onEdit(entry.manager, entry.job)} + > + + {translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')} + + ) : null} + + onRequestAction(entry.manager, entry.job, entry.job.enabled ? 'pause' : 'resume') + } + > + {entry.job.enabled ? : } + {entry.job.enabled + ? translate('auto.components.automations.AutomationsPage.b457436d6a', 'Pause') + : translate('auto.components.automations.AutomationsPage.376631ef2b', 'Resume')} + + + onRequestAction(entry.manager, entry.job, 'delete')} + > + + {translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')} + + + + ) + })} + + ) +} diff --git a/src/renderer/src/components/automations/AutomationListLocalRows.tsx b/src/renderer/src/components/automations/AutomationListLocalRows.tsx new file mode 100644 index 00000000000..1a5a4994aac --- /dev/null +++ b/src/renderer/src/components/automations/AutomationListLocalRows.tsx @@ -0,0 +1,245 @@ +import React from 'react' +import { Clock, Pause, Pencil, Play, Trash2 } from 'lucide-react' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger +} from '@/components/ui/context-menu' +import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' +import { cn } from '@/lib/utils' +import type { Automation, AutomationRun } from '../../../../shared/automations-types' +import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity' +import { formatAutomationSchedule } from '../../../../shared/automation-schedules' +import type { SshConnectionState } from '../../../../shared/ssh-types' +import type { ProjectHostSetup, Repo, Worktree } from '../../../../shared/types' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { TaskSourceHostAvailability } from '../task-source-context-summary' +import type { AutomationHostTarget } from './automation-host-client' +import { formatAutomationDateTimeWithRelative } from './automation-page-parts' +import { + formatAutomationCost, + formatAutomationTokens, + summarizeAutomationRunUsage +} from './automation-usage-model' +import { getAutomationTargetAvailability } from './automation-target-availability' +import { getAgentLabel } from './automation-draft-model' +import { translate } from '@/i18n/i18n' + +export function AutomationListLocalRows({ + automations, + selectedId, + isSelectedLocal, + runs, + relativeNow, + repoMap, + worktreeMap, + projectHostSetups, + sshConnectionStates, + runtimeStatusByEnvironmentId, + automationHostTarget, + automationSourceHostAvailabilityById, + onSelect, + onRunNow, + onEdit, + onToggle, + onDelete +}: { + automations: readonly Automation[] + selectedId: string | null | undefined + isSelectedLocal: boolean + runs: readonly AutomationRun[] + relativeNow: number + repoMap: ReadonlyMap + worktreeMap: ReadonlyMap + projectHostSetups: readonly ProjectHostSetup[] + sshConnectionStates: ReadonlyMap> + runtimeStatusByEnvironmentId: ReadonlyMap< + string, + { status: RuntimeStatus | null; checkedAt: number } + > + automationHostTarget: AutomationHostTarget | null + automationSourceHostAvailabilityById: ReadonlyMap + onSelect: (automationId: string) => void + onRunNow: (automation: Automation) => void + onEdit: (automation: Automation) => void + 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) => { + const automationRepo = repoMap.get(getAutomationRunRepoId(automation)) + const automationWorktree = automation.workspaceId + ? worktreeMap.get(automation.workspaceId) + : null + const automationRunAvailability = getAutomationTargetAvailability({ + automation, + repo: automationRepo, + workspace: automationWorktree, + projectHostSetups, + sshConnectionStates, + runtimeStatusByEnvironmentId, + 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' + ? translate( + 'auto.components.automations.AutomationsPage.createFromBaseRef', + 'Create from {{baseRef}}', + { baseRef: baseRefLabel } + ) + : (automationWorktree?.displayName ?? + translate( + 'auto.components.automations.AutomationsPage.missingWorkspace', + 'Missing workspace' + )) + const usageSummary = summarizeAutomationRunUsage( + runsByAutomationId.get(automation.id) ?? [] + ) + const usageText = + usageSummary.knownRuns > 0 + ? translate( + 'auto.components.automations.AutomationsPage.runUsageSummary', + '{{cost}} est. · {{tokens}} tokens', + { + cost: formatAutomationCost(usageSummary.estimatedCostUsd), + tokens: formatAutomationTokens(usageSummary.totalTokens) + } + ) + : usageSummary.unavailableRuns > 0 + ? 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) + : translate('auto.components.automations.AutomationsPage.paused', 'Paused') + const scheduleLabel = formatAutomationSchedule(automation.rrule) + return ( + + + + + + { + if (!automationRunAvailability.canRunNow) { + event.preventDefault() + return + } + onRunNow(automation) + }} + > + + + {automationRunAvailability.canRunNow + ? translate('auto.components.automations.AutomationsPage.2faecab10b', 'Run Now') + : automationRunAvailability.message} + + + onEdit(automation)}> + + {translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')} + + onToggle(automation)}> + {automation.enabled ? ( + + ) : ( + + )} + {automation.enabled + ? translate('auto.components.automations.AutomationsPage.b457436d6a', 'Pause') + : translate('auto.components.automations.AutomationsPage.376631ef2b', 'Resume')} + + + onDelete(automation)}> + + {translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')} + + + + ) + })} + + ) +} diff --git a/src/renderer/src/components/automations/AutomationListSearchField.tsx b/src/renderer/src/components/automations/AutomationListSearchField.tsx new file mode 100644 index 00000000000..3a2bceb7c48 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationListSearchField.tsx @@ -0,0 +1,106 @@ +import React, { useRef } from 'react' +import { Search, X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' + +type AutomationListSearchFieldProps = { + query: string + isTooLarge: boolean + onQueryChange: (query: string) => void + onClear: () => void + className?: string +} + +export function AutomationListSearchField({ + query, + isTooLarge, + onQueryChange, + onClear, + className +}: AutomationListSearchFieldProps): React.JSX.Element { + const inputRef = useRef(null) + const hasText = query !== '' + const tooLargeMessage = isTooLarge + ? translate( + 'auto.components.automations.AutomationListSearchField.tooLong', + 'Search text is too long — list is unfiltered' + ) + : null + + return ( +
+ + onQueryChange(event.target.value)} + onKeyDown={(event) => { + if (event.key !== 'Escape' || event.nativeEvent.isComposing) { + return + } + if (!hasText) { + return + } + event.preventDefault() + onClear() + }} + /> + {hasText ? ( +
+ {isTooLarge ? ( + + {translate( + 'auto.components.automations.AutomationListSearchField.tooLongShort', + 'Too long' + )} + + ) : null} + +
+ ) : null} + {isTooLarge ? ( +
+ {tooLargeMessage} +
+ ) : null} +
+ ) +} 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 new file mode 100644 index 00000000000..2d290f26ac6 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationsDetailPane.tsx @@ -0,0 +1,339 @@ +import React from 'react' +import { Eye, RefreshCw } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { cn } from '@/lib/utils' +import type { + Automation, + ExternalAutomationAction, + ExternalAutomationJob, + ExternalAutomationManager, + ExternalAutomationRun, + AutomationRun +} from '../../../../shared/automations-types' +import type { Worktree } from '../../../../shared/types' +import CommentMarkdown from '@/components/sidebar/CommentMarkdown' +import { AutomationDetail } from './AutomationDetail' +import { HermesCronOutputView } from './HermesCronOutputView' +import { AutomationRunPageFrame } from './AutomationRunPageFrame' +import { AutomationRunHistory } from './AutomationRunHistory' +import { ExternalAutomationManagers } from './ExternalAutomationManagers' +import type { FetchExternalAutomationRuns } from './ExternalAutomationRunTable' +import type { ExternalAutomationListEntry } from './external-automation-list-entries' +import { + formatExternalDate, + getExternalProviderLabel, + getExternalRunContent, + getExternalRunStatusLabel, + getExternalRunStatusVariant +} from './external-automation-display' +import { + formatAutomationDateTimeWithRelative, + getAutomationRunStatusLabel, + getAutomationRunStatusVariant +} from './automation-page-parts' +import { getAutomationRunContent } from './automation-run-content' +import type { AutomationTargetAvailability } from './automation-target-availability' +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 AutomationsDetailPaneProps = { + selected: Automation | null + selectedExternal: ExternalAutomationListEntry | null + selectedExternalRunPage: SelectedExternalRunPage | null + selectedAutomationRunPage: AutomationRun | null + selectedRuns: AutomationRun[] + activePaneTab: AutomationPaneTab + relativeNow: number + externalActionKey: string | null + selectedRepoDisplayName: string + selectedRepoDefaultBaseRef: string | null + selectedWorkspaceName: string + hostLabelById: ReadonlyMap + selectedRunNowAvailability: AutomationTargetAvailability | null + selectedExternalSourceAvailability: ExternalAutomationSourceAvailability | null + selectedExternalSshSource: { + manager: ExternalAutomationManager + } | null + selectedExternalSshConnected: boolean + selectedAutomationRunPageWorkspaceDisplay: AutomationRunWorkspaceDisplay | null + selectedAutomationRunPageViewState: AutomationRunViewState | null + canRerunSelectedAutomationRunPage: boolean + isSelectedAutomationRunPageRerunPending: boolean + worktreeMap: ReadonlyMap + fetchExternalAutomationRuns: FetchExternalAutomationRuns + onActivePaneTabChange: (tab: AutomationPaneTab) => void + onClearExternalRunPage: () => void + onClearAutomationRunPage: () => void + requestExternalAction: ( + manager: ExternalAutomationManager, + job: ExternalAutomationJob, + action: ExternalAutomationAction + ) => void + openExternalRunPage: ( + manager: ExternalAutomationManager, + job: ExternalAutomationJob, + run: ExternalAutomationRun + ) => void + openEditExternalDialog: (manager: ExternalAutomationManager, job: ExternalAutomationJob) => void + connectExternalAutomationSource: (manager: ExternalAutomationManager) => void + runNow: (automation: Automation) => void + openEditDialog: (automation: Automation) => void + toggleAutomation: (automation: Automation) => void + requestDeleteAutomation: (automation: Automation) => void + rerunAutomationRun: (automation: Automation, run: AutomationRun) => void + openRunWorkspace: (run: AutomationRun) => void + openAutomationRunPage: (run: AutomationRun) => void +} + +export function AutomationsDetailPane({ + selected, + selectedExternal, + selectedExternalRunPage, + selectedAutomationRunPage, + selectedRuns, + activePaneTab, + relativeNow, + externalActionKey, + selectedRepoDisplayName, + selectedRepoDefaultBaseRef, + selectedWorkspaceName, + hostLabelById, + selectedRunNowAvailability, + selectedExternalSourceAvailability, + selectedExternalSshSource, + selectedExternalSshConnected, + selectedAutomationRunPageWorkspaceDisplay, + selectedAutomationRunPageViewState, + canRerunSelectedAutomationRunPage, + isSelectedAutomationRunPageRerunPending, + worktreeMap, + fetchExternalAutomationRuns, + onActivePaneTabChange, + onClearExternalRunPage, + onClearAutomationRunPage, + requestExternalAction, + openExternalRunPage, + openEditExternalDialog, + connectExternalAutomationSource, + runNow, + openEditDialog, + toggleAutomation, + requestDeleteAutomation, + rerunAutomationRun, + openRunWorkspace, + openAutomationRunPage +}: AutomationsDetailPaneProps): React.JSX.Element { + return ( +
+ {selectedExternal ? ( +
+ {selectedExternalRunPage ? ( + + + + ) : selectedExternal.kind === 'job' ? ( + + ) : ( +
+
+
+
+ {selectedExternal.manager.targetLabel} +
+
+ {selectedExternalSourceAvailability?.summary} +
+
+ {selectedExternalSshSource ? ( + + ) : null} +
+
+ {selectedExternalSourceAvailability?.detail} +
+
+ )} +
+ ) : ( + onActivePaneTabChange(value as AutomationPaneTab)} + className="min-h-0 flex-1 gap-0" + > +
+ + + {translate('auto.components.automations.AutomationsPage.bb1b2cd31e', 'Overview')} + + + {translate('auto.components.automations.AutomationsPage.0e110a3469', 'Runs')}{' '} + {selectedRuns.length} + + +
+ + + void runNow(automation)} + onEdit={(automation) => void openEditDialog(automation)} + onToggle={(automation) => void toggleAutomation(automation)} + onDelete={requestDeleteAutomation} + /> + + + + {selectedAutomationRunPage ? ( + + {canRerunSelectedAutomationRunPage && selected ? ( + + ) : null} + {selectedAutomationRunPageViewState ? ( + + ) : null} + + } + onBack={onClearAutomationRunPage} + > + + + ) : selected ? ( + + ) : ( +
+ {translate( + 'auto.components.automations.AutomationsPage.c3a28c9793', + 'Select an automation to view runs.' + )} +
+ )} +
+
+ )} +
+ ) +} diff --git a/src/renderer/src/components/automations/AutomationsListPanel.tsx b/src/renderer/src/components/automations/AutomationsListPanel.tsx new file mode 100644 index 00000000000..31d9cc9d6d9 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationsListPanel.tsx @@ -0,0 +1,206 @@ +import React from 'react' +import { Plus } from 'lucide-react' +import { Button } from '@/components/ui/button' +import type { + Automation, + AutomationRun, + ExternalAutomationAction, + ExternalAutomationJob, + ExternalAutomationManager +} from '../../../../shared/automations-types' +import type { SshConnectionState } from '../../../../shared/ssh-types' +import type { ProjectHostSetup, Repo, Worktree } from '../../../../shared/types' +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' +import { AutomationListLocalRows } from './AutomationListLocalRows' +import { AutomationListExternalRows } from './AutomationListExternalRows' +import { translate } from '@/i18n/i18n' + +type AutomationsListPanelProps = { + hasListItems: boolean + hasFilteredListItems: boolean + isListSearchActive: boolean + listSearchQuery: string + isListSearchQueryTooLarge: boolean + onListSearchQueryChange: (query: string) => void + filteredAutomations: readonly Automation[] + filteredExternalAutomationEntries: readonly ExternalAutomationListEntry[] + selected: Automation | null + selectedExternal: ExternalAutomationListEntry | null + runs: readonly AutomationRun[] + relativeNow: number + repoMap: ReadonlyMap + worktreeMap: ReadonlyMap + projectHostSetups: readonly ProjectHostSetup[] + sshConnectionStates: ReadonlyMap> + runtimeStatusByEnvironmentId: ReadonlyMap< + string, + { status: RuntimeStatus | null; checkedAt: number } + > + automationHostTarget: AutomationHostTarget | null + automationSourceHostAvailabilityById: ReadonlyMap + externalActionKey: string | null + selectAutomationId: (automationId: string | null) => void + selectExternalKey: (externalKey: string | null) => void + setActivePaneTab: (tab: AutomationPaneTab) => void + runNow: (automation: Automation) => void + openEditDialog: (automation: Automation) => void + toggleAutomation: (automation: Automation) => void + requestDeleteAutomation: (automation: Automation) => void + requestExternalAction: ( + manager: ExternalAutomationManager, + job: ExternalAutomationJob, + action: ExternalAutomationAction + ) => void + openEditExternalDialog: (manager: ExternalAutomationManager, job: ExternalAutomationJob) => void + openCreateDialog: (template?: AutomationTemplate) => void +} + +export function AutomationsListPanel({ + hasListItems, + hasFilteredListItems, + isListSearchActive, + listSearchQuery, + isListSearchQueryTooLarge, + onListSearchQueryChange, + filteredAutomations, + filteredExternalAutomationEntries, + selected, + selectedExternal, + runs, + relativeNow, + repoMap, + worktreeMap, + projectHostSetups, + sshConnectionStates, + runtimeStatusByEnvironmentId, + automationHostTarget, + automationSourceHostAvailabilityById, + externalActionKey, + selectAutomationId, + selectExternalKey, + setActivePaneTab, + runNow, + openEditDialog, + toggleAutomation, + requestDeleteAutomation, + requestExternalAction, + openEditExternalDialog, + openCreateDialog +}: AutomationsListPanelProps): React.JSX.Element { + return ( +
+ {hasListItems ? ( +
+ + onListSearchQueryChange(clampAutomationListSearchQueryInput(query)) + } + onClear={() => onListSearchQueryChange('')} + /> +
+ ) : null} +
+ {hasFilteredListItems ? ( +
+ + {translate('auto.components.automations.AutomationsPage.761a35834d', 'Automation')} + + + {translate('auto.components.automations.AutomationsPage.587a4b205c', 'Next')} + +
+ ) : null} + { + selectExternalKey(null) + selectAutomationId(automationId) + }} + onRunNow={runNow} + onEdit={openEditDialog} + onToggle={toggleAutomation} + onDelete={requestDeleteAutomation} + /> + { + selectExternalKey(entryKey) + setActivePaneTab('overview') + }} + onRequestAction={requestExternalAction} + onEdit={openEditExternalDialog} + /> + {hasListItems && isListSearchActive && !hasFilteredListItems ? ( +
+ {translate( + 'auto.components.automations.AutomationsPage.noSearchMatches', + 'No automations match your search.' + )} +
+ ) : null} + {!hasListItems ? ( +
+
+ {translate( + 'auto.components.automations.AutomationsPage.d207ab4c25', + 'Start from a template' + )} +
+ {getAutomationTemplates().map((template) => ( + + ))} + +
+ ) : null} +
+
+ ) +} diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index d55ee012dae..97f15d844a6 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -1,46 +1,16 @@ /* eslint-disable max-lines -- Why: this page owns the automations list/detail - * orchestration while the form and detail presentation live in sibling files. */ + * orchestration while the form, list, and detail presentation live in sibling files. */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { - CalendarClock, - Check, - Clock, - Eye, - Pause, - Pencil, - Play, - Plus, - RefreshCw, - Trash2, - X -} from 'lucide-react' +import { CalendarClock, Plus, RefreshCw, X } from 'lucide-react' import { toast } from 'sonner' import { filterEnabledTuiAgents, isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' -import type { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger -} from '@/components/ui/context-menu' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useAppStore } from '@/store' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context' import { cn } from '@/lib/utils' -import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' import { getAgentCatalog } from '@/lib/agent-catalog' import { useRepoMap, useWorktreeMap } from '@/store/selectors' import { activateAndRevealWorktree } from '@/lib/worktree-activation' @@ -50,7 +20,6 @@ import type { ExternalAutomationJob, ExternalAutomationManager, ExternalAutomationRun, - AutomationPrecheck, AutomationRun, AutomationUpdateInput } from '../../../../shared/automations-types' @@ -61,34 +30,20 @@ import { parseExecutionHostId } from '../../../../shared/execution-host' import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' -import { TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import type { PreflightStatus } from '../../../../preload/api-types' -import type { RuntimeStatus } from '../../../../shared/runtime-types' import type { TaskSourceContext } from '../../../../shared/task-source-context' -import type { OrcaHooks, Repo, Worktree } from '../../../../shared/types' +import type { OrcaHooks, Repo } from '../../../../shared/types' import { getWorktreePathBasenameFromId } from '../../../../shared/worktree-id' import { - buildAutomationCronSchedule, buildAutomationRrule, - formatAutomationSchedule, isValidAutomationCronSchedule, isValidAutomationSchedule, tryParseAutomationRrule } from '../../../../shared/automation-schedules' -import { - formatAutomationDateTimeWithRelative, - getAutomationRunStatusLabel, - getAutomationRunStatusVariant -} from './automation-page-parts' -import { - formatAutomationCost, - formatAutomationTokens, - summarizeAutomationRunUsage -} from './automation-usage-model' import { canRerunAutomationRun, - getAutomationRerunPendingRemainingMs, - getAutomationRunViewState + getAutomationRunViewState, + waitForAutomationRerunPendingVisibility } from './automation-run-view-state' import { automationRunMatchesPaneKey, @@ -98,22 +53,17 @@ import { resolveAutomationRunOpenTarget } from './automation-run-open-target' import { getAutomationRunWorkspaceDisplay } from './automation-run-workspace-display' -import CommentMarkdown from '@/components/sidebar/CommentMarkdown' -import { AutomationDetail } from './AutomationDetail' -import { HermesCronOutputView } from './HermesCronOutputView' import { AutomationEditorDialog, type AutomationCreateTarget, type AutomationDraft } from './AutomationEditorDialog' -import { AutomationRunPageFrame } from './AutomationRunPageFrame' -import { AutomationRunHistory } from './AutomationRunHistory' import { getAutomationSetupDecisionDraftValue, getVisibleAutomationSetupDecision, resolveAutomationSetupDecisionForSave } from './automation-setup-decision' -import { getAutomationTemplates, type AutomationTemplate } from './automation-templates' +import type { AutomationTemplate } from './automation-templates' import { getAutomationTargetAvailability } from './automation-target-availability' import { buildAutomationRunContextForRepo } from './automation-run-context' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' @@ -125,14 +75,14 @@ import { } from '../task-source-provider-availability' import type { TaskSourceHostAvailability } from '../task-source-context-summary' import { - getExternalAutomationActionDisabledMessage, getExternalAutomationSourceAvailability, isSshConnectionBusy } from './external-automation-source-availability' import { createAutomationForTarget, deleteAutomationForTarget, - type AutomationHostTarget, + getAutomationHostTargetFromKey, + getAutomationHostTargetKey, getAutomationListTarget, getAutomationOwnerTarget, getAutomationTargetFromHostId, @@ -141,223 +91,38 @@ import { runAutomationNowForTarget, updateAutomationForTarget } from './automation-host-client' -import { getExternalAutomationScheduleDisplay } from './external-automation-schedule-display' -import { ExternalAutomationManagers } from './ExternalAutomationManagers' import type { FetchExternalAutomationRuns } from './ExternalAutomationRunTable' +import { + AUTOMATION_DEFAULT_TIME, + buildDraftPrecheck, + buildHermesCronSchedule, + formatTimeInput, + getDefaultWorktree, + parseDraftTime +} from './automation-draft-model' +import { + getRepoBackedAutomationSourceContext, + getRuntimeSourceHostAvailability, + type RepoBackedAutomationSourceContext +} from './automation-source-context' +import type { AutomationPaneTab, SelectedExternalRunPage } from './automation-page-state' +import { + getExternalAutomationKey, + getExternalAutomationSourceKey, + getExternalProviderLabel, + getExternalTargetKindLabel, + isMissingExternalRunsApiError +} from './external-automation-display' +import { buildExternalAutomationListEntries } from './external-automation-list-entries' +import { useAutomationListSearch } from './use-automation-list-search' +import { AutomationDeleteDialog, ExternalAutomationDeleteDialog } from './AutomationDeleteDialogs' +import { AutomationsListPanel } from './AutomationsListPanel' +import { AutomationsDetailPane } from './AutomationsDetailPane' import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour' import { translate } from '@/i18n/i18n' const AGENTS = getAgentCatalog().map((agent) => agent.id) -const DEFAULT_TIME = '09:00' const AUTOMATIONS_CHANGED_EVENT = 'orca:automations-changed' -type AutomationPaneTab = 'overview' | 'runs' -type RepoBackedAutomationSourceContext = TaskSourceContext & { provider: 'github' | 'gitlab' } - -type ExternalAutomationListEntry = - | { - kind: 'job' - key: string - manager: ExternalAutomationManager - job: ExternalAutomationJob - } - | { - kind: 'source' - key: string - manager: ExternalAutomationManager - } - -type SelectedExternalRunPage = { - manager: ExternalAutomationManager - job: ExternalAutomationJob - run: ExternalAutomationRun -} - -function getAutomationHostTargetKey(target: AutomationHostTarget): string { - return target.kind === 'environment' ? `environment:${target.environmentId}` : 'local' -} - -function getAutomationHostTargetFromKey(key: string | null): AutomationHostTarget | null { - if (!key) { - return null - } - if (key.startsWith('environment:')) { - return { kind: 'environment', environmentId: key.slice('environment:'.length) } - } - return { kind: 'local' } -} - -function getDefaultWorktree(worktrees: readonly Worktree[]): Worktree | null { - return worktrees.find((worktree) => worktree.isMainWorktree) ?? worktrees[0] ?? null -} - -function getRepoBackedAutomationSourceContext( - automation: Automation -): RepoBackedAutomationSourceContext | null { - const context = automation.sourceContext - return context?.provider === 'github' || context?.provider === 'gitlab' - ? (context as RepoBackedAutomationSourceContext) - : null -} - -function getRuntimeSourceHostAvailability( - context: TaskSourceContext, - runtimeStatusByEnvironmentId: ReadonlyMap< - string, - { status: RuntimeStatus | null; checkedAt: number } - > -): TaskSourceHostAvailability | null { - const parsed = parseExecutionHostId(context.hostId) - if (parsed?.kind !== 'runtime') { - return null - } - const entry = runtimeStatusByEnvironmentId.get(parsed.environmentId) - if (!entry) { - return { hostId: context.hostId, reason: 'checking-task-source-capability' } - } - if (!entry.status) { - return { hostId: context.hostId, health: 'disconnected' } - } - if (entry.status.graphStatus !== 'ready') { - return { hostId: context.hostId, health: 'connecting' } - } - const capabilities = entry.status.capabilities - if (!capabilities) { - return { hostId: context.hostId, reason: 'checking-task-source-capability' } - } - if (!capabilities.includes(TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY)) { - return { hostId: context.hostId, reason: 'missing-task-source-capability' } - } - return null -} - -function formatTimeInput(hour: number, minute: number): string { - return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}` -} - -function parseDraftTime(time: string): { hour: number; minute: number } { - const [rawHour, rawMinute] = time.split(':').map((part) => Number(part)) - return { - hour: Number.isFinite(rawHour) ? rawHour : 9, - minute: Number.isFinite(rawMinute) ? rawMinute : 0 - } -} - -function buildDraftPrecheck(draft: AutomationDraft): AutomationPrecheck | null { - const command = draft.precheckCommand.trim() - if (!command) { - return null - } - const rawTimeout = Number(draft.precheckTimeoutSeconds) - return { - command, - timeoutSeconds: Number.isFinite(rawTimeout) ? rawTimeout : 60 - } -} - -function buildHermesCronSchedule(draft: AutomationDraft): string { - if (draft.preset === 'custom') { - return draft.customSchedule.trim() - } - const { hour, minute } = parseDraftTime(draft.time) - return buildAutomationCronSchedule({ - preset: draft.preset, - hour, - minute, - dayOfWeek: Number(draft.dayOfWeek) - }) -} - -function getAgentLabel(agentId: string): string { - return getAgentCatalog().find((agent) => agent.id === agentId)?.label ?? agentId -} - -function getExternalAutomationKey( - manager: ExternalAutomationManager, - job: ExternalAutomationJob -): string { - return `${manager.id}:${job.id}` -} - -function getExternalAutomationSourceKey(manager: ExternalAutomationManager): string { - return `${manager.id}:source` -} - -function formatExternalDate(value: string | null, now: number): string { - if (!value) { - return 'Never' - } - const parsed = Date.parse(value) - if (!Number.isFinite(parsed)) { - return value - } - return formatAutomationDateTimeWithRelative(parsed, now) -} - -function getExternalProviderLabel(manager: ExternalAutomationManager): string { - return manager.provider === 'hermes' ? 'Hermes' : 'OpenClaw' -} - -function getExternalTargetKindLabel(manager: ExternalAutomationManager): string { - return manager.target.type === 'ssh' ? 'SSH host' : 'Local' -} - -function getExternalRunStatusLabel(run: ExternalAutomationRun): string { - switch (run.status) { - case 'completed': - return 'Completed' - case 'failed': - return 'Failed' - case 'unknown': - return 'Unknown' - } -} - -function getExternalRunStatusVariant( - run: ExternalAutomationRun -): React.ComponentProps['variant'] { - switch (run.status) { - case 'completed': - return 'secondary' - case 'failed': - return 'destructive' - case 'unknown': - return 'outline' - } -} - -function getExternalRunContent(run: ExternalAutomationRun): string { - return run.outputContent ?? run.error ?? run.outputPreview ?? 'No output content available.' -} - -function getAutomationRunContent(run: AutomationRun): string { - const savedOutput = run.outputSnapshot?.content.trim() - if (savedOutput) { - return run.outputSnapshot?.content ?? savedOutput - } - if (run.precheckResult) { - const output = [run.precheckResult.stderr.trim(), run.precheckResult.stdout.trim()] - .filter(Boolean) - .join('\n\n') - if (output) { - return output - } - } - return run.error ?? run.usage?.unavailableMessage ?? 'No output content available.' -} - -function isMissingExternalRunsApiError(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error) - return /listExternalRuns|automations:listExternalRuns|No handler registered/i.test(message) -} - -async function waitForAutomationRerunPendingVisibility(pendingStartedAt: number): Promise { - const remainingMs = getAutomationRerunPendingRemainingMs({ pendingStartedAt }) - if (remainingMs <= 0) { - return - } - await new Promise((resolve) => window.setTimeout(resolve, remainingMs)) -} - export default function AutomationsPage(): React.JSX.Element { const repos = useAppStore((s) => s.repos) const projectHostSetups = useAppStore((s) => s.projectHostSetups) @@ -415,6 +180,7 @@ export default function AutomationsPage(): React.JSX.Element { ) const [isLoading, setIsLoading] = useState(true) const [isSaving, setIsSaving] = useState(false) + const [listSearchQuery, setListSearchQuery] = useState('') const [createOpen, setCreateOpen] = useState(false) const [createTarget, setCreateTarget] = useState('orca') const [editingAutomationId, setEditingAutomationId] = useState(null) @@ -463,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()) @@ -488,40 +257,35 @@ export default function AutomationsPage(): React.JSX.Element { precheckCommand: '', precheckTimeoutSeconds: '60', preset: 'weekdays', - time: DEFAULT_TIME, + time: AUTOMATION_DEFAULT_TIME, dayOfWeek: '1', customSchedule: '', missedRunGraceMinutes: '720', scheduleWarning: null }) - const externalAutomationEntries = useMemo( - () => - externalManagers.flatMap((manager): ExternalAutomationListEntry[] => { - if (manager.jobs.length === 0) { - if ( - manager.provider === 'hermes' && - (manager.status === 'unavailable' || manager.error) - ) { - return [ - { - kind: 'source' as const, - key: getExternalAutomationSourceKey(manager), - manager - } - ] - } - return [] - } - return manager.jobs.map((job) => ({ - kind: 'job' as const, - key: getExternalAutomationKey(manager, job), - manager, - job - })) - }), + const externalAutomationEntries = useMemo( + () => buildExternalAutomationListEntries(externalManagers), [externalManagers] ) + const { + isListSearchQueryTooLarge, + isListSearchActive, + filteredAutomations, + filteredExternalAutomationEntries, + hasListItems, + hasFilteredListItems + } = useAutomationListSearch({ + listSearchQuery, + automations, + externalAutomationEntries, + repoMap, + selectedId, + selectedExternalKey, + selectAutomationId, + selectExternalKey + }) + const selectedExternal = externalAutomationEntries.find((entry) => entry.key === selectedExternalKey) ?? (automations.length === 0 ? (externalAutomationEntries[0] ?? null) : null) @@ -769,7 +533,10 @@ export default function AutomationsPage(): React.JSX.Element { : null const canRerunSelectedAutomationRunPage = selectedAutomationRunPage !== null && - canRerunAutomationRun({ automation: selected, run: selectedAutomationRunPage }) + canRerunAutomationRun({ + automation: selected, + run: selectedAutomationRunPage + }) const isSelectedAutomationRunPageRerunPending = selectedAutomationRunPage !== null && rerunRunIdsInFlight.has(selectedAutomationRunPage.id) const preflightStatusCurrent = preflightStatusContextKey === expectedPreflightContextKey @@ -1298,7 +1065,7 @@ export default function AutomationsPage(): React.JSX.Element { precheckCommand: '', precheckTimeoutSeconds: '60', preset: 'weekdays', - time: DEFAULT_TIME, + time: AUTOMATION_DEFAULT_TIME, dayOfWeek: '1', customSchedule: '', missedRunGraceMinutes: '720', @@ -1356,7 +1123,7 @@ export default function AutomationsPage(): React.JSX.Element { precheckCommand: latest.precheck?.command ?? '', precheckTimeoutSeconds: String(latest.precheck?.timeoutSeconds ?? 60), preset: schedule?.preset ?? (hasCustomSchedule ? 'custom' : 'weekdays'), - time: schedule ? formatTimeInput(schedule.hour, schedule.minute) : DEFAULT_TIME, + time: schedule ? formatTimeInput(schedule.hour, schedule.minute) : AUTOMATION_DEFAULT_TIME, dayOfWeek: String(schedule?.dayOfWeek ?? 1), customSchedule: hasCustomSchedule ? latest.rrule : '', missedRunGraceMinutes: String(latest.missedRunGraceMinutes), @@ -1404,7 +1171,7 @@ export default function AutomationsPage(): React.JSX.Element { precheckCommand: '', precheckTimeoutSeconds: '60', preset: hasCustomSchedule ? 'custom' : 'weekdays', - time: DEFAULT_TIME, + time: AUTOMATION_DEFAULT_TIME, dayOfWeek: '1', customSchedule: hasCustomSchedule ? rawSchedule : '', missedRunGraceMinutes: '720', @@ -2009,7 +1776,9 @@ export default function AutomationsPage(): React.JSX.Element { ) return } - const state = await window.api.ssh.connect({ targetId: manager.target.connectionId }) + const state = await window.api.ssh.connect({ + targetId: manager.target.connectionId + }) if (!state || state.status !== 'connected') { toast.error( state?.error ?? @@ -2107,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 ( @@ -2232,8 +2007,10 @@ export default function AutomationsPage(): React.JSX.Element { onSave={() => void saveAutomation()} /> - { if (open) { return @@ -2241,766 +2018,116 @@ export default function AutomationsPage(): React.JSX.Element { setDeleteTarget(null) setDontAskDeleteAgain(false) }} - > - { - event.preventDefault() - deleteConfirmButtonRef.current?.focus() - }} - > - - - {translate( - 'auto.components.automations.AutomationsPage.080dcb5fbb', - 'Delete Automation' - )} - - - {translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}{' '} - {deleteTarget?.name}{' '} - {translate( - 'auto.components.automations.AutomationsPage.b264564427', - 'and its run history. Workspaces created by previous runs are not deleted.' - )} - - - {deleteTarget ? ( -
-
{deleteTarget.name}
-
- {deleteTarget.workspaceMode === 'new_per_run' - ? translate( - 'auto.components.automations.AutomationsPage.cd8397cc32', - 'New workspace each run' - ) - : translate( - 'auto.components.automations.AutomationsPage.36f71740a7', - 'Selected workspace' - )} -
-
- ) : null} - - - - - -
-
+ onDontAskAgainToggle={() => setDontAskDeleteAgain((prev) => !prev)} + onCancel={() => { + setDeleteTarget(null) + setDontAskDeleteAgain(false) + }} + onConfirm={() => void confirmDeleteAutomation()} + /> - { if (!open) { setExternalDeleteTarget(null) } }} - > - { - event.preventDefault() - deleteConfirmButtonRef.current?.focus() - }} - > - - - {translate( - 'auto.components.automations.AutomationsPage.9adfab2596', - 'Delete External Automation' - )} - - - {translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}{' '} - - {externalDeleteTarget?.job.name} - {' '} - {translate('auto.components.automations.AutomationsPage.02a33e3204', 'from')}{' '} - {externalDeleteTarget - ? getExternalProviderLabel(externalDeleteTarget.manager) - : translate( - 'auto.components.automations.AutomationsPage.8500baacb4', - 'external source' - )}{' '} - {translate('auto.components.automations.AutomationsPage.1b586f0e2b', 'on')} - {externalDeleteTarget?.manager.targetLabel}. - - - {externalDeleteTarget ? ( -
-
- {externalDeleteTarget.job.name} -
-
- { - getExternalAutomationScheduleDisplay( - externalDeleteTarget.manager, - externalDeleteTarget.job - ).label - } -
-
- ) : null} - - - - -
-
+ onCancel={() => setExternalDeleteTarget(null)} + onConfirm={() => void confirmDeleteExternalAutomation()} + />
-
-
- {automations.length + externalAutomationEntries.length > 0 ? ( -
- - {translate( - 'auto.components.automations.AutomationsPage.761a35834d', - 'Automation' - )} - - - {translate('auto.components.automations.AutomationsPage.587a4b205c', 'Next')} - -
- ) : null} - {automations.map((automation) => { - const automationRepo = repoMap.get(getAutomationRunRepoId(automation)) - const automationWorktree = automation.workspaceId - ? worktreeMap.get(automation.workspaceId) - : null - const automationRunAvailability = getAutomationTargetAvailability({ - automation, - repo: automationRepo, - workspace: automationWorktree, - projectHostSetups, - sshConnectionStates, - runtimeStatusByEnvironmentId, - automationHostTarget, - sourceHostAvailability: automationSourceHostAvailabilityById.get(automation.id) - }) - const workspaceLabel = - automation.workspaceMode === 'new_per_run' - ? `Create from ${automation.baseBranch ?? automationRepo?.worktreeBaseRef ?? 'project default'}` - : (automationWorktree?.displayName ?? 'Missing workspace') - const usageSummary = summarizeAutomationRunUsage( - runs.filter((run) => run.automationId === automation.id) - ) - const usageText = - usageSummary.knownRuns > 0 - ? `${formatAutomationCost( - usageSummary.estimatedCostUsd - )} est. · ${formatAutomationTokens(usageSummary.totalTokens)} tokens` - : usageSummary.unavailableRuns > 0 - ? 'Usage unavailable' - : 'No run usage yet' - const nextRunLabel = automation.enabled - ? formatAutomationDateTimeWithRelative(automation.nextRunAt, relativeNow) - : 'Paused' - const scheduleLabel = formatAutomationSchedule(automation.rrule) - return ( - - - - - - { - if (!automationRunAvailability.canRunNow) { - event.preventDefault() - return - } - void runNow(automation) - }} - > - - - {automationRunAvailability.canRunNow - ? translate( - 'auto.components.automations.AutomationsPage.2faecab10b', - 'Run Now' - ) - : automationRunAvailability.message} - - - void openEditDialog(automation)}> - - {translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')} - - void toggleAutomation(automation)}> - {automation.enabled ? ( - - ) : ( - - )} - {automation.enabled - ? translate( - 'auto.components.automations.AutomationsPage.b457436d6a', - 'Pause' - ) - : translate( - 'auto.components.automations.AutomationsPage.376631ef2b', - 'Resume' - )} - - - requestDeleteAutomation(automation)} - > - - {translate( - 'auto.components.automations.AutomationsPage.15e0bfb13b', - 'Delete' - )} - - - - ) - })} - {externalAutomationEntries.map((entry) => { - const providerLabel = getExternalProviderLabel(entry.manager) - const targetKindLabel = getExternalTargetKindLabel(entry.manager) - if (entry.kind === 'source') { - const sshStatus = - entry.manager.target.type === 'ssh' - ? sshConnectionStates.get(entry.manager.target.connectionId)?.status - : undefined - const sourceAvailability = getExternalAutomationSourceAvailability({ - manager: entry.manager, - providerLabel, - targetKindLabel, - sshStatus - }) - return ( - + void runNow(automation)} + openEditDialog={(automation) => void openEditDialog(automation)} + toggleAutomation={(automation) => void toggleAutomation(automation)} + requestDeleteAutomation={requestDeleteAutomation} + requestExternalAction={requestExternalAction} + openEditExternalDialog={openEditExternalDialog} + openCreateDialog={openCreateDialog} + /> + + - - - - - requestExternalAction(entry.manager, entry.job, 'run')} - > - - - {disabledMessage ?? - translate( - 'auto.components.automations.AutomationsPage.2faecab10b', - 'Run Now' - )} - - - {entry.manager.provider === 'hermes' ? ( - openEditExternalDialog(entry.manager, entry.job)} - > - - {translate( - 'auto.components.automations.AutomationsPage.f4612e3f78', - 'Edit' - )} - - ) : null} - - requestExternalAction( - entry.manager, - entry.job, - entry.job.enabled ? 'pause' : 'resume' - ) - } - > - {entry.job.enabled ? ( - - ) : ( - - )} - {entry.job.enabled - ? translate( - 'auto.components.automations.AutomationsPage.b457436d6a', - 'Pause' - ) - : translate( - 'auto.components.automations.AutomationsPage.376631ef2b', - 'Resume' - )} - - - requestExternalAction(entry.manager, entry.job, 'delete')} - > - - {translate( - 'auto.components.automations.AutomationsPage.15e0bfb13b', - 'Delete' - )} - - - - ) - })} - {automations.length === 0 && externalAutomationEntries.length === 0 ? ( -
-
- {translate( - 'auto.components.automations.AutomationsPage.d207ab4c25', - 'Start from a template' - )} -
- {getAutomationTemplates().map((template) => ( - - ))} - -
- ) : null} -
-
- -
- {selectedExternal ? ( -
- {selectedExternalRunPage ? ( - setSelectedExternalRunPage(null)} - > - - - ) : selectedExternal.kind === 'job' ? ( - - ) : ( -
-
-
-
- {selectedExternal.manager.targetLabel} -
-
- {selectedExternalSourceAvailability?.summary} -
-
- {selectedExternalSshSource ? ( - - ) : null} -
-
- {selectedExternalSourceAvailability?.detail} -
-
- )} -
- ) : ( - setActivePaneTab(value as AutomationPaneTab)} - className="min-h-0 flex-1 gap-0" - > -
- - - {translate( - 'auto.components.automations.AutomationsPage.bb1b2cd31e', - 'Overview' - )} - - - {translate('auto.components.automations.AutomationsPage.0e110a3469', 'Runs')}{' '} - {selectedRuns.length} - - -
- - - void runNow(automation)} - onEdit={(automation) => void openEditDialog(automation)} - onToggle={(automation) => void toggleAutomation(automation)} - onDelete={requestDeleteAutomation} - /> - - - - {selectedAutomationRunPage ? ( - - {canRerunSelectedAutomationRunPage && selected ? ( - - ) : null} - {selectedAutomationRunPageViewState ? ( - - ) : null} - - } - onBack={() => setSelectedAutomationRunPageId(null)} - > - - - ) : selected ? ( - - ) : ( -
- {translate( - 'auto.components.automations.AutomationsPage.c3a28c9793', - 'Select an automation to view runs.' - )} -
- )} -
-
- )} -
+ : (selectedWorktree?.displayName ?? + translate( + 'auto.components.automations.AutomationsPage.missingWorkspace', + 'Missing workspace' + )) + } + hostLabelById={hostLabelById} + selectedRunNowAvailability={selectedRunNowAvailability} + selectedExternalSourceAvailability={selectedExternalSourceAvailability} + selectedExternalSshSource={ + selectedExternalSshSource ? { manager: selectedExternalSshSource.manager } : null + } + selectedExternalSshConnected={selectedExternalSshConnected} + selectedAutomationRunPageWorkspaceDisplay={selectedAutomationRunPageWorkspaceDisplay} + selectedAutomationRunPageViewState={selectedAutomationRunPageViewState} + canRerunSelectedAutomationRunPage={canRerunSelectedAutomationRunPage} + isSelectedAutomationRunPageRerunPending={isSelectedAutomationRunPageRerunPending} + worktreeMap={worktreeMap} + fetchExternalAutomationRuns={fetchExternalAutomationRuns} + onActivePaneTabChange={setActivePaneTab} + onClearExternalRunPage={() => setSelectedExternalRunPage(null)} + onClearAutomationRunPage={() => setSelectedAutomationRunPageId(null)} + requestExternalAction={requestExternalAction} + openExternalRunPage={openExternalRunPage} + openEditExternalDialog={openEditExternalDialog} + connectExternalAutomationSource={(manager) => + void connectExternalAutomationSource(manager) + } + runNow={(automation) => void runNow(automation)} + openEditDialog={(automation) => void openEditDialog(automation)} + toggleAutomation={(automation) => void toggleAutomation(automation)} + requestDeleteAutomation={requestDeleteAutomation} + rerunAutomationRun={(automation, run) => void rerunAutomationRun(automation, run)} + openRunWorkspace={openRunWorkspace} + openAutomationRunPage={openAutomationRunPage} + />
) diff --git a/src/renderer/src/components/automations/ExternalAutomationManagers.tsx b/src/renderer/src/components/automations/ExternalAutomationManagers.tsx index bd6e1b67549..f64684f8c29 100644 --- a/src/renderer/src/components/automations/ExternalAutomationManagers.tsx +++ b/src/renderer/src/components/automations/ExternalAutomationManagers.tsx @@ -10,7 +10,11 @@ import type { ExternalAutomationManager, ExternalAutomationRun } from '../../../../shared/automations-types' -import { formatAutomationDateTimeWithRelative } from './automation-page-parts' +import { + formatExternalDate, + getExternalProviderLabel, + getExternalTargetKindLabel +} from './external-automation-display' import { ExternalAutomationRunTable, type FetchExternalAutomationRuns @@ -37,17 +41,6 @@ type ExternalAutomationManagersProps = { onEdit?: (manager: ExternalAutomationManager, job: ExternalAutomationJob) => void } -function formatExternalDate(value: string | null, now: number): string { - if (!value) { - return 'Never' - } - const parsed = Date.parse(value) - if (!Number.isFinite(parsed)) { - return value - } - return formatAutomationDateTimeWithRelative(parsed, now) -} - function actionKey( manager: ExternalAutomationManager, job: ExternalAutomationJob, @@ -56,14 +49,6 @@ function actionKey( return `${manager.id}:${job.id}:${action}` } -function getProviderLabel(manager: ExternalAutomationManager): string { - return manager.provider === 'hermes' ? 'Hermes' : 'OpenClaw' -} - -function getTargetKindLabel(manager: ExternalAutomationManager): string { - return manager.target.type === 'ssh' ? 'SSH host' : 'Local' -} - function ExternalActionButton({ label, disabled, @@ -140,7 +125,7 @@ export function ExternalAutomationManagers({
{manager.targetLabel}
- {getProviderLabel(manager)} / {getTargetKindLabel(manager)} ·{' '} + {getExternalProviderLabel(manager)} / {getExternalTargetKindLabel(manager)} ·{' '} {manager.status === 'available' ? manager.canManage ? translate( @@ -236,8 +221,8 @@ export function ExternalAutomationManagers({ 'auto.components.automations.ExternalAutomationManagers.20fd7a3a15', 'next' )}{' '} - {formatExternalDate(job.nextRunAt, now)} · {getProviderLabel(manager)} /{' '} - {manager.targetLabel} + {formatExternalDate(job.nextRunAt, now)} ·{' '} + {getExternalProviderLabel(manager)} / {manager.targetLabel}
{manager.provider === 'hermes' ? (
diff --git a/src/renderer/src/components/automations/ExternalAutomationRunTable.tsx b/src/renderer/src/components/automations/ExternalAutomationRunTable.tsx index f6e5f693371..2c322737cc8 100644 --- a/src/renderer/src/components/automations/ExternalAutomationRunTable.tsx +++ b/src/renderer/src/components/automations/ExternalAutomationRunTable.tsx @@ -10,7 +10,11 @@ import type { ExternalAutomationManager, ExternalAutomationRun } from '../../../../shared/automations-types' -import { formatAutomationDateTimeWithRelative } from './automation-page-parts' +import { + formatExternalDate, + getExternalRunStatusLabel, + getExternalRunStatusVariant +} from './external-automation-display' import { createExternalAutomationRunTableState, resolveExternalAutomationFetchedRuns, @@ -41,41 +45,6 @@ type ExternalAutomationRunTableProps = { onOpenRun?: (run: ExternalAutomationRun) => void } -function formatExternalDate(value: string | null, now: number): string { - if (!value) { - return 'Never' - } - const parsed = Date.parse(value) - if (!Number.isFinite(parsed)) { - return value - } - return formatAutomationDateTimeWithRelative(parsed, now) -} - -function getRunStatusLabel(run: ExternalAutomationRun): string { - switch (run.status) { - case 'completed': - return 'Completed' - case 'failed': - return 'Failed' - case 'unknown': - return 'Unknown' - } -} - -function getRunStatusVariant( - run: ExternalAutomationRun -): React.ComponentProps['variant'] { - switch (run.status) { - case 'completed': - return 'secondary' - case 'failed': - return 'destructive' - case 'unknown': - return 'outline' - } -} - function getRunSummary(run: ExternalAutomationRun): string { return run.error ?? run.outputPreview ?? 'No output preview' } @@ -263,7 +232,9 @@ export function ExternalAutomationRunTable({ {getRunSummary(run)} - {getRunStatusLabel(run)} + + {getExternalRunStatusLabel(run)} + ))}
diff --git a/src/renderer/src/components/automations/automation-draft-model.ts b/src/renderer/src/components/automations/automation-draft-model.ts new file mode 100644 index 00000000000..747bc615782 --- /dev/null +++ b/src/renderer/src/components/automations/automation-draft-model.ts @@ -0,0 +1,52 @@ +import { getAgentCatalog } from '@/lib/agent-catalog' +import type { AutomationPrecheck } from '../../../../shared/automations-types' +import { buildAutomationCronSchedule } from '../../../../shared/automation-schedules' +import type { Worktree } from '../../../../shared/types' +import type { AutomationDraft } from './AutomationEditorDialog' + +export const AUTOMATION_DEFAULT_TIME = '09:00' + +export function getDefaultWorktree(worktrees: readonly Worktree[]): Worktree | null { + return worktrees.find((worktree) => worktree.isMainWorktree) ?? worktrees[0] ?? null +} + +export function formatTimeInput(hour: number, minute: number): string { + return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}` +} + +export function parseDraftTime(time: string): { hour: number; minute: number } { + const [rawHour, rawMinute] = time.split(':').map((part) => Number(part)) + return { + hour: Number.isFinite(rawHour) ? rawHour : 9, + minute: Number.isFinite(rawMinute) ? rawMinute : 0 + } +} + +export function buildDraftPrecheck(draft: AutomationDraft): AutomationPrecheck | null { + const command = draft.precheckCommand.trim() + if (!command) { + return null + } + const rawTimeout = Number(draft.precheckTimeoutSeconds) + return { + command, + timeoutSeconds: Number.isFinite(rawTimeout) ? rawTimeout : 60 + } +} + +export function buildHermesCronSchedule(draft: AutomationDraft): string { + if (draft.preset === 'custom') { + return draft.customSchedule.trim() + } + const { hour, minute } = parseDraftTime(draft.time) + return buildAutomationCronSchedule({ + preset: draft.preset, + hour, + minute, + dayOfWeek: Number(draft.dayOfWeek) + }) +} + +export function getAgentLabel(agentId: string): string { + return getAgentCatalog().find((agent) => agent.id === agentId)?.label ?? agentId +} diff --git a/src/renderer/src/components/automations/automation-host-client.ts b/src/renderer/src/components/automations/automation-host-client.ts index b3569b4b7b2..36e1ff0560a 100644 --- a/src/renderer/src/components/automations/automation-host-client.ts +++ b/src/renderer/src/components/automations/automation-host-client.ts @@ -26,6 +26,20 @@ export type AutomationHostTarget = | { kind: 'local' } | { kind: 'environment'; environmentId: string } +export function getAutomationHostTargetKey(target: AutomationHostTarget): string { + return target.kind === 'environment' ? `environment:${target.environmentId}` : 'local' +} + +export function getAutomationHostTargetFromKey(key: string | null): AutomationHostTarget | null { + if (!key) { + return null + } + if (key.startsWith('environment:')) { + return { kind: 'environment', environmentId: key.slice('environment:'.length) } + } + return { kind: 'local' } +} + export function getAutomationTargetFromHostId( hostId: string | null | undefined ): AutomationHostTarget { diff --git a/src/renderer/src/components/automations/automation-list-search.test.ts b/src/renderer/src/components/automations/automation-list-search.test.ts new file mode 100644 index 00000000000..47f59faaba6 --- /dev/null +++ b/src/renderer/src/components/automations/automation-list-search.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from 'vitest' +import { + AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS, + AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES, + AUTOMATION_LIST_SEARCH_UNKNOWN_PROJECT, + automationListSearchFieldsMatch, + automationListSearchIndexMatches, + buildAutomationListSearchFingerprint, + buildAutomationListSearchIndex, + buildAutomationProjectSearchText, + clampAutomationListSearchQueryInput, + filterByActiveAutomationListSearchQuery, + filterByAutomationListSearch, + filterByAutomationListSearchIndex, + getActiveAutomationListSearchQuery, + isAutomationListSearchQueryTooLarge, + normalizeAutomationListSearchField, + resolveAutomationListSearchQuery, + truncateAutomationListSearchField +} from './automation-list-search' + +describe('automation-list-search', () => { + it('normalizes query casing and whitespace', () => { + expect(getActiveAutomationListSearchQuery(' Auto PR ')).toBe('auto pr') + expect(resolveAutomationListSearchQuery(' Auto PR ')).toEqual({ + status: 'active', + query: 'auto pr' + }) + }) + + it('rejects oversized queries without searching', () => { + const oversized = 'a'.repeat(AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES + 1) + expect(isAutomationListSearchQueryTooLarge(oversized)).toBe(true) + expect(getActiveAutomationListSearchQuery(oversized)).toBeNull() + expect(resolveAutomationListSearchQuery(oversized)).toEqual({ status: 'too_large' }) + expect( + automationListSearchFieldsMatch( + { name: 'Auto PR', project: 'orca', prompt: 'nudge' }, + oversized + ) + ).toBe(false) + + const items = [ + { id: '1', name: 'Auto PR', project: 'orca', prompt: 'nudge' }, + { id: '2', name: 'Nightly', project: 'mobile', prompt: 'ship' } + ] + // Why: oversized paste must leave the list unfiltered, not blank it. + 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' }) + const items = [{ name: 'A', project: 'p1', prompt: 'one' }] + expect(filterByAutomationListSearch(items, ' ', (item) => item)).toBe(items) + }) + + it('clamps stored query input so multi-MB pastes are discarded', () => { + const hugePaste = 'a'.repeat(AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES * 100) + const clamped = clampAutomationListSearchQueryInput(hugePaste) + expect(clamped.length).toBe(AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES + 1) + expect(isAutomationListSearchQueryTooLarge(clamped)).toBe(true) + expect(clampAutomationListSearchQueryInput('auto pr')).toBe('auto pr') + }) + + it('caps indexed field length so huge prompts stay bounded', () => { + const prompt = `${'x'.repeat(AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS)}unique-tail-token` + const index = buildAutomationListSearchIndex({ + name: 'Nightly', + project: 'mobile', + prompt + }) + expect(index.prompt.length).toBe(AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS) + expect(automationListSearchIndexMatches(index, 'unique-tail-token')).toBe(false) + expect(automationListSearchIndexMatches(index, 'xxxx')).toBe(true) + }) + + it('null-safely normalizes missing fields', () => { + expect(normalizeAutomationListSearchField(null, 10)).toBe('') + expect(normalizeAutomationListSearchField(undefined, 10)).toBe('') + expect( + buildAutomationListSearchIndex({ + name: 'Job', + project: 'host', + prompt: null as unknown as string + }).prompt + ).toBe('') + }) + + it('does not split surrogate pairs when truncating', () => { + const emoji = '😀' + const value = `${'a'.repeat(7)}${emoji}` + expect(truncateAutomationListSearchField(value, 8)).toBe('a'.repeat(7)) + expect(truncateAutomationListSearchField(value, 9)).toBe(value) + }) + + it('indexes unknown project fallback for missing repos', () => { + expect(buildAutomationProjectSearchText({})).toBe(AUTOMATION_LIST_SEARCH_UNKNOWN_PROJECT) + expect(buildAutomationProjectSearchText({ displayName: ' ', path: null })).toBe( + AUTOMATION_LIST_SEARCH_UNKNOWN_PROJECT + ) + expect(buildAutomationProjectSearchText({ displayName: 'orca', path: '/tmp/orca' })).toBe( + 'orca /tmp/orca' + ) + const index = buildAutomationListSearchIndex({ + name: 'Orphan', + project: buildAutomationProjectSearchText({}), + prompt: 'hi' + }) + expect(automationListSearchIndexMatches(index, 'unknown')).toBe(true) + }) + + it('matches name, project, or prompt', () => { + const fields = { + name: 'Auto PR assignment', + project: 'orca / main', + prompt: 'Assign reviewers for open PRs' + } + expect(automationListSearchFieldsMatch(fields, 'assignment')).toBe(true) + expect(automationListSearchFieldsMatch(fields, 'ORCA')).toBe(true) + expect(automationListSearchFieldsMatch(fields, 'reviewers')).toBe(true) + expect(automationListSearchFieldsMatch(fields, 'missing')).toBe(false) + }) + + it('filters by active query without re-resolving bounds', () => { + const items = [ + { id: '1', name: 'Auto Issue assignment', project: 'orca', prompt: 'triage issues' }, + { id: '2', name: 'Nightly deploy', project: 'mobile', prompt: 'ship apk' }, + { id: '3', name: 'PR nudge', project: 'orca', prompt: 'remind reviewers' } + ] + const indexes = items.map((item) => + buildAutomationListSearchIndex({ + name: item.name, + project: item.project, + prompt: item.prompt + }) + ) + expect( + filterByActiveAutomationListSearchQuery(items, indexes, 'apk').map((item) => item.id) + ).toEqual(['2']) + expect( + filterByAutomationListSearchIndex(items, indexes, 'orca').map((item) => item.id) + ).toEqual(['1', '3']) + expect(filterByAutomationListSearchIndex(items, indexes, ' ')).toBe(items) + expect( + filterByAutomationListSearchIndex( + items, + indexes, + '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', () => { + const sources = [ + { name: 'A', project: 'p1', prompt: 'one' }, + { name: 'B', project: 'p2', prompt: 'two' } + ] + expect(buildAutomationListSearchFingerprint(sources)).toBe( + buildAutomationListSearchFingerprint([ + { name: 'A', project: 'p1', prompt: 'one' }, + { name: 'B', project: 'p2', prompt: 'two' } + ]) + ) + expect(buildAutomationListSearchFingerprint(sources)).not.toBe( + buildAutomationListSearchFingerprint([ + { name: 'A', project: 'p1', prompt: 'changed' }, + { name: 'B', project: 'p2', prompt: 'two' } + ]) + ) + }) +}) diff --git a/src/renderer/src/components/automations/automation-list-search.ts b/src/renderer/src/components/automations/automation-list-search.ts new file mode 100644 index 00000000000..b27da8fe221 --- /dev/null +++ b/src/renderer/src/components/automations/automation-list-search.ts @@ -0,0 +1,244 @@ +import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' + +/** Pasted queries above this are rejected so filtering never runs on unbounded input. */ +export const AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES = 2 * 1024 + +// Why: prompts can be multi-MB agent instructions. Index only a fixed prefix so +// lowercasing/includes stay O(bound) per automation rather than O(prompt size). +export const AUTOMATION_LIST_SEARCH_NAME_MAX_CODE_UNITS = 512 +export const AUTOMATION_LIST_SEARCH_PROJECT_MAX_CODE_UNITS = 1_024 +export const AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS = 8 * 1024 + +/** Indexed when a local automation has no resolved project so "unknown" still matches. */ +export const AUTOMATION_LIST_SEARCH_UNKNOWN_PROJECT = 'unknown project' + +export type AutomationListSearchFields = { + name: string + project: string + prompt: string +} + +/** Lowercased, length-capped fields ready for substring match. */ +export type AutomationListSearchIndex = { + name: string + project: string + prompt: string +} + +export type AutomationListSearchQueryResolution = + | { status: 'inactive' } + | { status: 'too_large' } + | { status: 'active'; query: string } + +export function isAutomationListSearchQueryTooLarge( + rawQuery: string, + maxBytes = AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES +): boolean { + return isClipboardTextByteLengthOverLimit(rawQuery, maxBytes) +} + +/** + * Caps the controlled input value so a multi-MB paste cannot pin renderer + * memory. Keeping maxBytes+1 code units is enough for the over-limit check + * (`length > maxBytes`) while discarding the rest of the paste. + */ +export function clampAutomationListSearchQueryInput( + rawQuery: string, + maxBytes = AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES +): string { + const maxStoredCodeUnits = maxBytes + 1 + if (rawQuery.length <= maxStoredCodeUnits) { + return rawQuery + } + return rawQuery.slice(0, maxStoredCodeUnits) +} + +export function resolveAutomationListSearchQuery( + rawQuery: string, + maxBytes = AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES +): AutomationListSearchQueryResolution { + // Why: length pre-check short-circuits multi-MB pastes before UTF-8 scan. + if (isClipboardTextByteLengthOverLimit(rawQuery, maxBytes)) { + return { status: 'too_large' } + } + const query = rawQuery.trim().toLowerCase() + if (!query) { + return { status: 'inactive' } + } + return { status: 'active', query } +} + +/** Active lowercase query, or null when search must not run. */ +export function getActiveAutomationListSearchQuery( + rawQuery: string, + maxBytes = AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES +): string | null { + const resolved = resolveAutomationListSearchQuery(rawQuery, maxBytes) + return resolved.status === 'active' ? resolved.query : null +} + +/** Avoid splitting a surrogate pair at the cap boundary. */ +export function truncateAutomationListSearchField(value: string, maxCodeUnits: number): string { + if (value.length <= maxCodeUnits) { + return value + } + let end = maxCodeUnits + const last = value.charCodeAt(end - 1) + // High surrogate at the cut would leave an orphan low surrogate. + if (last >= 0xd800 && last <= 0xdbff) { + end -= 1 + } + return value.slice(0, end) +} + +export function normalizeAutomationListSearchField( + value: string | null | undefined, + maxCodeUnits: number +): string { + if (value == null || value === '') { + return '' + } + return truncateAutomationListSearchField(value, maxCodeUnits).toLowerCase() +} + +export function buildAutomationListSearchIndex( + fields: AutomationListSearchFields +): AutomationListSearchIndex { + return { + name: normalizeAutomationListSearchField( + fields.name, + AUTOMATION_LIST_SEARCH_NAME_MAX_CODE_UNITS + ), + project: normalizeAutomationListSearchField( + fields.project, + AUTOMATION_LIST_SEARCH_PROJECT_MAX_CODE_UNITS + ), + prompt: normalizeAutomationListSearchField( + fields.prompt, + AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS + ) + } +} + +export function buildAutomationProjectSearchText(parts: { + displayName?: string | null + path?: string | null +}): string { + const joined = [parts.displayName, parts.path] + .map((part) => part?.trim() ?? '') + .filter(Boolean) + .join(' ') + return joined || AUTOMATION_LIST_SEARCH_UNKNOWN_PROJECT +} + +export function automationListSearchIndexMatches( + index: AutomationListSearchIndex, + activeQuery: string +): boolean { + // Why: check short fields first so huge-prompt includes rarely run. + return ( + index.name.includes(activeQuery) || + index.project.includes(activeQuery) || + index.prompt.includes(activeQuery) + ) +} + +export function automationListSearchFieldsMatch( + fields: AutomationListSearchFields, + rawQuery: string +): boolean { + const resolved = resolveAutomationListSearchQuery(rawQuery) + if (resolved.status === 'too_large') { + return false + } + if (resolved.status === 'inactive') { + return true + } + return automationListSearchIndexMatches(buildAutomationListSearchIndex(fields), resolved.query) +} + +/** + * Filters with an already-resolved active query. Callers must pass null/skip + * when search is inactive or too large so this never runs "for free". + */ +export function filterByActiveAutomationListSearchQuery( + items: readonly T[], + indexes: readonly AutomationListSearchIndex[], + activeQuery: string +): T[] { + if (indexes.length !== items.length) { + return [...items] + } + const matches: T[] = [] + for (let i = 0; i < items.length; i += 1) { + const item = items[i] + const index = indexes[i] + if (item !== undefined && index && automationListSearchIndexMatches(index, activeQuery)) { + matches.push(item) + } + } + return matches +} + +/** + * Filters items by a prebuilt index. Empty and oversized queries leave the + * original array reference untouched so the list stays unfiltered and search + * work is skipped entirely. + */ +export function filterByAutomationListSearchIndex( + items: readonly T[], + indexes: readonly AutomationListSearchIndex[], + rawQuery: string +): readonly T[] { + const activeQuery = getActiveAutomationListSearchQuery(rawQuery) + if (activeQuery === null) { + return items + } + return filterByActiveAutomationListSearchQuery(items, indexes, activeQuery) +} + +/** Builds indexes then filters. Prefer prebuilt indexes when filtering often. */ +export function filterByAutomationListSearch( + items: readonly T[], + rawQuery: string, + getFields: (item: T) => AutomationListSearchFields +): readonly T[] { + const activeQuery = getActiveAutomationListSearchQuery(rawQuery) + if (activeQuery === null) { + return items + } + const matches: T[] = [] + for (const item of items) { + if ( + automationListSearchIndexMatches(buildAutomationListSearchIndex(getFields(item)), activeQuery) + ) { + matches.push(item) + } + } + return matches +} + +/** + * Content fingerprint for search-relevant fields only. Used so list refresh + * ticks that replace arrays with equivalent search content do not rebuild + * indexes or re-run filtering. + */ +export function buildAutomationListSearchFingerprint( + sources: readonly AutomationListSearchFields[] +): string { + if (sources.length === 0) { + return '' + } + let fingerprint = '' + for (let i = 0; i < sources.length; i += 1) { + if (i > 0) { + fingerprint += '\u0000' + } + const source = sources[i] + if (!source) { + continue + } + fingerprint += `${source.name}\u0001${source.project}\u0001${source.prompt}` + } + return fingerprint +} 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/automation-run-content.ts b/src/renderer/src/components/automations/automation-run-content.ts new file mode 100644 index 00000000000..5ea2eddabb4 --- /dev/null +++ b/src/renderer/src/components/automations/automation-run-content.ts @@ -0,0 +1,17 @@ +import type { AutomationRun } from '../../../../shared/automations-types' + +export function getAutomationRunContent(run: AutomationRun): string { + const savedOutput = run.outputSnapshot?.content.trim() + if (savedOutput) { + return run.outputSnapshot?.content ?? savedOutput + } + if (run.precheckResult) { + const output = [run.precheckResult.stderr.trim(), run.precheckResult.stdout.trim()] + .filter(Boolean) + .join('\n\n') + if (output) { + return output + } + } + return run.error ?? run.usage?.unavailableMessage ?? 'No output content available.' +} diff --git a/src/renderer/src/components/automations/automation-run-view-state.ts b/src/renderer/src/components/automations/automation-run-view-state.ts index dd300871ac7..56f55dec088 100644 --- a/src/renderer/src/components/automations/automation-run-view-state.ts +++ b/src/renderer/src/components/automations/automation-run-view-state.ts @@ -21,6 +21,16 @@ export function getAutomationRerunPendingRemainingMs({ return Math.max(0, pendingStartedAt + AUTOMATION_RERUN_PENDING_MIN_VISIBLE_MS - now) } +export async function waitForAutomationRerunPendingVisibility( + pendingStartedAt: number +): Promise { + const remainingMs = getAutomationRerunPendingRemainingMs({ pendingStartedAt }) + if (remainingMs <= 0) { + return + } + await new Promise((resolve) => window.setTimeout(resolve, remainingMs)) +} + export function canRerunAutomationRun({ automation, run diff --git a/src/renderer/src/components/automations/automation-source-context.ts b/src/renderer/src/components/automations/automation-source-context.ts new file mode 100644 index 00000000000..e8a05ebabc5 --- /dev/null +++ b/src/renderer/src/components/automations/automation-source-context.ts @@ -0,0 +1,56 @@ +import { TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { parseExecutionHostId } from '../../../../shared/execution-host' +import type { Automation } from '../../../../shared/automations-types' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { TaskSourceContext } from '../../../../shared/task-source-context' +import type { TaskSourceHostAvailability } from '../task-source-context-summary' + +export type RepoBackedAutomationSourceContext = TaskSourceContext & { + provider: 'github' | 'gitlab' +} + +export function getRepoBackedAutomationSourceContext( + automation: Automation +): RepoBackedAutomationSourceContext | null { + const context = automation.sourceContext + return context?.provider === 'github' || context?.provider === 'gitlab' + ? (context as RepoBackedAutomationSourceContext) + : null +} + +export function getRuntimeSourceHostAvailability( + context: TaskSourceContext, + runtimeStatusByEnvironmentId: ReadonlyMap< + string, + { status: RuntimeStatus | null; checkedAt: number } + > +): TaskSourceHostAvailability | null { + const parsed = parseExecutionHostId(context.hostId) + if (parsed?.kind !== 'runtime') { + return null + } + const entry = runtimeStatusByEnvironmentId.get(parsed.environmentId) + if (!entry) { + return { + hostId: context.hostId, + reason: 'checking-task-source-capability' + } + } + if (!entry.status) { + return { hostId: context.hostId, health: 'disconnected' } + } + if (entry.status.graphStatus !== 'ready') { + return { hostId: context.hostId, health: 'connecting' } + } + const capabilities = entry.status.capabilities + if (!capabilities) { + return { + hostId: context.hostId, + reason: 'checking-task-source-capability' + } + } + if (!capabilities.includes(TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY)) { + return { hostId: context.hostId, reason: 'missing-task-source-capability' } + } + return null +} diff --git a/src/renderer/src/components/automations/external-automation-display.ts b/src/renderer/src/components/automations/external-automation-display.ts new file mode 100644 index 00000000000..ee8e9d19d68 --- /dev/null +++ b/src/renderer/src/components/automations/external-automation-display.ts @@ -0,0 +1,71 @@ +import React from 'react' +import type { Badge } from '@/components/ui/badge' +import type { + ExternalAutomationJob, + ExternalAutomationManager, + ExternalAutomationRun +} from '../../../../shared/automations-types' +import { formatAutomationDateTimeWithRelative } from './automation-page-parts' + +export function getExternalAutomationKey( + manager: ExternalAutomationManager, + job: ExternalAutomationJob +): string { + return `${manager.id}:${job.id}` +} + +export function getExternalAutomationSourceKey(manager: ExternalAutomationManager): string { + return `${manager.id}:source` +} + +export function formatExternalDate(value: string | null, now: number): string { + if (!value) { + return 'Never' + } + const parsed = Date.parse(value) + if (!Number.isFinite(parsed)) { + return value + } + return formatAutomationDateTimeWithRelative(parsed, now) +} + +export function getExternalProviderLabel(manager: ExternalAutomationManager): string { + return manager.provider === 'hermes' ? 'Hermes' : 'OpenClaw' +} + +export function getExternalTargetKindLabel(manager: ExternalAutomationManager): string { + return manager.target.type === 'ssh' ? 'SSH host' : 'Local' +} + +export function getExternalRunStatusLabel(run: ExternalAutomationRun): string { + switch (run.status) { + case 'completed': + return 'Completed' + case 'failed': + return 'Failed' + case 'unknown': + return 'Unknown' + } +} + +export function getExternalRunStatusVariant( + run: ExternalAutomationRun +): React.ComponentProps['variant'] { + switch (run.status) { + case 'completed': + return 'secondary' + case 'failed': + return 'destructive' + case 'unknown': + return 'outline' + } +} + +export function getExternalRunContent(run: ExternalAutomationRun): string { + return run.outputContent ?? run.error ?? run.outputPreview ?? 'No output content available.' +} + +export function isMissingExternalRunsApiError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + return /listExternalRuns|automations:listExternalRuns|No handler registered/i.test(message) +} diff --git a/src/renderer/src/components/automations/external-automation-list-entries.ts b/src/renderer/src/components/automations/external-automation-list-entries.ts new file mode 100644 index 00000000000..01b3a84720a --- /dev/null +++ b/src/renderer/src/components/automations/external-automation-list-entries.ts @@ -0,0 +1,46 @@ +import type { + ExternalAutomationJob, + ExternalAutomationManager +} from '../../../../shared/automations-types' +import { + getExternalAutomationKey, + getExternalAutomationSourceKey +} from './external-automation-display' + +export type ExternalAutomationListEntry = + | { + kind: 'job' + key: string + manager: ExternalAutomationManager + job: ExternalAutomationJob + } + | { + kind: 'source' + key: string + manager: ExternalAutomationManager + } + +export function buildExternalAutomationListEntries( + managers: readonly ExternalAutomationManager[] +): ExternalAutomationListEntry[] { + return managers.flatMap((manager): ExternalAutomationListEntry[] => { + if (manager.jobs.length === 0) { + if (manager.provider === 'hermes' && (manager.status === 'unavailable' || manager.error)) { + return [ + { + kind: 'source' as const, + key: getExternalAutomationSourceKey(manager), + manager + } + ] + } + return [] + } + return manager.jobs.map((job) => ({ + kind: 'job' as const, + key: getExternalAutomationKey(manager, job), + manager, + job + })) + }) +} diff --git a/src/renderer/src/components/automations/use-automation-list-search.ts b/src/renderer/src/components/automations/use-automation-list-search.ts new file mode 100644 index 00000000000..ce16d98c06a --- /dev/null +++ b/src/renderer/src/components/automations/use-automation-list-search.ts @@ -0,0 +1,265 @@ +import { useDeferredValue, useEffect, useMemo } from 'react' +import type { Automation } from '../../../../shared/automations-types' +import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity' +import type { Repo } from '../../../../shared/types' +import { + automationListSearchIndexMatches, + buildAutomationListSearchIndex, + buildAutomationProjectSearchText, + resolveAutomationListSearchQuery, + truncateAutomationListSearchField, + AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS, + type AutomationListSearchFields, + type AutomationListSearchIndex +} from './automation-list-search' +import type { ExternalAutomationListEntry } from './external-automation-list-entries' +import { getExternalProviderLabel } from './external-automation-display' + +export function useAutomationListSearch({ + listSearchQuery, + automations, + externalAutomationEntries, + repoMap, + selectedId, + selectedExternalKey, + selectAutomationId, + selectExternalKey +}: { + listSearchQuery: string + automations: readonly Automation[] + externalAutomationEntries: readonly ExternalAutomationListEntry[] + repoMap: ReadonlyMap + selectedId: string | null + selectedExternalKey: string | null + selectAutomationId: (automationId: string | null) => void + selectExternalKey: (externalKey: string | null) => void +}): { + isListSearchQueryTooLarge: boolean + isListSearchActive: boolean + filteredAutomations: readonly Automation[] + filteredExternalAutomationEntries: readonly ExternalAutomationListEntry[] + hasListItems: boolean + hasFilteredListItems: boolean +} { + // Why: keep the input snappy; matching is deferred so caret never waits on + // index scans. Only the normalized active query can fire a search. + const deferredListSearchQuery = useDeferredValue(listSearchQuery) + const liveListSearchResolution = useMemo( + () => resolveAutomationListSearchQuery(listSearchQuery), + [listSearchQuery] + ) + const deferredListSearchResolution = useMemo( + () => resolveAutomationListSearchQuery(deferredListSearchQuery), + [deferredListSearchQuery] + ) + // Why: field feedback tracks the live value so a huge paste is labeled + // immediately; list filtering stays on the deferred resolution. + const isListSearchQueryTooLarge = liveListSearchResolution.status === 'too_large' + // Why: null means search must not run (empty, whitespace, or too large). + const activeListSearchQuery = + deferredListSearchResolution.status === 'active' ? deferredListSearchResolution.query : null + 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. Prompts + // are truncated to the indexed prefix so each tick stays O(bound) per row. + const automationSearchFingerprint = useMemo( + () => + automations + .map((automation) => { + const repo = repoMap.get(getAutomationRunRepoId(automation)) + const project = buildAutomationProjectSearchText({ + displayName: repo?.displayName, + path: repo?.path + }) + 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] + ) + const automationSearchRows = useMemo((): { + id: string + index: AutomationListSearchIndex + }[] => { + return automations.map((automation) => { + const repo = repoMap.get(getAutomationRunRepoId(automation)) + return { + id: automation.id, + index: buildAutomationListSearchIndex({ + name: automation.name, + project: buildAutomationProjectSearchText({ + displayName: repo?.displayName, + path: repo?.path + }), + prompt: automation.prompt + }) + } + }) + // eslint-disable-next-line react-hooks/exhaustive-deps -- fingerprint is the rebuild gate + }, [automationSearchFingerprint]) + + const externalAutomationSearchFingerprint = useMemo( + () => + externalAutomationEntries + .map((entry) => { + if (entry.kind === 'source') { + return `${entry.key}\u0001${entry.manager.targetLabel}\u0001${getExternalProviderLabel(entry.manager)}\u0001` + } + 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] + ) + const externalAutomationSearchRows = useMemo((): { + key: string + index: AutomationListSearchIndex + }[] => { + return externalAutomationEntries.map((entry) => { + const fields: AutomationListSearchFields = + entry.kind === 'source' + ? { + name: entry.manager.targetLabel, + project: `${getExternalProviderLabel(entry.manager)} ${entry.manager.targetLabel}`, + prompt: '' + } + : { + name: entry.job.name, + project: [ + getExternalProviderLabel(entry.manager), + entry.manager.targetLabel, + entry.job.workdir + ] + .filter(Boolean) + .join(' '), + prompt: entry.job.prompt ?? entry.job.promptPreview ?? '' + } + return { key: entry.key, index: buildAutomationListSearchIndex(fields) } + }) + // eslint-disable-next-line react-hooks/exhaustive-deps -- fingerprint is the rebuild gate + }, [externalAutomationSearchFingerprint]) + + // Why: matching runs only when the normalized query or search content changes — + // never on relativeNow / nextRunAt / usage refresh alone. + const filteredAutomationIds = useMemo((): readonly string[] | null => { + if (activeListSearchQuery === null) { + return null + } + const ids: string[] = [] + for (const row of automationSearchRows) { + if (automationListSearchIndexMatches(row.index, activeListSearchQuery)) { + ids.push(row.id) + } + } + return ids + }, [activeListSearchQuery, automationSearchRows]) + + const filteredExternalAutomationKeys = useMemo((): readonly string[] | null => { + if (activeListSearchQuery === null) { + return null + } + const keys: string[] = [] + for (const row of externalAutomationSearchRows) { + if (automationListSearchIndexMatches(row.index, activeListSearchQuery)) { + keys.push(row.key) + } + } + return keys + }, [activeListSearchQuery, externalAutomationSearchRows]) + + const filteredAutomations = useMemo((): readonly Automation[] => { + if (filteredAutomationIds === null) { + return automations + } + if (filteredAutomationIds.length === 0) { + return [] + } + const byId = new Map(automations.map((automation) => [automation.id, automation])) + const next: Automation[] = [] + for (const id of filteredAutomationIds) { + const automation = byId.get(id) + if (automation) { + next.push(automation) + } + } + return next + }, [automations, filteredAutomationIds]) + + const filteredExternalAutomationEntries = useMemo((): readonly ExternalAutomationListEntry[] => { + if (filteredExternalAutomationKeys === null) { + return externalAutomationEntries + } + if (filteredExternalAutomationKeys.length === 0) { + return [] + } + const byKey = new Map(externalAutomationEntries.map((entry) => [entry.key, entry])) + const next: ExternalAutomationListEntry[] = [] + for (const key of filteredExternalAutomationKeys) { + const entry = byKey.get(key) + if (entry) { + next.push(entry) + } + } + return next + }, [externalAutomationEntries, filteredExternalAutomationKeys]) + + const hasListItems = automations.length + externalAutomationEntries.length > 0 + const hasFilteredListItems = + filteredAutomations.length + filteredExternalAutomationEntries.length > 0 + + // Why: when search hides the current row, move selection to the first visible + // match so list highlight and detail stay aligned. No matches → keep detail. + useEffect(() => { + if (activeListSearchQuery === null) { + return + } + const localVisible = + selectedExternalKey === null && + selectedId != null && + filteredAutomations.some((automation) => automation.id === selectedId) + const externalVisible = + selectedExternalKey != null && + filteredExternalAutomationEntries.some((entry) => entry.key === selectedExternalKey) + if (localVisible || externalVisible) { + return + } + const firstLocal = filteredAutomations[0] + if (firstLocal) { + if (selectedExternalKey !== null) { + selectExternalKey(null) + } + if (selectedId !== firstLocal.id) { + selectAutomationId(firstLocal.id) + } + return + } + const firstExternal = filteredExternalAutomationEntries[0] + if (firstExternal && selectedExternalKey !== firstExternal.key) { + selectExternalKey(firstExternal.key) + } + }, [ + activeListSearchQuery, + filteredAutomations, + filteredExternalAutomationEntries, + selectAutomationId, + selectExternalKey, + selectedExternalKey, + selectedId + ]) + + return { + isListSearchQueryTooLarge, + isListSearchActive, + filteredAutomations, + filteredExternalAutomationEntries, + hasListItems, + hasFilteredListItems + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index b3ce98a4edd..b7f0deb0871 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -13943,7 +13943,20 @@ "a21f6c33ad": "Automation source refreshed.", "53f06f0ad5": "Retry source", "pendingAutomationMissing": "Automation no longer available.", - "pendingAutomationRunMissing": "Run history no longer available." + "pendingAutomationRunMissing": "Run history no longer available.", + "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...", @@ -14056,6 +14069,13 @@ "5a7863909c": "Run setup for each new workspace", "18f000ad4e": "Advanced", "874b72195b": "When this automation creates a workspace, prepare it the same way creating a worktree by hand does — run the project's setup and open its terminal tabs." + }, + "AutomationListSearchField": { + "tooLong": "Search text is too long — list is unfiltered", + "label": "Search automations", + "placeholder": "Search by name, project, or prompt", + "tooLongShort": "Too long", + "clear": "Clear search" } }, "agent": {