nexus/ui/src/pages/RoutineDetail.tsx
dotta bdeaaeac9c Simplify routine configuration UI
- Add "Every minute" schedule preset as finest granularity
- Remove status and priority from advanced delivery settings
- Auto-generate trigger labels from kind instead of manual input

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-19 16:36:39 -05:00

1008 lines
39 KiB
TypeScript

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,
ListTree,
Play,
RefreshCw,
Repeat,
Save,
Webhook,
Zap,
} from "lucide-react";
import { routinesApi, type RoutineTriggerResponse, type RotateRoutineTriggerResponse } from "../api/routines";
import { issuesApi } from "../api/issues";
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 { timeAgo } from "../lib/timeAgo";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { AgentIcon } from "../components/AgentIconPicker";
import { IssueRow } from "../components/IssueRow";
import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector";
import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor";
import { ScheduleEditor, describeSchedule } from "../components/ScheduleEditor";
import { RunButton, PauseResumeButton } 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", "api"];
const signingModes = ["bearer", "hmac_sha256"];
const routineTabs = ["triggers", "runs", "issues", "activity"] as const;
const concurrencyPolicyDescriptions: Record<string, string> = {
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<string, string> = {
skip_missed: "Ignore schedule windows that were missed while the routine or scheduler was paused.",
enqueue_missed_with_cap: "Catch up missed schedule windows with a capped backlog after recovery.",
};
const signingModeDescriptions: Record<string, string> = {
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,
}: {
trigger: RoutineTrigger;
onSave: (id: string, patch: Record<string, unknown>) => void;
onRotate: (id: string) => void;
}) {
const [draft, setDraft] = useState({
label: trigger.label ?? "",
enabled: trigger.enabled ? "true" : "false",
cronExpression: trigger.cronExpression ?? "",
signingMode: trigger.signingMode ?? "bearer",
replayWindowSec: String(trigger.replayWindowSec ?? 300),
});
useEffect(() => {
setDraft({
label: trigger.label ?? "",
enabled: trigger.enabled ? "true" : "false",
cronExpression: trigger.cronExpression ?? "",
signingMode: trigger.signingMode ?? "bearer",
replayWindowSec: String(trigger.replayWindowSec ?? 300),
});
}, [trigger]);
return (
<div className="rounded-lg border border-border p-4 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-medium">
{trigger.kind === "schedule" ? <Clock3 className="h-3.5 w-3.5" /> : trigger.kind === "webhook" ? <Webhook className="h-3.5 w-3.5" /> : <Zap className="h-3.5 w-3.5" />}
{trigger.label ?? trigger.kind}
</div>
<span className="text-xs text-muted-foreground">
{trigger.kind === "schedule" && trigger.nextRunAt
? `Next: ${new Date(trigger.nextRunAt).toLocaleString()}`
: trigger.kind === "webhook"
? "Webhook"
: "API"}
</span>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs">Label</Label>
<Input
value={draft.label}
onChange={(event) => setDraft((current) => ({ ...current, label: event.target.value }))}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Enabled</Label>
<Select value={draft.enabled} onValueChange={(enabled) => setDraft((current) => ({ ...current, enabled }))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">Enabled</SelectItem>
<SelectItem value="false">Paused</SelectItem>
</SelectContent>
</Select>
</div>
{trigger.kind === "schedule" && (
<div className="md:col-span-2 space-y-1.5">
<Label className="text-xs">Schedule</Label>
<ScheduleEditor
value={draft.cronExpression}
onChange={(cronExpression) => setDraft((current) => ({ ...current, cronExpression }))}
/>
</div>
)}
{trigger.kind === "webhook" && (
<>
<div className="space-y-1.5">
<Label className="text-xs">Signing mode</Label>
<Select
value={draft.signingMode}
onValueChange={(signingMode) => setDraft((current) => ({ ...current, signingMode }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{signingModes.map((mode) => (
<SelectItem key={mode} value={mode}>{mode}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Replay window (seconds)</Label>
<Input
value={draft.replayWindowSec}
onChange={(event) => setDraft((current) => ({ ...current, replayWindowSec: event.target.value }))}
/>
</div>
</>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
onSave(trigger.id, {
label: draft.label.trim() || null,
enabled: draft.enabled === "true",
...(trigger.kind === "schedule"
? { cronExpression: draft.cronExpression.trim(), timezone: getLocalTimezone() }
: {}),
...(trigger.kind === "webhook"
? {
signingMode: draft.signingMode,
replayWindowSec: Number(draft.replayWindowSec || "300"),
}
: {}),
})
}
>
<Save className="mr-1.5 h-3.5 w-3.5" />
Save
</Button>
{trigger.kind === "webhook" && (
<Button variant="outline" size="sm" onClick={() => onRotate(trigger.id)}>
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
Rotate secret
</Button>
)}
{trigger.lastResult && <span className="text-xs text-muted-foreground">Last: {trigger.lastResult}</span>}
</div>
</div>
);
}
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<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 [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",
status: "active",
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 { data: routineRuns } = useQuery({
queryKey: queryKeys.routines.runs(routineId!),
queryFn: () => routinesApi.listRuns(routineId!),
enabled: !!routineId,
});
const relatedActivityIds = useMemo(
() => ({
triggerIds: routine?.triggers.map((trigger) => trigger.id) ?? [],
runIds: routineRuns?.map((run) => run.id) ?? [],
}),
[routine?.triggers, routineRuns],
);
const { data: executionIssues } = useQuery({
queryKey: ["routine-execution-issues", selectedCompanyId, routineId],
queryFn: () =>
issuesApi.list(selectedCompanyId!, {
originKind: "routine_execution",
originId: routineId!,
includeRoutineExecutions: true,
}),
enabled: !!selectedCompanyId && !!routineId,
});
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,
status: routine.status,
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.status !== routineDefaults.status ||
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: () =>
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 () => {
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 () => {
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<RoutineTriggerResponse> => {
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<string, unknown> }) => 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 rotateTrigger = useMutation({
mutationFn: (id: string): Promise<RotateRoutineTriggerResponse> => 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<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) {
return <EmptyState icon={Repeat} message="Select a company to view routines." />;
}
if (isLoading) {
return <PageSkeleton variant="issues-list" />;
}
if (error || !routine) {
return (
<p className="pt-6 text-sm text-destructive">
{error instanceof Error ? error.message : "Routine not found"}
</p>
);
}
return (
<div className="max-w-2xl space-y-6">
{/* Header: status + actions */}
<div className="flex items-center gap-2">
<Badge variant={routine.status === "active" ? "default" : "secondary"}>
{routine.status.replaceAll("_", " ")}
</Badge>
{routine.activeIssue && (
<Link
to={`/issues/${routine.activeIssue.identifier ?? routine.activeIssue.id}`}
className="text-xs text-muted-foreground hover:underline"
>
{routine.activeIssue.identifier ?? routine.activeIssue.id.slice(0, 8)}
</Link>
)}
<div className="ml-auto flex items-center gap-2">
<RunButton onClick={() => runRoutine.mutate()} disabled={runRoutine.isPending} />
<PauseResumeButton
isPaused={routine.status === "paused"}
onPause={() => updateRoutineStatus.mutate("paused")}
onResume={() => updateRoutineStatus.mutate("active")}
disabled={updateRoutineStatus.isPending || routine.status === "archived"}
/>
</div>
</div>
{/* Secret message banner */}
{secretMessage && (
<div className="rounded-lg border border-blue-500/30 bg-blue-500/5 p-4 space-y-3 text-sm">
<div>
<p className="font-medium">{secretMessage.title}</p>
<p className="text-xs text-muted-foreground">Save this now. Paperclip will not show the secret value again.</p>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Input value={secretMessage.webhookUrl} readOnly className="flex-1" />
<Button variant="outline" size="sm" onClick={() => copySecretValue("Webhook URL", secretMessage.webhookUrl)}>
<Copy className="h-3.5 w-3.5 mr-1" />
URL
</Button>
</div>
<div className="flex items-center gap-2">
<Input value={secretMessage.webhookSecret} readOnly className="flex-1" />
<Button variant="outline" size="sm" onClick={() => copySecretValue("Webhook secret", secretMessage.webhookSecret)}>
<Copy className="h-3.5 w-3.5 mr-1" />
Secret
</Button>
</div>
</div>
</div>
)}
{/* Title */}
<textarea
ref={titleInputRef}
className="w-full resize-none overflow-hidden bg-transparent text-xl font-bold outline-none placeholder:text-muted-foreground/50"
placeholder="Routine title"
rows={1}
value={editDraft.title}
onChange={(event) => {
setEditDraft((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 (editDraft.assigneeAgentId) {
if (editDraft.projectId) {
descriptionEditorRef.current?.focus();
} else {
projectSelectorRef.current?.focus();
}
} else {
assigneeSelectorRef.current?.focus();
}
}
}}
/>
{/* Assignment row */}
<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>
{/* Instructions */}
<MarkdownEditor
ref={descriptionEditorRef}
value={editDraft.description}
onChange={(description) => setEditDraft((current) => ({ ...current, description }))}
placeholder="Add instructions..."
bordered={false}
contentClassName="min-h-[120px] text-[15px] leading-7"
onSubmit={() => {
if (!saveRoutine.isPending && editDraft.title.trim() && editDraft.projectId && editDraft.assigneeAgentId) {
saveRoutine.mutate();
}
}}
/>
{/* Advanced delivery settings */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger className="flex w-full items-center justify-between text-left">
<span className="text-sm font-medium">Advanced delivery settings</span>
{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">
<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>
{/* Save bar */}
<div className="flex items-center justify-between">
{isEditDirty ? (
<span className="text-xs text-amber-600">Unsaved changes</span>
) : (
<span />
)}
<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>
<Separator />
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-3">
<TabsList variant="line" className="w-full justify-start gap-1">
<TabsTrigger value="triggers" className="gap-1.5">
<Clock3 className="h-3.5 w-3.5" />
Triggers
</TabsTrigger>
<TabsTrigger value="runs" className="gap-1.5">
<Play className="h-3.5 w-3.5" />
Runs
</TabsTrigger>
<TabsTrigger value="issues" className="gap-1.5">
<ListTree className="h-3.5 w-3.5" />
Issues
</TabsTrigger>
<TabsTrigger value="activity" className="gap-1.5">
<ActivityIcon className="h-3.5 w-3.5" />
Activity
</TabsTrigger>
</TabsList>
<TabsContent value="triggers" className="space-y-4">
{/* Add trigger form */}
<div className="rounded-lg border border-border p-4 space-y-3">
<p className="text-sm font-medium">Add trigger</p>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs">Kind</Label>
<Select value={newTrigger.kind} onValueChange={(kind) => setNewTrigger((current) => ({ ...current, kind }))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{triggerKinds.map((kind) => (
<SelectItem key={kind} value={kind}>{kind}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{newTrigger.kind === "schedule" && (
<div className="md:col-span-2 space-y-1.5">
<Label className="text-xs">Schedule</Label>
<ScheduleEditor
value={newTrigger.cronExpression}
onChange={(cronExpression) => setNewTrigger((current) => ({ ...current, cronExpression }))}
/>
</div>
)}
{newTrigger.kind === "webhook" && (
<>
<div className="space-y-1.5">
<Label className="text-xs">Signing mode</Label>
<Select value={newTrigger.signingMode} onValueChange={(signingMode) => setNewTrigger((current) => ({ ...current, signingMode }))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{signingModes.map((mode) => (
<SelectItem key={mode} value={mode}>{mode}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{signingModeDescriptions[newTrigger.signingMode]}</p>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Replay window (seconds)</Label>
<Input value={newTrigger.replayWindowSec} onChange={(event) => setNewTrigger((current) => ({ ...current, replayWindowSec: event.target.value }))} />
</div>
</>
)}
</div>
<div className="flex items-center justify-end">
<Button size="sm" onClick={() => createTrigger.mutate()} disabled={createTrigger.isPending}>
{createTrigger.isPending ? "Adding..." : "Add trigger"}
</Button>
</div>
</div>
{/* Existing triggers */}
{routine.triggers.length === 0 ? (
<p className="text-xs text-muted-foreground">No triggers configured yet.</p>
) : (
<div className="space-y-3">
{routine.triggers.map((trigger) => (
<TriggerEditor
key={trigger.id}
trigger={trigger}
onSave={(id, patch) => updateTrigger.mutate({ id, patch })}
onRotate={(id) => rotateTrigger.mutate(id)}
/>
))}
</div>
)}
</TabsContent>
<TabsContent value="runs">
{(routineRuns ?? []).length === 0 ? (
<p className="text-xs text-muted-foreground">No runs yet.</p>
) : (
<div className="border border-border rounded-lg divide-y divide-border">
{(routineRuns ?? []).map((run) => (
<div key={run.id} className="flex items-center justify-between px-3 py-2 text-sm">
<div className="flex items-center gap-2 min-w-0">
<Badge variant="outline" className="shrink-0">{run.source}</Badge>
<Badge variant={run.status === "failed" ? "destructive" : "secondary"} className="shrink-0">
{run.status.replaceAll("_", " ")}
</Badge>
{run.trigger && (
<span className="text-muted-foreground truncate">{run.trigger.label ?? run.trigger.kind}</span>
)}
{run.linkedIssue && (
<Link to={`/issues/${run.linkedIssue.identifier ?? run.linkedIssue.id}`} className="text-muted-foreground hover:underline truncate">
{run.linkedIssue.identifier ?? run.linkedIssue.id.slice(0, 8)}
</Link>
)}
</div>
<span className="text-xs text-muted-foreground shrink-0 ml-2">{timeAgo(run.triggeredAt)}</span>
</div>
))}
</div>
)}
</TabsContent>
<TabsContent value="issues">
{(executionIssues ?? []).length === 0 ? (
<p className="text-xs text-muted-foreground">No execution issues yet.</p>
) : (
<div className="border border-border rounded-lg divide-y divide-border">
{(executionIssues ?? []).map((issue) => (
<IssueRow key={issue.id} issue={issue} />
))}
</div>
)}
</TabsContent>
<TabsContent value="activity">
{(activity ?? []).length === 0 ? (
<p className="text-xs text-muted-foreground">No activity yet.</p>
) : (
<div className="space-y-1.5">
{(activity ?? []).map((event) => (
<div key={event.id} className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="font-medium text-foreground/80">{event.action.replaceAll(".", " ")}</span>
{event.details && Object.keys(event.details).length > 0 && (
<>
{Object.entries(event.details).slice(0, 3).map(([key, value]) => (
<span key={key} className="rounded-full border border-border px-1.5 py-0.5">
{key.replaceAll("_", " ")}: {formatActivityDetailValue(value)}
</span>
))}
</>
)}
<span className="ml-auto shrink-0">{timeAgo(event.createdAt)}</span>
</div>
))}
</div>
)}
</TabsContent>
</Tabs>
</div>
);
}