import { startTransition, useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Link, useNavigate, useSearchParams } from "@/lib/router"; import { Check, ChevronDown, ChevronRight, Layers, MoreHorizontal, Plus, Repeat } from "lucide-react"; import { routinesApi } from "../api/routines"; import { agentsApi } from "../api/agents"; import { projectsApi } from "../api/projects"; import { issuesApi } from "../api/issues"; import { heartbeatsApi } from "../api/heartbeats"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useToastActions } from "../context/ToastContext"; import { queryKeys } from "../lib/queryKeys"; import { groupBy } from "../lib/groupBy"; import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb"; import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees"; import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects"; import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { EmptyState } from "../components/EmptyState"; import { IssuesList } from "../components/IssuesList"; import { PageSkeleton } from "../components/PageSkeleton"; import { PageTabBar } from "../components/PageTabBar"; import { AgentIcon } from "../components/AgentIconPicker"; import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector"; import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor"; import { RoutineRunVariablesDialog, 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 { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Tabs, TabsContent } from "@/components/ui/tabs"; 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"; } type RoutinesTab = "routines" | "runs"; type RoutineGroupBy = "none" | "project" | "assignee"; type RoutineViewState = { groupBy: RoutineGroupBy; collapsedGroups: string[]; }; type RoutineGroup = { key: string; label: string | null; items: RoutineListItem[]; }; const defaultRoutineViewState: RoutineViewState = { groupBy: "none", collapsedGroups: [], }; function getRoutineViewState(key: string): RoutineViewState { try { const raw = localStorage.getItem(key); if (raw) return { ...defaultRoutineViewState, ...JSON.parse(raw) }; } catch { // Ignore malformed local state and fall back to defaults. } return { ...defaultRoutineViewState }; } function saveRoutineViewState(key: string, state: RoutineViewState) { localStorage.setItem(key, JSON.stringify(state)); } function formatRoutineRunStatus(value: string | null | undefined) { if (!value) return null; return value.replaceAll("_", " "); } function buildRoutineMutationPayload(input: { title: string; description: string; projectId: string; assigneeAgentId: string; priority: string; concurrencyPolicy: string; catchUpPolicy: string; variables: RoutineVariable[]; }) { return { ...input, description: input.description.trim() || null, projectId: input.projectId || null, assigneeAgentId: input.assigneeAgentId || null, }; } export function buildRoutineGroups( routines: RoutineListItem[], groupByValue: RoutineGroupBy, projectById: Map, agentById: Map, ): RoutineGroup[] { if (groupByValue === "none") { return [{ key: "__all", label: null, items: routines }]; } if (groupByValue === "project") { const groups = groupBy(routines, (routine) => routine.projectId ?? "__no_project"); return Object.keys(groups) .sort((left, right) => { const leftLabel = left === "__no_project" ? "No project" : (projectById.get(left)?.name ?? "Unknown project"); const rightLabel = right === "__no_project" ? "No project" : (projectById.get(right)?.name ?? "Unknown project"); return leftLabel.localeCompare(rightLabel); }) .map((key) => ({ key, label: key === "__no_project" ? "No project" : (projectById.get(key)?.name ?? "Unknown project"), items: groups[key]!, })); } const groups = groupBy(routines, (routine) => routine.assigneeAgentId ?? "__unassigned"); return Object.keys(groups) .sort((left, right) => { const leftLabel = left === "__unassigned" ? "Unassigned" : (agentById.get(left)?.name ?? "Unknown agent"); const rightLabel = right === "__unassigned" ? "Unassigned" : (agentById.get(right)?.name ?? "Unknown agent"); return leftLabel.localeCompare(rightLabel); }) .map((key) => ({ key, label: key === "__unassigned" ? "Unassigned" : (agentById.get(key)?.name ?? "Unknown agent"), items: groups[key]!, })); } function buildRoutinesTabHref(tab: RoutinesTab) { return tab === "runs" ? "/routines?tab=runs" : "/routines"; } function RoutineListRow({ routine, projectById, agentById, runningRoutineId, statusMutationRoutineId, href, onRunNow, onToggleEnabled, onToggleArchived, }: { routine: RoutineListItem; projectById: Map; agentById: Map; runningRoutineId: string | null; statusMutationRoutineId: string | null; href: string; onRunNow: (routine: RoutineListItem) => void; onToggleEnabled: (routine: RoutineListItem, enabled: boolean) => void; onToggleArchived: (routine: RoutineListItem) => void; }) { const enabled = routine.status === "active"; const isArchived = routine.status === "archived"; const isStatusPending = statusMutationRoutineId === routine.id; const project = routine.projectId ? projectById.get(routine.projectId) ?? null : null; const agent = routine.assigneeAgentId ? agentById.get(routine.assigneeAgentId) ?? null : null; const isDraft = !isArchived && !routine.assigneeAgentId; return (
{routine.title} {(isArchived || routine.status === "paused" || isDraft) ? ( {isArchived ? "archived" : isDraft ? "draft" : "paused"} ) : null}
{routine.projectId ? (project?.name ?? "Unknown project") : "No project"} {agent?.icon ? : null} {routine.assigneeAgentId ? (agent?.name ?? "Unknown agent") : "No default agent"} {formatLastRunTimestamp(routine.lastRun?.triggeredAt)} {routine.lastRun ? ` ยท ${formatRoutineRunStatus(routine.lastRun.status)}` : ""}
{ event.preventDefault(); event.stopPropagation(); }}>
onToggleEnabled(routine, enabled)} disabled={isStatusPending || isArchived} aria-label={enabled ? `Disable ${routine.title}` : `Enable ${routine.title}`} /> {isArchived ? "Archived" : isDraft ? "Draft" : enabled ? "On" : "Off"}
Edit onRunNow(routine)} > {runningRoutineId === routine.id ? "Running..." : "Run now"} onToggleEnabled(routine, enabled)} disabled={isStatusPending || isArchived} > {enabled ? "Pause" : "Enable"} onToggleArchived(routine)} disabled={isStatusPending} > {routine.status === "archived" ? "Restore" : "Archive"}
); } export function Routines() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const { pushToast } = useToastActions(); 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 activeTab: RoutinesTab = searchParams.get("tab") === "runs" ? "runs" : "routines"; 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: [], }); const routineViewStateKey = selectedCompanyId ? `paperclip:routines-view:${selectedCompanyId}` : "paperclip:routines-view"; const [routineViewState, setRoutineViewState] = useState(() => getRoutineViewState(routineViewStateKey)); useEffect(() => { setBreadcrumbs([{ label: "Routines" }]); }, [setBreadcrumbs]); useEffect(() => { setRoutineViewState(getRoutineViewState(routineViewStateKey)); }, [routineViewStateKey]); 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: routineExecutionIssues, isLoading: recentRunsLoading, error: recentRunsError } = useQuery({ queryKey: [...queryKeys.issues.list(selectedCompanyId!), "routine-executions"], queryFn: () => issuesApi.list(selectedCompanyId!, { originKind: "routine_execution" }), enabled: !!selectedCompanyId && activeTab === "runs", }); const { data: liveRuns } = useQuery({ queryKey: queryKeys.liveRuns(selectedCompanyId!), queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!), enabled: !!selectedCompanyId && activeTab === "runs", refetchInterval: 5000, }); useEffect(() => { autoResizeTextarea(titleInputRef.current); }, [draft.title, composerOpen]); const createRoutine = useMutation({ mutationFn: () => routinesApi.create(selectedCompanyId!, buildRoutineMutationPayload(draft)), 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: routine.assigneeAgentId ? "Add the first trigger to turn it into a live workflow." : "Draft saved. Add a default agent before enabling automation.", tone: "success", }); navigate(`/routines/${routine.id}?tab=triggers`); }, }); const updateIssue = useMutation({ mutationFn: ({ id, data }: { id: string; data: Record }) => issuesApi.update(id, data), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: [...queryKeys.issues.list(selectedCompanyId!), "routine-executions"] }); }, }); 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?.assigneeAgentId !== undefined ? { assigneeAgentId: data.assigneeAgentId } : {}), ...(data?.projectId !== undefined ? { projectId: data.projectId } : {}), ...(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 recentProjectIds = useMemo(() => getRecentProjectIds(), [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 liveIssueIds = useMemo(() => { const ids = new Set(); for (const run of liveRuns ?? []) { if (run.issueId) ids.add(run.issueId); } return ids; }, [liveRuns]); const routineGroups = useMemo( () => buildRoutineGroups(routines ?? [], routineViewState.groupBy, projectById, agentById), [agentById, projectById, routineViewState.groupBy, routines], ); const recentRunsIssueLinkState = useMemo( () => createIssueDetailLocationState( "Recent Runs", buildRoutinesTabHref("runs"), "issues", ), [], ); const currentAssignee = draft.assigneeAgentId ? agentById.get(draft.assigneeAgentId) ?? null : null; const currentProject = draft.projectId ? projectById.get(draft.projectId) ?? null : null; function updateRoutineView(patch: Partial) { setRoutineViewState((current) => { const next = { ...current, ...patch }; saveRoutineViewState(routineViewStateKey, next); return next; }); } function handleTabChange(tab: string) { const nextTab = tab === "runs" ? "runs" : "routines"; startTransition(() => { navigate(buildRoutinesTabHref(nextTab)); }); } function handleRunNow(routine: RoutineListItem) { setRunDialogRoutine(routine); } function handleToggleEnabled(routine: RoutineListItem, enabled: boolean) { if (!enabled && !routine.assigneeAgentId) { pushToast({ title: "Default agent required", body: "Set a default agent before enabling routine automation.", tone: "warn", }); return; } updateRoutineStatus.mutate({ id: routine.id, status: nextRoutineStatus(routine.status, !enabled), }); } function handleToggleArchived(routine: RoutineListItem) { updateRoutineStatus.mutate({ id: routine.id, status: routine.status === "archived" ? "active" : "archived", }); } if (!selectedCompanyId) { return ; } if (isLoading) { return ; } return (

Routines

Recurring work definitions that materialize into auditable execution issues.

{(routines ?? []).length} routine{(routines ?? []).length === 1 ? "" : "s"}

{([ ["project", "Project"], ["assignee", "Agent"], ["none", "None"], ] as const).map(([value, label]) => ( ))}
updateIssue.mutate({ id, data })} />
{ if (!createRoutine.isPending) { setComposerOpen(open); } }} >

New routine

Define the recurring work first. Default project and agent are optional for draft routines.