Turn routines index into a table

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-03-19 12:07:49 -05:00
parent 500d926da7
commit 54dd8f7ac8

View File

@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate } from "@/lib/router"; 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 { routinesApi } from "../api/routines";
import { agentsApi } from "../api/agents"; import { agentsApi } from "../api/agents";
import { projectsApi } from "../api/projects"; import { projectsApi } from "../api/projects";
@@ -18,7 +18,14 @@ import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEd
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; 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 { import {
Select, Select,
SelectContent, SelectContent,
@@ -26,7 +33,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { timeAgo } from "../lib/timeAgo";
const priorities = ["critical", "high", "medium", "low"]; const priorities = ["critical", "high", "medium", "low"];
const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"]; const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"];
@@ -41,18 +47,22 @@ const catchUpPolicyDescriptions: Record<string, string> = {
enqueue_missed_with_cap: "Catch up missed schedule windows with a capped backlog after recovery.", enqueue_missed_with_cap: "Catch up missed schedule windows with a capped backlog after recovery.",
}; };
function triggerIcon(kind: string) {
if (kind === "schedule") return <Clock3 className="h-3.5 w-3.5" />;
if (kind === "webhook") return <Webhook className="h-3.5 w-3.5" />;
return <Play className="h-3.5 w-3.5" />;
}
function autoResizeTextarea(element: HTMLTextAreaElement | null) { function autoResizeTextarea(element: HTMLTextAreaElement | null) {
if (!element) return; if (!element) return;
element.style.height = "auto"; element.style.height = "auto";
element.style.height = `${element.scrollHeight}px`; 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() { export function Routines() {
const { selectedCompanyId } = useCompany(); const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs(); const { setBreadcrumbs } = useBreadcrumbs();
@@ -64,6 +74,7 @@ export function Routines() {
const assigneeSelectorRef = useRef<HTMLButtonElement | null>(null); const assigneeSelectorRef = useRef<HTMLButtonElement | null>(null);
const projectSelectorRef = useRef<HTMLButtonElement | null>(null); const projectSelectorRef = useRef<HTMLButtonElement | null>(null);
const [runningRoutineId, setRunningRoutineId] = useState<string | null>(null); const [runningRoutineId, setRunningRoutineId] = useState<string | null>(null);
const [statusMutationRoutineId, setStatusMutationRoutineId] = useState<string | null>(null);
const [composerOpen, setComposerOpen] = useState(false); const [composerOpen, setComposerOpen] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false); const [advancedOpen, setAdvancedOpen] = useState(false);
const [draft, setDraft] = useState({ const [draft, setDraft] = useState({
@@ -96,12 +107,6 @@ export function Routines() {
enabled: !!selectedCompanyId, enabled: !!selectedCompanyId,
}); });
useEffect(() => {
if (!isLoading && (routines?.length ?? 0) === 0) {
setComposerOpen(true);
}
}, [isLoading, routines]);
useEffect(() => { useEffect(() => {
autoResizeTextarea(titleInputRef.current); autoResizeTextarea(titleInputRef.current);
}, [draft.title, composerOpen]); }, [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({ const runRoutine = useMutation({
mutationFn: (id: string) => routinesApi.run(id), mutationFn: (id: string) => routinesApi.run(id),
onMutate: (id) => { onMutate: (id) => {
setRunningRoutineId(id); setRunningRoutineId(id);
}, },
onSuccess: async () => { onSuccess: async (_, id) => {
await queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }); await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(id) }),
]);
}, },
onSettled: () => { onSettled: () => {
setRunningRoutineId(null); setRunningRoutineId(null);
@@ -176,18 +207,10 @@ export function Routines() {
})), })),
[projects], [projects],
); );
const agentName = useMemo(
() => new Map((agents ?? []).map((agent) => [agent.id, agent.name])),
[agents],
);
const agentById = useMemo( const agentById = useMemo(
() => new Map((agents ?? []).map((agent) => [agent.id, agent])), () => new Map((agents ?? []).map((agent) => [agent.id, agent])),
[agents], [agents],
); );
const projectName = useMemo(
() => new Map((projects ?? []).map((project) => [project.id, project.name])),
[projects],
);
const projectById = useMemo( const projectById = useMemo(
() => new Map((projects ?? []).map((project) => [project.id, project])), () => new Map((projects ?? []).map((project) => [project.id, project])),
[projects], [projects],
@@ -209,20 +232,24 @@ export function Routines() {
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">Routines</h1> <h1 className="text-2xl font-semibold tracking-tight">Routines</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Define recurring work once, then let Paperclip materialize each execution as an auditable issue. Recurring work definitions that materialize into auditable execution issues.
</p> </p>
</div> </div>
<Button <Button onClick={() => setComposerOpen(true)}>
onClick={() => setComposerOpen((open) => !open)}
variant={composerOpen ? "outline" : "default"}
>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
{composerOpen ? "Hide composer" : "Create routine"} Create routine
</Button> </Button>
</div> </div>
{composerOpen ? ( <Dialog
<Card className="overflow-hidden"> open={composerOpen}
onOpenChange={(open) => {
if (!createRoutine.isPending) {
setComposerOpen(open);
}
}}
>
<DialogContent showCloseButton={false} className="max-w-3xl gap-0 overflow-hidden p-0">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border/60 px-5 py-3"> <div className="flex flex-wrap items-center justify-between gap-3 border-b border-border/60 px-5 py-3">
<div> <div>
<p className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">New routine</p> <p className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">New routine</p>
@@ -470,8 +497,8 @@ export function Routines() {
) : null} ) : null}
</div> </div>
</div> </div>
</Card> </DialogContent>
) : null} </Dialog>
{error ? ( {error ? (
<Card> <Card>
@@ -481,92 +508,129 @@ export function Routines() {
</Card> </Card>
) : null} ) : null}
<div className="grid gap-4"> <Card className="overflow-hidden">
{(routines ?? []).length === 0 ? ( {(routines ?? []).length === 0 ? (
<EmptyState <CardContent className="py-12">
icon={Repeat} <EmptyState
message="No routines yet. Use Create routine to define the first recurring workflow." icon={Repeat}
/> message="No routines yet. Use Create routine to define the first recurring workflow."
/>
</CardContent>
) : ( ) : (
(routines ?? []).map((routine) => ( <div className="overflow-x-auto">
<Card key={routine.id}> <table className="min-w-full divide-y divide-border text-sm">
<CardContent className="flex flex-col gap-4 pt-6 md:flex-row md:items-start md:justify-between"> <thead className="bg-muted/30">
<div className="min-w-0 space-y-3"> <tr className="text-left text-xs uppercase tracking-[0.16em] text-muted-foreground">
<div className="flex flex-wrap items-center gap-2"> <th className="px-4 py-3 font-medium">Name</th>
<Link to={`/routines/${routine.id}`} className="text-base font-medium hover:underline"> <th className="px-4 py-3 font-medium">Last run</th>
{routine.title} <th className="px-4 py-3 font-medium">Enabled</th>
</Link> <th className="w-12 px-4 py-3" />
<Badge variant={routine.status === "active" ? "default" : "secondary"}> </tr>
{routine.status.replaceAll("_", " ")} </thead>
</Badge> <tbody className="divide-y divide-border">
<Badge variant="outline">{routine.priority}</Badge> {(routines ?? []).map((routine) => {
</div> const enabled = routine.status === "active";
{routine.description ? ( const isArchived = routine.status === "archived";
<p className="line-clamp-2 text-sm text-muted-foreground"> const isStatusPending = statusMutationRoutineId === routine.id;
{routine.description} return (
</p> <tr key={routine.id} className="align-middle">
) : null} <td className="px-4 py-3">
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground"> <div className="min-w-[240px]">
<span>Project: {projectName.get(routine.projectId) ?? routine.projectId.slice(0, 8)}</span> <Link to={`/routines/${routine.id}`} className="font-medium hover:underline">
<span>Assignee: {agentName.get(routine.assigneeAgentId) ?? routine.assigneeAgentId.slice(0, 8)}</span> {routine.title}
<span>Concurrency: {routine.concurrencyPolicy.replaceAll("_", " ")}</span> </Link>
</div> <div className="mt-1 text-xs text-muted-foreground">
<div className="flex flex-wrap gap-2"> {routine.projectId ? `${projectById.get(routine.projectId)?.name ?? "Unknown project"}` : "No project"}
{routine.triggers.length === 0 ? ( {isArchived ? " · archived" : routine.status === "paused" ? " · paused" : ""}
<Badge variant="outline">No triggers</Badge> </div>
) : ( </div>
routine.triggers.map((trigger) => ( </td>
<Badge key={trigger.id} variant="outline" className="gap-1"> <td className="px-4 py-3 text-muted-foreground">
{triggerIcon(trigger.kind)} <div>{formatLastRunTimestamp(routine.lastRun?.triggeredAt)}</div>
{trigger.label ?? trigger.kind} {routine.lastRun ? (
{!trigger.enabled && " paused"} <div className="mt-1 text-xs">{routine.lastRun.status.replaceAll("_", " ")}</div>
</Badge> ) : null}
)) </td>
)} <td className="px-4 py-3">
</div> <div className="flex items-center gap-3">
</div> <button
<div className="flex shrink-0 flex-col gap-3 md:min-w-[250px]"> type="button"
<div className="rounded-lg border border-border bg-muted/30 p-3 text-sm"> role="switch"
<p className="font-medium">Last run</p> aria-checked={enabled}
{routine.lastRun ? ( aria-label={enabled ? `Disable ${routine.title}` : `Enable ${routine.title}`}
<div className="mt-1 space-y-1 text-muted-foreground"> disabled={isStatusPending || isArchived}
<p>{routine.lastRun.status.replaceAll("_", " ")}</p> className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
<p>{timeAgo(routine.lastRun.triggeredAt)}</p> enabled ? "bg-foreground" : "bg-muted"
</div> } ${isStatusPending || isArchived ? "cursor-not-allowed opacity-50" : ""}`}
) : ( onClick={() =>
<p className="mt-1 text-muted-foreground">No executions yet.</p> updateRoutineStatus.mutate({
)} id: routine.id,
</div> status: nextRoutineStatus(routine.status, !enabled),
<div className="rounded-lg border border-border bg-muted/30 p-3 text-sm"> })
<p className="font-medium">Active execution issue</p> }
{routine.activeIssue ? ( >
<Link to={`/issues/${routine.activeIssue.identifier ?? routine.activeIssue.id}`} className="mt-1 block text-muted-foreground hover:underline"> <span
{routine.activeIssue.identifier ?? routine.activeIssue.id.slice(0, 8)} · {routine.activeIssue.title} className={`inline-block h-5 w-5 rounded-full bg-background shadow-sm transition-transform ${
</Link> enabled ? "translate-x-5" : "translate-x-0.5"
) : ( }`}
<p className="mt-1 text-muted-foreground">Nothing open.</p> />
)} </button>
</div> <span className="text-xs text-muted-foreground">
<div className="flex gap-2"> {isArchived ? "Archived" : enabled ? "On" : "Off"}
<Button </span>
variant="outline" </div>
className="flex-1" </td>
onClick={() => runRoutine.mutate(routine.id)} <td className="px-4 py-3 text-right">
disabled={runningRoutineId === routine.id} <DropdownMenu>
> <DropdownMenuTrigger asChild>
<Play className="mr-2 h-4 w-4" /> <Button variant="ghost" size="icon-sm" aria-label={`More actions for ${routine.title}`}>
{runningRoutineId === routine.id ? "Running..." : "Run now"} <MoreHorizontal className="h-4 w-4" />
</Button> </Button>
<Button asChild className="flex-1"> </DropdownMenuTrigger>
<Link to={`/routines/${routine.id}`}>Open</Link> <DropdownMenuContent align="end">
</Button> <DropdownMenuItem onClick={() => navigate(`/routines/${routine.id}`)}>
</div> Open
</div> </DropdownMenuItem>
</CardContent> <DropdownMenuItem
</Card> disabled={runningRoutineId === routine.id || isArchived}
)) onClick={() => runRoutine.mutate(routine.id)}
>
{runningRoutineId === routine.id ? "Running..." : "Run now"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() =>
updateRoutineStatus.mutate({
id: routine.id,
status: enabled ? "paused" : "active",
})
}
disabled={isStatusPending || isArchived}
>
{enabled ? "Pause" : "Enable"}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
updateRoutineStatus.mutate({
id: routine.id,
status: routine.status === "archived" ? "active" : "archived",
})
}
disabled={isStatusPending}
>
{routine.status === "archived" ? "Restore" : "Archive"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)} )}
</div> </Card>
</div> </div>
); );
} }