import { useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@/lib/router"; import { ChevronDown, ChevronRight, MoreHorizontal, Play, Plus, Repeat } 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 { Dialog, DialogContent } from "@/components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; 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 in capped batches after recovery.", }; function autoResizeTextarea(element: HTMLTextAreaElement | null) { if (!element) return; element.style.height = "auto"; element.style.height = `${element.scrollHeight}px`; } function formatLastRunTimestamp(value: Date | string | null | undefined) { if (!value) return "Never"; return new Date(value).toLocaleString(); } function nextRoutineStatus(currentStatus: string, enabled: boolean) { if (currentStatus === "archived" && enabled) return "active"; return enabled ? "active" : "paused"; } 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 [statusMutationRoutineId, setStatusMutationRoutineId] = 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(() => { 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 updateRoutineStatus = useMutation({ mutationFn: ({ id, status }: { id: string; status: string }) => routinesApi.update(id, { status }), onMutate: ({ id }) => { setStatusMutationRoutineId(id); }, onSuccess: async (_, variables) => { await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }), queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(variables.id) }), ]); }, onSettled: () => { setStatusMutationRoutineId(null); }, onError: (mutationError) => { pushToast({ title: "Failed to update routine", body: mutationError instanceof Error ? mutationError.message : "Paperclip could not update the routine.", tone: "error", }); }, }); const runRoutine = useMutation({ mutationFn: (id: string) => routinesApi.run(id), onMutate: (id) => { setRunningRoutineId(id); }, onSuccess: async (_, id) => { await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }), queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(id) }), ]); }, 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 agentById = useMemo( () => new Map((agents ?? []).map((agent) => [agent.id, agent])), [agents], ); 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 Beta

Recurring work definitions that materialize into auditable execution issues.

{ if (!createRoutine.isPending) { setComposerOpen(open); } }} >

New routine

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