Refine routines editor flow

Align the routines list and detail editors with the issue-composer interaction model, and fix the scheduler's typed date comparison.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-03-19 11:36:01 -05:00
parent 8f5196f7d6
commit 500d926da7
3 changed files with 654 additions and 292 deletions

View File

@@ -1,5 +1,5 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import { and, asc, desc, eq, inArray, isNull, ne, or, sql } from "drizzle-orm"; import { and, asc, desc, eq, inArray, isNotNull, isNull, lte, ne, or } from "drizzle-orm";
import type { Db } from "@paperclipai/db"; import type { Db } from "@paperclipai/db";
import { import {
agents, agents,
@@ -1030,8 +1030,8 @@ export function routineService(db: Db) {
eq(routineTriggers.kind, "schedule"), eq(routineTriggers.kind, "schedule"),
eq(routineTriggers.enabled, true), eq(routineTriggers.enabled, true),
eq(routines.status, "active"), eq(routines.status, "active"),
sql`${routineTriggers.nextRunAt} is not null`, isNotNull(routineTriggers.nextRunAt),
sql`${routineTriggers.nextRunAt} <= ${now}`, lte(routineTriggers.nextRunAt, now),
), ),
) )
.orderBy(asc(routineTriggers.nextRunAt), asc(routineTriggers.createdAt)); .orderBy(asc(routineTriggers.nextRunAt), asc(routineTriggers.createdAt));

View File

@@ -2,6 +2,8 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { Link, useLocation, useNavigate, useParams } from "@/lib/router"; import { Link, useLocation, useNavigate, useParams } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
ChevronDown,
ChevronRight,
Clock3, Clock3,
Copy, Copy,
Play, Play,
@@ -21,12 +23,16 @@ import { useToast } from "../context/ToastContext";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { EmptyState } from "../components/EmptyState"; import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton"; import { PageSkeleton } from "../components/PageSkeleton";
import { AgentIcon } from "../components/AgentIconPicker";
import { IssueRow } from "../components/IssueRow"; import { IssueRow } from "../components/IssueRow";
import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector";
import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor";
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -68,6 +74,12 @@ type SecretMessage = {
webhookSecret: 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 { function isRoutineTab(value: string | null): value is RoutineTab {
return value !== null && routineTabs.includes(value as RoutineTab); return value !== null && routineTabs.includes(value as RoutineTab);
} }
@@ -244,7 +256,12 @@ export function RoutineDetail() {
const location = useLocation(); const location = useLocation();
const { pushToast } = useToast(); const { pushToast } = useToast();
const hydratedRoutineIdRef = useRef<string | null>(null); const hydratedRoutineIdRef = useRef<string | null>(null);
const titleInputRef = useRef<HTMLTextAreaElement | null>(null);
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
const assigneeSelectorRef = useRef<HTMLButtonElement | null>(null);
const projectSelectorRef = useRef<HTMLButtonElement | null>(null);
const [secretMessage, setSecretMessage] = useState<SecretMessage | null>(null); const [secretMessage, setSecretMessage] = useState<SecretMessage | null>(null);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [newTrigger, setNewTrigger] = useState({ const [newTrigger, setNewTrigger] = useState({
kind: "schedule", kind: "schedule",
label: "", label: "",
@@ -354,6 +371,10 @@ export function RoutineDetail() {
} }
}, [routine, routineDefaults, isEditDirty, setBreadcrumbs]); }, [routine, routineDefaults, isEditDirty, setBreadcrumbs]);
useEffect(() => {
autoResizeTextarea(titleInputRef.current);
}, [editDraft.title, routine?.id]);
const copySecretValue = async (label: string, value: string) => { const copySecretValue = async (label: string, value: string) => {
try { try {
await navigator.clipboard.writeText(value); await navigator.clipboard.writeText(value);
@@ -504,14 +525,38 @@ export function RoutineDetail() {
}, },
}); });
const agentName = useMemo( const agentById = useMemo(
() => new Map((agents ?? []).map((agent) => [agent.id, agent.name])), () => new Map((agents ?? []).map((agent) => [agent.id, agent])),
[agents], [agents],
); );
const projectName = useMemo( const projectById = useMemo(
() => new Map((projects ?? []).map((project) => [project.id, project.name])), () => new Map((projects ?? []).map((project) => [project.id, project])),
[projects], [projects],
); );
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [routine?.id]);
const assigneeOptions = useMemo<InlineEntityOption[]>(
() =>
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<InlineEntityOption[]>(
() =>
(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) { if (!selectedCompanyId) {
return <EmptyState icon={Repeat} message="Select a company to view routines." />; return <EmptyState icon={Repeat} message="Select a company to view routines." />;
@@ -566,149 +611,266 @@ export function RoutineDetail() {
</Card> </Card>
)} )}
<Card> <Card className="overflow-hidden">
<CardHeader> <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"> <div>
<div> <p className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">Routine definition</p>
<CardTitle>{routine.title}</CardTitle> <p className="text-sm text-muted-foreground">
<CardDescription> Keep the work definition primary. Triggers, runs, and audit history branch off this source object.
Project {projectName.get(routine.projectId) ?? routine.projectId.slice(0, 8)} · Assignee {agentName.get(routine.assigneeAgentId) ?? routine.assigneeAgentId.slice(0, 8)}
</CardDescription>
</div>
<div className="flex gap-2">
<Badge variant={routine.status === "active" ? "default" : "secondary"}>
{routine.status.replaceAll("_", " ")}
</Badge>
<Button onClick={() => runRoutine.mutate()} disabled={runRoutine.isPending}>
<Play className="mr-2 h-4 w-4" />
Run now
</Button>
</div>
</div>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="space-y-2 md:col-span-2">
<Label>Title</Label>
<Input value={editDraft.title} onChange={(event) => setEditDraft((current) => ({ ...current, title: event.target.value }))} />
</div>
<div className="space-y-2 md:col-span-2">
<Label>Instructions</Label>
<Textarea
rows={4}
value={editDraft.description}
onChange={(event) => setEditDraft((current) => ({ ...current, description: event.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>Project</Label>
<Select value={editDraft.projectId} onValueChange={(projectId) => setEditDraft((current) => ({ ...current, projectId }))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(projects ?? []).map((project) => (
<SelectItem key={project.id} value={project.id}>{project.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Assignee</Label>
<Select value={editDraft.assigneeAgentId} onValueChange={(assigneeAgentId) => setEditDraft((current) => ({ ...current, assigneeAgentId }))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(agents ?? []).map((agent) => (
<SelectItem key={agent.id} value={agent.id}>{agent.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Status</Label>
<Select value={editDraft.status} onValueChange={(status) => setEditDraft((current) => ({ ...current, status }))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{routineStatuses.map((status) => (
<SelectItem key={status} value={status}>{status.replaceAll("_", " ")}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Priority</Label>
<Select value={editDraft.priority} onValueChange={(priority) => setEditDraft((current) => ({ ...current, priority }))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{priorities.map((priority) => (
<SelectItem key={priority} value={priority}>{priority}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Concurrency</Label>
<Select value={editDraft.concurrencyPolicy} onValueChange={(concurrencyPolicy) => setEditDraft((current) => ({ ...current, concurrencyPolicy }))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{concurrencyPolicies.map((value) => (
<SelectItem key={value} value={value}>{value.replaceAll("_", " ")}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{concurrencyPolicyDescriptions[editDraft.concurrencyPolicy]}
</p> </p>
</div> </div>
<div className="space-y-2"> <div className="flex gap-2">
<Label>Catch-up</Label> <Badge variant={routine.status === "active" ? "default" : "secondary"}>
<Select value={editDraft.catchUpPolicy} onValueChange={(catchUpPolicy) => setEditDraft((current) => ({ ...current, catchUpPolicy }))}> {routine.status.replaceAll("_", " ")}
<SelectTrigger> </Badge>
<SelectValue /> <Button onClick={() => runRoutine.mutate()} disabled={runRoutine.isPending}>
</SelectTrigger> <Play className="mr-2 h-4 w-4" />
<SelectContent> Run now
{catchUpPolicies.map((value) => ( </Button>
<SelectItem key={value} value={value}>{value.replaceAll("_", " ")}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{catchUpPolicyDescriptions[editDraft.catchUpPolicy]}
</p>
</div> </div>
<div className="md:col-span-2 flex items-center justify-between"> </div>
<div className="text-sm text-muted-foreground">
{routine.activeIssue ? ( <div className="px-5 pt-5 pb-3">
<span> <textarea
Active issue:{" "} ref={titleInputRef}
<Link to={`/issues/${routine.activeIssue.identifier ?? routine.activeIssue.id}`} className="hover:underline"> className="w-full resize-none overflow-hidden bg-transparent text-xl font-semibold outline-none placeholder:text-muted-foreground/50"
{routine.activeIssue.identifier ?? routine.activeIssue.id.slice(0, 8)} placeholder="Routine title"
</Link> rows={1}
</span> value={editDraft.title}
) : ( onChange={(event) => {
"No active execution issue." setEditDraft((current) => ({ ...current, title: event.target.value }));
)} autoResizeTextarea(event.target);
</div> }}
<div className="flex items-center gap-3"> onKeyDown={(event) => {
{isEditDirty && ( if (event.key === "Enter" && !event.metaKey && !event.ctrlKey && !event.nativeEvent.isComposing) {
<span className="text-xs text-amber-600"> event.preventDefault();
Unsaved routine edits stay local until you save. descriptionEditorRef.current?.focus();
</span> return;
)} }
<Button onClick={() => saveRoutine.mutate()} disabled={saveRoutine.isPending}> if (event.key === "Tab" && !event.shiftKey) {
<Save className="mr-2 h-4 w-4" /> event.preventDefault();
Save routine if (editDraft.assigneeAgentId) {
</Button> if (editDraft.projectId) {
descriptionEditorRef.current?.focus();
} else {
projectSelectorRef.current?.focus();
}
} else {
assigneeSelectorRef.current?.focus();
}
}
}}
/>
</div>
<div className="px-5 pb-3">
<div className="overflow-x-auto overscroll-x-contain">
<div className="inline-flex min-w-full flex-wrap items-center gap-2 text-sm text-muted-foreground sm:min-w-max sm:flex-nowrap">
<span>For</span>
<InlineEntitySelector
ref={assigneeSelectorRef}
value={editDraft.assigneeAgentId}
options={assigneeOptions}
placeholder="Assignee"
noneLabel="No assignee"
searchPlaceholder="Search assignees..."
emptyMessage="No assignees found."
onChange={(assigneeAgentId) => {
if (assigneeAgentId) trackRecentAssignee(assigneeAgentId);
setEditDraft((current) => ({ ...current, assigneeAgentId }));
}}
onConfirm={() => {
if (editDraft.projectId) {
descriptionEditorRef.current?.focus();
} else {
projectSelectorRef.current?.focus();
}
}}
renderTriggerValue={(option) =>
option ? (
currentAssignee ? (
<>
<AgentIcon icon={currentAssignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{option.label}</span>
</>
) : (
<span className="truncate">{option.label}</span>
)
) : (
<span className="text-muted-foreground">Assignee</span>
)
}
renderOption={(option) => {
if (!option.id) return <span className="truncate">{option.label}</span>;
const assignee = agentById.get(option.id);
return (
<>
{assignee ? <AgentIcon icon={assignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
<span className="truncate">{option.label}</span>
</>
);
}}
/>
<span>in</span>
<InlineEntitySelector
ref={projectSelectorRef}
value={editDraft.projectId}
options={projectOptions}
placeholder="Project"
noneLabel="No project"
searchPlaceholder="Search projects..."
emptyMessage="No projects found."
onChange={(projectId) => setEditDraft((current) => ({ ...current, projectId }))}
onConfirm={() => descriptionEditorRef.current?.focus()}
renderTriggerValue={(option) =>
option && currentProject ? (
<>
<span
className="h-3.5 w-3.5 shrink-0 rounded-sm"
style={{ backgroundColor: currentProject.color ?? "#64748b" }}
/>
<span className="truncate">{option.label}</span>
</>
) : (
<span className="text-muted-foreground">Project</span>
)
}
renderOption={(option) => {
if (!option.id) return <span className="truncate">{option.label}</span>;
const project = projectById.get(option.id);
return (
<>
<span
className="h-3.5 w-3.5 shrink-0 rounded-sm"
style={{ backgroundColor: project?.color ?? "#64748b" }}
/>
<span className="truncate">{option.label}</span>
</>
);
}}
/>
</div> </div>
</div> </div>
</CardContent> </div>
<div className="border-t border-border/60 px-5 py-4">
<MarkdownEditor
ref={descriptionEditorRef}
value={editDraft.description}
onChange={(description) => setEditDraft((current) => ({ ...current, description }))}
placeholder="Add instructions..."
bordered={false}
contentClassName="min-h-[180px] text-sm text-muted-foreground"
onSubmit={() => {
if (!saveRoutine.isPending && editDraft.title.trim() && editDraft.projectId && editDraft.assigneeAgentId) {
saveRoutine.mutate();
}
}}
/>
</div>
<div className="border-t border-border/60 px-5 py-3">
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger className="flex w-full items-center justify-between text-left">
<div>
<p className="text-sm font-medium">Advanced delivery settings</p>
<p className="text-sm text-muted-foreground">Status and execution policy stay secondary to the work definition.</p>
</div>
{advancedOpen ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
</CollapsibleTrigger>
<CollapsibleContent className="pt-3">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Status</p>
<Select value={editDraft.status} onValueChange={(status) => setEditDraft((current) => ({ ...current, status }))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{routineStatuses.map((status) => (
<SelectItem key={status} value={status}>{status.replaceAll("_", " ")}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Priority</p>
<Select value={editDraft.priority} onValueChange={(priority) => setEditDraft((current) => ({ ...current, priority }))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{priorities.map((priority) => (
<SelectItem key={priority} value={priority}>{priority.replaceAll("_", " ")}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Concurrency</p>
<Select
value={editDraft.concurrencyPolicy}
onValueChange={(concurrencyPolicy) => setEditDraft((current) => ({ ...current, concurrencyPolicy }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{concurrencyPolicies.map((value) => (
<SelectItem key={value} value={value}>{value.replaceAll("_", " ")}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{concurrencyPolicyDescriptions[editDraft.concurrencyPolicy]}</p>
</div>
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Catch-up</p>
<Select
value={editDraft.catchUpPolicy}
onValueChange={(catchUpPolicy) => setEditDraft((current) => ({ ...current, catchUpPolicy }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{catchUpPolicies.map((value) => (
<SelectItem key={value} value={value}>{value.replaceAll("_", " ")}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{catchUpPolicyDescriptions[editDraft.catchUpPolicy]}</p>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
<div className="flex flex-col gap-3 border-t border-border/60 px-5 py-4 md:flex-row md:items-center md:justify-between">
<div className="text-sm text-muted-foreground">
{routine.activeIssue ? (
<span>
Active issue:{" "}
<Link to={`/issues/${routine.activeIssue.identifier ?? routine.activeIssue.id}`} className="hover:underline">
{routine.activeIssue.identifier ?? routine.activeIssue.id.slice(0, 8)}
</Link>
</span>
) : (
"No active execution issue."
)}
</div>
<div className="flex flex-col gap-2 md:items-end">
{isEditDirty ? (
<span className="text-xs text-amber-600">Unsaved routine edits stay local until you save.</span>
) : null}
<Button
onClick={() => saveRoutine.mutate()}
disabled={saveRoutine.isPending || !editDraft.title.trim() || !editDraft.projectId || !editDraft.assigneeAgentId}
>
<Save className="mr-2 h-4 w-4" />
Save routine
</Button>
</div>
</div>
</Card> </Card>
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4"> <Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">

View File

@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery, useMutation, 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 { Repeat, Plus, Play, Clock3, Webhook } from "lucide-react"; import { ChevronDown, ChevronRight, Clock3, Play, Plus, Repeat, Webhook } 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";
@@ -9,13 +9,16 @@ import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToast } from "../context/ToastContext"; import { useToast } from "../context/ToastContext";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
import { EmptyState } from "../components/EmptyState"; import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton"; 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 { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -23,7 +26,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { timeAgo } from "../lib/timeAgo"; import { timeAgo } from "../lib/timeAgo";
const priorities = ["critical", "high", "medium", "low"]; const priorities = ["critical", "high", "medium", "low"];
@@ -45,13 +47,25 @@ function triggerIcon(kind: string) {
return <Play className="h-3.5 w-3.5" />; return <Play className="h-3.5 w-3.5" />;
} }
function autoResizeTextarea(element: HTMLTextAreaElement | null) {
if (!element) return;
element.style.height = "auto";
element.style.height = `${element.scrollHeight}px`;
}
export function Routines() { export function Routines() {
const { selectedCompanyId } = useCompany(); const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs(); const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
const { pushToast } = useToast(); const { pushToast } = useToast();
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
const titleInputRef = useRef<HTMLTextAreaElement | null>(null);
const assigneeSelectorRef = 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 [composerOpen, setComposerOpen] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [draft, setDraft] = useState({ const [draft, setDraft] = useState({
title: "", title: "",
description: "", description: "",
@@ -82,6 +96,16 @@ export function Routines() {
enabled: !!selectedCompanyId, enabled: !!selectedCompanyId,
}); });
useEffect(() => {
if (!isLoading && (routines?.length ?? 0) === 0) {
setComposerOpen(true);
}
}, [isLoading, routines]);
useEffect(() => {
autoResizeTextarea(titleInputRef.current);
}, [draft.title, composerOpen]);
const createRoutine = useMutation({ const createRoutine = useMutation({
mutationFn: () => mutationFn: () =>
routinesApi.create(selectedCompanyId!, { routinesApi.create(selectedCompanyId!, {
@@ -98,6 +122,8 @@ export function Routines() {
concurrencyPolicy: "coalesce_if_active", concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed", catchUpPolicy: "skip_missed",
}); });
setComposerOpen(false);
setAdvancedOpen(false);
await queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }); await queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) });
pushToast({ pushToast({
title: "Routine created", title: "Routine created",
@@ -119,15 +145,56 @@ export function Routines() {
onSettled: () => { onSettled: () => {
setRunningRoutineId(null); setRunningRoutineId(null);
}, },
onError: (error) => { onError: (mutationError) => {
pushToast({ pushToast({
title: "Routine run failed", title: "Routine run failed",
body: error instanceof Error ? error.message : "Paperclip could not start the routine run.", body: mutationError instanceof Error ? mutationError.message : "Paperclip could not start the routine run.",
tone: "error", tone: "error",
}); });
}, },
}); });
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [composerOpen]);
const assigneeOptions = useMemo<InlineEntityOption[]>(
() =>
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<InlineEntityOption[]>(
() =>
(projects ?? []).map((project) => ({
id: project.id,
label: project.name,
searchText: project.description ?? "",
})),
[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],
);
const currentAssignee = draft.assigneeAgentId ? agentById.get(draft.assigneeAgentId) ?? null : null;
const currentProject = draft.projectId ? projectById.get(draft.projectId) ?? null : null;
if (!selectedCompanyId) { if (!selectedCompanyId) {
return <EmptyState icon={Repeat} message="Select a company to view routines." />; return <EmptyState icon={Repeat} message="Select a company to view routines." />;
} }
@@ -136,162 +203,295 @@ export function Routines() {
return <PageSkeleton variant="issues-list" />; return <PageSkeleton variant="issues-list" />;
} }
const agentName = new Map((agents ?? []).map((agent) => [agent.id, agent.name]));
const projectName = new Map((projects ?? []).map((project) => [project.id, project.name]));
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Card> <div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<CardHeader> <div className="space-y-1">
<CardTitle>Create Routine</CardTitle> <h1 className="text-2xl font-semibold tracking-tight">Routines</h1>
<CardDescription> <p className="text-sm text-muted-foreground">
Define recurring work once, then add the first trigger on the next screen to make it live. Define recurring work once, then let Paperclip materialize each execution as an auditable issue.
</CardDescription> </p>
</CardHeader> </div>
<CardContent className="grid gap-4 md:grid-cols-2"> <Button
<div className="space-y-2 md:col-span-2"> onClick={() => setComposerOpen((open) => !open)}
<Label htmlFor="routine-title">Title</Label> variant={composerOpen ? "outline" : "default"}
<Input >
id="routine-title" <Plus className="mr-2 h-4 w-4" />
value={draft.title} {composerOpen ? "Hide composer" : "Create routine"}
onChange={(event) => setDraft((current) => ({ ...current, title: event.target.value }))} </Button>
placeholder="Review the last 24 hours of merged code" </div>
/>
</div> {composerOpen ? (
<div className="space-y-2 md:col-span-2"> <Card className="overflow-hidden">
<Label htmlFor="routine-description">Instructions</Label> <div className="flex flex-wrap items-center justify-between gap-3 border-b border-border/60 px-5 py-3">
<Textarea <div>
id="routine-description" <p className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">New routine</p>
value={draft.description} <p className="text-sm text-muted-foreground">
onChange={(event) => setDraft((current) => ({ ...current, description: event.target.value }))} Define the recurring work first. Trigger setup comes next on the detail page.
rows={4} </p>
placeholder="Summarize noteworthy changes, update docs if needed, and leave a concise report." </div>
/>
</div>
<div className="space-y-2">
<Label>Project</Label>
<Select value={draft.projectId} onValueChange={(projectId) => setDraft((current) => ({ ...current, projectId }))}>
<SelectTrigger>
<SelectValue placeholder="Choose project" />
</SelectTrigger>
<SelectContent>
{(projects ?? []).map((project) => (
<SelectItem key={project.id} value={project.id}>{project.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Assignee</Label>
<Select
value={draft.assigneeAgentId}
onValueChange={(assigneeAgentId) => setDraft((current) => ({ ...current, assigneeAgentId }))}
>
<SelectTrigger>
<SelectValue placeholder="Choose assignee" />
</SelectTrigger>
<SelectContent>
{(agents ?? []).map((agent) => (
<SelectItem key={agent.id} value={agent.id}>{agent.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Priority</Label>
<Select value={draft.priority} onValueChange={(priority) => setDraft((current) => ({ ...current, priority }))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{priorities.map((priority) => (
<SelectItem key={priority} value={priority}>{priority.replace("_", " ")}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Concurrency</Label>
<Select
value={draft.concurrencyPolicy}
onValueChange={(concurrencyPolicy) => setDraft((current) => ({ ...current, concurrencyPolicy }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{concurrencyPolicies.map((value) => (
<SelectItem key={value} value={value}>{value.replaceAll("_", " ")}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{concurrencyPolicyDescriptions[draft.concurrencyPolicy]}
</p>
</div>
<div className="space-y-2">
<Label>Catch-up</Label>
<Select
value={draft.catchUpPolicy}
onValueChange={(catchUpPolicy) => setDraft((current) => ({ ...current, catchUpPolicy }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{catchUpPolicies.map((value) => (
<SelectItem key={value} value={value}>{value.replaceAll("_", " ")}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{catchUpPolicyDescriptions[draft.catchUpPolicy]}
</p>
</div>
<div className="md:col-span-2 flex items-center justify-between">
<p className="text-sm text-muted-foreground">
After creation, Paperclip takes you straight to trigger setup for schedule, webhook, or API entrypoints.
</p>
<Button <Button
onClick={() => createRoutine.mutate()} variant="ghost"
disabled={ size="sm"
createRoutine.isPending || onClick={() => {
!draft.title.trim() || setComposerOpen(false);
!draft.projectId || setAdvancedOpen(false);
!draft.assigneeAgentId }}
} disabled={createRoutine.isPending}
> >
<Plus className="mr-2 h-4 w-4" /> Cancel
{createRoutine.isPending ? "Creating..." : "Create Routine"}
</Button> </Button>
</div> </div>
{createRoutine.isError && (
<p className="md:col-span-2 text-sm text-destructive">
{createRoutine.error instanceof Error ? createRoutine.error.message : "Failed to create routine"}
</p>
)}
</CardContent>
</Card>
{error && ( <div className="px-5 pt-5 pb-3">
<textarea
ref={titleInputRef}
className="w-full resize-none overflow-hidden bg-transparent text-xl font-semibold outline-none placeholder:text-muted-foreground/50"
placeholder="Routine title"
rows={1}
value={draft.title}
onChange={(event) => {
setDraft((current) => ({ ...current, title: event.target.value }));
autoResizeTextarea(event.target);
}}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.metaKey && !event.ctrlKey && !event.nativeEvent.isComposing) {
event.preventDefault();
descriptionEditorRef.current?.focus();
return;
}
if (event.key === "Tab" && !event.shiftKey) {
event.preventDefault();
if (draft.assigneeAgentId) {
if (draft.projectId) {
descriptionEditorRef.current?.focus();
} else {
projectSelectorRef.current?.focus();
}
} else {
assigneeSelectorRef.current?.focus();
}
}
}}
autoFocus
/>
</div>
<div className="px-5 pb-3">
<div className="overflow-x-auto overscroll-x-contain">
<div className="inline-flex min-w-full flex-wrap items-center gap-2 text-sm text-muted-foreground sm:min-w-max sm:flex-nowrap">
<span>For</span>
<InlineEntitySelector
ref={assigneeSelectorRef}
value={draft.assigneeAgentId}
options={assigneeOptions}
placeholder="Assignee"
noneLabel="No assignee"
searchPlaceholder="Search assignees..."
emptyMessage="No assignees found."
onChange={(assigneeAgentId) => {
if (assigneeAgentId) trackRecentAssignee(assigneeAgentId);
setDraft((current) => ({ ...current, assigneeAgentId }));
}}
onConfirm={() => {
if (draft.projectId) {
descriptionEditorRef.current?.focus();
} else {
projectSelectorRef.current?.focus();
}
}}
renderTriggerValue={(option) =>
option ? (
currentAssignee ? (
<>
<AgentIcon icon={currentAssignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{option.label}</span>
</>
) : (
<span className="truncate">{option.label}</span>
)
) : (
<span className="text-muted-foreground">Assignee</span>
)
}
renderOption={(option) => {
if (!option.id) return <span className="truncate">{option.label}</span>;
const assignee = agentById.get(option.id);
return (
<>
{assignee ? <AgentIcon icon={assignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
<span className="truncate">{option.label}</span>
</>
);
}}
/>
<span>in</span>
<InlineEntitySelector
ref={projectSelectorRef}
value={draft.projectId}
options={projectOptions}
placeholder="Project"
noneLabel="No project"
searchPlaceholder="Search projects..."
emptyMessage="No projects found."
onChange={(projectId) => setDraft((current) => ({ ...current, projectId }))}
onConfirm={() => descriptionEditorRef.current?.focus()}
renderTriggerValue={(option) =>
option && currentProject ? (
<>
<span
className="h-3.5 w-3.5 shrink-0 rounded-sm"
style={{ backgroundColor: currentProject.color ?? "#64748b" }}
/>
<span className="truncate">{option.label}</span>
</>
) : (
<span className="text-muted-foreground">Project</span>
)
}
renderOption={(option) => {
if (!option.id) return <span className="truncate">{option.label}</span>;
const project = projectById.get(option.id);
return (
<>
<span
className="h-3.5 w-3.5 shrink-0 rounded-sm"
style={{ backgroundColor: project?.color ?? "#64748b" }}
/>
<span className="truncate">{option.label}</span>
</>
);
}}
/>
</div>
</div>
</div>
<div className="border-t border-border/60 px-5 py-4">
<MarkdownEditor
ref={descriptionEditorRef}
value={draft.description}
onChange={(description) => setDraft((current) => ({ ...current, description }))}
placeholder="Add instructions..."
bordered={false}
contentClassName="min-h-[160px] text-sm text-muted-foreground"
onSubmit={() => {
if (!createRoutine.isPending && draft.title.trim() && draft.projectId && draft.assigneeAgentId) {
createRoutine.mutate();
}
}}
/>
</div>
<div className="border-t border-border/60 px-5 py-3">
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger className="flex w-full items-center justify-between text-left">
<div>
<p className="text-sm font-medium">Advanced delivery settings</p>
<p className="text-sm text-muted-foreground">Keep policy controls secondary to the work definition.</p>
</div>
{advancedOpen ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
</CollapsibleTrigger>
<CollapsibleContent className="pt-3">
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Priority</p>
<Select value={draft.priority} onValueChange={(priority) => setDraft((current) => ({ ...current, priority }))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{priorities.map((priority) => (
<SelectItem key={priority} value={priority}>{priority.replace("_", " ")}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Concurrency</p>
<Select
value={draft.concurrencyPolicy}
onValueChange={(concurrencyPolicy) => setDraft((current) => ({ ...current, concurrencyPolicy }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{concurrencyPolicies.map((value) => (
<SelectItem key={value} value={value}>{value.replaceAll("_", " ")}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{concurrencyPolicyDescriptions[draft.concurrencyPolicy]}</p>
</div>
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Catch-up</p>
<Select
value={draft.catchUpPolicy}
onValueChange={(catchUpPolicy) => setDraft((current) => ({ ...current, catchUpPolicy }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{catchUpPolicies.map((value) => (
<SelectItem key={value} value={value}>{value.replaceAll("_", " ")}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{catchUpPolicyDescriptions[draft.catchUpPolicy]}</p>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
<div className="flex flex-col gap-3 border-t border-border/60 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-muted-foreground">
After creation, Paperclip takes you straight to trigger setup for schedules, webhooks, or internal runs.
</div>
<div className="flex flex-col gap-2 sm:items-end">
<Button
onClick={() => createRoutine.mutate()}
disabled={
createRoutine.isPending ||
!draft.title.trim() ||
!draft.projectId ||
!draft.assigneeAgentId
}
>
<Plus className="mr-2 h-4 w-4" />
{createRoutine.isPending ? "Creating..." : "Create routine"}
</Button>
{createRoutine.isError ? (
<p className="text-sm text-destructive">
{createRoutine.error instanceof Error ? createRoutine.error.message : "Failed to create routine"}
</p>
) : null}
</div>
</div>
</Card>
) : null}
{error ? (
<Card> <Card>
<CardContent className="pt-6 text-sm text-destructive"> <CardContent className="pt-6 text-sm text-destructive">
{error instanceof Error ? error.message : "Failed to load routines"} {error instanceof Error ? error.message : "Failed to load routines"}
</CardContent> </CardContent>
</Card> </Card>
)} ) : null}
<div className="grid gap-4"> <div className="grid gap-4">
{(routines ?? []).length === 0 ? ( {(routines ?? []).length === 0 ? (
<EmptyState <EmptyState
icon={Repeat} icon={Repeat}
message="No routines yet. Create the first recurring workflow above." message="No routines yet. Use Create routine to define the first recurring workflow."
/> />
) : ( ) : (
(routines ?? []).map((routine) => ( (routines ?? []).map((routine) => (
<Card key={routine.id}> <Card key={routine.id}>
<CardContent className="flex flex-col gap-4 pt-6 md:flex-row md:items-start md:justify-between"> <CardContent className="flex flex-col gap-4 pt-6 md:flex-row md:items-start md:justify-between">
<div className="space-y-3 min-w-0"> <div className="min-w-0 space-y-3">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Link to={`/routines/${routine.id}`} className="text-base font-medium hover:underline"> <Link to={`/routines/${routine.id}`} className="text-base font-medium hover:underline">
{routine.title} {routine.title}
@@ -301,11 +501,11 @@ export function Routines() {
</Badge> </Badge>
<Badge variant="outline">{routine.priority}</Badge> <Badge variant="outline">{routine.priority}</Badge>
</div> </div>
{routine.description && ( {routine.description ? (
<p className="line-clamp-2 text-sm text-muted-foreground"> <p className="line-clamp-2 text-sm text-muted-foreground">
{routine.description} {routine.description}
</p> </p>
)} ) : null}
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground"> <div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>Project: {projectName.get(routine.projectId) ?? routine.projectId.slice(0, 8)}</span> <span>Project: {projectName.get(routine.projectId) ?? routine.projectId.slice(0, 8)}</span>
<span>Assignee: {agentName.get(routine.assigneeAgentId) ?? routine.assigneeAgentId.slice(0, 8)}</span> <span>Assignee: {agentName.get(routine.assigneeAgentId) ?? routine.assigneeAgentId.slice(0, 8)}</span>