diff --git a/ui/src/pages/Routines.tsx b/ui/src/pages/Routines.tsx index d621cbe4..89f98e21 100644 --- a/ui/src/pages/Routines.tsx +++ b/ui/src/pages/Routines.tsx @@ -1,7 +1,7 @@ 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 { ChevronDown, ChevronRight, MoreHorizontal, Play, Plus, Repeat } from "lucide-react"; import { routinesApi } from "../api/routines"; import { agentsApi } from "../api/agents"; import { projectsApi } from "../api/projects"; @@ -18,7 +18,14 @@ import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEd 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 { Dialog, DialogContent } from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Select, SelectContent, @@ -26,7 +33,6 @@ import { 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"]; @@ -41,18 +47,22 @@ const catchUpPolicyDescriptions: Record = { 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`; } +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(); @@ -64,6 +74,7 @@ export function Routines() { 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({ @@ -96,12 +107,6 @@ export function Routines() { enabled: !!selectedCompanyId, }); - useEffect(() => { - if (!isLoading && (routines?.length ?? 0) === 0) { - setComposerOpen(true); - } - }, [isLoading, routines]); - useEffect(() => { autoResizeTextarea(titleInputRef.current); }, [draft.title, composerOpen]); @@ -134,13 +139,39 @@ export function Routines() { }, }); + 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 () => { - await queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }); + onSuccess: async (_, id) => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }), + queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(id) }), + ]); }, onSettled: () => { setRunningRoutineId(null); @@ -176,18 +207,10 @@ export function Routines() { })), [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], @@ -209,20 +232,24 @@ export function Routines() {

Routines

- Define recurring work once, then let Paperclip materialize each execution as an auditable issue. + Recurring work definitions that materialize into auditable execution issues.

- - {composerOpen ? ( - + { + if (!createRoutine.isPending) { + setComposerOpen(open); + } + }} + > +

New routine

@@ -470,8 +497,8 @@ export function Routines() { ) : null}
-
- ) : null} + + {error ? ( @@ -481,92 +508,129 @@ export function Routines() { ) : null} -
+ {(routines ?? []).length === 0 ? ( - + + + ) : ( - (routines ?? []).map((routine) => ( - - -
-
- - {routine.title} - - - {routine.status.replaceAll("_", " ")} - - {routine.priority} -
- {routine.description ? ( -

- {routine.description} -

- ) : null} -
- Project: {projectName.get(routine.projectId) ?? routine.projectId.slice(0, 8)} - Assignee: {agentName.get(routine.assigneeAgentId) ?? routine.assigneeAgentId.slice(0, 8)} - Concurrency: {routine.concurrencyPolicy.replaceAll("_", " ")} -
-
- {routine.triggers.length === 0 ? ( - No triggers - ) : ( - routine.triggers.map((trigger) => ( - - {triggerIcon(trigger.kind)} - {trigger.label ?? trigger.kind} - {!trigger.enabled && " paused"} - - )) - )} -
-
-
-
-

Last run

- {routine.lastRun ? ( -
-

{routine.lastRun.status.replaceAll("_", " ")}

-

{timeAgo(routine.lastRun.triggeredAt)}

-
- ) : ( -

No executions yet.

- )} -
-
-

Active execution issue

- {routine.activeIssue ? ( - - {routine.activeIssue.identifier ?? routine.activeIssue.id.slice(0, 8)} · {routine.activeIssue.title} - - ) : ( -

Nothing open.

- )} -
-
- - -
-
-
-
- )) +
+ + + + + + + + + + {(routines ?? []).map((routine) => { + const enabled = routine.status === "active"; + const isArchived = routine.status === "archived"; + const isStatusPending = statusMutationRoutineId === routine.id; + return ( + + + + + + + ); + })} + +
NameLast runEnabled +
+
+ + {routine.title} + +
+ {routine.projectId ? `${projectById.get(routine.projectId)?.name ?? "Unknown project"}` : "No project"} + {isArchived ? " · archived" : routine.status === "paused" ? " · paused" : ""} +
+
+
+
{formatLastRunTimestamp(routine.lastRun?.triggeredAt)}
+ {routine.lastRun ? ( +
{routine.lastRun.status.replaceAll("_", " ")}
+ ) : null} +
+
+ + + {isArchived ? "Archived" : enabled ? "On" : "Off"} + +
+
+ + + + + + navigate(`/routines/${routine.id}`)}> + Open + + runRoutine.mutate(routine.id)} + > + {runningRoutineId === routine.id ? "Running..." : "Run now"} + + + + updateRoutineStatus.mutate({ + id: routine.id, + status: enabled ? "paused" : "active", + }) + } + disabled={isStatusPending || isArchived} + > + {enabled ? "Pause" : "Enable"} + + + updateRoutineStatus.mutate({ + id: routine.id, + status: routine.status === "archived" ? "active" : "archived", + }) + } + disabled={isStatusPending} + > + {routine.status === "archived" ? "Restore" : "Archive"} + + + +
+
)} -
+ ); }