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
This commit is contained in:
Jinjing
2026-08-04 12:13:40 -07:00
committed by GitHub
parent 4734428654
commit bc1feb5a4f
22 changed files with 2499 additions and 1115 deletions
@@ -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<HTMLButtonElement | null>
onOpenChange: (open: boolean) => void
onDontAskAgainToggle: () => void
onCancel: () => void
onConfirm: () => void
}): React.JSX.Element {
return (
<Dialog open={deleteTarget !== null} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-md"
onOpenAutoFocus={(event) => {
event.preventDefault()
confirmButtonRef.current?.focus()
}}
>
<DialogHeader>
<DialogTitle className="text-sm">
{translate(
'auto.components.automations.AutomationsPage.080dcb5fbb',
'Delete Automation'
)}
</DialogTitle>
<DialogDescription className="text-xs">
{translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}{' '}
<span className="break-all font-medium text-foreground">{deleteTarget?.name}</span>{' '}
{translate(
'auto.components.automations.AutomationsPage.b264564427',
'and its run history. Workspaces created by previous runs are not deleted.'
)}
</DialogDescription>
</DialogHeader>
{deleteTarget ? (
<div className="rounded-md border border-border/70 bg-muted/35 px-3 py-2 text-xs">
<div className="break-all font-medium text-foreground">{deleteTarget.name}</div>
<div className="mt-1 text-muted-foreground">
{deleteTarget.workspaceMode === 'new_per_run'
? translate(
'auto.components.automations.AutomationsPage.cd8397cc32',
'New workspace each run'
)
: translate(
'auto.components.automations.AutomationsPage.36f71740a7',
'Selected workspace'
)}
</div>
</div>
) : null}
<button
type="button"
role="checkbox"
aria-checked={dontAskDeleteAgain}
onClick={onDontAskAgainToggle}
className="flex items-center gap-2 rounded-sm px-1 py-1 text-xs text-foreground/80 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span
className={`flex size-4 items-center justify-center rounded-sm border transition-colors ${
dontAskDeleteAgain
? 'border-foreground bg-foreground text-background'
: 'border-muted-foreground bg-transparent'
}`}
>
{dontAskDeleteAgain ? <Check className="size-3" strokeWidth={3} /> : null}
</span>
{translate('auto.components.automations.AutomationsPage.1e2e41392f', "Don't ask again")}
</button>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
{translate('auto.components.automations.AutomationsPage.73f630b49d', 'Cancel')}
</Button>
<Button ref={confirmButtonRef} variant="destructive" onClick={onConfirm}>
<Trash2 className="size-4" />
{translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export function ExternalAutomationDeleteDialog({
externalDeleteTarget,
confirmButtonRef,
onOpenChange,
onCancel,
onConfirm
}: {
externalDeleteTarget: {
manager: ExternalAutomationManager
job: ExternalAutomationJob
} | null
confirmButtonRef: React.RefObject<HTMLButtonElement | null>
onOpenChange: (open: boolean) => void
onCancel: () => void
onConfirm: () => void
}): React.JSX.Element {
return (
<Dialog open={externalDeleteTarget !== null} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-md"
onOpenAutoFocus={(event) => {
event.preventDefault()
confirmButtonRef.current?.focus()
}}
>
<DialogHeader>
<DialogTitle className="text-sm">
{translate(
'auto.components.automations.AutomationsPage.9adfab2596',
'Delete External Automation'
)}
</DialogTitle>
<DialogDescription className="text-xs">
{translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}{' '}
<span className="break-all font-medium text-foreground">
{externalDeleteTarget?.job.name}
</span>{' '}
{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}.
</DialogDescription>
</DialogHeader>
{externalDeleteTarget ? (
<div className="rounded-md border border-border/70 bg-muted/35 px-3 py-2 text-xs">
<div className="break-all font-medium text-foreground">
{externalDeleteTarget.job.name}
</div>
<div className="mt-1 text-muted-foreground">
{
getExternalAutomationScheduleDisplay(
externalDeleteTarget.manager,
externalDeleteTarget.job
).label
}
</div>
</div>
) : null}
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
{translate('auto.components.automations.AutomationsPage.73f630b49d', 'Cancel')}
</Button>
<Button ref={confirmButtonRef} variant="destructive" onClick={onConfirm}>
<Trash2 className="size-4" />
{translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -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<string, Pick<SshConnectionState, 'status'>>
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 (
<button
key={entry.key}
type="button"
onClick={() => onSelect(entry.key)}
className={cn(
'mb-1 grid w-full grid-cols-[minmax(0,1fr)_auto] gap-3 rounded-md border px-3 py-2 text-left text-sm transition-colors',
selectedExternalKey === entry.key
? 'border-foreground/30 bg-muted/70 text-foreground shadow-sm'
: 'border-transparent hover:bg-muted/50'
)}
>
<span className="min-w-0">
<span className="flex min-w-0 items-center gap-2">
<span className="size-2 rounded-full bg-muted-foreground/40" />
<span className="truncate font-medium">{entry.manager.targetLabel}</span>
</span>
<span className="mt-1 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
<span>
{providerLabel}{' '}
{translate('auto.components.automations.AutomationsPage.82eb6cb933', 'source')}
</span>
<span className="shrink-0">/</span>
<span className="truncate">{targetKindLabel}</span>
</span>
<span className="mt-1 block truncate text-xs text-muted-foreground">
{sourceAvailability.summary}
</span>
</span>
<span className="flex max-w-28 flex-col items-end gap-1 text-right text-xs text-muted-foreground">
<Clock className="size-3.5" />
<span className="line-clamp-2">{sourceAvailability.statusLabel}</span>
</span>
</button>
)
}
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 (
<ContextMenu key={entry.key}>
<ContextMenuTrigger asChild>
<button
type="button"
onClick={() => onSelect(entry.key)}
className={cn(
'mb-1 grid w-full grid-cols-[minmax(0,1fr)_auto] gap-3 rounded-md border px-3 py-2 text-left text-sm transition-colors',
selectedExternalKey === entry.key
? 'border-foreground/30 bg-muted/70 text-foreground shadow-sm'
: 'border-transparent hover:bg-muted/50'
)}
>
<span className="min-w-0">
<span className="flex min-w-0 items-center gap-2">
<span
className={cn(
'size-2 rounded-full',
entry.job.enabled ? 'bg-foreground' : 'bg-muted-foreground/40'
)}
/>
<span className="truncate font-medium">{entry.job.name}</span>
</span>
<span className="mt-1 block truncate text-xs font-medium text-foreground/80">
{scheduleDisplay.label}
</span>
<span className="mt-1 flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
<span className="truncate">
{providerLabel} / {entry.manager.targetLabel}
</span>
<span className="shrink-0">·</span>
<span className="truncate">
{entry.manager.provider === 'hermes'
? translate(
'auto.components.automations.AutomationsPage.runCount',
'{{count}} runs',
{ count: entry.job.runCount }
)
: entry.manager.canManage
? translate(
'auto.components.automations.AutomationsPage.aecdc3681f',
'Manageable'
)
: translate(
'auto.components.automations.AutomationsPage.e059042585',
'Read-only'
)}
</span>
</span>
</span>
<span className="flex max-w-28 flex-col items-end gap-1 text-right text-xs text-muted-foreground">
<Clock className="size-3.5" />
<span className="line-clamp-2">{nextRunLabel}</span>
</span>
</button>
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
<ContextMenuItem
disabled={actionDisabled}
onSelect={() => onRequestAction(entry.manager, entry.job, 'run')}
>
<Play className="size-3.5" />
<span className="min-w-0 truncate">
{disabledMessage ??
translate('auto.components.automations.AutomationsPage.2faecab10b', 'Run Now')}
</span>
</ContextMenuItem>
{entry.manager.provider === 'hermes' ? (
<ContextMenuItem
disabled={!entry.manager.canManage || externalActionKey !== null}
onSelect={() => onEdit(entry.manager, entry.job)}
>
<Pencil className="size-3.5" />
{translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')}
</ContextMenuItem>
) : null}
<ContextMenuItem
disabled={actionDisabled}
onSelect={() =>
onRequestAction(entry.manager, entry.job, entry.job.enabled ? 'pause' : 'resume')
}
>
{entry.job.enabled ? <Pause className="size-3.5" /> : <Play className="size-3.5" />}
{entry.job.enabled
? translate('auto.components.automations.AutomationsPage.b457436d6a', 'Pause')
: translate('auto.components.automations.AutomationsPage.376631ef2b', 'Resume')}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
disabled={actionDisabled}
onSelect={() => onRequestAction(entry.manager, entry.job, 'delete')}
>
<Trash2 className="size-3.5" />
{translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
})}
</>
)
}
@@ -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<string, Repo>
worktreeMap: ReadonlyMap<string, Worktree>
projectHostSetups: readonly ProjectHostSetup[]
sshConnectionStates: ReadonlyMap<string, Pick<SshConnectionState, 'status'>>
runtimeStatusByEnvironmentId: ReadonlyMap<
string,
{ status: RuntimeStatus | null; checkedAt: number }
>
automationHostTarget: AutomationHostTarget | null
automationSourceHostAvailabilityById: ReadonlyMap<string, TaskSourceHostAvailability[]>
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<string, AutomationRun[]>()
for (const run of runs) {
const existing = grouped.get(run.automationId)
if (existing) {
existing.push(run)
} else {
grouped.set(run.automationId, [run])
}
}
return grouped
}, [runs])
return (
<>
{automations.map((automation) => {
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 (
<ContextMenu key={automation.id}>
<ContextMenuTrigger asChild>
<button
type="button"
onClick={() => onSelect(automation.id)}
className={cn(
'mb-1 grid w-full grid-cols-[minmax(0,1fr)_auto] gap-3 rounded-md border px-3 py-2 text-left text-sm transition-colors',
isSelectedLocal && selectedId === automation.id
? 'border-foreground/30 bg-muted/70 text-foreground shadow-sm'
: 'border-transparent hover:bg-muted/50'
)}
>
<span className="min-w-0">
<span className="flex min-w-0 items-center gap-2">
<span
className={cn(
'size-2 rounded-full',
automation.enabled ? 'bg-foreground' : 'bg-muted-foreground/40'
)}
/>
<span className="truncate font-medium">{automation.name}</span>
</span>
<span className="mt-1 block truncate text-xs font-medium text-foreground/80">
{scheduleLabel}
</span>
<span className="mt-1 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
{automationRepo ? (
<RepoBadgeLabel
name={automationRepo.displayName}
color={automationRepo.badgeColor}
badgeClassName="size-1.5"
/>
) : (
<span>
{translate(
'auto.components.automations.AutomationsPage.13118faadf',
'Unknown project'
)}
</span>
)}
<span className="shrink-0">/</span>
<span className="truncate">{workspaceLabel}</span>
<span className="shrink-0">·</span>
<span className="truncate">{getAgentLabel(automation.agentId)}</span>
</span>
<span className="mt-1 block truncate text-xs text-muted-foreground">
{usageText}
</span>
</span>
<span className="flex max-w-28 flex-col items-end gap-1 text-right text-xs text-muted-foreground">
<Clock className="size-3.5" />
<span className="line-clamp-2">{nextRunLabel}</span>
</span>
</button>
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
<ContextMenuItem
disabled={!automationRunAvailability.canRunNow}
onSelect={(event) => {
if (!automationRunAvailability.canRunNow) {
event.preventDefault()
return
}
onRunNow(automation)
}}
>
<Play className="size-3.5" />
<span className="min-w-0 truncate">
{automationRunAvailability.canRunNow
? translate('auto.components.automations.AutomationsPage.2faecab10b', 'Run Now')
: automationRunAvailability.message}
</span>
</ContextMenuItem>
<ContextMenuItem onSelect={() => onEdit(automation)}>
<Pencil className="size-3.5" />
{translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')}
</ContextMenuItem>
<ContextMenuItem onSelect={() => onToggle(automation)}>
{automation.enabled ? (
<Pause className="size-3.5" />
) : (
<Play className="size-3.5" />
)}
{automation.enabled
? translate('auto.components.automations.AutomationsPage.b457436d6a', 'Pause')
: translate('auto.components.automations.AutomationsPage.376631ef2b', 'Resume')}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem variant="destructive" onSelect={() => onDelete(automation)}>
<Trash2 className="size-3.5" />
{translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
})}
</>
)
}
@@ -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<HTMLInputElement>(null)
const hasText = query !== ''
const tooLargeMessage = isTooLarge
? translate(
'auto.components.automations.AutomationListSearchField.tooLong',
'Search text is too long — list is unfiltered'
)
: null
return (
<div className={cn('relative', className)}>
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
ref={inputRef}
value={query}
aria-label={translate(
'auto.components.automations.AutomationListSearchField.label',
'Search automations'
)}
placeholder={translate(
'auto.components.automations.AutomationListSearchField.placeholder',
'Search by name, project, or prompt'
)}
aria-invalid={isTooLarge || undefined}
aria-describedby={isTooLarge ? 'automations-list-search-too-large' : undefined}
// Why: the page-level capture Escape handler blurs inputs; this opts out
// so the first Escape clears the query without also losing focus.
data-escape-clears-value={hasText ? 'true' : undefined}
className={cn(
'h-8 border-border/60 bg-background pl-8 text-xs',
hasText && (isTooLarge ? 'pr-20' : 'pr-7')
)}
onChange={(event) => onQueryChange(event.target.value)}
onKeyDown={(event) => {
if (event.key !== 'Escape' || event.nativeEvent.isComposing) {
return
}
if (!hasText) {
return
}
event.preventDefault()
onClear()
}}
/>
{hasText ? (
<div className="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5">
{isTooLarge ? (
<span
id="automations-list-search-too-large"
title={tooLargeMessage ?? undefined}
className="text-[10px] text-destructive"
>
{translate(
'auto.components.automations.AutomationListSearchField.tooLongShort',
'Too long'
)}
</span>
) : null}
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate(
'auto.components.automations.AutomationListSearchField.clear',
'Clear search'
)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
onClear()
inputRef.current?.focus()
}}
>
<X className="size-3.5" />
</Button>
</div>
) : null}
{isTooLarge ? (
<div role="status" aria-live="polite" className="sr-only">
{tooLargeMessage}
</div>
) : null}
</div>
)
}
@@ -19,7 +19,7 @@ import { translate } from '@/i18n/i18n'
type AutomationRunHistoryProps = {
runs: AutomationRun[]
automationId: string
worktreeMap: Map<string, Worktree>
worktreeMap: ReadonlyMap<string, Worktree>
onOpenRun: (run: AutomationRun) => void
}
@@ -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<string, string>
selectedRunNowAvailability: AutomationTargetAvailability | null
selectedExternalSourceAvailability: ExternalAutomationSourceAvailability | null
selectedExternalSshSource: {
manager: ExternalAutomationManager
} | null
selectedExternalSshConnected: boolean
selectedAutomationRunPageWorkspaceDisplay: AutomationRunWorkspaceDisplay | null
selectedAutomationRunPageViewState: AutomationRunViewState | null
canRerunSelectedAutomationRunPage: boolean
isSelectedAutomationRunPageRerunPending: boolean
worktreeMap: ReadonlyMap<string, Worktree>
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 (
<section className="flex min-h-0 flex-col overflow-hidden">
{selectedExternal ? (
<div className="scrollbar-sleek min-h-0 overflow-auto p-5">
{selectedExternalRunPage ? (
<AutomationRunPageFrame
title={selectedExternalRunPage.job.name}
breadcrumbs={[
formatExternalDate(selectedExternalRunPage.run.runAt, relativeNow),
getExternalProviderLabel(selectedExternalRunPage.manager),
selectedExternalRunPage.manager.targetLabel
]}
detail={selectedExternalRunPage.run.outputPath}
statusLabel={getExternalRunStatusLabel(selectedExternalRunPage.run)}
statusVariant={getExternalRunStatusVariant(selectedExternalRunPage.run)}
onBack={onClearExternalRunPage}
>
<HermesCronOutputView content={getExternalRunContent(selectedExternalRunPage.run)} />
</AutomationRunPageFrame>
) : selectedExternal.kind === 'job' ? (
<ExternalAutomationManagers
managers={[
{
...selectedExternal.manager,
jobs: [selectedExternal.job]
}
]}
now={relativeNow}
runningActionKey={externalActionKey}
onAction={requestExternalAction}
onFetchRuns={fetchExternalAutomationRuns}
onOpenRun={openExternalRunPage}
onEdit={openEditExternalDialog}
/>
) : (
<div className="rounded-md border border-border/50 bg-muted/20 shadow-sm">
<div className="flex items-center justify-between border-b border-border/50 px-3 py-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium">
{selectedExternal.manager.targetLabel}
</div>
<div className="text-xs text-muted-foreground">
{selectedExternalSourceAvailability?.summary}
</div>
</div>
{selectedExternalSshSource ? (
<Button
type="button"
variant="outline"
size="sm"
disabled={selectedExternalSourceAvailability?.isConnecting ?? false}
onClick={() =>
void connectExternalAutomationSource(selectedExternalSshSource.manager)
}
>
{selectedExternalSourceAvailability?.isConnecting ? (
<RefreshCw className="size-3.5 animate-spin" />
) : null}
{selectedExternalSourceAvailability?.isConnecting
? translate(
'auto.components.automations.AutomationsPage.f93ed7a6f8',
'Connecting...'
)
: selectedExternalSshConnected
? translate(
'auto.components.automations.AutomationsPage.53f06f0ad5',
'Retry source'
)
: translate(
'auto.components.automations.AutomationsPage.7934ee0d81',
'Connect SSH'
)}
</Button>
) : null}
</div>
<div className="px-3 py-6 text-sm text-muted-foreground">
{selectedExternalSourceAvailability?.detail}
</div>
</div>
)}
</div>
) : (
<Tabs
value={activePaneTab}
onValueChange={(value) => onActivePaneTabChange(value as AutomationPaneTab)}
className="min-h-0 flex-1 gap-0"
>
<div
className="flex shrink-0 items-center justify-between border-b border-border/50 px-5 py-2"
data-contextual-tour-target="automations-runs"
>
<TabsList variant="line" className="h-8">
<TabsTrigger value="overview">
{translate('auto.components.automations.AutomationsPage.bb1b2cd31e', 'Overview')}
</TabsTrigger>
<TabsTrigger value="runs" disabled={!selected}>
{translate('auto.components.automations.AutomationsPage.0e110a3469', 'Runs')}{' '}
<span className="text-xs text-muted-foreground">{selectedRuns.length}</span>
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="overview" className="scrollbar-sleek min-h-0 overflow-auto p-5">
<AutomationDetail
automation={selected}
runs={selectedRuns}
projectName={selectedRepoDisplayName}
projectDefaultBaseRef={selectedRepoDefaultBaseRef}
workspaceName={selectedWorkspaceName}
hostLabelById={hostLabelById}
runNowAvailability={selectedRunNowAvailability}
now={relativeNow}
onRunNow={(automation) => void runNow(automation)}
onEdit={(automation) => void openEditDialog(automation)}
onToggle={(automation) => void toggleAutomation(automation)}
onDelete={requestDeleteAutomation}
/>
</TabsContent>
<TabsContent value="runs" className="scrollbar-sleek min-h-0 overflow-auto p-5">
{selectedAutomationRunPage ? (
<AutomationRunPageFrame
title={selected?.name ?? selectedAutomationRunPage.title}
breadcrumbs={[
formatAutomationDateTimeWithRelative(
selectedAutomationRunPage.scheduledFor,
relativeNow
),
'Orca',
selectedAutomationRunPageWorkspaceDisplay?.detailLabel ??
translate(
'auto.components.automations.AutomationsPage.noWorkspace',
'No workspace'
)
]}
detail={
selectedAutomationRunPage.outputSnapshot?.truncated
? translate(
'auto.components.automations.AutomationsPage.latestSavedOutput',
'Latest saved output'
)
: null
}
statusLabel={getAutomationRunStatusLabel(selectedAutomationRunPage.status)}
statusVariant={getAutomationRunStatusVariant(selectedAutomationRunPage.status)}
actions={
<>
{canRerunSelectedAutomationRunPage && selected ? (
<Button
type="button"
variant="outline"
size="sm"
disabled={isSelectedAutomationRunPageRerunPending}
onClick={() => void rerunAutomationRun(selected, selectedAutomationRunPage)}
>
<RefreshCw
className={cn(
'size-3.5',
isSelectedAutomationRunPageRerunPending && 'animate-spin'
)}
/>
{translate(
'auto.components.automations.AutomationsPage.295698292f',
'Rerun'
)}
</Button>
) : null}
{selectedAutomationRunPageViewState ? (
<Button
type="button"
variant="outline"
size="sm"
disabled={!selectedAutomationRunPageViewState.canOpen}
onClick={() => openRunWorkspace(selectedAutomationRunPage)}
>
<Eye className="size-3.5" />
{selectedAutomationRunPageViewState.actionLabel}
</Button>
) : null}
</>
}
onBack={onClearAutomationRunPage}
>
<CommentMarkdown
variant="document"
content={getAutomationRunContent(selectedAutomationRunPage)}
className="text-sm leading-relaxed text-foreground"
/>
</AutomationRunPageFrame>
) : selected ? (
<AutomationRunHistory
runs={selectedRuns}
automationId={selected.id}
worktreeMap={worktreeMap}
onOpenRun={openAutomationRunPage}
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{translate(
'auto.components.automations.AutomationsPage.c3a28c9793',
'Select an automation to view runs.'
)}
</div>
)}
</TabsContent>
</Tabs>
)}
</section>
)
}
@@ -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<string, Repo>
worktreeMap: ReadonlyMap<string, Worktree>
projectHostSetups: readonly ProjectHostSetup[]
sshConnectionStates: ReadonlyMap<string, Pick<SshConnectionState, 'status'>>
runtimeStatusByEnvironmentId: ReadonlyMap<
string,
{ status: RuntimeStatus | null; checkedAt: number }
>
automationHostTarget: AutomationHostTarget | null
automationSourceHostAvailabilityById: ReadonlyMap<string, TaskSourceHostAvailability[]>
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 (
<section
className="flex min-h-0 flex-col border-r border-border/50 bg-muted/20"
data-contextual-tour-target="automations-list"
>
{hasListItems ? (
<div className="shrink-0 border-b border-border/40 px-2 py-2">
<AutomationListSearchField
query={listSearchQuery}
isTooLarge={isListSearchQueryTooLarge}
onQueryChange={(query) =>
onListSearchQueryChange(clampAutomationListSearchQueryInput(query))
}
onClear={() => onListSearchQueryChange('')}
/>
</div>
) : null}
<div className="scrollbar-sleek min-h-0 flex-1 overflow-auto p-2">
{hasFilteredListItems ? (
<div className="grid grid-cols-[1fr_auto] gap-2 px-2 pb-2 text-[11px] font-medium uppercase text-muted-foreground">
<span>
{translate('auto.components.automations.AutomationsPage.761a35834d', 'Automation')}
</span>
<span>
{translate('auto.components.automations.AutomationsPage.587a4b205c', 'Next')}
</span>
</div>
) : null}
<AutomationListLocalRows
automations={filteredAutomations}
selectedId={selected?.id}
isSelectedLocal={selectedExternal === null}
runs={runs}
relativeNow={relativeNow}
repoMap={repoMap}
worktreeMap={worktreeMap}
projectHostSetups={projectHostSetups}
sshConnectionStates={sshConnectionStates}
runtimeStatusByEnvironmentId={runtimeStatusByEnvironmentId}
automationHostTarget={automationHostTarget}
automationSourceHostAvailabilityById={automationSourceHostAvailabilityById}
onSelect={(automationId) => {
selectExternalKey(null)
selectAutomationId(automationId)
}}
onRunNow={runNow}
onEdit={openEditDialog}
onToggle={toggleAutomation}
onDelete={requestDeleteAutomation}
/>
<AutomationListExternalRows
entries={filteredExternalAutomationEntries}
selectedExternalKey={selectedExternal?.key}
relativeNow={relativeNow}
sshConnectionStates={sshConnectionStates}
externalActionKey={externalActionKey}
onSelect={(entryKey) => {
selectExternalKey(entryKey)
setActivePaneTab('overview')
}}
onRequestAction={requestExternalAction}
onEdit={openEditExternalDialog}
/>
{hasListItems && isListSearchActive && !hasFilteredListItems ? (
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
{translate(
'auto.components.automations.AutomationsPage.noSearchMatches',
'No automations match your search.'
)}
</div>
) : null}
{!hasListItems ? (
<div className="grid gap-2 p-2">
<div className="px-1 pb-1 text-sm font-medium">
{translate(
'auto.components.automations.AutomationsPage.d207ab4c25',
'Start from a template'
)}
</div>
{getAutomationTemplates().map((template) => (
<button
key={template.id}
type="button"
onClick={() => openCreateDialog(template)}
className="rounded-md border border-border/70 bg-background px-3 py-2 text-left shadow-xs transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<div className="text-[11px] font-medium uppercase text-muted-foreground">
{template.category}
</div>
<div className="mt-1 text-sm font-medium">{template.label}</div>
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">
{template.description}
</div>
</button>
))}
<Button
type="button"
variant="outline"
className="mt-1 w-full justify-start"
onClick={() => openCreateDialog()}
>
<Plus className="size-4" />
{translate('auto.components.automations.AutomationsPage.25060635c6', 'Add new')}
</Button>
</div>
) : null}
</div>
</section>
)
}
File diff suppressed because it is too large Load Diff
@@ -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({
<div className="min-w-0">
<div className="truncate text-sm font-medium">{manager.targetLabel}</div>
<div className="text-xs text-muted-foreground">
{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}
</div>
{manager.provider === 'hermes' ? (
<div className="mt-1 truncate text-xs text-muted-foreground">
@@ -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<typeof Badge>['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({
<span className="min-w-0 truncate text-xs text-muted-foreground">
{getRunSummary(run)}
</span>
<Badge variant={getRunStatusVariant(run)}>{getRunStatusLabel(run)}</Badge>
<Badge variant={getExternalRunStatusVariant(run)}>
{getExternalRunStatusLabel(run)}
</Badge>
</button>
))}
</div>
@@ -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
}
@@ -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 {
@@ -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' }
])
)
})
})
@@ -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<T>(
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<T>(
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<T>(
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
}
@@ -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
}
@@ -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.'
}
@@ -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<void> {
const remainingMs = getAutomationRerunPendingRemainingMs({ pendingStartedAt })
if (remainingMs <= 0) {
return
}
await new Promise<void>((resolve) => window.setTimeout(resolve, remainingMs))
}
export function canRerunAutomationRun({
automation,
run
@@ -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
}
@@ -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<typeof Badge>['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)
}
@@ -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
}))
})
}
@@ -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<string, Repo>
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
}
}
+21 -1
View File
@@ -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": {