mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user