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 { instanceSettingsApi } from "../api/instanceSettings"; 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 { RoutineRunVariablesDialog, routineRunNeedsConfiguration, type RoutineRunDialogSubmitData, } from "../components/RoutineRunVariablesDialog"; import { RoutineVariablesEditor, RoutineVariablesHint } from "../components/RoutineVariablesEditor"; 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"; import type { RoutineListItem, RoutineVariable } from "@paperclipai/shared"; 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 [runDialogRoutine, setRunDialogRoutine] = useState(null); const [composerOpen, setComposerOpen] = useState(false); const [advancedOpen, setAdvancedOpen] = useState(false); const [draft, setDraft] = useState<{ title: string; description: string; projectId: string; assigneeAgentId: string; priority: string; concurrencyPolicy: string; catchUpPolicy: string; variables: RoutineVariable[]; }>({ title: "", description: "", projectId: "", assigneeAgentId: "", priority: "medium", concurrencyPolicy: "coalesce_if_active", catchUpPolicy: "skip_missed", variables: [], }); 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, }); const { data: experimentalSettings } = useQuery({ queryKey: queryKeys.instance.experimentalSettings, queryFn: () => instanceSettingsApi.getExperimental(), retry: false, }); 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", variables: [], }); 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, data }: { id: string; data?: RoutineRunDialogSubmitData }) => routinesApi.run(id, { ...(data?.variables && Object.keys(data.variables).length > 0 ? { variables: data.variables } : {}), ...(data?.executionWorkspaceId !== undefined ? { executionWorkspaceId: data.executionWorkspaceId } : {}), ...(data?.executionWorkspacePreference !== undefined ? { executionWorkspacePreference: data.executionWorkspacePreference } : {}), ...(data?.executionWorkspaceSettings !== undefined ? { executionWorkspaceSettings: data.executionWorkspaceSettings } : {}), }), onMutate: ({ id }) => { setRunningRoutineId(id); }, onSuccess: async (_, { id }) => { setRunDialogRoutine(null); 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 runDialogProject = runDialogRoutine?.projectId ? projectById.get(runDialogRoutine.projectId) ?? null : null; const currentAssignee = draft.assigneeAgentId ? agentById.get(draft.assigneeAgentId) ?? null : null; const currentProject = draft.projectId ? projectById.get(draft.projectId) ?? null : null; function handleRunNow(routine: RoutineListItem) { const project = routine.projectId ? projectById.get(routine.projectId) ?? null : null; const needsConfiguration = routineRunNeedsConfiguration({ variables: routine.variables ?? [], project, isolatedWorkspacesEnabled: experimentalSettings?.enableIsolatedWorkspaces === true, }); if (needsConfiguration) { setRunDialogRoutine(routine); return; } runRoutine.mutate({ id: routine.id, data: {} }); } 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.