import { useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Link, useNavigate } from "@/lib/router"; import { ChevronDown, ChevronRight, Clock3, Play, Plus, Repeat, Webhook } from "lucide-react"; import { routinesApi } from "../api/routines"; import { agentsApi } from "../api/agents"; import { projectsApi } from "../api/projects"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useToast } from "../context/ToastContext"; import { queryKeys } from "../lib/queryKeys"; import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees"; import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; import { AgentIcon } from "../components/AgentIconPicker"; import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector"; import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Badge } from "@/components/ui/badge"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { timeAgo } from "../lib/timeAgo"; const priorities = ["critical", "high", "medium", "low"]; const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"]; const catchUpPolicies = ["skip_missed", "enqueue_missed_with_cap"]; const concurrencyPolicyDescriptions: Record = { coalesce_if_active: "If a run is already active, keep just one follow-up run queued.", always_enqueue: "Queue every trigger occurrence, even if the routine is already running.", skip_if_active: "Drop new trigger occurrences while a run is still active.", }; const catchUpPolicyDescriptions: Record = { skip_missed: "Ignore windows that were missed while the scheduler or routine was paused.", enqueue_missed_with_cap: "Catch up missed schedule windows with a capped backlog after recovery.", }; function triggerIcon(kind: string) { if (kind === "schedule") return ; if (kind === "webhook") return ; return ; } function autoResizeTextarea(element: HTMLTextAreaElement | null) { if (!element) return; element.style.height = "auto"; element.style.height = `${element.scrollHeight}px`; } export function Routines() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); const navigate = useNavigate(); const { pushToast } = useToast(); const descriptionEditorRef = useRef(null); const titleInputRef = useRef(null); const assigneeSelectorRef = useRef(null); const projectSelectorRef = useRef(null); const [runningRoutineId, setRunningRoutineId] = useState(null); const [composerOpen, setComposerOpen] = useState(false); const [advancedOpen, setAdvancedOpen] = useState(false); const [draft, setDraft] = useState({ title: "", description: "", projectId: "", assigneeAgentId: "", priority: "medium", concurrencyPolicy: "coalesce_if_active", catchUpPolicy: "skip_missed", }); useEffect(() => { setBreadcrumbs([{ label: "Routines" }]); }, [setBreadcrumbs]); const { data: routines, isLoading, error } = useQuery({ queryKey: queryKeys.routines.list(selectedCompanyId!), queryFn: () => routinesApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); const { data: agents } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), queryFn: () => agentsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); const { data: projects } = useQuery({ queryKey: queryKeys.projects.list(selectedCompanyId!), queryFn: () => projectsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); useEffect(() => { if (!isLoading && (routines?.length ?? 0) === 0) { setComposerOpen(true); } }, [isLoading, routines]); useEffect(() => { autoResizeTextarea(titleInputRef.current); }, [draft.title, composerOpen]); const createRoutine = useMutation({ mutationFn: () => routinesApi.create(selectedCompanyId!, { ...draft, description: draft.description.trim() || null, }), onSuccess: async (routine) => { setDraft({ title: "", description: "", projectId: "", assigneeAgentId: "", priority: "medium", concurrencyPolicy: "coalesce_if_active", catchUpPolicy: "skip_missed", }); setComposerOpen(false); setAdvancedOpen(false); await queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }); pushToast({ title: "Routine created", body: "Add the first trigger to turn it into a live workflow.", tone: "success", }); navigate(`/routines/${routine.id}?tab=triggers`); }, }); const runRoutine = useMutation({ mutationFn: (id: string) => routinesApi.run(id), onMutate: (id) => { setRunningRoutineId(id); }, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }); }, onSettled: () => { setRunningRoutineId(null); }, onError: (mutationError) => { pushToast({ title: "Routine run failed", body: mutationError instanceof Error ? mutationError.message : "Paperclip could not start the routine run.", tone: "error", }); }, }); const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [composerOpen]); const assigneeOptions = useMemo( () => sortAgentsByRecency( (agents ?? []).filter((agent) => agent.status !== "terminated"), recentAssigneeIds, ).map((agent) => ({ id: agent.id, label: agent.name, searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`, })), [agents, recentAssigneeIds], ); const projectOptions = useMemo( () => (projects ?? []).map((project) => ({ id: project.id, label: project.name, searchText: project.description ?? "", })), [projects], ); const agentName = useMemo( () => new Map((agents ?? []).map((agent) => [agent.id, agent.name])), [agents], ); const agentById = useMemo( () => new Map((agents ?? []).map((agent) => [agent.id, agent])), [agents], ); const projectName = useMemo( () => new Map((projects ?? []).map((project) => [project.id, project.name])), [projects], ); const projectById = useMemo( () => new Map((projects ?? []).map((project) => [project.id, project])), [projects], ); const currentAssignee = draft.assigneeAgentId ? agentById.get(draft.assigneeAgentId) ?? null : null; const currentProject = draft.projectId ? projectById.get(draft.projectId) ?? null : null; if (!selectedCompanyId) { return ; } if (isLoading) { return ; } return (

Routines

Define recurring work once, then let Paperclip materialize each execution as an auditable issue.

{composerOpen ? (

New routine

Define the recurring work first. Trigger setup comes next on the detail page.