import { useEffect, useMemo, useRef, useState } from "react"; import { Link, useLocation, useNavigate, useParams } from "@/lib/router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Activity as ActivityIcon, ChevronDown, ChevronRight, Clock3, Copy, Play, RefreshCw, Repeat, Save, Trash2, Webhook, Zap, } from "lucide-react"; import { routinesApi, type RoutineTriggerResponse, type RotateRoutineTriggerResponse } from "../api/routines"; import { heartbeatsApi } from "../api/heartbeats"; import { LiveRunWidget } from "../components/LiveRunWidget"; 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 { buildRoutineTriggerPatch } from "../lib/routine-trigger-patch"; import { timeAgo } from "../lib/timeAgo"; 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 { ScheduleEditor, describeSchedule } from "../components/ScheduleEditor"; import { RunButton } from "../components/AgentActionButtons"; import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees"; import { Button } from "@/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Separator } from "@/components/ui/separator"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Badge } from "@/components/ui/badge"; import type { RoutineTrigger } from "@paperclipai/shared"; const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"]; const catchUpPolicies = ["skip_missed", "enqueue_missed_with_cap"]; const triggerKinds = ["schedule", "webhook"]; const signingModes = ["bearer", "hmac_sha256"]; const routineTabs = ["triggers", "runs", "activity"] as const; const concurrencyPolicyDescriptions: Record = { coalesce_if_active: "Keep one follow-up run queued while an active run is still working.", always_enqueue: "Queue every trigger occurrence, even if several runs stack up.", skip_if_active: "Drop overlapping trigger occurrences while the routine is already active.", }; const catchUpPolicyDescriptions: Record = { skip_missed: "Ignore schedule windows that were missed while the routine or scheduler was paused.", enqueue_missed_with_cap: "Catch up missed schedule windows in capped batches after recovery.", }; const signingModeDescriptions: Record = { bearer: "Expect a shared bearer token in the Authorization header.", hmac_sha256: "Expect an HMAC SHA-256 signature over the request using the shared secret.", }; type RoutineTab = (typeof routineTabs)[number]; type SecretMessage = { title: string; webhookUrl: string; webhookSecret: string; }; function autoResizeTextarea(element: HTMLTextAreaElement | null) { if (!element) return; element.style.height = "auto"; element.style.height = `${element.scrollHeight}px`; } function isRoutineTab(value: string | null): value is RoutineTab { return value !== null && routineTabs.includes(value as RoutineTab); } function getRoutineTabFromSearch(search: string): RoutineTab { const tab = new URLSearchParams(search).get("tab"); return isRoutineTab(tab) ? tab : "triggers"; } function formatActivityDetailValue(value: unknown): string { if (value === null) return "null"; if (typeof value === "string") return value; if (typeof value === "number" || typeof value === "boolean") return String(value); if (Array.isArray(value)) return value.length === 0 ? "[]" : value.map((item) => formatActivityDetailValue(item)).join(", "); try { return JSON.stringify(value); } catch { return "[unserializable]"; } } function getLocalTimezone(): string { try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { return "UTC"; } } function TriggerEditor({ trigger, onSave, onRotate, onDelete, }: { trigger: RoutineTrigger; onSave: (id: string, patch: Record) => void; onRotate: (id: string) => void; onDelete: (id: string) => void; }) { const [draft, setDraft] = useState({ label: trigger.label ?? "", cronExpression: trigger.cronExpression ?? "", signingMode: trigger.signingMode ?? "bearer", replayWindowSec: String(trigger.replayWindowSec ?? 300), }); useEffect(() => { setDraft({ label: trigger.label ?? "", cronExpression: trigger.cronExpression ?? "", signingMode: trigger.signingMode ?? "bearer", replayWindowSec: String(trigger.replayWindowSec ?? 300), }); }, [trigger]); return (
{trigger.kind === "schedule" ? : trigger.kind === "webhook" ? : } {trigger.label ?? trigger.kind}
{trigger.kind === "schedule" && trigger.nextRunAt ? `Next: ${new Date(trigger.nextRunAt).toLocaleString()}` : trigger.kind === "webhook" ? "Webhook" : "API"}
setDraft((current) => ({ ...current, label: event.target.value }))} />
{trigger.kind === "schedule" && (
setDraft((current) => ({ ...current, cronExpression }))} />
)} {trigger.kind === "webhook" && ( <>
setDraft((current) => ({ ...current, replayWindowSec: event.target.value }))} />
)}
{trigger.lastResult && Last: {trigger.lastResult}}
{trigger.kind === "webhook" && ( )}
); } export function RoutineDetail() { const { routineId } = useParams<{ routineId: string }>(); const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); const navigate = useNavigate(); const location = useLocation(); const { pushToast } = useToast(); const hydratedRoutineIdRef = useRef(null); const titleInputRef = useRef(null); const descriptionEditorRef = useRef(null); const assigneeSelectorRef = useRef(null); const projectSelectorRef = useRef(null); const [secretMessage, setSecretMessage] = useState(null); const [advancedOpen, setAdvancedOpen] = useState(false); const [newTrigger, setNewTrigger] = useState({ kind: "schedule", cronExpression: "0 10 * * *", signingMode: "bearer", replayWindowSec: "300", }); const [editDraft, setEditDraft] = useState({ title: "", description: "", projectId: "", assigneeAgentId: "", priority: "medium", concurrencyPolicy: "coalesce_if_active", catchUpPolicy: "skip_missed", }); const activeTab = useMemo(() => getRoutineTabFromSearch(location.search), [location.search]); const { data: routine, isLoading, error } = useQuery({ queryKey: queryKeys.routines.detail(routineId!), queryFn: () => routinesApi.get(routineId!), enabled: !!routineId, }); const activeIssueId = routine?.activeIssue?.id; const { data: liveRuns } = useQuery({ queryKey: queryKeys.issues.liveRuns(activeIssueId!), queryFn: () => heartbeatsApi.liveRunsForIssue(activeIssueId!), enabled: !!activeIssueId, refetchInterval: 3000, }); const hasLiveRun = (liveRuns ?? []).length > 0; const { data: routineRuns } = useQuery({ queryKey: queryKeys.routines.runs(routineId!), queryFn: () => routinesApi.listRuns(routineId!), enabled: !!routineId, refetchInterval: hasLiveRun ? 3000 : false, }); const relatedActivityIds = useMemo( () => ({ triggerIds: routine?.triggers.map((trigger) => trigger.id) ?? [], runIds: routineRuns?.map((run) => run.id) ?? [], }), [routine?.triggers, routineRuns], ); const { data: activity } = useQuery({ queryKey: [ ...queryKeys.routines.activity(selectedCompanyId!, routineId!), relatedActivityIds.triggerIds.join(","), relatedActivityIds.runIds.join(","), ], queryFn: () => routinesApi.activity(selectedCompanyId!, routineId!, relatedActivityIds), enabled: !!selectedCompanyId && !!routineId && !!routine, }); 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 routineDefaults = useMemo( () => routine ? { title: routine.title, description: routine.description ?? "", projectId: routine.projectId, assigneeAgentId: routine.assigneeAgentId, priority: routine.priority, concurrencyPolicy: routine.concurrencyPolicy, catchUpPolicy: routine.catchUpPolicy, } : null, [routine], ); const isEditDirty = useMemo(() => { if (!routineDefaults) return false; return ( editDraft.title !== routineDefaults.title || editDraft.description !== routineDefaults.description || editDraft.projectId !== routineDefaults.projectId || editDraft.assigneeAgentId !== routineDefaults.assigneeAgentId || editDraft.priority !== routineDefaults.priority || editDraft.concurrencyPolicy !== routineDefaults.concurrencyPolicy || editDraft.catchUpPolicy !== routineDefaults.catchUpPolicy ); }, [editDraft, routineDefaults]); useEffect(() => { if (!routine) return; setBreadcrumbs([{ label: "Routines", href: "/routines" }, { label: routine.title }]); if (!routineDefaults) return; const changedRoutine = hydratedRoutineIdRef.current !== routine.id; if (changedRoutine || !isEditDirty) { setEditDraft(routineDefaults); hydratedRoutineIdRef.current = routine.id; } }, [routine, routineDefaults, isEditDirty, setBreadcrumbs]); useEffect(() => { autoResizeTextarea(titleInputRef.current); }, [editDraft.title, routine?.id]); const copySecretValue = async (label: string, value: string) => { try { await navigator.clipboard.writeText(value); pushToast({ title: `${label} copied`, tone: "success" }); } catch (error) { pushToast({ title: `Failed to copy ${label.toLowerCase()}`, body: error instanceof Error ? error.message : "Clipboard access was denied.", tone: "error", }); } }; const setActiveTab = (value: string) => { if (!routineId || !isRoutineTab(value)) return; const params = new URLSearchParams(location.search); if (value === "triggers") { params.delete("tab"); } else { params.set("tab", value); } const search = params.toString(); navigate( { pathname: location.pathname, search: search ? `?${search}` : "", }, { replace: true }, ); }; const saveRoutine = useMutation({ mutationFn: () => { return routinesApi.update(routineId!, { ...editDraft, description: editDraft.description.trim() || null, }); }, onSuccess: async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }), queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }), queryClient.invalidateQueries({ queryKey: queryKeys.routines.activity(selectedCompanyId!, routineId!) }), ]); }, onError: (error) => { pushToast({ title: "Failed to save routine", body: error instanceof Error ? error.message : "Paperclip could not save the routine.", tone: "error", }); }, }); const runRoutine = useMutation({ mutationFn: () => routinesApi.run(routineId!), onSuccess: async () => { pushToast({ title: "Routine run started", tone: "success" }); setActiveTab("runs"); await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }), queryClient.invalidateQueries({ queryKey: queryKeys.routines.runs(routineId!) }), queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }), queryClient.invalidateQueries({ queryKey: queryKeys.routines.activity(selectedCompanyId!, routineId!) }), ]); }, onError: (error) => { pushToast({ title: "Routine run failed", body: error instanceof Error ? error.message : "Paperclip could not start the routine run.", tone: "error", }); }, }); const updateRoutineStatus = useMutation({ mutationFn: (status: string) => routinesApi.update(routineId!, { status }), onSuccess: async (_data, status) => { pushToast({ title: "Routine saved", body: status === "paused" ? "Automation paused." : "Automation enabled.", tone: "success", }); await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }), queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }), ]); }, onError: (error) => { pushToast({ title: "Failed to update routine", body: error instanceof Error ? error.message : "Paperclip could not update the routine.", tone: "error", }); }, }); const createTrigger = useMutation({ mutationFn: async (): Promise => { const existingOfKind = (routine?.triggers ?? []).filter((t) => t.kind === newTrigger.kind).length; const autoLabel = existingOfKind > 0 ? `${newTrigger.kind}-${existingOfKind + 1}` : newTrigger.kind; return routinesApi.createTrigger(routineId!, { kind: newTrigger.kind, label: autoLabel, ...(newTrigger.kind === "schedule" ? { cronExpression: newTrigger.cronExpression.trim(), timezone: getLocalTimezone() } : {}), ...(newTrigger.kind === "webhook" ? { signingMode: newTrigger.signingMode, replayWindowSec: Number(newTrigger.replayWindowSec || "300"), } : {}), }); }, onSuccess: async (result) => { if (result.secretMaterial) { setSecretMessage({ title: "Webhook trigger created", webhookUrl: result.secretMaterial.webhookUrl, webhookSecret: result.secretMaterial.webhookSecret, }); } await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }), queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }), queryClient.invalidateQueries({ queryKey: queryKeys.routines.activity(selectedCompanyId!, routineId!) }), ]); }, onError: (error) => { pushToast({ title: "Failed to add trigger", body: error instanceof Error ? error.message : "Paperclip could not create the trigger.", tone: "error", }); }, }); const updateTrigger = useMutation({ mutationFn: ({ id, patch }: { id: string; patch: Record }) => routinesApi.updateTrigger(id, patch), onSuccess: async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }), queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }), queryClient.invalidateQueries({ queryKey: queryKeys.routines.activity(selectedCompanyId!, routineId!) }), ]); }, onError: (error) => { pushToast({ title: "Failed to update trigger", body: error instanceof Error ? error.message : "Paperclip could not update the trigger.", tone: "error", }); }, }); const deleteTrigger = useMutation({ mutationFn: (id: string) => routinesApi.deleteTrigger(id), onSuccess: async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }), queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }), queryClient.invalidateQueries({ queryKey: queryKeys.routines.activity(selectedCompanyId!, routineId!) }), ]); }, onError: (error) => { pushToast({ title: "Failed to delete trigger", body: error instanceof Error ? error.message : "Paperclip could not delete the trigger.", tone: "error", }); }, }); const rotateTrigger = useMutation({ mutationFn: (id: string): Promise => routinesApi.rotateTriggerSecret(id), onSuccess: async (result) => { setSecretMessage({ title: "Webhook secret rotated", webhookUrl: result.secretMaterial.webhookUrl, webhookSecret: result.secretMaterial.webhookSecret, }); await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }), queryClient.invalidateQueries({ queryKey: queryKeys.routines.activity(selectedCompanyId!, routineId!) }), ]); }, onError: (error) => { pushToast({ title: "Failed to rotate webhook secret", body: error instanceof Error ? error.message : "Paperclip could not rotate the webhook secret.", tone: "error", }); }, }); 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 recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [routine?.id]); 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 currentAssignee = editDraft.assigneeAgentId ? agentById.get(editDraft.assigneeAgentId) ?? null : null; const currentProject = editDraft.projectId ? projectById.get(editDraft.projectId) ?? null : null; if (!selectedCompanyId) { return ; } if (isLoading) { return ; } if (error || !routine) { return (

{error instanceof Error ? error.message : "Routine not found"}

); } const automationEnabled = routine.status === "active"; const automationToggleDisabled = updateRoutineStatus.isPending || routine.status === "archived"; const automationLabel = routine.status === "archived" ? "Archived" : automationEnabled ? "Active" : "Paused"; const automationLabelClassName = routine.status === "archived" ? "text-muted-foreground" : automationEnabled ? "text-emerald-400" : "text-muted-foreground"; return (
{/* Header: editable title + actions */}