Turn routines index into a table
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user