feat(automations): restore column sorting on the list (#18885)

The flat-table redesign in #16532 dropped the sort UI, orphaning
AutomationListSortHeader, nextAutomationListSort and the whole
AutomationListViewItem layer. Wire them back to the rendered list.

Name and Last run become interactive header cells again; the other six
columns stay plain text. Sorting now spans local and external rows as
one list, so the panel renders per-row components from a single sorted
collection instead of two independent sections.

Two model fixes fall out of that:

- View items key on the host-qualified row key, not the bare automation
  ID. The old builder predated automation-list-row-identity, so under
  All hosts two authorities returning the same ID collapsed in the sort
  tie-break.
- sortAutomationListViewItems takes the locale as a parameter instead of
  reading getIntlLocale(). A hidden global read is invisible to a
  dependency array, and the list result is memoized.

Keyboard traversal and focus recovery now read the sorted order, so
arrow navigation matches what is on screen. The dead unified filter is
removed in favor of the live row/entry filters the page already used.
This commit is contained in:
Neil
2026-09-05 16:16:08 -07:00
committed by GitHub
parent a730becd7a
commit abdee9ebd3
25 changed files with 1241 additions and 986 deletions
@@ -0,0 +1,260 @@
import React from 'react'
import { MoreHorizontal, Pause, Pencil, Play, Trash2 } from 'lucide-react'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
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 type { ExternalAutomationScope } from './external-automation-scope-client'
import {
formatExternalDate,
getExternalProviderLabel,
getExternalTargetKindLabel
} from './external-automation-display'
import { getExternalAutomationScheduleDisplay } from './external-automation-schedule-display'
import { getExternalAutomationActionDisabledMessage } from './external-automation-source-availability'
import { AUTOMATIONS_TABLE_GRID_CLASS } from './automations-table-layout'
import {
LIST_TABLE_ROW_CLASS,
LIST_TABLE_ROW_SELECTED_CLASS,
LIST_TABLE_STICKY_ROW_CELL_CLASS
} from '@/lib/list-table-layout'
import { isPortaledRowMenuClick, isRowActivationKey } from '@/lib/list-row-interaction'
import { getExternalAutomationLastRunSnapshot } from './automation-list-last-run'
import { AutomationListLastRunCell } from './AutomationListLastRunCell'
import { AutomationListStatusCell } from './AutomationListStatusCell'
import { translate } from '@/i18n/i18n'
export type AutomationListExternalRowProps = {
entry: 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,
scope: ExternalAutomationScope
) => void
onEdit: (
manager: ExternalAutomationManager,
job: ExternalAutomationJob,
scope: ExternalAutomationScope
) => void
}
export function AutomationListExternalRow({
entry,
selectedExternalKey,
relativeNow,
sshConnectionStates,
externalActionKey,
onSelect,
onRequestAction,
onEdit
}: AutomationListExternalRowProps): React.JSX.Element {
const providerLabel = getExternalProviderLabel(entry.manager)
const targetKindLabel = getExternalTargetKindLabel(entry.manager)
const isSelected = selectedExternalKey === entry.key
const sshStatus =
entry.manager.target.type === 'ssh'
? sshConnectionStates.get(entry.manager.target.connectionId)?.status
: undefined
const disabledMessage = getExternalAutomationActionDisabledMessage({
manager: entry.manager,
providerLabel,
targetKindLabel,
sshStatus,
actionInProgress: externalActionKey !== null
})
const actionDisabled = disabledMessage !== null
const scheduleLabel = getExternalAutomationScheduleDisplay(entry.manager, entry.job).label
const hostLabel = entry.manager.targetLabel || entry.manager.label || 'Local'
const projectLabel = entry.job.workdir ?? providerLabel
const nextRunLabel = entry.job.enabled
? formatExternalDate(entry.job.nextRunAt, relativeNow)
: translate('auto.components.automations.AutomationsPage.paused', 'Paused')
const lastRunSnapshot = getExternalAutomationLastRunSnapshot(entry.job)
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<div
role="button"
tabIndex={0}
data-current={isSelected ? 'true' : undefined}
onClick={(event) => {
// Why: Radix portals menus out of the row DOM, but React still
// bubbles those clicks here — ignore so menu actions don't open detail.
if (isPortaledRowMenuClick(event)) {
return
}
onSelect(entry.key)
}}
onKeyDown={(event) => {
if (!isRowActivationKey(event)) {
return
}
event.preventDefault()
onSelect(entry.key)
}}
className={cn(
AUTOMATIONS_TABLE_GRID_CLASS,
LIST_TABLE_ROW_CLASS,
isSelected && LIST_TABLE_ROW_SELECTED_CLASS
)}
>
<span className={LIST_TABLE_STICKY_ROW_CELL_CLASS}>
<span className="min-w-0 truncate font-medium">{entry.job.name}</span>
</span>
<span className="min-w-0 truncate text-muted-foreground" title={scheduleLabel}>
{scheduleLabel}
</span>
<span className="min-w-0 truncate text-muted-foreground" title={projectLabel}>
{projectLabel}
</span>
<span className="min-w-0 truncate text-muted-foreground" title={hostLabel}>
{hostLabel}
</span>
<span className="min-w-0 truncate text-muted-foreground" title={nextRunLabel}>
{nextRunLabel}
</span>
<AutomationListLastRunCell snapshot={lastRunSnapshot} now={relativeNow} />
<AutomationListStatusCell enabled={entry.job.enabled} />
<span className="truncate text-center text-xs text-muted-foreground">
{providerLabel}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="size-7 text-muted-foreground"
aria-label={translate(
'auto.components.automations.AutomationsPage.rowActions',
'Automation actions'
)}
onClick={(event) => event.stopPropagation()}
>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
disabled={actionDisabled}
onSelect={() => onRequestAction(entry.manager, entry.job, 'run', entry.scope)}
>
<Play className="size-3.5" />
<span className="min-w-0 truncate">
{disabledMessage ??
translate('auto.components.automations.AutomationsPage.2faecab10b', 'Run Now')}
</span>
</DropdownMenuItem>
{entry.manager.provider === 'hermes' ? (
<DropdownMenuItem
disabled={!entry.manager.canManage || externalActionKey !== null}
onSelect={() => onEdit(entry.manager, entry.job, entry.scope)}
>
<Pencil className="size-3.5" />
{translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')}
</DropdownMenuItem>
) : null}
<DropdownMenuItem
disabled={actionDisabled}
onSelect={() =>
onRequestAction(
entry.manager,
entry.job,
entry.job.enabled ? 'pause' : 'resume',
entry.scope
)
}
>
{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')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
disabled={actionDisabled}
onSelect={() => onRequestAction(entry.manager, entry.job, 'delete', entry.scope)}
>
<Trash2 className="size-3.5" />
{translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
<ContextMenuItem
disabled={actionDisabled}
onSelect={() => onRequestAction(entry.manager, entry.job, 'run', entry.scope)}
>
<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, entry.scope)}
>
<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.scope
)
}
>
{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', entry.scope)}
>
<Trash2 className="size-3.5" />
{translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}
@@ -1,285 +1,23 @@
import React from 'react'
import { MoreHorizontal, Pause, Pencil, Play, Trash2 } from 'lucide-react'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
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 type { ExternalAutomationScope } from './external-automation-scope-client'
import {
formatExternalDate,
getExternalProviderLabel,
getExternalTargetKindLabel
} from './external-automation-display'
import { getExternalAutomationScheduleDisplay } from './external-automation-schedule-display'
import { getExternalAutomationActionDisabledMessage } from './external-automation-source-availability'
import { AUTOMATIONS_TABLE_GRID_CLASS } from './automations-table-layout'
import {
LIST_TABLE_ROW_CLASS,
LIST_TABLE_ROW_SELECTED_CLASS,
LIST_TABLE_STICKY_ROW_CELL_CLASS
} from '@/lib/list-table-layout'
import { isPortaledRowMenuClick, isRowActivationKey } from '@/lib/list-row-interaction'
import { getExternalAutomationLastRunSnapshot } from './automation-list-last-run'
import { AutomationListLastRunCell } from './AutomationListLastRunCell'
import { AutomationListStatusCell } from './AutomationListStatusCell'
import { translate } from '@/i18n/i18n'
AutomationListExternalRow,
type AutomationListExternalRowProps
} from './AutomationListExternalRow'
export type AutomationListExternalRowsProps = Omit<AutomationListExternalRowProps, 'entry'> & {
entries: readonly ExternalAutomationListEntry[]
}
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,
scope: ExternalAutomationScope
) => void
onEdit: (
manager: ExternalAutomationManager,
job: ExternalAutomationJob,
scope: ExternalAutomationScope
) => void
}): React.JSX.Element {
...rowProps
}: AutomationListExternalRowsProps): React.JSX.Element {
return (
<>
{entries.map((entry) => {
const providerLabel = getExternalProviderLabel(entry.manager)
const targetKindLabel = getExternalTargetKindLabel(entry.manager)
const isSelected = selectedExternalKey === entry.key
const sshStatus =
entry.manager.target.type === 'ssh'
? sshConnectionStates.get(entry.manager.target.connectionId)?.status
: undefined
const disabledMessage = getExternalAutomationActionDisabledMessage({
manager: entry.manager,
providerLabel,
targetKindLabel,
sshStatus,
actionInProgress: externalActionKey !== null
})
const actionDisabled = disabledMessage !== null
const scheduleLabel = getExternalAutomationScheduleDisplay(entry.manager, entry.job).label
const hostLabel = entry.manager.targetLabel || entry.manager.label || 'Local'
const projectLabel = entry.job.workdir ?? providerLabel
const nextRunLabel = entry.job.enabled
? formatExternalDate(entry.job.nextRunAt, relativeNow)
: translate('auto.components.automations.AutomationsPage.paused', 'Paused')
const lastRunSnapshot = getExternalAutomationLastRunSnapshot(entry.job)
return (
<ContextMenu key={entry.key}>
<ContextMenuTrigger asChild>
<div
role="button"
tabIndex={0}
data-current={isSelected ? 'true' : undefined}
onClick={(event) => {
// Why: Radix portals menus out of the row DOM, but React still
// bubbles those clicks here — ignore so menu actions don't open detail.
if (isPortaledRowMenuClick(event)) {
return
}
onSelect(entry.key)
}}
onKeyDown={(event) => {
if (!isRowActivationKey(event)) {
return
}
event.preventDefault()
onSelect(entry.key)
}}
className={cn(
AUTOMATIONS_TABLE_GRID_CLASS,
LIST_TABLE_ROW_CLASS,
isSelected && LIST_TABLE_ROW_SELECTED_CLASS
)}
>
<span className={LIST_TABLE_STICKY_ROW_CELL_CLASS}>
<span className="min-w-0 truncate font-medium">{entry.job.name}</span>
</span>
<span className="min-w-0 truncate text-muted-foreground" title={scheduleLabel}>
{scheduleLabel}
</span>
<span className="min-w-0 truncate text-muted-foreground" title={projectLabel}>
{projectLabel}
</span>
<span className="min-w-0 truncate text-muted-foreground" title={hostLabel}>
{hostLabel}
</span>
<span className="min-w-0 truncate text-muted-foreground" title={nextRunLabel}>
{nextRunLabel}
</span>
<AutomationListLastRunCell snapshot={lastRunSnapshot} now={relativeNow} />
<AutomationListStatusCell enabled={entry.job.enabled} />
<span className="truncate text-center text-xs text-muted-foreground">
{providerLabel}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="size-7 text-muted-foreground"
aria-label={translate(
'auto.components.automations.AutomationsPage.rowActions',
'Automation actions'
)}
onClick={(event) => event.stopPropagation()}
>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
disabled={actionDisabled}
onSelect={() => onRequestAction(entry.manager, entry.job, 'run', entry.scope)}
>
<Play className="size-3.5" />
<span className="min-w-0 truncate">
{disabledMessage ??
translate(
'auto.components.automations.AutomationsPage.2faecab10b',
'Run Now'
)}
</span>
</DropdownMenuItem>
{entry.manager.provider === 'hermes' ? (
<DropdownMenuItem
disabled={!entry.manager.canManage || externalActionKey !== null}
onSelect={() => onEdit(entry.manager, entry.job, entry.scope)}
>
<Pencil className="size-3.5" />
{translate(
'auto.components.automations.AutomationsPage.f4612e3f78',
'Edit'
)}
</DropdownMenuItem>
) : null}
<DropdownMenuItem
disabled={actionDisabled}
onSelect={() =>
onRequestAction(
entry.manager,
entry.job,
entry.job.enabled ? 'pause' : 'resume',
entry.scope
)
}
>
{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'
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
disabled={actionDisabled}
onSelect={() =>
onRequestAction(entry.manager, entry.job, 'delete', entry.scope)
}
>
<Trash2 className="size-3.5" />
{translate(
'auto.components.automations.AutomationsPage.15e0bfb13b',
'Delete'
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
<ContextMenuItem
disabled={actionDisabled}
onSelect={() => onRequestAction(entry.manager, entry.job, 'run', entry.scope)}
>
<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, entry.scope)}
>
<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.scope
)
}
>
{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', entry.scope)}
>
<Trash2 className="size-3.5" />
{translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
})}
{entries.map((entry) => (
<AutomationListExternalRow key={entry.key} entry={entry} {...rowProps} />
))}
</>
)
}
@@ -0,0 +1,391 @@
import React from 'react'
import { MoreHorizontal, Pause, Pencil, Play, Trash2 } from 'lucide-react'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { AgentIcon } from '@/lib/agent-catalog'
import { cn } from '@/lib/utils'
import type { AutomationRun } from '../../../../shared/automations-types'
import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity'
import { formatUiAutomationSchedule } from './automation-schedule-label'
import {
getExecutionHostLabel,
getLocalExecutionHostLabel,
getRepoExecutionHostId
} from '../../../../shared/execution-host'
import type { SshConnectionState } from '../../../../shared/ssh-types'
import type { ProjectHostSetup } from '../../../../shared/project-types'
import type { Repo } from '../../../../shared/repo-types'
import type { Worktree } from '../../../../shared/worktree/types'
import type { RuntimeStatus } from '../../../../shared/runtime-types'
import type { TaskSourceHostAvailability } from '../task-source-context-summary'
import type { AutomationRowAction } from './automation-captured-owner'
import type { AutomationHostTarget } from './automation-host-client'
import {
getAutomationRowLastRunSnapshot,
getLocalAutomationLastRunSnapshot
} from './automation-list-last-run'
import { AutomationListLastRunCell } from './AutomationListLastRunCell'
import { formatAutomationDateTimeWithRelative } from './automation-page-parts'
import { getAutomationTargetAvailability } from './automation-target-availability'
import { getAgentLabel } from './automation-draft-model'
import type { AutomationListRow } from './automation-list-row-identity'
import {
formatAutomationCost,
formatAutomationTokens,
type AutomationUsageSummary
} from './automation-usage-model'
import { AUTOMATIONS_TABLE_GRID_CLASS } from './automations-table-layout'
import {
LIST_TABLE_ROW_CLASS,
LIST_TABLE_ROW_SELECTED_CLASS,
LIST_TABLE_STICKY_ROW_CELL_CLASS
} from '@/lib/list-table-layout'
import { isPortaledRowMenuClick, isRowActivationKey } from '@/lib/list-row-interaction'
import { AutomationListStatusCell } from './AutomationListStatusCell'
import { translate } from '@/i18n/i18n'
export type AutomationListLocalRowProps = {
row: AutomationListRow
selectedRowKey: string | null | undefined
isSelectedLocal: boolean
lastRunByAutomationId: ReadonlyMap<string, AutomationRun>
relativeNow: number
repoMap: ReadonlyMap<string, Repo>
worktreeMap: ReadonlyMap<string, Worktree>
repoForRow?: (row: AutomationListRow) => Repo | undefined
worktreeForRow?: (row: AutomationListRow, repo: Repo | undefined) => Worktree | undefined
projectHostSetups: readonly ProjectHostSetup[]
sshConnectionStates: ReadonlyMap<string, Pick<SshConnectionState, 'status'>>
runtimeStatusByEnvironmentId: ReadonlyMap<
string,
{ status: RuntimeStatus | null; checkedAt: number }
>
hostTargetFor: (row: AutomationListRow) => AutomationHostTarget | null
automationSourceHostAvailabilityByRowKey: ReadonlyMap<string, TaskSourceHostAvailability[]>
hostLabelById?: ReadonlyMap<string, string>
isActionEnabled?: (row: AutomationListRow, action: AutomationRowAction) => boolean
onSelect: (rowKey: string) => void
onRunNow: (row: AutomationListRow) => void
onEdit: (row: AutomationListRow) => void
onToggle: (row: AutomationListRow) => void
onDelete: (row: AutomationListRow) => void
}
const EMPTY_HOST_LABELS: ReadonlyMap<string, string> = new Map()
function automationUsageText(summary: AutomationUsageSummary | undefined): string {
if (!summary || summary.unavailableRuns > 0) {
return summary?.knownRuns
? usageAmountText(summary)
: translate(
'auto.components.automations.AutomationsPage.usageUnavailable',
'Usage unavailable'
)
}
return summary.knownRuns > 0
? usageAmountText(summary)
: translate('auto.components.automations.AutomationsPage.noRunUsageYet', 'No run usage yet')
}
function usageAmountText(summary: AutomationUsageSummary): string {
return translate(
'auto.components.automations.AutomationsPage.runUsageSummary',
'{{cost}} est. · {{tokens}} tokens',
{
cost: formatAutomationCost(summary.estimatedCostUsd),
tokens: formatAutomationTokens(summary.totalTokens)
}
)
}
export function AutomationListLocalRow({
row,
selectedRowKey,
isSelectedLocal,
lastRunByAutomationId,
relativeNow,
repoMap,
worktreeMap,
repoForRow,
worktreeForRow,
projectHostSetups,
sshConnectionStates,
runtimeStatusByEnvironmentId,
hostTargetFor,
automationSourceHostAvailabilityByRowKey,
hostLabelById = EMPTY_HOST_LABELS,
isActionEnabled,
onSelect,
onRunNow,
onEdit,
onToggle,
onDelete
}: AutomationListLocalRowProps): React.JSX.Element {
const allows = (row: AutomationListRow, action: AutomationRowAction): boolean =>
isActionEnabled?.(row, action) ?? true
const { automation } = row
const automationRepo = repoForRow?.(row) ?? repoMap.get(getAutomationRunRepoId(automation))
const automationWorktree = automation.workspaceId
? (worktreeForRow?.(row, automationRepo) ?? worktreeMap.get(automation.workspaceId))
: null
const automationRunAvailability = getAutomationTargetAvailability({
automation,
repo: automationRepo,
workspace: automationWorktree,
projectHostSetups,
sshConnectionStates,
runtimeStatusByEnvironmentId,
automationHostTarget: hostTargetFor(row),
sourceHostAvailability: automationSourceHostAvailabilityByRowKey.get(row.key)
})
const projectLabel =
automationRepo?.displayName ??
translate('auto.components.automations.AutomationsPage.13118faadf', 'Unknown project')
const scheduleLabel = formatUiAutomationSchedule(automation.rrule)
const nextRunLabel = automation.enabled
? formatAutomationDateTimeWithRelative(automation.nextRunAt, relativeNow)
: translate('auto.components.automations.enablement.paused', 'Paused')
const isSelected = isSelectedLocal && selectedRowKey === row.key
const agentLabel = getAgentLabel(automation.agentId)
const hostId =
automation.runContext?.hostId ??
(automationRepo ? getRepoExecutionHostId(automationRepo) : null)
const hostLabel =
row.hostLabel ||
(hostId
? (hostLabelById.get(hostId) ?? getExecutionHostLabel(hostId))
: getLocalExecutionHostLabel())
const agentTooltipLabel = `${agentLabel} · ${hostLabel} · ${automationUsageText(row.usageSummary ?? undefined)}`
const canRunNow = automationRunAvailability.canRunNow && allows(row, 'run')
const lastRun = lastRunByAutomationId.get(automation.id)
// Without a fetched run, the row's projected summary carries the newest
// retained run's status — the list never downloads run history for this.
const lastRunSnapshot = lastRun
? getLocalAutomationLastRunSnapshot(automation, lastRun)
: getAutomationRowLastRunSnapshot(row)
const actionItems = (
<>
<MenuRunItem
disabled={!canRunNow}
label={
automationRunAvailability.canRunNow
? translate('auto.components.automations.AutomationsPage.2faecab10b', 'Run Now')
: automationRunAvailability.message
}
onSelect={() => onRunNow(row)}
/>
<MenuItem
disabled={!allows(row, 'edit')}
icon={<Pencil className="size-3.5" />}
label={translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')}
onSelect={() => onEdit(row)}
/>
<MenuItem
disabled={!allows(row, 'toggle')}
icon={automation.enabled ? <Pause className="size-3.5" /> : <Play className="size-3.5" />}
label={
automation.enabled
? translate('auto.components.automations.AutomationsPage.b457436d6a', 'Pause')
: translate('auto.components.automations.AutomationsPage.376631ef2b', 'Resume')
}
onSelect={() => onToggle(row)}
/>
<MenuSeparator />
<MenuItem
disabled={!allows(row, 'delete')}
icon={<Trash2 className="size-3.5" />}
label={translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}
variant="destructive"
onSelect={() => onDelete(row)}
/>
</>
)
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<div
role="button"
tabIndex={0}
data-automation-row-id={row.key}
data-current={isSelected ? 'true' : undefined}
onClick={(event) => {
// Why: Radix portals menus out of the row DOM, but React still
// bubbles those clicks here — ignore so menu actions don't open detail.
if (isPortaledRowMenuClick(event)) {
return
}
onSelect(row.key)
}}
onKeyDown={(event) => {
if (!isRowActivationKey(event)) {
return
}
event.preventDefault()
onSelect(row.key)
}}
className={cn(
AUTOMATIONS_TABLE_GRID_CLASS,
LIST_TABLE_ROW_CLASS,
isSelected && LIST_TABLE_ROW_SELECTED_CLASS
)}
>
<span className={LIST_TABLE_STICKY_ROW_CELL_CLASS}>
<span className="min-w-0 truncate font-medium">{automation.name}</span>
</span>
<span className="min-w-0 truncate text-muted-foreground" title={scheduleLabel}>
{scheduleLabel}
</span>
<span className="min-w-0 truncate text-muted-foreground" title={projectLabel}>
{projectLabel}
</span>
<span className="min-w-0 truncate text-muted-foreground" title={hostLabel}>
{hostLabel}
</span>
<span className="min-w-0 truncate text-muted-foreground" title={nextRunLabel}>
{nextRunLabel}
</span>
<AutomationListLastRunCell snapshot={lastRunSnapshot} now={relativeNow} />
<AutomationListStatusCell enabled={automation.enabled} />
<Tooltip>
<TooltipTrigger asChild>
<span
className="flex items-center justify-center text-muted-foreground"
aria-label={agentTooltipLabel}
>
<AgentIcon agent={automation.agentId} size={16} />
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{agentTooltipLabel}
</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="size-7 text-muted-foreground"
aria-label={translate(
'auto.components.automations.AutomationListLocalRows.c92c9463c6',
'Automation actions'
)}
onClick={(event) => event.stopPropagation()}
>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
disabled={!canRunNow}
onSelect={() => {
if (canRunNow) {
onRunNow(row)
}
}}
>
<Play className="size-3.5" />
<span className="min-w-0 truncate">
{automationRunAvailability.canRunNow
? translate('auto.components.automations.AutomationsPage.2faecab10b', 'Run Now')
: automationRunAvailability.message}
</span>
</DropdownMenuItem>
<DropdownMenuItem disabled={!allows(row, 'edit')} onSelect={() => onEdit(row)}>
<Pencil className="size-3.5" />
{translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')}
</DropdownMenuItem>
<DropdownMenuItem disabled={!allows(row, 'toggle')} onSelect={() => onToggle(row)}>
{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')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
disabled={!allows(row, 'delete')}
onSelect={() => onDelete(row)}
>
<Trash2 className="size-3.5" />
{translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-48">{actionItems}</ContextMenuContent>
</ContextMenu>
)
}
function MenuRunItem({
disabled,
label,
onSelect
}: {
disabled: boolean
label: string
onSelect: () => void
}): React.JSX.Element {
return (
<ContextMenuItem
disabled={disabled}
onSelect={(event) => {
if (disabled) {
event.preventDefault()
return
}
onSelect()
}}
>
<Play className="size-3.5" />
<span className="min-w-0 truncate">{label}</span>
</ContextMenuItem>
)
}
function MenuItem({
disabled,
icon,
label,
onSelect,
variant
}: {
disabled?: boolean
icon: React.ReactNode
label: string
onSelect: () => void
variant?: 'destructive'
}): React.JSX.Element {
return (
<ContextMenuItem disabled={disabled} variant={variant} onSelect={onSelect}>
{icon}
{label}
</ContextMenuItem>
)
}
function MenuSeparator(): React.JSX.Element {
return <ContextMenuSeparator />
}
@@ -1,414 +1,20 @@
import React from 'react'
import { MoreHorizontal, Pause, Pencil, Play, Trash2 } from 'lucide-react'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { AgentIcon } from '@/lib/agent-catalog'
import { cn } from '@/lib/utils'
import type { AutomationRun } from '../../../../shared/automations-types'
import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity'
import { formatUiAutomationSchedule } from './automation-schedule-label'
import {
getExecutionHostLabel,
getLocalExecutionHostLabel,
getRepoExecutionHostId
} from '../../../../shared/execution-host'
import type { SshConnectionState } from '../../../../shared/ssh-types'
import type { ProjectHostSetup } from '../../../../shared/project-types'
import type { Repo } from '../../../../shared/repo-types'
import type { Worktree } from '../../../../shared/worktree/types'
import type { RuntimeStatus } from '../../../../shared/runtime-types'
import type { TaskSourceHostAvailability } from '../task-source-context-summary'
import type { AutomationRowAction } from './automation-captured-owner'
import type { AutomationHostTarget } from './automation-host-client'
import {
getAutomationRowLastRunSnapshot,
getLocalAutomationLastRunSnapshot
} from './automation-list-last-run'
import { AutomationListLastRunCell } from './AutomationListLastRunCell'
import { formatAutomationDateTimeWithRelative } from './automation-page-parts'
import { getAutomationTargetAvailability } from './automation-target-availability'
import { getAgentLabel } from './automation-draft-model'
import type { AutomationListRow } from './automation-list-row-identity'
import {
formatAutomationCost,
formatAutomationTokens,
type AutomationUsageSummary
} from './automation-usage-model'
import { AUTOMATIONS_TABLE_GRID_CLASS } from './automations-table-layout'
import {
LIST_TABLE_ROW_CLASS,
LIST_TABLE_ROW_SELECTED_CLASS,
LIST_TABLE_STICKY_ROW_CELL_CLASS
} from '@/lib/list-table-layout'
import { isPortaledRowMenuClick, isRowActivationKey } from '@/lib/list-row-interaction'
import { AutomationListStatusCell } from './AutomationListStatusCell'
import { translate } from '@/i18n/i18n'
import { AutomationListLocalRow, type AutomationListLocalRowProps } from './AutomationListLocalRow'
export type AutomationListLocalRowsProps = {
export type AutomationListLocalRowsProps = Omit<AutomationListLocalRowProps, 'row'> & {
rows: readonly AutomationListRow[]
selectedRowKey: string | null | undefined
isSelectedLocal: boolean
lastRunByAutomationId: ReadonlyMap<string, AutomationRun>
relativeNow: number
repoMap: ReadonlyMap<string, Repo>
worktreeMap: ReadonlyMap<string, Worktree>
repoForRow?: (row: AutomationListRow) => Repo | undefined
worktreeForRow?: (row: AutomationListRow, repo: Repo | undefined) => Worktree | undefined
projectHostSetups: readonly ProjectHostSetup[]
sshConnectionStates: ReadonlyMap<string, Pick<SshConnectionState, 'status'>>
runtimeStatusByEnvironmentId: ReadonlyMap<
string,
{ status: RuntimeStatus | null; checkedAt: number }
>
hostTargetFor: (row: AutomationListRow) => AutomationHostTarget | null
automationSourceHostAvailabilityByRowKey: ReadonlyMap<string, TaskSourceHostAvailability[]>
hostLabelById?: ReadonlyMap<string, string>
isActionEnabled?: (row: AutomationListRow, action: AutomationRowAction) => boolean
onSelect: (rowKey: string) => void
onRunNow: (row: AutomationListRow) => void
onEdit: (row: AutomationListRow) => void
onToggle: (row: AutomationListRow) => void
onDelete: (row: AutomationListRow) => void
}
const EMPTY_HOST_LABELS: ReadonlyMap<string, string> = new Map()
function automationUsageText(summary: AutomationUsageSummary | undefined): string {
if (!summary || summary.unavailableRuns > 0) {
return summary?.knownRuns
? usageAmountText(summary)
: translate(
'auto.components.automations.AutomationsPage.usageUnavailable',
'Usage unavailable'
)
}
return summary.knownRuns > 0
? usageAmountText(summary)
: translate('auto.components.automations.AutomationsPage.noRunUsageYet', 'No run usage yet')
}
function usageAmountText(summary: AutomationUsageSummary): string {
return translate(
'auto.components.automations.AutomationsPage.runUsageSummary',
'{{cost}} est. · {{tokens}} tokens',
{
cost: formatAutomationCost(summary.estimatedCostUsd),
tokens: formatAutomationTokens(summary.totalTokens)
}
)
}
export function AutomationListLocalRows({
rows,
selectedRowKey,
isSelectedLocal,
lastRunByAutomationId,
relativeNow,
repoMap,
worktreeMap,
repoForRow,
worktreeForRow,
projectHostSetups,
sshConnectionStates,
runtimeStatusByEnvironmentId,
hostTargetFor,
automationSourceHostAvailabilityByRowKey,
hostLabelById = EMPTY_HOST_LABELS,
isActionEnabled,
onSelect,
onRunNow,
onEdit,
onToggle,
onDelete
...rowProps
}: AutomationListLocalRowsProps): React.JSX.Element {
const allows = (row: AutomationListRow, action: AutomationRowAction): boolean =>
isActionEnabled?.(row, action) ?? true
return (
<>
{rows.map((row) => {
const { automation } = row
const automationRepo = repoForRow?.(row) ?? repoMap.get(getAutomationRunRepoId(automation))
const automationWorktree = automation.workspaceId
? (worktreeForRow?.(row, automationRepo) ?? worktreeMap.get(automation.workspaceId))
: null
const automationRunAvailability = getAutomationTargetAvailability({
automation,
repo: automationRepo,
workspace: automationWorktree,
projectHostSetups,
sshConnectionStates,
runtimeStatusByEnvironmentId,
automationHostTarget: hostTargetFor(row),
sourceHostAvailability: automationSourceHostAvailabilityByRowKey.get(row.key)
})
const projectLabel =
automationRepo?.displayName ??
translate('auto.components.automations.AutomationsPage.13118faadf', 'Unknown project')
const scheduleLabel = formatUiAutomationSchedule(automation.rrule)
const nextRunLabel = automation.enabled
? formatAutomationDateTimeWithRelative(automation.nextRunAt, relativeNow)
: translate('auto.components.automations.enablement.paused', 'Paused')
const isSelected = isSelectedLocal && selectedRowKey === row.key
const agentLabel = getAgentLabel(automation.agentId)
const hostId =
automation.runContext?.hostId ??
(automationRepo ? getRepoExecutionHostId(automationRepo) : null)
const hostLabel =
row.hostLabel ||
(hostId
? (hostLabelById.get(hostId) ?? getExecutionHostLabel(hostId))
: getLocalExecutionHostLabel())
const agentTooltipLabel = `${agentLabel} · ${hostLabel} · ${automationUsageText(row.usageSummary ?? undefined)}`
const canRunNow = automationRunAvailability.canRunNow && allows(row, 'run')
const lastRun = lastRunByAutomationId.get(automation.id)
// Without a fetched run, the row's projected summary carries the newest
// retained run's status — the list never downloads run history for this.
const lastRunSnapshot = lastRun
? getLocalAutomationLastRunSnapshot(automation, lastRun)
: getAutomationRowLastRunSnapshot(row)
const actionItems = (
<>
<MenuRunItem
disabled={!canRunNow}
label={
automationRunAvailability.canRunNow
? translate('auto.components.automations.AutomationsPage.2faecab10b', 'Run Now')
: automationRunAvailability.message
}
onSelect={() => onRunNow(row)}
/>
<MenuItem
disabled={!allows(row, 'edit')}
icon={<Pencil className="size-3.5" />}
label={translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')}
onSelect={() => onEdit(row)}
/>
<MenuItem
disabled={!allows(row, 'toggle')}
icon={
automation.enabled ? <Pause className="size-3.5" /> : <Play className="size-3.5" />
}
label={
automation.enabled
? translate('auto.components.automations.AutomationsPage.b457436d6a', 'Pause')
: translate('auto.components.automations.AutomationsPage.376631ef2b', 'Resume')
}
onSelect={() => onToggle(row)}
/>
<MenuSeparator />
<MenuItem
disabled={!allows(row, 'delete')}
icon={<Trash2 className="size-3.5" />}
label={translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}
variant="destructive"
onSelect={() => onDelete(row)}
/>
</>
)
return (
<ContextMenu key={row.key}>
<ContextMenuTrigger asChild>
<div
role="button"
tabIndex={0}
data-automation-row-id={row.key}
data-current={isSelected ? 'true' : undefined}
onClick={(event) => {
// Why: Radix portals menus out of the row DOM, but React still
// bubbles those clicks here — ignore so menu actions don't open detail.
if (isPortaledRowMenuClick(event)) {
return
}
onSelect(row.key)
}}
onKeyDown={(event) => {
if (!isRowActivationKey(event)) {
return
}
event.preventDefault()
onSelect(row.key)
}}
className={cn(
AUTOMATIONS_TABLE_GRID_CLASS,
LIST_TABLE_ROW_CLASS,
isSelected && LIST_TABLE_ROW_SELECTED_CLASS
)}
>
<span className={LIST_TABLE_STICKY_ROW_CELL_CLASS}>
<span className="min-w-0 truncate font-medium">{automation.name}</span>
</span>
<span className="min-w-0 truncate text-muted-foreground" title={scheduleLabel}>
{scheduleLabel}
</span>
<span className="min-w-0 truncate text-muted-foreground" title={projectLabel}>
{projectLabel}
</span>
<span className="min-w-0 truncate text-muted-foreground" title={hostLabel}>
{hostLabel}
</span>
<span className="min-w-0 truncate text-muted-foreground" title={nextRunLabel}>
{nextRunLabel}
</span>
<AutomationListLastRunCell snapshot={lastRunSnapshot} now={relativeNow} />
<AutomationListStatusCell enabled={automation.enabled} />
<Tooltip>
<TooltipTrigger asChild>
<span
className="flex items-center justify-center text-muted-foreground"
aria-label={agentTooltipLabel}
>
<AgentIcon agent={automation.agentId} size={16} />
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{agentTooltipLabel}
</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="size-7 text-muted-foreground"
aria-label={translate(
'auto.components.automations.AutomationListLocalRows.c92c9463c6',
'Automation actions'
)}
onClick={(event) => event.stopPropagation()}
>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
disabled={!canRunNow}
onSelect={() => {
if (canRunNow) {
onRunNow(row)
}
}}
>
<Play className="size-3.5" />
<span className="min-w-0 truncate">
{automationRunAvailability.canRunNow
? translate(
'auto.components.automations.AutomationsPage.2faecab10b',
'Run Now'
)
: automationRunAvailability.message}
</span>
</DropdownMenuItem>
<DropdownMenuItem disabled={!allows(row, 'edit')} onSelect={() => onEdit(row)}>
<Pencil className="size-3.5" />
{translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')}
</DropdownMenuItem>
<DropdownMenuItem
disabled={!allows(row, 'toggle')}
onSelect={() => onToggle(row)}
>
{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'
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
disabled={!allows(row, 'delete')}
onSelect={() => onDelete(row)}
>
<Trash2 className="size-3.5" />
{translate(
'auto.components.automations.AutomationsPage.15e0bfb13b',
'Delete'
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-48">{actionItems}</ContextMenuContent>
</ContextMenu>
)
})}
{rows.map((row) => (
<AutomationListLocalRow key={row.key} row={row} {...rowProps} />
))}
</>
)
}
function MenuRunItem({
disabled,
label,
onSelect
}: {
disabled: boolean
label: string
onSelect: () => void
}): React.JSX.Element {
return (
<ContextMenuItem
disabled={disabled}
onSelect={(event) => {
if (disabled) {
event.preventDefault()
return
}
onSelect()
}}
>
<Play className="size-3.5" />
<span className="min-w-0 truncate">{label}</span>
</ContextMenuItem>
)
}
function MenuItem({
disabled,
icon,
label,
onSelect,
variant
}: {
disabled?: boolean
icon: React.ReactNode
label: string
onSelect: () => void
variant?: 'destructive'
}): React.JSX.Element {
return (
<ContextMenuItem disabled={disabled} variant={variant} onSelect={onSelect}>
{icon}
{label}
</ContextMenuItem>
)
}
function MenuSeparator(): React.JSX.Element {
return <ContextMenuSeparator />
}
@@ -0,0 +1,51 @@
import React from 'react'
import { ArrowDown, ArrowUp } from 'lucide-react'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import type { AutomationListSort, AutomationListSortField } from './automation-list-view'
export function AutomationListSortHeader({
field,
label,
sort,
onSort
}: {
field: AutomationListSortField
label: string
sort: AutomationListSort | null
onSort: (field: AutomationListSortField) => void
}): React.JSX.Element {
const active = sort?.field === field
const direction = active ? sort.direction : null
// Why: one interpolated key per direction — word order and punctuation around
// the column name differ per language.
const sortedLabel =
direction === 'asc'
? translate(
'auto.components.automations.AutomationListSortHeader.sortedAscending',
'{{value0}}, sorted ascending',
{ value0: label }
)
: direction === 'desc'
? translate(
'auto.components.automations.AutomationListSortHeader.sortedDescending',
'{{value0}}, sorted descending',
{ value0: label }
)
: null
return (
<button
type="button"
onClick={() => onSort(field)}
aria-label={sortedLabel ?? label}
className={cn(
'flex min-w-0 items-center gap-1 rounded-sm text-left text-[11px] font-medium tracking-[0.08em] uppercase select-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none',
active && 'text-foreground'
)}
>
<span className="truncate">{label}</span>
{direction === 'asc' ? <ArrowUp aria-hidden="true" className="size-3 shrink-0" /> : null}
{direction === 'desc' ? <ArrowDown aria-hidden="true" className="size-3 shrink-0" /> : null}
</button>
)
}
@@ -1,7 +1,8 @@
// @vitest-environment happy-dom
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import userEvent from '@testing-library/user-event'
import { AutomationListTableHeader } from './AutomationListTableHeader'
import {
LIST_TABLE_HEADER_CLASS,
@@ -43,3 +44,45 @@ describe('AutomationListTableHeader', () => {
expect(nameCell.className).toBe(LIST_TABLE_STICKY_HEADER_CELL_CLASS)
})
})
describe('AutomationListTableHeader sorting', () => {
afterEach(cleanup)
it('exposes only the orderable columns as buttons', () => {
render(<AutomationListTableHeader sort={null} onSort={() => {}} />)
expect(screen.getAllByRole('button').map((button) => button.textContent)).toEqual([
'Name',
'Last run'
])
})
it('reports the sorted column and direction in the accessible name', () => {
const { rerender } = render(
<AutomationListTableHeader sort={{ field: 'name', direction: 'asc' }} onSort={() => {}} />
)
expect(screen.getByRole('button', { name: 'Name, sorted ascending' })).toBeDefined()
expect(screen.getByRole('button', { name: 'Last run' })).toBeDefined()
rerender(
<AutomationListTableHeader sort={{ field: 'lastRun', direction: 'desc' }} onSort={() => {}} />
)
expect(screen.getByRole('button', { name: 'Last run, sorted descending' })).toBeDefined()
expect(screen.getByRole('button', { name: 'Name' })).toBeDefined()
})
it('requests a sort for the clicked column', async () => {
const onSort = vi.fn()
render(<AutomationListTableHeader sort={null} onSort={onSort} />)
await userEvent.click(screen.getByRole('button', { name: 'Last run' }))
expect(onSort.mock.calls).toEqual([['lastRun']])
})
it('stays non-interactive when the list cannot be sorted', () => {
render(<AutomationListTableHeader />)
expect(screen.queryAllByRole('button')).toEqual([])
})
})
@@ -5,34 +5,85 @@ import {
LIST_TABLE_HEADER_CLASS,
LIST_TABLE_STICKY_HEADER_CELL_CLASS
} from '@/lib/list-table-layout'
import { AutomationListSortHeader } from './AutomationListSortHeader'
import type { AutomationListSort, AutomationListSortField } from './automation-list-view'
export function AutomationListTableHeader(): React.JSX.Element {
const labels = [
['auto.components.automations.AutomationsPage.tableName', 'Name'],
['auto.components.automations.AutomationDetail.18763ded26', 'Schedule'],
['auto.components.automations.AutomationsPage.tableProject', 'Project'],
['auto.components.automations.AutomationsPage.tableHost', 'Host'],
['auto.components.automations.AutomationDetail.578ff46987', 'Next run'],
['auto.components.automations.AutomationsPage.tableLastRun', 'Last run'],
['auto.components.automations.AutomationsPage.tableStatus', 'Status'],
['auto.components.automations.AutomationDetail.2df8970cd5', 'Agent']
] as const
type HeaderColumn = {
key: string
fallback: string
/** Absent for columns the list cannot order by. */
sortField?: AutomationListSortField
}
const COLUMNS: readonly HeaderColumn[] = [
{
key: 'auto.components.automations.AutomationsPage.tableName',
fallback: 'Name',
sortField: 'name'
},
{
key: 'auto.components.automations.AutomationDetail.18763ded26',
fallback: 'Schedule'
},
{
key: 'auto.components.automations.AutomationsPage.tableProject',
fallback: 'Project'
},
{
key: 'auto.components.automations.AutomationsPage.tableHost',
fallback: 'Host'
},
{
key: 'auto.components.automations.AutomationDetail.578ff46987',
fallback: 'Next run'
},
{
key: 'auto.components.automations.AutomationsPage.tableLastRun',
fallback: 'Last run',
sortField: 'lastRun'
},
{
key: 'auto.components.automations.AutomationsPage.tableStatus',
fallback: 'Status'
},
{
key: 'auto.components.automations.AutomationDetail.2df8970cd5',
fallback: 'Agent'
}
]
export function AutomationListTableHeader({
sort = null,
onSort
}: {
sort?: AutomationListSort | null
onSort?: (field: AutomationListSortField) => void
} = {}): React.JSX.Element {
return (
<div className={`${AUTOMATIONS_TABLE_GRID_CLASS} ${LIST_TABLE_HEADER_CLASS}`}>
{labels.map(([key, fallback], index) => (
<span
key={key}
className={
index === 0
? LIST_TABLE_STICKY_HEADER_CELL_CLASS
: index === labels.length - 1
? 'text-center'
: undefined
}
>
{translate(key, fallback)}
</span>
))}
{COLUMNS.map((column, index) => {
const label = translate(column.key, column.fallback)
const className =
index === 0
? LIST_TABLE_STICKY_HEADER_CELL_CLASS
: index === COLUMNS.length - 1
? 'text-center'
: undefined
return (
<span key={column.key} className={className}>
{column.sortField && onSort ? (
<AutomationListSortHeader
field={column.sortField}
label={label}
sort={sort}
onSort={onSort}
/>
) : (
label
)}
</span>
)
})}
<span className="sr-only">
{translate('auto.components.automations.AutomationsPage.tableActions', 'Actions')}
</span>
@@ -11,7 +11,12 @@ import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { AutomationsListPanel } from './AutomationsListPanel'
import { EMPTY_AUTOMATION_LIST_FILTER } from './automation-list-view'
import {
buildAutomationListViewItems,
EMPTY_AUTOMATION_LIST_FILTER,
type AutomationListSort,
type AutomationListSortField
} from './automation-list-view'
import type { AutomationHostCatalogView } from './use-automation-host-catalog'
import {
makeAutomation,
@@ -49,7 +54,13 @@ const HOST_CATALOG = {
status: 'all',
announceFallback: false
},
rows: { rows: [], automations: [], capturedOwners: new Map(), groups: [], answered: true },
rows: {
rows: [],
automations: [],
capturedOwners: new Map(),
groups: [],
answered: true
},
loadCounts: { failedHostCount: 0, totalHostCount: 1 },
selectHost: () => undefined,
recover: () => undefined,
@@ -70,6 +81,8 @@ function renderPanel(
selectExternalKey?: (key: string | null) => void
externalEntries?: readonly ExternalAutomationListEntry[]
setActivePaneTab?: (tab: AutomationPaneTab) => void
listSort?: AutomationListSort | null
onListSortChange?: (field: AutomationListSortField) => void
} = {}
): void {
const externalEntries = options.externalEntries ?? []
@@ -95,8 +108,12 @@ function renderPanel(
externalManagersUncheckedNotice={uncheckedNotice}
onSelectHost={() => undefined}
onRecoverHost={() => undefined}
filteredRows={rows}
filteredExternalAutomationEntries={externalEntries}
sortedListItems={buildAutomationListViewItems({
rows,
externalEntries
})}
listSort={options.listSort ?? null}
onListSortChange={options.onListSortChange ?? (() => undefined)}
selectedRowKey={options.selectedRowKey ?? null}
selectedExternalKey={options.selectedExternalKey ?? null}
relativeNow={0}
@@ -221,7 +238,11 @@ describe('AutomationsListPanel enter key navigation', () => {
const input = searchField()
expect(input).not.toBeNull()
const enter = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
const enter = new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true
})
input?.dispatchEvent(enter)
expect(enter.defaultPrevented).toBe(true)
@@ -252,7 +273,11 @@ describe('AutomationsListPanel enter key navigation', () => {
const input = searchField()
expect(input).not.toBeNull()
const enter = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
const enter = new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true
})
input?.dispatchEvent(enter)
expect(enter.defaultPrevented).toBe(true)
@@ -272,7 +297,11 @@ describe('AutomationsListPanel enter key navigation', () => {
const input = searchField()
expect(input).not.toBeNull()
const enter = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
const enter = new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true
})
input?.dispatchEvent(enter)
expect(detailOpened).toBe(false)
@@ -23,13 +23,19 @@ import {
import type { AutomationListRow } from './automation-list-row-identity'
import type { AutomationPaneTab } from './automation-page-state'
import { AutomationListFilterPills } from './AutomationListFilterMenu'
import { isAutomationListFilterActive, type AutomationListFilter } from './automation-list-view'
import {
isAutomationListFilterActive,
type AutomationListFilter,
type AutomationListSort,
type AutomationListSortField,
type AutomationListViewItem
} from './automation-list-view'
import { automationHostFilterStableKey } from '../../../../shared/automation-host-filter'
import type { AutomationTemplate } from './automation-templates'
import type { ExternalAutomationListEntry } from './external-automation-list-entries'
import type { ExternalAutomationScope } from './external-automation-scope-client'
import { AutomationListLocalRows } from './AutomationListLocalRows'
import { AutomationListExternalRows } from './AutomationListExternalRows'
import { AutomationListLocalRow } from './AutomationListLocalRow'
import { AutomationListExternalRow } from './AutomationListExternalRow'
import { AutomationHostFilterNotice, AutomationHostLoadSummary } from './AutomationHostFilterNotice'
import { AutomationListEmptyView } from './AutomationListEmptyView'
import { resolveAutomationListEmptyState } from './automation-list-empty-state'
@@ -63,8 +69,10 @@ type AutomationsListPanelProps = {
action: AutomationHostRecoveryAction,
entry?: AutomationHostCatalogEntry | null
) => void
filteredRows: readonly AutomationListRow[]
filteredExternalAutomationEntries: readonly ExternalAutomationListEntry[]
/** Both collections as one list in render order; the sort spans local and external rows. */
sortedListItems: readonly AutomationListViewItem[]
listSort: AutomationListSort | null
onListSortChange: (field: AutomationListSortField) => void
selectedRowKey: string | null
selectedExternalKey: string | null
selectedExternal?: ExternalAutomationListEntry | null
@@ -124,8 +132,9 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS
externalManagersUncheckedNotice,
onSelectHost,
onRecoverHost,
filteredRows,
filteredExternalAutomationEntries,
sortedListItems,
listSort,
onListSortChange,
selectedRowKey,
selectedExternalKey,
relativeNow,
@@ -161,18 +170,20 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS
// Hosts moved into the Filters menu, so its toolbar row is the focus fallback now.
const toolbarRef = useRef<HTMLDivElement>(null)
const pendingKeyboardScrollRef = useRef(false)
const rowKeys = React.useMemo(() => filteredRows.map((row) => row.key), [filteredRows])
const visibleItems = React.useMemo(
() => [
...filteredRows.map((row) => ({ kind: 'local' as const, id: row.key })),
...filteredExternalAutomationEntries.map((entry) => ({
kind: 'external' as const,
id: entry.key
}))
],
[filteredExternalAutomationEntries, filteredRows]
// Why: keyboard traversal and focus recovery read render order, which the sort owns.
const rowKeys = React.useMemo(
() => sortedListItems.filter((item) => item.kind === 'local').map((item) => item.id),
[sortedListItems]
)
useAutomationListFocusRecovery({ rowKeys, containerRef: listRef, fallbackRef: toolbarRef })
const visibleItems = React.useMemo(
() => sortedListItems.map((item) => ({ kind: item.kind, id: item.id })),
[sortedListItems]
)
useAutomationListFocusRecovery({
rowKeys,
containerRef: listRef,
fallbackRef: toolbarRef
})
const handleSearchArrowNavigate = React.useCallback(
(key: AutomationListArrowKey) => {
const next = getAutomationListArrowNavigationTarget({
@@ -331,24 +342,30 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS
>
{hasFilteredListItems ? (
<div className="min-w-full w-fit">
<AutomationListTableHeader />
<AutomationListTableHeader sort={listSort} onSort={onListSortChange} />
<div className="divide-y divide-border/50">
<AutomationListLocalRows {...rowProps} rows={filteredRows} />
<AutomationListExternalRows
entries={filteredExternalAutomationEntries}
selectedExternalKey={selectedExternalKey}
relativeNow={relativeNow}
sshConnectionStates={sshConnectionStates}
externalActionKey={externalActionKey}
onSelect={(entryKey) => {
selectAutomationRow(null)
selectExternalKey(entryKey)
setActivePaneTab('overview')
onOpenDetail()
}}
onRequestAction={requestExternalAction}
onEdit={openEditExternalDialog}
/>
{sortedListItems.map((item) =>
item.kind === 'local' ? (
<AutomationListLocalRow key={item.id} {...rowProps} row={item.row} />
) : (
<AutomationListExternalRow
key={item.id}
entry={item.entry}
selectedExternalKey={selectedExternalKey}
relativeNow={relativeNow}
sshConnectionStates={sshConnectionStates}
externalActionKey={externalActionKey}
onSelect={(entryKey) => {
selectAutomationRow(null)
selectExternalKey(entryKey)
setActivePaneTab('overview')
onOpenDetail()
}}
onRequestAction={requestExternalAction}
onEdit={openEditExternalDialog}
/>
)
)}
</div>
</div>
) : (
@@ -19,7 +19,6 @@ import {
addRuntimeProject,
api,
installAutomationsPageHarness,
listedRow,
mocks,
renderPage,
runtimeHost,
@@ -30,6 +29,7 @@ import {
scopedList,
settleHostQueries
} from './automations-page-test-harness'
import { listedRow } from './automations-page-listed-items'
import { makeAutomation, REPO_ID, WORKSPACE_ID } from './automations-page-fixtures'
import type { Repo } from '../../../../shared/repo-types'
import type { ProjectHostSetup } from '../../../../shared/project-types'
@@ -22,6 +22,7 @@ import {
SELF_PRECONDITION,
settleHostQueries
} from './automations-page-test-harness'
import { listedRows } from './automations-page-listed-items'
import { makeAutomation } from './automations-page-fixtures'
installAutomationsPageHarness()
@@ -36,9 +37,7 @@ async function collidingHosts(): Promise<void> {
}
function selectDesktopRow(): string {
const row = mocks.listPanel?.filteredRows.find(
(candidate) => candidate.automation.name === 'Desktop nightly'
)
const row = listedRows().find((candidate) => candidate.automation.name === 'Desktop nightly')
expect(row).toBeDefined()
return row?.key ?? ''
}
@@ -58,9 +57,7 @@ describe('AutomationsPage row actions under a colliding automation id', () => {
await renderPage()
await settleHostQueries()
const remote = mocks.listPanel?.filteredRows.find(
(candidate) => candidate.automation.name === 'Remote nightly'
)
const remote = listedRows().find((candidate) => candidate.automation.name === 'Remote nightly')
await act(async () => {
mocks.listPanel?.selectAutomationRow(remote?.key ?? '')
})
@@ -20,6 +20,7 @@ import {
RUNTIME_SELF_FILTER,
settleHostQueries
} from './automations-page-test-harness'
import { listedExternalEntries } from './automations-page-listed-items'
import { makeExternalManager } from './automations-page-fixtures'
installAutomationsPageHarness()
@@ -117,7 +118,7 @@ describe('AutomationsPage external manager probes', () => {
await renderPage()
await settleHostQueries()
expect(mocks.listPanel?.filteredExternalAutomationEntries).toEqual([])
expect(listedExternalEntries()).toEqual([])
})
it('drops the previous host rows when the selection moves, not when the new probe lands', async () => {
@@ -127,7 +128,7 @@ describe('AutomationsPage external manager probes', () => {
const { rerender } = await renderPage()
await settleHostQueries()
expect(mocks.listPanel?.filteredExternalAutomationEntries.length).toBeGreaterThan(0)
expect(listedExternalEntries().length).toBeGreaterThan(0)
// The new host never answers, so anything still listed belongs to the old one.
api.automations.listExternalManagerForOwner.mockImplementation(
@@ -137,7 +138,7 @@ describe('AutomationsPage external manager probes', () => {
await rerender()
await settleHostQueries()
expect(mocks.listPanel?.filteredExternalAutomationEntries).toEqual([])
expect(listedExternalEntries()).toEqual([])
})
it('reports a host it could not check rather than showing it as clean', async () => {
@@ -15,7 +15,6 @@ import {
addRuntimeProject,
api,
installAutomationsPageHarness,
listedRow,
mocks,
renderPage,
runtimeHost,
@@ -26,6 +25,7 @@ import {
scopedList,
settleHostQueries
} from './automations-page-test-harness'
import { listedRow } from './automations-page-listed-items'
import { makeAutomation } from './automations-page-fixtures'
installAutomationsPageHarness()
@@ -22,6 +22,7 @@ import {
SELF_PRECONDITION,
settleHostQueries
} from './automations-page-test-harness'
import { listedRows } from './automations-page-listed-items'
import { makeAutomation, makeRun } from './automations-page-fixtures'
installAutomationsPageHarness()
@@ -69,7 +70,7 @@ describe('AutomationsPage refresh', () => {
await renderPage()
expect(mocks.listPanel?.filteredRows[0]?.usageSummary).toEqual(usageSummary)
expect(listedRows()[0]?.usageSummary).toEqual(usageSummary)
})
it('does not re-list through the active runtime just because one is selected', async () => {
@@ -231,9 +232,7 @@ describe('AutomationsPage multi-host selection', () => {
)
).toEqual(['Desktop nightly', 'Remote nightly'])
const remote = mocks.listPanel?.filteredRows.find(
(row) => row.automation.name === 'Remote nightly'
)
const remote = listedRows().find((row) => row.automation.name === 'Remote nightly')
await act(async () => {
mocks.listPanel?.selectAutomationRow(remote?.key ?? '')
})
@@ -16,12 +16,12 @@ import type { Automation } from '../../../../shared/automations-types'
import {
api,
installAutomationsPageHarness,
listedRow,
mocks,
renderPage,
scopedList,
settleHostQueries
} from './automations-page-test-harness'
import { listedRow, listedRows } from './automations-page-listed-items'
import { makeAutomation } from './automations-page-fixtures'
installAutomationsPageHarness()
@@ -42,7 +42,7 @@ function desktopStoreHolds(automations: Automation[]): void {
/** The next-run column reads this; the mocked list panel renders only names. */
function listedNextRunAt(): number | null | undefined {
return mocks.listPanel?.filteredRows[0]?.automation.nextRunAt
return listedRows()[0]?.automation.nextRunAt
}
describe('AutomationsPage run visibility', () => {
@@ -24,13 +24,13 @@ import {
api,
DESKTOP_SELF_OWNER,
installAutomationsPageHarness,
listedRow,
mocks,
renderPage,
rows,
scopedList,
SELF_PRECONDITION
} from './automations-page-test-harness'
import { listedRow, listedExternalEntries } from './automations-page-listed-items'
import {
makeAutomation,
makeExternalManager,
@@ -147,7 +147,7 @@ describe('AutomationsPage list rendering', () => {
api.automations.updateExternalForOwner.mockResolvedValue(undefined)
await renderPage()
const entry = mocks.listPanel?.filteredExternalAutomationEntries[0]
const entry = listedExternalEntries()[0]
if (!entry) {
throw new Error('no external entry to edit')
}
@@ -177,7 +177,7 @@ describe('AutomationsPage list rendering', () => {
api.automations.runExternalActionForOwner.mockResolvedValue(undefined)
await renderPage()
const entry = mocks.listPanel?.filteredExternalAutomationEntries[0]
const entry = listedExternalEntries()[0]
if (!entry) {
throw new Error('no external entry to act on')
}
@@ -217,7 +217,7 @@ describe('AutomationsPage list rendering', () => {
api.automations.listExternalRunsForOwner.mockResolvedValue({ runs: [], total: 0 })
const { container } = await renderPage()
const entry = mocks.listPanel?.filteredExternalAutomationEntries[0]
const entry = listedExternalEntries()[0]
if (!entry) {
throw new Error('no external entry to read runs for')
}
@@ -1,6 +1,7 @@
import React from 'react'
import type { AutomationsPageController } from './use-automations-page-controller'
import { AutomationsListPanel } from './AutomationsListPanel'
import { nextAutomationListSort } from './automation-list-view'
export function AutomationsPageListPanel({
controller,
@@ -45,8 +46,6 @@ export function AutomationsPageListPanel({
hasListItems,
hasFilteredListItems,
isListSearchQueryTooLarge,
filteredRows,
filteredExternalAutomationEntries,
selectedRow,
selectedExternal,
searchCounts
@@ -79,8 +78,9 @@ export function AutomationsPageListPanel({
void pageRefresh.refresh()
}
}}
filteredRows={filteredRows}
filteredExternalAutomationEntries={filteredExternalAutomationEntries}
sortedListItems={list.sortedListItems}
listSort={local.listSort}
onListSortChange={(field) => local.setListSort(nextAutomationListSort(local.listSort, field))}
selectedRowKey={selectedRow?.key ?? null}
selectedExternalKey={local.selectedExternalKey}
selectedExternal={selectedExternal}
@@ -5,32 +5,34 @@ import {
type AutomationListSort,
type AutomationListViewItem
} from './automation-list-view'
import { unscopedAutomationListRows } from './automation-list-row-identity'
import { makeAutomation } from './automations-page-fixtures'
const locale = vi.hoisted(() => ({ value: 'en' }))
vi.mock('@/i18n/i18n', () => ({ getIntlLocale: () => locale.value }))
afterEach(() => {
vi.restoreAllMocks()
locale.value = 'en'
})
function rows(count = 512): AutomationListViewItem[] {
function items(count = 512): AutomationListViewItem[] {
const names = ['Alpha', 'álpha', 'Ångström', 'Zebra', 'Örebro', 'I', 'ı', 'İ', 'job 10', 'job 2']
return buildAutomationListViewItems({
automations: Array.from({ length: count }, (_, index) =>
makeAutomation({ id: `job-${index}`, name: names[(index * 7) % names.length] })
rows: unscopedAutomationListRows(
Array.from({ length: count }, (_, index) =>
makeAutomation({
id: `job-${index}`,
name: names[(index * 7) % names.length]
})
)
),
externalEntries: [],
runs: []
externalEntries: []
})
}
function previousOrder(items: AutomationListViewItem[], sort: AutomationListSort) {
/** The pre-collator comparator, resolving options on every comparison. */
function previousOrder(list: AutomationListViewItem[], sort: AutomationListSort, locale: string) {
function compare(left: AutomationListViewItem, right: AutomationListViewItem) {
const value =
sort.field === 'name'
? left.name.localeCompare(right.name, locale.value, { sensitivity: 'base' })
? left.name.localeCompare(right.name, locale, { sensitivity: 'base' })
: (left.lastRunAt ?? 0) - (right.lastRunAt ?? 0)
return value !== 0
? sort.direction === 'asc'
@@ -38,37 +40,35 @@ function previousOrder(items: AutomationListViewItem[], sort: AutomationListSort
: -value
: left.id.localeCompare(right.id)
}
return [...items].sort(compare)
return [...list].sort(compare)
}
describe('automation list collation', () => {
it.each(['en', 'sv', 'tr', 'ja'])(
'preserves %s ordering, tie-breaks and input identity',
(language) => {
locale.value = language
const items = rows()
const original = [...items]
(locale) => {
const list = items()
const original = [...list]
for (const direction of ['asc', 'desc'] as const) {
const sort = { field: 'name', direction } as const
const expected = previousOrder(items, sort)
const result = sortAutomationListViewItems(items, sort)
const expected = previousOrder(list, sort, locale)
const result = sortAutomationListViewItems(list, sort, locale)
expect(result).toEqual(expected)
expect(result.every((row, index) => row === expected[index])).toBe(true)
}
expect(items).toEqual(original)
expect(list).toEqual(original)
}
)
it('resolves collation once per name sort and responds to locale changes', () => {
const items = rows()
it('resolves collation once per name sort and follows the locale it is given', () => {
const list = items()
const OriginalCollator = Intl.Collator
const construct = vi.spyOn(Intl, 'Collator').mockImplementation(function (locales, options) {
return new OriginalCollator(locales, options)
})
const compare = vi.spyOn(String.prototype, 'localeCompare')
sortAutomationListViewItems(items, { field: 'name', direction: 'asc' })
locale.value = 'sv'
sortAutomationListViewItems(items, { field: 'name', direction: 'desc' })
sortAutomationListViewItems(list, { field: 'name', direction: 'asc' }, 'en')
sortAutomationListViewItems(list, { field: 'name', direction: 'desc' }, 'sv')
expect(construct.mock.calls).toEqual([
['en', { sensitivity: 'base' }],
['sv', { sensitivity: 'base' }]
@@ -76,16 +76,39 @@ describe('automation list collation', () => {
expect(compare.mock.calls.filter((args) => args.length >= 3)).toHaveLength(0)
})
it('orders by row key, not the bare automation ID, so hosts cannot collapse', () => {
const duplicate = makeAutomation({ id: 'shared', name: 'Same' })
const list = buildAutomationListViewItems({
rows: [
{
key: 'row|host-b|shared',
automation: duplicate,
hostLabel: 'b',
usageSummary: null
},
{
key: 'row|host-a|shared',
automation: duplicate,
hostLabel: 'a',
usageSummary: null
}
],
externalEntries: []
})
const sorted = sortAutomationListViewItems(list, { field: 'name', direction: 'asc' }, 'en')
expect(sorted.map((item) => item.id)).toEqual(['row|host-a|shared', 'row|host-b|shared'])
})
it('does not construct collation for unsorted, time-sorted or trivial lists', () => {
const items = rows()
const list = items()
const construct = vi.spyOn(Intl, 'Collator')
expect(sortAutomationListViewItems(items, null)).toEqual(items)
expect(sortAutomationListViewItems(list, null, 'en')).toEqual(list)
const sort = { field: 'lastRun', direction: 'desc' } as const
expect(sortAutomationListViewItems(items, sort)).toEqual(previousOrder(items, sort))
expect(sortAutomationListViewItems([], { field: 'name', direction: 'asc' })).toEqual([])
expect(sortAutomationListViewItems(list, sort, 'en')).toEqual(previousOrder(list, sort, 'en'))
expect(sortAutomationListViewItems([], { field: 'name', direction: 'asc' }, 'en')).toEqual([])
expect(
sortAutomationListViewItems(items.slice(0, 1), { field: 'name', direction: 'asc' })
).toEqual(items.slice(0, 1))
sortAutomationListViewItems(list.slice(0, 1), { field: 'name', direction: 'asc' }, 'en')
).toEqual(list.slice(0, 1))
expect(construct).not.toHaveBeenCalled()
})
})
@@ -1,7 +1,6 @@
import { describe, expect, it } from 'vitest'
import type {
Automation,
AutomationRun,
AutomationRunStatus,
ExternalAutomationJob,
ExternalAutomationManager
@@ -48,31 +47,6 @@ function makeAutomation(overrides: Partial<Automation> = {}): Automation {
}
}
function makeRun(overrides: Partial<AutomationRun> = {}): AutomationRun {
return {
id: 'run-1',
automationId: 'automation-1',
title: 'Zebra job',
scheduledFor: 10,
status: 'completed',
trigger: 'scheduled',
workspaceId: 'worktree-1',
sessionKind: 'terminal',
chatSessionId: null,
terminalSessionId: null,
terminalPaneKey: null,
terminalPtyId: null,
outputSnapshot: null,
precheckResult: null,
usage: null,
error: null,
startedAt: 20,
dispatchedAt: 30,
createdAt: 10,
...overrides
}
}
function makeExternalEntry(
overrides: Partial<ExternalAutomationJob> = {}
): ExternalAutomationListEntry {
@@ -118,22 +92,69 @@ function makeExternalEntry(
}
}
/** A catalog row with an optional projected last-run status, keyed like a real host row. */
function makeCatalogRow(
id: string,
overrides: Partial<Automation> = {},
lastRunStatus?: AutomationRunStatus
): AutomationListRow {
return {
key: `row|host|${id}`,
automation: makeAutomation({ id, ...overrides }),
hostLabel: 'This computer',
usageSummary: lastRunStatus
? {
knownRuns: 1,
unavailableRuns: 0,
inputTokens: 0,
outputTokens: 0,
cacheTokens: 0,
reasoningOutputTokens: 0,
totalTokens: 0,
estimatedCostUsd: null,
lastRunStatus,
lastRunAt: 111
}
: null
}
}
const rowKey = (id: string): string => `row|host|${id}`
describe('automation-list-view', () => {
it('counts and detects active filters', () => {
expect(isAutomationListFilterActive({ status: 'all', lastRun: 'all', agentIds: [] })).toBe(
false
)
expect(isAutomationListFilterActive({ status: 'paused', lastRun: 'all', agentIds: [] })).toBe(
true
)
expect(countAutomationListFilters({ status: 'paused', lastRun: 'failed', agentIds: [] })).toBe(
2
)
expect(
isAutomationListFilterActive({
status: 'all',
lastRun: 'all',
agentIds: []
})
).toBe(false)
expect(
isAutomationListFilterActive({
status: 'paused',
lastRun: 'all',
agentIds: []
})
).toBe(true)
expect(
countAutomationListFilters({
status: 'paused',
lastRun: 'failed',
agentIds: []
})
).toBe(2)
})
it('toggles sort direction and defaults last run to newest first', () => {
expect(nextAutomationListSort(null, 'name')).toEqual({ field: 'name', direction: 'asc' })
expect(nextAutomationListSort(null, 'lastRun')).toEqual({ field: 'lastRun', direction: 'desc' })
expect(nextAutomationListSort(null, 'name')).toEqual({
field: 'name',
direction: 'asc'
})
expect(nextAutomationListSort(null, 'lastRun')).toEqual({
field: 'lastRun',
direction: 'desc'
})
expect(nextAutomationListSort({ field: 'name', direction: 'asc' }, 'name')).toEqual({
field: 'name',
direction: 'desc'
@@ -146,82 +167,62 @@ describe('automation-list-view', () => {
it('filters by enabled state and last-run outcome', () => {
const items = applyAutomationListView({
automations: [
makeAutomation({ id: 'paused', name: 'Paused', enabled: false }),
makeAutomation({ id: 'ok', name: 'Healthy' })
rows: [
makeCatalogRow('paused', { name: 'Paused', enabled: false }, 'completed'),
makeCatalogRow('ok', { name: 'Healthy' }, 'dispatch_failed')
],
externalEntries: [makeExternalEntry()],
runs: [
makeRun({ automationId: 'paused', status: 'completed' }),
makeRun({ automationId: 'ok', status: 'dispatch_failed' })
],
filter: { status: 'enabled', lastRun: 'failed', agentIds: [] },
sort: null
sort: null,
locale: 'en'
})
expect(items.map((item) => item.id)).toEqual(['ok', 'manager-1:job-1'])
expect(items.map((item) => item.id)).toEqual([rowKey('ok'), 'manager-1:job-1'])
})
it('filters local rows by multiple agents and leaves external rows out of agent scopes', () => {
const items = applyAutomationListView({
automations: [
makeAutomation({ id: 'codex-job', agentId: 'codex' }),
makeAutomation({ id: 'claude-job', agentId: 'claude' })
rows: [
makeCatalogRow('codex-job', { agentId: 'codex' }),
makeCatalogRow('claude-job', { agentId: 'claude' })
],
externalEntries: [makeExternalEntry()],
runs: [],
filter: { status: 'all', lastRun: 'all', agentIds: ['codex', 'claude'] },
sort: null
sort: null,
locale: 'en'
})
expect(items.map((item) => item.id)).toEqual(['codex-job', 'claude-job'])
expect(items.map((item) => item.id)).toEqual([rowKey('codex-job'), rowKey('claude-job')])
})
it('counts an agent filter alongside status and last-run filters', () => {
expect(isAutomationListFilterActive({ status: 'all', lastRun: 'all', agentIds: [] })).toBe(
false
)
expect(
countAutomationListFilters({ status: 'paused', lastRun: 'failed', agentIds: ['codex'] })
isAutomationListFilterActive({
status: 'all',
lastRun: 'all',
agentIds: []
})
).toBe(false)
expect(
countAutomationListFilters({
status: 'paused',
lastRun: 'failed',
agentIds: ['codex']
})
).toBe(3)
})
it('sorts by name across local and external rows', () => {
const items = applyAutomationListView({
automations: [makeAutomation({ name: 'Zebra job' })],
rows: [makeCatalogRow('zebra', { name: 'Zebra job' })],
externalEntries: [makeExternalEntry({ name: 'Alpha digest' })],
runs: [],
filter: { status: 'all', lastRun: 'all', agentIds: [] },
sort: { field: 'name', direction: 'asc' }
sort: { field: 'name', direction: 'asc' },
locale: 'en'
})
expect(items.map((item) => item.name)).toEqual(['Alpha digest', 'Zebra job'])
})
it('filters catalog rows by status, agent, and the projected last-run status', () => {
function makeCatalogRow(
id: string,
overrides: Partial<Automation>,
lastRunStatus?: AutomationRunStatus
): AutomationListRow {
return {
key: `row|host|${id}`,
automation: makeAutomation({ id, ...overrides }),
hostLabel: 'This computer',
usageSummary: lastRunStatus
? {
knownRuns: 1,
unavailableRuns: 0,
inputTokens: 0,
outputTokens: 0,
cacheTokens: 0,
reasoningOutputTokens: 0,
totalTokens: 0,
estimatedCostUsd: null,
lastRunStatus,
lastRunAt: 111
}
: null
}
}
const rows = [
makeCatalogRow('paused-codex', { enabled: false, agentId: 'codex' }),
makeCatalogRow('failed-claude', { agentId: 'claude' }, 'dispatch_failed'),
@@ -229,9 +230,10 @@ describe('automation-list-view', () => {
makeCatalogRow('never-codex', { agentId: 'codex' })
]
const ids = (filter: Partial<AutomationListFilter>) =>
filterAutomationListRows(rows, { ...EMPTY_AUTOMATION_LIST_FILTER, ...filter }).map(
(row) => row.automation.id
)
filterAutomationListRows(rows, {
...EMPTY_AUTOMATION_LIST_FILTER,
...filter
}).map((row) => row.automation.id)
expect(ids({ status: 'paused' })).toEqual(['paused-codex'])
expect(ids({ agentIds: ['claude'] })).toEqual(['failed-claude'])
@@ -249,7 +251,10 @@ describe('automation-list-view', () => {
catalogRef:
targetId === null
? null
: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId } },
: {
authority: { kind: 'desktop' },
selector: { kind: 'ssh', targetId }
},
hostLabel: targetId ?? '',
usageSummary: null
})
@@ -257,9 +262,10 @@ describe('automation-list-view', () => {
const keyOf = (row: AutomationListRow): string =>
row.catalogRef ? hostStableKey(row.catalogRef) : ''
const ids = (hostStableKeys: readonly string[]) =>
filterAutomationListRows(rows, { ...EMPTY_AUTOMATION_LIST_FILTER, hostStableKeys }).map(
(row) => row.automation.id
)
filterAutomationListRows(rows, {
...EMPTY_AUTOMATION_LIST_FILTER,
hostStableKeys
}).map((row) => row.automation.id)
// Multi-select is any-of; a pre-catalog row names no host and is excluded.
expect(ids([keyOf(rows[0]), keyOf(rows[1])])).toEqual(['on-a', 'on-b'])
@@ -290,15 +296,22 @@ describe('automation-list-view', () => {
it('sorts by last run newest first and keeps never-run rows last', () => {
const items = applyAutomationListView({
automations: [
makeAutomation({ id: 'old', name: 'Old' }),
makeAutomation({ id: 'never', name: 'Never' })
rows: [
makeCatalogRow('old', {
name: 'Old',
lastRunAt: Date.parse('2026-08-11T09:00:00Z')
}),
makeCatalogRow('never', { name: 'Never' })
],
externalEntries: [makeExternalEntry({ lastRunAt: '2026-08-12T09:00:00Z' })],
runs: [makeRun({ automationId: 'old', dispatchedAt: Date.parse('2026-08-11T09:00:00Z') })],
filter: { status: 'all', lastRun: 'all', agentIds: [] },
sort: { field: 'lastRun', direction: 'desc' }
sort: { field: 'lastRun', direction: 'desc' },
locale: 'en'
})
expect(items.map((item) => item.id)).toEqual(['manager-1:job-1', 'old', 'never'])
expect(items.map((item) => item.id)).toEqual([
'manager-1:job-1',
rowKey('old'),
rowKey('never')
])
})
})
@@ -1,5 +1,3 @@
import { getIntlLocale } from '@/i18n/i18n'
import type { Automation, AutomationRun } from '../../../../shared/automations-types'
import type { TuiAgent } from '../../../../shared/tui-agent'
import { hostStableKey } from '../../../../shared/automation-owner-key'
import type { AutomationListRow } from './automation-list-row-identity'
@@ -7,8 +5,6 @@ import type { ExternalAutomationListEntry } from './external-automation-list-ent
import {
getAutomationRowLastRunSnapshot,
getExternalAutomationLastRunSnapshot,
getLocalAutomationLastRunSnapshot,
indexLatestAutomationRuns,
type AutomationLastRunSnapshot
} from './automation-list-last-run'
@@ -22,6 +18,13 @@ export type AutomationListSort = {
direction: AutomationListSortDirection
}
/**
* A row and an external job flattened to what the shared list renders and sorts.
*
* `id` is the row's own key, never the bare automation ID: under All hosts two
* authorities can return the same ID, and the sort tie-break decides render
* order, so a bare ID would collapse them. See `automation-list-row-identity`.
*/
export type AutomationListViewItem =
| {
kind: 'local'
@@ -31,7 +34,7 @@ export type AutomationListViewItem =
lastRunAt: number | null
lastRun: AutomationLastRunSnapshot
agentId: TuiAgent
automation: Automation
row: AutomationListRow
}
| {
kind: 'external'
@@ -117,30 +120,26 @@ function matchesLastRunFilter(
return snapshot.tone === filter
}
/** Flattens the two rendered collections into one sortable list, preserving row identity. */
export function buildAutomationListViewItems({
automations,
externalEntries,
runs
rows,
externalEntries
}: {
automations: readonly Automation[]
rows: readonly AutomationListRow[]
externalEntries: readonly ExternalAutomationListEntry[]
runs: readonly AutomationRun[]
}): AutomationListViewItem[] {
const lastRunByAutomationId = indexLatestAutomationRuns(runs)
const locals: AutomationListViewItem[] = automations.map((automation) => {
const lastRun = getLocalAutomationLastRunSnapshot(
automation,
lastRunByAutomationId.get(automation.id)
)
const locals: AutomationListViewItem[] = rows.map((row) => {
// Why: the same snapshot the row cell renders, so the sort matches the column.
const lastRun = getAutomationRowLastRunSnapshot(row)
return {
kind: 'local',
id: automation.id,
name: automation.name,
enabled: automation.enabled,
id: row.key,
name: row.automation.name,
enabled: row.automation.enabled,
lastRunAt: lastRun.at,
lastRun,
agentId: automation.agentId,
automation
agentId: row.automation.agentId,
row
}
})
const externals: AutomationListViewItem[] = externalEntries.map((entry) => {
@@ -217,34 +216,21 @@ export function filterExternalAutomationListEntries(
)
}
export function filterAutomationListViewItems(
items: readonly AutomationListViewItem[],
filter: AutomationListFilter
): AutomationListViewItem[] {
if (!isAutomationListFilterActive(filter)) {
return [...items]
}
return items.filter(
(item) =>
matchesStatusFilter(item.enabled, filter.status) &&
matchesLastRunFilter(item.lastRun, filter.lastRun) &&
(filter.agentIds.length === 0 ||
(item.agentId !== null && filter.agentIds.includes(item.agentId)))
)
}
/**
* `locale` is a parameter, not a `getIntlLocale()` read, so callers memoizing this
* can declare it — a hidden read is invisible to a dependency array.
*/
export function sortAutomationListViewItems(
items: readonly AutomationListViewItem[],
sort: AutomationListSort | null
sort: AutomationListSort | null,
locale: string
): AutomationListViewItem[] {
if (!sort || items.length < 2) {
return [...items]
}
const next = [...items]
const compareNames =
sort.field === 'name'
? new Intl.Collator(getIntlLocale(), { sensitivity: 'base' }).compare
: null
sort.field === 'name' ? new Intl.Collator(locale, { sensitivity: 'base' }).compare : null
next.sort((left, right) => {
const compared = compareNames
? compareNames(left.name, right.name)
@@ -257,24 +243,26 @@ export function sortAutomationListViewItems(
return next
}
/** The rendered list: filter each collection with its own rules, then sort as one. */
export function applyAutomationListView({
automations,
rows,
externalEntries,
runs,
filter,
sort
sort,
locale
}: {
automations: readonly Automation[]
rows: readonly AutomationListRow[]
externalEntries: readonly ExternalAutomationListEntry[]
runs: readonly AutomationRun[]
filter: AutomationListFilter
sort: AutomationListSort | null
locale: string
}): AutomationListViewItem[] {
return sortAutomationListViewItems(
filterAutomationListViewItems(
buildAutomationListViewItems({ automations, externalEntries, runs }),
filter
),
sort
buildAutomationListViewItems({
rows: filterAutomationListRows(rows, filter),
externalEntries: filterExternalAutomationListEntries(externalEntries, filter)
}),
sort,
locale
)
}
@@ -0,0 +1,32 @@
/**
* What the page actually listed, read back from the mocked list panel.
*
* Tests act through the same authority-qualified keys and render order the
* user's click carries, rather than synthesizing either.
*/
import type { AutomationListRow } from './automation-list-row-identity'
import type { ExternalAutomationListEntry } from './external-automation-list-entries'
import { mocks } from './automations-page-test-harness'
function listedItems() {
return mocks.listPanel?.sortedListItems ?? []
}
/** Local rows the page listed, in render order. */
export function listedRows(): readonly AutomationListRow[] {
return listedItems().flatMap((item) => (item.kind === 'local' ? [item.row] : []))
}
/** External entries the page listed, in render order. */
export function listedExternalEntries(): readonly ExternalAutomationListEntry[] {
return listedItems().flatMap((item) => (item.kind === 'external' ? [item.entry] : []))
}
export function listedRow(automationId: string): AutomationListRow {
const row = listedRows().find((entry) => entry.automation.id === automationId)
if (!row) {
throw new Error(`no listed row for ${automationId}`)
}
return row
}
@@ -27,6 +27,7 @@ import type { AutomationHostCatalogView } from './use-automation-host-catalog'
import type { AutomationCreateDestinationControl } from './use-automation-create-destination'
import type { ExternalAutomationListEntry } from './external-automation-list-entries'
import type { AutomationListRow } from './automation-list-row-identity'
import type { AutomationListViewItem } from './automation-list-view'
import { resetAutomationCapabilityProbes } from './automation-scoped-list-client'
import {
addRuntimeProject as addRuntimeProjectFixture,
@@ -39,7 +40,7 @@ export const RUNTIME_REPO_ID = RUNTIME_REPO_ID_FIXTURE
export const RUNTIME_WORKSPACE_ID = RUNTIME_WORKSPACE_ID_FIXTURE
export type ListPanelProps = {
filteredExternalAutomationEntries: ExternalAutomationListEntry[]
sortedListItems: readonly AutomationListViewItem[]
selectedExternal: ExternalAutomationListEntry | null
openEditExternalDialog: (
manager: ExternalAutomationListEntry['manager'],
@@ -55,7 +56,6 @@ export type ListPanelProps = {
) => void
hasListItems: boolean
hasFilteredListItems: boolean
filteredRows: readonly AutomationListRow[]
selectedRowKey: string | null
selectedExternalKey: string | null
hostCatalog: AutomationHostCatalogView
@@ -211,30 +211,31 @@ vi.mock('./AutomationsListPanel', () => ({
return (
<div data-testid="list-panel">
<button aria-label="Refresh automations" onClick={props.onRefresh} />
{props.filteredRows.map((row) => (
<button
type="button"
data-testid="automation-row"
key={row.key}
onClick={() => selectAutomationRow(row.key)}
>
{row.automation.name}
</button>
))}
{props.filteredExternalAutomationEntries.map((entry) => (
<button
type="button"
data-testid="external-row"
key={entry.key}
onClick={() => {
props.selectAutomationRow(null)
props.selectExternalKey(entry.key)
props.onOpenDetail()
}}
>
{entry.job.name}
</button>
))}
{props.sortedListItems.map((item) =>
item.kind === 'local' ? (
<button
type="button"
data-testid="automation-row"
key={item.id}
onClick={() => selectAutomationRow(item.id)}
>
{item.row.automation.name}
</button>
) : (
<button
type="button"
data-testid="external-row"
key={item.id}
onClick={() => {
props.selectAutomationRow(null)
props.selectExternalKey(item.id)
props.onOpenDetail()
}}
>
{item.entry.job.name}
</button>
)
)}
{props.hasListItems ? null : <div data-testid="empty-state" />}
</div>
)
@@ -407,18 +408,6 @@ export async function refreshOnFocus(): Promise<void> {
})
}
/**
* The row the page actually listed for an ID, so tests act through the same
* authority-qualified key the user's click carries rather than a synthesized one.
*/
export function listedRow(automationId: string): AutomationListRow {
const row = mocks.listPanel?.filteredRows.find((entry) => entry.automation.id === automationId)
if (!row) {
throw new Error(`no listed row for ${automationId}`)
}
return row
}
export function rows(container: HTMLElement, testId: string): string[] {
return [...container.querySelectorAll(`[data-testid="${testId}"]`)].map(
(node) => node.textContent ?? ''
@@ -4,9 +4,12 @@ import { buildExternalAutomationListEntries } from './external-automation-list-e
import { externalAutomationScopeEntries } from './external-automation-scope-gating'
import { externalAutomationUncheckedNotice } from './external-automation-unchecked-hosts'
import {
buildAutomationListViewItems,
filterAutomationListRows,
filterExternalAutomationListEntries
filterExternalAutomationListEntries,
sortAutomationListViewItems
} from './automation-list-view'
import { getIntlLocale } from '@/i18n/i18n'
import { unscopedAutomationListRows } from './automation-list-row-identity'
import { useAutomationHostCatalog } from './use-automation-host-catalog'
import { useAutomationListSearch } from './use-automation-list-search'
@@ -28,6 +31,7 @@ export function useAutomationsPageListState({
failedAuthorityKeys,
listSearchQuery,
listFilter,
listSort,
selectedRowKey,
selectedExternalKey,
selectedAutomationRuns,
@@ -129,6 +133,21 @@ export function useAutomationsPageListState({
() => externalAutomationUncheckedNotice(scopedExternal.failures, hostCatalog.entries),
[hostCatalog.entries, scopedExternal.failures]
)
// Why: a language switch changes collation without touching rows, so the locale
// has to reach the memo as a value.
const sortLocale = getIntlLocale()
const sortedListItems = useMemo(
() =>
sortAutomationListViewItems(
buildAutomationListViewItems({
rows: filteredRows,
externalEntries: filteredExternalAutomationEntries
}),
listSort,
sortLocale
),
[filteredExternalAutomationEntries, filteredRows, listSort, sortLocale]
)
return {
hostCatalog,
@@ -146,6 +165,7 @@ export function useAutomationsPageListState({
isListSearchQueryTooLarge,
filteredRows,
filteredExternalAutomationEntries,
sortedListItems,
hasListItems,
hasFilteredListItems,
searchCounts,
@@ -12,7 +12,11 @@ import type { AutomationActionNotice } from './automation-row-action-dispatch'
import type { AutomationHostCatalogEntry } from './automation-host-catalog-types'
import type { AutomationCreateDestination } from './automation-create-destination'
import type { AutomationListRow } from './automation-list-row-identity'
import { EMPTY_AUTOMATION_LIST_FILTER, type AutomationListFilter } from './automation-list-view'
import {
EMPTY_AUTOMATION_LIST_FILTER,
type AutomationListFilter,
type AutomationListSort
} from './automation-list-view'
import type {
AutomationPaneTab,
AutomationRunPageOrigin,
@@ -54,6 +58,7 @@ export function useAutomationsPageLocalState(store: AutomationsPageStoreState) {
const [isSaving, setIsSaving] = useState(false)
const [listSearchQuery, setListSearchQuery] = useState('')
const [listFilter, setListFilter] = useState<AutomationListFilter>(EMPTY_AUTOMATION_LIST_FILTER)
const [listSort, setListSort] = useState<AutomationListSort | null>(null)
const [createOpen, setCreateOpen] = useState(false)
const [createTarget, setCreateTarget] = useState<AutomationCreateTarget>('orca')
const [editingAutomationId, setEditingAutomationId] = useState<string | null>(null)
@@ -178,6 +183,8 @@ export function useAutomationsPageLocalState(store: AutomationsPageStoreState) {
setListSearchQuery,
listFilter,
setListFilter,
listSort,
setListSort,
createOpen,
setCreateOpen,
createTarget,
@@ -56,7 +56,7 @@ const INVENTORY: readonly InventoryGroup[] = [
'src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.tsx',
2
],
['src/renderer/src/components/automations/AutomationListLocalRows.tsx', 2],
['src/renderer/src/components/automations/AutomationListLocalRow.tsx', 2],
'src/renderer/src/components/automations/automation-draft-model.ts',
['src/renderer/src/components/automations/automation-list-search-rows.ts', 2],
['src/renderer/src/components/dashboard-popout/AgentMapSnapshotWorkspaceMenu.tsx', 2],