import { sendToAgentChat } from "@agent-native/core/client/agent-chat"; import { PromptComposer } from "@agent-native/core/client/composer"; import { useChangeVersions } from "@agent-native/core/client/hooks"; import { IconFileSearch, IconListDetails, IconPlus, IconSearch, IconSettingsAutomation, } from "@tabler/icons-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMemo, useState } from "react"; import { Link, useSearchParams } from "react-router"; import { toast } from "sonner"; import { AutomationDetailsPanel } from "../../components/automation-details-panel"; import { DispatchShell } from "../../components/dispatch-shell"; import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; import { Input } from "../../components/ui/input"; import { Popover, PopoverContent, PopoverTrigger, } from "../../components/ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "../../components/ui/select"; import { Skeleton } from "../../components/ui/skeleton"; import { Switch } from "../../components/ui/switch"; import { automationIdentity, automationLastCheck, automationLastRun, automationNextRun, automationScopeLabel, automationStatus, automationTarget, automationTroubleshootPath, belongsToDispatch, sortAutomations, type AutomationStatusTone, } from "../../lib/automation-display"; import { listDispatchAutomations, setDispatchAutomationEnabled, type DispatchAutomationItem, type SetDispatchAutomationEnabledInput, } from "../../lib/automations"; import { cn } from "../../lib/utils"; const AUTOMATIONS_QUERY_KEY = ["dispatch-automations"] as const; export function meta() { return [{ title: "Automations — Dispatch" }]; } function StatusDot({ tone }: { tone: AutomationStatusTone }) { return ( ); } function useAutomations() { const version = useChangeVersions(["action", "screen-refresh"]); return useQuery({ queryKey: [...AUTOMATIONS_QUERY_KEY, version], queryFn: listDispatchAutomations, placeholderData: (prev) => prev, staleTime: 5_000, }); } function useToggleAutomation() { const queryClient = useQueryClient(); return useMutation({ mutationFn: setDispatchAutomationEnabled, onMutate: async (input: SetDispatchAutomationEnabledInput) => { await queryClient.cancelQueries({ queryKey: AUTOMATIONS_QUERY_KEY }); const snapshots = queryClient.getQueriesData({ queryKey: AUTOMATIONS_QUERY_KEY, }); queryClient.setQueriesData( { queryKey: AUTOMATIONS_QUERY_KEY }, (rows) => rows?.map((item) => automationIdentity(item) === automationIdentity(input) ? { ...item, enabled: input.enabled } : item, ), ); return { snapshots }; }, onError: (err, _input, context) => { for (const [queryKey, data] of context?.snapshots ?? []) { queryClient.setQueryData(queryKey, data); } toast.error( `Could not update automation: ${ err instanceof Error ? err.message : String(err) }`, ); }, onSuccess: (updated) => { queryClient.setQueriesData( { queryKey: AUTOMATIONS_QUERY_KEY }, (rows) => rows?.map((item) => automationIdentity(item) === automationIdentity(updated) ? updated : item, ), ); }, onSettled: () => { void queryClient.invalidateQueries({ queryKey: AUTOMATIONS_QUERY_KEY }); }, }); } function CreateAutomationButton() { const [open, setOpen] = useState(false); const [scope, setScope] = useState<"personal" | "organization">("personal"); function handleSubmit(text: string) { const trimmed = text.trim(); if (!trimmed) return; window.dispatchEvent( new CustomEvent("agent-panel:set-mode", { detail: { mode: "chat" }, }), ); sendToAgentChat({ message: trimmed, context: `The user wants to create a new automation. Scope: ${scope}. Use manage-automations with action=define to create it. Ask clarifying questions if needed about what event to trigger on, conditions, and what actions to take.`, submit: true, newTab: true, }); setOpen(false); } return ( New automation New automation setScope(event.target.value as "personal" | "organization") } className="mt-2 w-full cursor-pointer rounded-md border border-input bg-background px-3 py-1.5 text-xs text-foreground" > Personal Organization ); } export default function AutomationsRoute() { const [searchParams, setSearchParams] = useSearchParams(); const [view, setView] = useState<"dispatch" | "all">("dispatch"); const [query, setQuery] = useState(""); const automationsQuery = useAutomations(); const toggleAutomation = useToggleAutomation(); const automations = automationsQuery.data ?? []; const visibleAutomations = useMemo( () => view === "all" ? automations : automations.filter((item) => belongsToDispatch(item)), [automations, view], ); const ordered = useMemo( () => sortAutomations(visibleAutomations), [visibleAutomations], ); const filtered = useMemo(() => { const normalized = query.trim().toLowerCase(); if (!normalized) return ordered; return ordered.filter((item) => [ item.name, item.event, item.schedule, item.scheduleDescription, item.body, item.model, item.domain, ] .filter(Boolean) .join(" ") .toLowerCase() .includes(normalized), ); }, [ordered, query]); const enabledCount = visibleAutomations.filter((item) => item.enabled).length; const errorCount = visibleAutomations.filter( (item) => item.enabled && (item.lastStatus === "error" || item.lastStatus === "skipped"), ).length; const pendingToggleIdentity = toggleAutomation.isPending ? toggleAutomation.variables ? automationIdentity(toggleAutomation.variables) : null : null; // URL-backed selection (mirrors dreams.tsx's `?dreamId=`): the currently // open automation lives in `automationId` so it survives reload, Back, and // sharing a link, instead of vanishing local state. const selectedAutomationId = searchParams.get("automationId"); const detailsTarget = selectedAutomationId ? (filtered.find( (item) => automationIdentity(item) === selectedAutomationId, ) ?? null) : null; function selectAutomation(item: DispatchAutomationItem) { // Push, don't replace: each row click is an explicit selection the user // should be able to Back out of one step at a time, not a URL // canonicalization that should collapse into the current entry. const next = new URLSearchParams(searchParams); next.set("automationId", automationIdentity(item)); setSearchParams(next); } return ( setQuery(event.target.value)} placeholder="Search automations" aria-label="Search automations" className="h-8 pl-8 text-xs" /> {enabledCount} enabled {errorCount > 0 ? ` · ${errorCount} errors` : ""} { if (value === "dispatch" || value === "all") setView(value); }} > Dispatch automations All apps {automationsQuery.isLoading && ordered.length === 0 ? ( Array.from({ length: 4 }).map((_, index) => ( )) ) : filtered.length > 0 ? ( filtered.map((item) => { const status = automationStatus(item); const canUpdate = item.canUpdate !== false; const isToggling = pendingToggleIdentity === automationIdentity(item); const isSelected = detailsTarget !== null && automationIdentity(detailsTarget) === automationIdentity(item); return ( selectAutomation(item)} > {item.name} {automationTarget(item)} Last {automationLastRun(item)} Next {automationNextRun(item)} {automationScopeLabel(item)} {item.lastCheck ? ( Checked {automationLastCheck(item)} ) : null} {item.lastError ? ( {item.lastError} Troubleshoot in Thread Debug ) : null} {status.label} toggleAutomation.mutate({ owner: item.owner, path: item.path, enabled: checked, }) } /> ); }) ) : ( {query ? "No matching automations." : view === "all" ? "No automations yet. Create one here, or ask Dispatch to set up a scheduled or event-triggered job." : "No Dispatch automations yet. Switch to All apps to inspect workspace automations."} )} {detailsTarget ? ( toggleAutomation.mutate({ owner: detailsTarget.owner, path: detailsTarget.path, enabled: !detailsTarget.enabled, }) } /> ) : ( Select an automation Inspect its prompt, configuration, capabilities, and run history. )} ); }
New automation
Select an automation
Inspect its prompt, configuration, capabilities, and run history.