import { AgentAvatar } from "@/components/AgentAvatar"; import { useMemo, useState } from "react"; import { ArrowRight, Braces, Clock3, Edit3, Play, X, } from "lucide-react"; import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { RadioCardGroup } from "@/components/ui/radio-card"; import { cn } from "@/lib/utils"; import { nextCronFires, previewFirePolicies } from "../../lib/cron-fires"; import { timeAgo } from "../../lib/timeAgo"; import { EmptyState } from "../EmptyState"; import { InlineEntitySelector } from "../InlineEntitySelector"; import { DocumentAnnotationsCountChip, IssueDocumentAnnotations } from "../IssueDocumentAnnotations"; import { MarkdownEditor } from "../MarkdownEditor"; import { RoutineVariablesEditor, RoutineVariablesHint } from "../RoutineVariablesEditor"; import { EnvironmentVariablesEditor } from "../environment-variables-editor"; import { useRoutineDetail } from "./context"; import type { EnvBinding, RoutineDetail as RoutineDetailType } from "@paperclipai/shared"; const concurrencyPolicyOptions = [ { value: "coalesce_if_active", title: "Coalesce if active", description: "Keep one follow-up run queued while an active run is still working.", }, { value: "always_enqueue", title: "Always enqueue", description: "Queue every trigger occurrence, even if several runs stack up.", }, { value: "skip_if_active", title: "Skip if active", description: "Drop overlapping trigger occurrences while the routine is already active.", }, ]; const catchUpPolicyOptions = [ { value: "skip_missed", title: "Skip missed", description: "Ignore schedule windows that were missed while paused.", }, { value: "enqueue_missed_with_cap", title: "Enqueue missed with cap", description: "Catch up missed schedule windows after recovery; sub-hourly schedules are combined into one catch-up run, slower schedules replay each missed window up to a cap.", }, ]; const activityGatePolicyOptions = [ { value: "always", title: "Run on every scheduled tick", description: "Fire on the schedule no matter what — the default behavior.", }, { value: "require_external_activity", title: "Skip when there's been no activity since the last run", description: "On a scheduled tick, only run if something happened since the last run that finished. Lets a watcher-style routine stay asleep while the system is settled instead of burning tokens.", }, ]; const activityGateScopeOptions = [ { value: "company", title: "Organization-wide", description: "Any activity across the organization counts as a reason to run.", }, { value: "project", title: "This project", description: "Only activity in the routine's project counts as a reason to run.", }, ]; export function OverviewSection({ defaultDescriptionAnnotationsOpen = false, }: { defaultDescriptionAnnotationsOpen?: boolean; } = {}) { const ctx = useRoutineDetail(); const { routine, editDraft, setEditDraft, assigneeOptions, projectOptions, recentAssigneeIds, recentProjectIds, agentById, projectById, currentAssignee, currentProject, mentionOptions, assigneeSelectorRef, projectSelectorRef, descriptionEditorRef, routineRuns, activity, saveRoutine, saveConflict, isSectionDirty, navigateToSection, } = ctx; const [descriptionAnnotationsOpen, setDescriptionAnnotationsOpen] = useState(defaultDescriptionAnnotationsOpen); const activeTriggers = routine.triggers.length; const nextFire = useMemo(() => { const upcoming = routine.triggers .filter((trigger) => trigger.kind === "schedule" && trigger.nextRunAt) .map((trigger) => new Date(trigger.nextRunAt as Date)) .sort((a, b) => a.getTime() - b.getTime())[0]; return upcoming ? upcoming.toLocaleString() : null; }, [routine.triggers]); const lastRun = (routineRuns ?? [])[0] ?? null; const recentActivity = (activity ?? []).slice(0, 5); return (
{/* Assignment row */}
For setEditDraft((current) => ({ ...current, assigneeAgentId })) } onConfirm={() => { if (editDraft.projectId) { descriptionEditorRef.current?.focus(); } else { projectSelectorRef.current?.focus(); } }} renderTriggerValue={(option) => option ? ( currentAssignee ? ( <> {option.label} ) : ( {option.label} ) ) : ( Responsible ) } renderOption={(option) => { if (!option.id) return {option.label}; const assignee = agentById.get(option.id); return ( <> {assignee ? ( ) : null} {option.label} ); }} /> in setEditDraft((current) => ({ ...current, projectId }))} onConfirm={() => descriptionEditorRef.current?.focus()} renderTriggerValue={(option) => option && currentProject ? ( <> {option.label} ) : ( Project ) } renderOption={(option) => { if (!option.id) return {option.label}; const project = projectById.get(option.id); return ( <> {option.label} ); }} />
{!routine.assigneeAgentId ? (
Default agent required. This routine can stay as a draft and still run manually, but automation stays paused until you assign a default agent.
) : null} {/* Instructions */}
{routine.descriptionDocument ? ( setDescriptionAnnotationsOpen((open) => !open)} /> ) : null}
{routine.descriptionDocument ? ( setEditDraft((current) => ({ ...current, description }))} placeholder="Add instructions..." bordered={false} contentClassName="min-h-(--sz-120px) text-sm leading-7" mentions={mentionOptions} onSubmit={() => { if (!saveRoutine.isPending && editDraft.title.trim()) { saveRoutine.mutate(); } }} /> ) : ( setEditDraft((current) => ({ ...current, description }))} placeholder="Add instructions..." bordered={false} contentClassName="min-h-(--sz-120px) text-sm leading-7" mentions={mentionOptions} onSubmit={() => { if (!saveRoutine.isPending && editDraft.title.trim()) { saveRoutine.mutate(); } }} /> )}
{/* Variables peek */}
setEditDraft((current) => ({ ...current, variables }))} />
{/* Summary cards */}
navigateToSection("triggers")} ariaLabel={`${activeTriggers} triggers. Open triggers.`} /> navigateToSection("runs")} ariaLabel={lastRun ? `Last run ${lastRun.status}. Open runs.` : "No runs. Open runs."} />
{/* Recent activity */}

Recent activity

{recentActivity.length === 0 ? (

No activity yet.

) : (
{recentActivity.map((event) => (
{event.action} {event.details && Object.keys(event.details).length > 0 ? Object.keys(event.details).slice(0, 3).join(" · ") : ""} {timeAgo(event.createdAt)}
))}
)}
); } function SummaryCard({ icon: Icon, label, value, hint, to, ariaLabel, }: { icon: typeof Clock3; label: string; value: string; hint: string; to: () => void; ariaLabel: string; }) { return ( ); } export { RoutineTriggers as TriggersSection } from "../routine-triggers/RoutineTriggers"; export function VariablesSection() { const ctx = useRoutineDetail(); const { editDraft, setEditDraft, navigateToSection } = ctx; const hasVariables = editDraft.variables.length > 0; return (
Variables are auto-detected from {"{{placeholders}}"} in the title & instructions. The variable name is read-only — rename by editing the placeholder.
{hasVariables ? ( setEditDraft((current) => ({ ...current, variables }))} /> ) : ( navigateToSection("overview")} /> )}
); } export function SecretsSection() { const ctx = useRoutineDetail(); const { editDraft, setEditDraft, availableSecrets, createSecret } = ctx; // Project/company-scoped secrets that already see real usage, surfaced as // quick-bind chips (§3.4). Ranked by reference count then recency. const recentlyUsedSecrets = useMemo( () => [...availableSecrets] .filter((secret) => secret.status === "active") .sort((a, b) => { const refDelta = (b.referenceCount ?? 0) - (a.referenceCount ?? 0); if (refDelta !== 0) return refDelta; return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); }) .slice(0, 8), [availableSecrets], ); return (
Routine secrets apply to every task this routine creates. They override matching keys in project and agent env. PAPERCLIP_* names are reserved.
} secrets={availableSecrets} recentlyUsedSecrets={recentlyUsedSecrets} onCreateSecret={async (name, value) => createSecret.mutateAsync({ name, value })} onChange={(env) => setEditDraft((current) => ({ ...current, env: env ?? null }))} />
); } export function DeliverySection() { const ctx = useRoutineDetail(); const { editDraft, setEditDraft, routine } = ctx; // The activity gate only affects schedule ticks (webhook/manual/API fires are // themselves activity and always run), so the control is only meaningful for // routines that have a schedule trigger. Disable — rather than hide — it // elsewhere so the capability stays discoverable. const hasScheduleTrigger = routine.triggers.some((trigger) => trigger.kind === "schedule"); const gateEnabled = editDraft.activityGatePolicy === "require_external_activity"; return (

Concurrency

setEditDraft((current) => ({ ...current, concurrencyPolicy })) } options={concurrencyPolicyOptions} />

Catch-up

setEditDraft((current) => ({ ...current, catchUpPolicy })) } options={catchUpPolicyOptions} />

Advanced run policy

setEditDraft((current) => ({ ...current, activityGatePolicy })) } options={activityGatePolicyOptions} disabled={!hasScheduleTrigger} /> {!hasScheduleTrigger ? (

Add a schedule trigger to gate runs on activity. Webhook, manual, and API fires always run.

) : gateEnabled ? (
setEditDraft((current) => ({ ...current, activityGateScope })) } options={activityGateScopeOptions} />
) : null}
); } const dispositionToneClass: Record = { queued: "text-emerald-600 dark:text-emerald-400", coalesced: "text-amber-600 dark:text-amber-400", skipped: "text-muted-foreground", }; /** * "Next 5 fires" preview (§3.5) — the strongest "what does this policy mean?" * surface. Picks the soonest-firing schedule trigger, computes its next fires * client-side, and annotates each with how the chosen concurrency policy would * treat it. */ function NextFiresPreview({ triggers, concurrencyPolicy, }: { triggers: RoutineDetailType["triggers"]; concurrencyPolicy: string; }) { const preview = useMemo(() => { const schedule = triggers .filter((trigger) => trigger.kind === "schedule" && trigger.enabled && trigger.cronExpression) .map((trigger) => { const fires = nextCronFires(trigger.cronExpression, 5, { timeZone: trigger.timezone ?? "UTC", }); return { trigger, fires }; }) .filter((entry) => entry.fires.length > 0) .sort((a, b) => a.fires[0]!.getTime() - b.fires[0]!.getTime())[0]; if (!schedule) return null; return { timeZone: schedule.trigger.timezone ?? "UTC", entries: previewFirePolicies(schedule.fires, concurrencyPolicy), }; }, [triggers, concurrencyPolicy]); return (

Next 5 fires

{preview ? ( <>
{preview.entries.map((entry, index) => (
· {formatFireTime(entry.at, preview.timeZone)} {entry.label} {entry.note ? ( ({entry.note}) ) : null}
))}

Preview assumes the previous run is still in flight when the next fires. Times shown in{" "} {preview.timeZone}.

) : (

No enabled schedule trigger to preview. Add a schedule in Triggers to see how this policy treats upcoming fires.

)}
); } function formatFireTime(date: Date, timeZone: string): string { try { return new Intl.DateTimeFormat(undefined, { timeZone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23", }) .format(date) .replace(",", ""); } catch { return date.toISOString(); } }