import { useEffect, useMemo, useState } from "react"; import type { FormEvent, ReactNode } from "react"; import { ArrowUpDown, Check, ChevronDown, ChevronRight, CircleAlert, Clipboard, ExternalLink, Loader2, PauseCircle, Pencil, PlayCircle, Search, Trash2, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { channelUiPresentation } from "@/channel-plugins/registry"; import { SETTINGS_SEARCH_INPUT_CLASS } from "@/components/settings/shared/SettingsControls"; import { AppsActionButton } from "@/components/settings/system/AppsSettings"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { formControlFocusClassName } from "@/components/ui/form-control"; import { Input } from "@/components/ui/input"; import { SegmentedControl } from "@/components/ui/segmented-control"; import { Textarea } from "@/components/ui/textarea"; import { copyTextToClipboard } from "@/lib/clipboard"; import { fmtDateTime, relativeTime } from "@/lib/format"; import type { AutomationsPayload, AutomationUpdatePayload, SessionAutomationJob } from "@/lib/types"; import { cn } from "@/lib/utils"; export type AutomationFilter = "all" | "active" | "paused" | "failed" | "system"; export type AutomationSort = "next" | "last" | "updated" | "name"; export type AutomationAction = "enable" | "disable" | "delete" | "run"; export function AutomationsSettings({ payload, loading, query, filter, sort, actionKey, error, onQueryChange, onFilterChange, onSortChange, onAction, onRequestEdit, onRequestDelete, onBackToChat, }: { payload: AutomationsPayload | null; loading: boolean; query: string; filter: AutomationFilter; sort: AutomationSort; actionKey: string | null; error: string | null; onQueryChange: (value: string) => void; onFilterChange: (value: AutomationFilter) => void; onSortChange: (value: AutomationSort) => void; onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise; onRequestEdit: (job: SessionAutomationJob) => void; onRequestDelete: (job: SessionAutomationJob) => void; onBackToChat: () => void; }) { const { t, i18n } = useTranslation(); const tx = (key: string, fallback: string, values?: Record) => t(key, { defaultValue: fallback, ...(values ?? {}) }); const jobs = payload?.jobs ?? []; const locale = i18n.resolvedLanguage || i18n.language; const [selectedJobId, setSelectedJobId] = useState(null); const filtered = useMemo(() => { const searchTokens = parseAutomationSearchQuery(query); return sortAutomationJobs(jobs, sort) .filter((job) => automationMatchesFilter(job, filter)) .filter((job) => !searchTokens.length || automationMatchesSearch(job, searchTokens)); }, [filter, jobs, query, sort]); const activeCount = jobs.filter((job) => { const key = automationStatusKey(job); return key === "active" || key === "running"; }).length; const pausedCount = jobs.filter((job) => automationStatusKey(job) === "paused").length; const failedCount = jobs.filter(automationNeedsAttention).length; const systemCount = jobs.filter((job) => job.protected).length; const summaryOptions: Array<{ value: AutomationFilter; label: string; count: number }> = [ { value: "all", label: tx("settings.automations.filters.all", "All"), count: jobs.length }, { value: "active", label: tx("settings.automations.filters.active", "Active"), count: activeCount }, { value: "paused", label: tx("settings.automations.filters.paused", "Paused"), count: pausedCount }, { value: "failed", label: tx("settings.automations.filters.failed", "Needs attention"), count: failedCount }, { value: "system", label: tx("settings.automations.filters.system", "System"), count: systemCount }, ]; const sortLabel = { next: tx("settings.automations.sort.next", "Next run"), last: tx("settings.automations.sort.last", "Last run"), updated: tx("settings.automations.sort.updated", "Updated"), name: tx("settings.automations.sort.name", "Name"), } satisfies Record; const selectedJob = filtered.find((job) => job.id === selectedJobId) ?? filtered[0] ?? null; useEffect(() => { if (!filtered.length) { if (selectedJobId !== null) setSelectedJobId(null); return; } if (!selectedJobId || !filtered.some((job) => job.id === selectedJobId)) { setSelectedJobId(filtered[0].id); } }, [filtered, selectedJobId]); return (
{jobs.length ? (
{summaryOptions.map((option) => ( ))}
onQueryChange(event.target.value)} placeholder={tx( "settings.automations.search", "Search task, message, linked chat, or schedule", )} className={cn( "h-9 w-full rounded-control pl-9 text-[13px]", SETTINGS_SEARCH_INPUT_CLASS, )} />
{(Object.keys(sortLabel) as AutomationSort[]).map((value) => ( onSortChange(value)}> {sortLabel[value]} {sort === value ? : null} ))}
) : null} {error ? (
{error}
) : null} {loading && !payload ? (
{tx("settings.automations.loading", "Loading automations...")}
) : filtered.length && selectedJob ? (
) : (
{jobs.length ? tx("settings.automations.noMatches", "No automations match this view.") : tx("settings.automations.empty", "No automations yet.")}
{!jobs.length ? ( <>
{tx( "settings.automations.emptyHint", "Create automations in a chat so they keep the right context.", )}
) : ( )}
)}
); } function AutomationListItem({ job, locale, selected, onSelect, }: { job: SessionAutomationJob; locale: string; selected: boolean; onSelect: () => void; }) { const { t } = useTranslation(); const tx = (key: string, fallback: string, values?: Record) => t(key, { defaultValue: fallback, ...(values ?? {}) }); const status = automationStatus(job, tx); const origin = automationOriginLabel(job, tx); const nextRun = formatAutomationNext(job, tx); const summary = automationSummary(job, tx); return (
); } function AutomationDetailPanel({ job, locale, actionKey, onAction, onRequestEdit, onRequestDelete, }: { job: SessionAutomationJob; locale: string; actionKey: string | null; onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise; onRequestEdit: (job: SessionAutomationJob) => void; onRequestDelete: (job: SessionAutomationJob) => void; }) { const { t } = useTranslation(); const tx = (key: string, fallback: string, values?: Record) => t(key, { defaultValue: fallback, ...(values ?? {}) }); const status = automationStatus(job, tx); const origin = automationOriginLabel(job, tx); const originHref = job.origin?.channel === "websocket" && job.origin.session_key ? `#/chat/${encodeURIComponent(job.origin.session_key)}` : null; const created = job.created_at_ms ? fmtDateTime(job.created_at_ms, locale) : null; const updated = job.updated_at_ms ? fmtDateTime(job.updated_at_ms, locale) : null; const localTrigger = isLocalTriggerAutomation(job); const triggerCommand = automationTriggerCommand(job); const message = automationDetailText(job, tx); const messageLabel = localTrigger ? tx("settings.automations.fields.command", "Command") : tx("settings.automations.fields.message", "Message"); const schedule = formatAutomationSchedule(job, locale, tx); const [messageExpanded, setMessageExpanded] = useState(false); const [commandCopied, setCommandCopied] = useState(false); const messageNeedsExpansion = automationMessageNeedsExpansion(message); useEffect(() => { setMessageExpanded(false); setCommandCopied(false); }, [job.id]); return (

{job.name || job.id}

{status.label} {job.delete_after_run ? ( {tx("settings.automations.oneShot", "One-time")} ) : null}

{schedule} · {origin}

{messageLabel}
{localTrigger && triggerCommand ? ( ) : null}
{message}
{messageNeedsExpansion ? ( ) : null}
{formatAutomationNext(job, tx)} {originHref ? ( {origin} ) : ( origin )}
{job.state.last_error ? (
{job.state.last_error}
) : null}
); } function AutomationActionGroup({ job, actionKey, onAction, onRequestEdit, onRequestDelete, }: { job: SessionAutomationJob; actionKey: string | null; onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise; onRequestEdit: (job: SessionAutomationJob) => void; onRequestDelete: (job: SessionAutomationJob) => void; }) { const { t } = useTranslation(); const tx = (key: string, fallback: string, values?: Record) => t(key, { defaultValue: fallback, ...(values ?? {}) }); const canManage = !job.protected; const hasLinkedChat = Boolean(job.origin); const localTrigger = isLocalTriggerAutomation(job); const canRun = canManage && hasLinkedChat && job.enabled && !job.state.pending && !localTrigger; const toggleAction: AutomationAction = job.enabled ? "disable" : "enable"; const canToggle = canManage && (job.enabled || hasLinkedChat); const toggleBusy = actionKey === `${toggleAction}:${job.id}`; if (!canManage) { return ( {tx("settings.automations.protected", "Protected")} ); } return (
onRequestEdit(job)} > {!localTrigger ? ( void onAction("run", job)} > ) : null} void onAction(toggleAction, job)} > {job.enabled ? ( ) : ( )} onRequestDelete(job)} >
); } function AutomationStatusBadge({ tone = "neutral", children, }: { tone?: "neutral" | "success" | "warning"; children: ReactNode; }) { return ( {children} ); } function automationMessageNeedsExpansion(message: string): boolean { return message.length > 360 || message.split(/\r?\n/).length > 6; } function AutomationDetail({ label, title, secondary, children, }: { label: string; title?: string; secondary?: ReactNode; children: ReactNode; }) { return (
{label}
{children}
{secondary ? (
{secondary}
) : null}
); } type AutomationEveryUnit = "second" | "minute" | "hour" | "day"; type AutomationEditDraft = { name: string; message: string; scheduleKind: "at" | "every" | "cron"; everyValue: string; everyUnit: AutomationEveryUnit; cronExpr: string; tz: string; atLocal: string; }; type AutomationScheduleUpdate = NonNullable; const AUTOMATION_EVERY_UNITS: Array<{ value: AutomationEveryUnit; ms: number }> = [ { value: "second", ms: 1000 }, { value: "minute", ms: 60_000 }, { value: "hour", ms: 3_600_000 }, { value: "day", ms: 86_400_000 }, ]; export function AutomationEditDialog({ job, saving, onOpenChange, onSave, }: { job: SessionAutomationJob | null; saving: boolean; onOpenChange: (open: boolean) => void; onSave: (job: SessionAutomationJob, values: AutomationUpdatePayload) => void | Promise; }) { const { t } = useTranslation(); const tx = (key: string, fallback: string, values?: Record) => t(key, { defaultValue: fallback, ...(values ?? {}) }); const [draft, setDraft] = useState(() => automationDraftFromJob(null)); const localTrigger = isLocalTriggerAutomation(job); useEffect(() => { setDraft(automationDraftFromJob(job)); }, [job]); const validation = automationEditDraftError(draft, job, tx); const scheduleOptions = [ { value: "every", label: tx("settings.automations.scheduleTypes.every", "Interval") }, { value: "cron", label: tx("settings.automations.scheduleTypes.cron", "Cron") }, { value: "at", label: tx("settings.automations.scheduleTypes.at", "Once") }, ]; const unitLabels: Record = { second: tx("settings.automations.everyUnits.second", "Seconds"), minute: tx("settings.automations.everyUnits.minute", "Minutes"), hour: tx("settings.automations.everyUnits.hour", "Hours"), day: tx("settings.automations.everyUnits.day", "Days"), }; const submit = (event: FormEvent) => { event.preventDefault(); const payload = automationUpdatePayloadFromDraft(draft, job); if (!job || typeof payload === "string") return; void onSave(job, payload); }; return ( {job ? (
{tx("settings.automations.editTitle", "Edit automation")}
{!localTrigger ? (