From bf62e3fbf169ac686b6b10e395dfe31a06eb449a Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 9 Jun 2026 17:04:25 -0500 Subject: [PATCH] =?UTF-8?q?feat(ui):=20routine=20detail=20page=20=E2=80=94?= =?UTF-8?q?=20variation=20C=20sub-sidebar=20layout=20(PAP-10732)=20(#7848)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Rebuilds the routine detail page as **variation C** — a sub-sidebar shell that splits the page into **ROUTINE** (Overview · Triggers · Variables · Secrets · Delivery) and **OPERATE** (Runs · Activity · History), per the engineering spec on PAP-10730. Replaces the previous 5-tab `?tab=…` layout in `ui/src/pages/RoutineDetail.tsx`. Implements PAP-10732. Design source of truth: PAP-10730 `spec` document; approved direction PAP-10709. ## What changed - **Routing** (`ui/src/App.tsx`): real sub-routes under `routines/:routineId/:section`. Bare `/routines/:id` redirects to the last-viewed section (`localStorage`) or `overview`; old `?tab=…` URLs redirect to the matching section for back-compat. Every section URL is bookmarkable. - **Shell** (`RoutineDetail.tsx`): slim 56px sticky header (title + managed-by-plugin chip + Run / Active toggle), page-local sub-sidebar, full-canvas section body, per-section sticky save bar. All routine state/mutations stay in the shell and flow to sections via a `RoutineDetailContext`. - **New components**: `RoutineSubSidebar` (+ mobile ``. */ +export function RoutineSectionPicker({ + activeSection, + onNavigate, + isSectionDirty, +}: { + activeSection: RoutineSectionKey; + onNavigate: (section: RoutineSectionKey) => void; + isSectionDirty: (section: RoutineSectionKey) => boolean; +}) { + return ( +
+ +
+ ); +} + +export { ALL_ITEMS as ROUTINE_NAV_ITEMS }; diff --git a/ui/src/components/RoutineTriggerCard.tsx b/ui/src/components/RoutineTriggerCard.tsx new file mode 100644 index 00000000..480d26b2 --- /dev/null +++ b/ui/src/components/RoutineTriggerCard.tsx @@ -0,0 +1,194 @@ +import { useEffect, useState } from "react"; +import { Clock3, RefreshCw, Save, Trash2, Webhook, Zap } from "lucide-react"; +import type { RoutineTrigger } from "@paperclipai/shared"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { ScheduleEditor } from "./ScheduleEditor"; +import { buildRoutineTriggerPatch } from "../lib/routine-trigger-patch"; +import { describeCron } from "../lib/cron-readable"; + +const signingModes = ["bearer", "hmac_sha256", "github_hmac", "none"]; +const SIGNING_MODES_WITHOUT_REPLAY_WINDOW = new Set(["github_hmac", "none"]); + +function getLocalTimezone(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone; + } catch { + return "UTC"; + } +} + +/** + * Single trigger card with its own field-edit + save state (§3.2). Extracted + * from the previous inline `TriggerEditor` in `RoutineDetail.tsx` — same logic. + */ +export function RoutineTriggerCard({ + trigger, + onSave, + onRotate, + onDelete, + disabled, +}: { + trigger: RoutineTrigger; + onSave: (id: string, patch: Record) => void; + onRotate: (id: string) => void; + onDelete: (id: string) => void; + disabled?: boolean; +}) { + const [draft, setDraft] = useState({ + label: trigger.label ?? "", + cronExpression: trigger.cronExpression ?? "", + signingMode: trigger.signingMode ?? "bearer", + replayWindowSec: String(trigger.replayWindowSec ?? 300), + }); + + useEffect(() => { + setDraft({ + label: trigger.label ?? "", + cronExpression: trigger.cronExpression ?? "", + signingMode: trigger.signingMode ?? "bearer", + replayWindowSec: String(trigger.replayWindowSec ?? 300), + }); + }, [trigger]); + + const KindIcon = + trigger.kind === "schedule" ? Clock3 : trigger.kind === "webhook" ? Webhook : Zap; + const humanCron = trigger.kind === "schedule" ? describeCron(draft.cronExpression) : null; + const lastResultOk = + trigger.lastResult != null && + /succeed|success|ok|200|delivered/i.test(String(trigger.lastResult)); + + return ( +
event.preventDefault()} + > +
+
+
+ + {trigger.label ?? trigger.kind} +
+ {humanCron ? ( +

+ {humanCron} +

+ ) : null} +
+
+ {trigger.lastResult ? ( + + {String(trigger.lastResult)} + + ) : null} + + {trigger.kind === "schedule" && trigger.nextRunAt + ? `Next: ${new Date(trigger.nextRunAt).toLocaleString()}` + : trigger.kind === "webhook" + ? "Webhook" + : "API"} + +
+
+ +
+
+ + setDraft((current) => ({ ...current, label: event.target.value }))} + /> +
+ {trigger.kind === "schedule" && ( +
+ + + setDraft((current) => ({ ...current, cronExpression })) + } + /> +
+ )} + {trigger.kind === "webhook" && ( + <> +
+ + +
+ {!SIGNING_MODES_WITHOUT_REPLAY_WINDOW.has(draft.signingMode) && ( +
+ + + setDraft((current) => ({ ...current, replayWindowSec: event.target.value })) + } + /> +
+ )} + + )} +
+ + {!disabled && ( +
+ + {trigger.kind === "webhook" && ( + + )} + +
+ )} +
+ ); +} diff --git a/ui/src/components/routine-sections/context.tsx b/ui/src/components/routine-sections/context.tsx new file mode 100644 index 00000000..7f969f9d --- /dev/null +++ b/ui/src/components/routine-sections/context.tsx @@ -0,0 +1,171 @@ +import { createContext, useContext } from "react"; +import type { UseMutationResult } from "@tanstack/react-query"; +import type { + CompanySecret, + RoutineDetail as RoutineDetailType, + RoutineEnvConfig, + RoutineVariable, +} from "@paperclipai/shared"; +import type { MarkdownEditorRef, MentionOption } from "../MarkdownEditor"; +import type { InlineEntityOption } from "../InlineEntitySelector"; +import type { RoutineHistoryDirtyFieldDescriptor } from "../RoutineHistoryTab"; +import type { RoutineRunDialogSubmitData } from "../RoutineRunVariablesDialog"; +import type { + RoutineTriggerResponse, + RotateRoutineTriggerResponse, +} from "../../api/routines"; +import type { agentsApi } from "../../api/agents"; +import type { projectsApi } from "../../api/projects"; +import type { secretsApi } from "../../api/secrets"; + +export const ROUTINE_SECTION_KEYS = [ + "overview", + "triggers", + "variables", + "secrets", + "delivery", + "runs", + "activity", + "history", +] as const; + +export type RoutineSectionKey = (typeof ROUTINE_SECTION_KEYS)[number]; + +/** Editable sections own a save bar; read-only (operate) sections do not. */ +export const EDITABLE_SECTIONS: RoutineSectionKey[] = [ + "overview", + "triggers", + "variables", + "secrets", + "delivery", +]; + +/** Which dirty-field keys belong to which section (for scoped save state). */ +export const SECTION_FIELD_KEYS: Record = { + overview: ["title", "description", "projectId", "assigneeAgentId", "priority"], + variables: ["variables"], + secrets: ["env"], + delivery: ["concurrencyPolicy", "catchUpPolicy"], +}; + +export type RoutineEditDraft = { + title: string; + description: string; + projectId: string; + assigneeAgentId: string; + priority: string; + concurrencyPolicy: string; + catchUpPolicy: string; + variables: RoutineVariable[]; + env: RoutineEnvConfig | null; +}; + +export type NewTriggerDraft = { + kind: string; + cronExpression: string; + signingMode: string; + replayWindowSec: string; +}; + +export type SecretMessage = { + title: string; + entries: Array<{ webhookUrl: string; webhookSecret: string }>; +}; + +type AgentList = Awaited>; +type ProjectList = Awaited>; +type SecretList = Awaited>; +type RoutineRunList = Awaited>; +type RoutineActivityList = Awaited< + ReturnType +>; + +export type RoutineDetailContextValue = { + routine: RoutineDetailType; + routineId: string; + companyId: string; + + // edit state + editDraft: RoutineEditDraft; + setEditDraft: React.Dispatch>; + routineDefaults: RoutineEditDraft; + dirtyFields: RoutineHistoryDirtyFieldDescriptor[]; + isEditDirty: boolean; + sectionDirtyFields: (section: RoutineSectionKey) => RoutineHistoryDirtyFieldDescriptor[]; + isSectionDirty: (section: RoutineSectionKey) => boolean; + discardSection: (section: RoutineSectionKey) => void; + + // save + saveRoutine: UseMutationResult; + saveConflict: boolean; + reloadLatest: () => void; + + // header / automation + automationEnabled: boolean; + automationLabel: string; + automationLabelClassName: string; + automationToggleDisabled: boolean; + onToggleAutomation: () => void; + onOpenRunDialog: () => void; + runRoutinePending: boolean; + + // triggers + newTrigger: NewTriggerDraft; + setNewTrigger: React.Dispatch>; + createTrigger: UseMutationResult; + updateTrigger: UseMutationResult }, unknown>; + deleteTrigger: UseMutationResult; + rotateTrigger: UseMutationResult; + + // secrets + secretMessage: SecretMessage | null; + setSecretMessage: (message: SecretMessage | null) => void; + copySecretValue: (label: string, value: string) => void; + availableSecrets: SecretList; + createSecret: UseMutationResult; + + // entities + agents: AgentList; + projects: ProjectList; + agentById: Map; + projectById: Map; + assigneeOptions: InlineEntityOption[]; + projectOptions: InlineEntityOption[]; + recentAssigneeIds: string[]; + recentProjectIds: string[]; + mentionOptions: MentionOption[]; + currentAssignee: AgentList[number] | null; + currentProject: ProjectList[number] | null; + + // operate data + routineRuns: RoutineRunList | undefined; + activity: RoutineActivityList | undefined; + hasLiveRun: boolean; + activeIssueId: string | undefined; + + // refs + titleInputRef: React.RefObject; + descriptionEditorRef: React.RefObject; + assigneeSelectorRef: React.RefObject; + projectSelectorRef: React.RefObject; + + // history restore plumbing + onHistoryRestoreSecretMaterials: ( + response: import("../../api/routines").RestoreRoutineRevisionResponse, + ) => void; + onHistoryRestored: ( + response: import("../../api/routines").RestoreRoutineRevisionResponse, + ) => void; + + navigateToSection: (section: RoutineSectionKey, options?: { replace?: boolean }) => void; +}; + +export const RoutineDetailContext = createContext(null); + +export function useRoutineDetail(): RoutineDetailContextValue { + const value = useContext(RoutineDetailContext); + if (!value) { + throw new Error("useRoutineDetail must be used within a RoutineDetailContext provider"); + } + return value; +} diff --git a/ui/src/components/routine-sections/editable-sections.tsx b/ui/src/components/routine-sections/editable-sections.tsx new file mode 100644 index 00000000..c1cc6371 --- /dev/null +++ b/ui/src/components/routine-sections/editable-sections.tsx @@ -0,0 +1,564 @@ +import { useMemo } from "react"; +import { + ArrowRight, + Braces, + Clock3, + Edit3, + KeyRound, + Play, +} 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 { timeAgo } from "../../lib/timeAgo"; +import { EmptyState } from "../EmptyState"; +import { InlineEntitySelector } from "../InlineEntitySelector"; +import { AgentIcon } from "../AgentIconPicker"; +import { MarkdownEditor } from "../MarkdownEditor"; +import { ScheduleEditor } from "../ScheduleEditor"; +import { RoutineVariablesEditor, RoutineVariablesHint } from "../RoutineVariablesEditor"; +import { RoutineTriggerCard } from "../RoutineTriggerCard"; +import { EnvVarEditor } from "../EnvVarEditor"; +import { useRoutineDetail } from "./context"; +import type { EnvBinding } 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 in capped batches after recovery.", + }, +]; + +const triggerKinds = ["schedule", "webhook"]; +const signingModes = ["bearer", "hmac_sha256", "github_hmac", "none"]; +const signingModeDescriptions: Record = { + 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.", + github_hmac: "Accept GitHub-style X-Hub-Signature-256 header (HMAC over raw body, no timestamp).", + none: "No authentication — the webhook URL itself acts as a shared secret.", +}; +const SIGNING_MODES_WITHOUT_REPLAY_WINDOW = new Set(["github_hmac", "none"]); + +export function OverviewSection() { + const ctx = useRoutineDetail(); + const { + routine, + editDraft, + setEditDraft, + assigneeOptions, + projectOptions, + recentAssigneeIds, + recentProjectIds, + agentById, + projectById, + currentAssignee, + currentProject, + mentionOptions, + assigneeSelectorRef, + projectSelectorRef, + descriptionEditorRef, + routineRuns, + activity, + saveRoutine, + navigateToSection, + } = ctx; + + 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 boundSecrets = editDraft.env ? Object.keys(editDraft.env).length : 0; + 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} + ) + ) : ( + Assignee + ) + } + 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 */} + setEditDraft((current) => ({ ...current, description }))} + placeholder="Add instructions..." + bordered={false} + contentClassName="min-h-[120px] text-[15px] 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("secrets")} + ariaLabel={`${boundSecrets} secrets bound. Open secrets.`} + /> + 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 function TriggersSection() { + const ctx = useRoutineDetail(); + const { routine, newTrigger, setNewTrigger, createTrigger, updateTrigger, deleteTrigger, rotateTrigger } = ctx; + + return ( +
+ {/* Add trigger form */} +
+

Add trigger

+
+
+ + +
+ {newTrigger.kind === "schedule" && ( +
+ + + setNewTrigger((current) => ({ ...current, cronExpression })) + } + /> +
+ )} + {newTrigger.kind === "webhook" && ( + <> +
+ + +

+ {signingModeDescriptions[newTrigger.signingMode]} +

+
+ {!SIGNING_MODES_WITHOUT_REPLAY_WINDOW.has(newTrigger.signingMode) && ( +
+ + + setNewTrigger((current) => ({ ...current, replayWindowSec: event.target.value })) + } + /> +
+ )} + + )} +
+
+ +
+
+ + {/* Existing triggers */} + {routine.triggers.length === 0 ? ( + + ) : ( +
+ {routine.triggers.map((trigger) => ( + updateTrigger.mutate({ id, patch })} + onRotate={(id) => rotateTrigger.mutate(id)} + onDelete={(id) => deleteTrigger.mutate(id)} + /> + ))} +
+ )} +
+ ); +} + +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, secretMessage, copySecretValue } = ctx; + + return ( +
+
+ Routine secrets apply to every task this routine creates. They override matching keys in + project and agent env. PAPERCLIP_* names are reserved. +
+ + {secretMessage ? ( +
+
+

{secretMessage.title}

+

+ Save this now. Paperclip will not show the secret value again. +

+
+
+ {secretMessage.entries.map((entry, index) => ( +
+
+ + +
+
+ + +
+
+ ))} +
+
+ ) : null} + + } + secrets={availableSecrets} + 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 } = ctx; + + return ( +
+
+

+ Concurrency +

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

+ Catch-up +

+ + setEditDraft((current) => ({ ...current, catchUpPolicy })) + } + options={catchUpPolicyOptions} + /> +
+
+ ); +} diff --git a/ui/src/components/routine-sections/operate-sections.tsx b/ui/src/components/routine-sections/operate-sections.tsx new file mode 100644 index 00000000..2edd4fb4 --- /dev/null +++ b/ui/src/components/routine-sections/operate-sections.tsx @@ -0,0 +1,149 @@ +import { useMemo } from "react"; +import { Activity as ActivityIcon, Play } from "lucide-react"; +import { Link } from "@/lib/router"; +import { Badge } from "@/components/ui/badge"; +import { timeAgo } from "../../lib/timeAgo"; +import { EmptyState } from "../EmptyState"; +import { LiveRunWidget } from "../LiveRunWidget"; +import { RoutineHistoryTab } from "../RoutineHistoryTab"; +import { RoutineActivityRow } from "../RoutineActivityRow"; +import { useRoutineDetail } from "./context"; + +export function RunsSection() { + const ctx = useRoutineDetail(); + const { routine, routineRuns, hasLiveRun, activeIssueId, onOpenRunDialog } = ctx; + const runs = routineRuns ?? []; + + return ( +
+ {hasLiveRun && activeIssueId ? ( + + ) : null} + {runs.length === 0 ? ( + + ) : ( +
+ {runs.map((run) => ( +
+
+ + {run.source} + + + {run.status.replaceAll("_", " ")} + + {run.trigger ? ( + + {run.trigger.label ?? run.trigger.kind} + + ) : null} + {run.linkedIssue ? ( + + {run.linkedIssue.identifier ?? run.linkedIssue.id.slice(0, 8)} + + ) : null} +
+ + {timeAgo(run.triggeredAt)} + +
+ ))} +
+ )} +
+ ); +} + +export function ActivitySection() { + const ctx = useRoutineDetail(); + const { activity } = ctx; + const events = activity ?? []; + + const groups = useMemo(() => { + const byDay = new Map(); + for (const event of events) { + let label = "Earlier"; + try { + label = new Date(event.createdAt).toLocaleDateString(undefined, { + weekday: "short", + month: "short", + day: "numeric", + }); + } catch { + /* keep fallback label */ + } + const bucket = byDay.get(label) ?? []; + bucket.push(event); + byDay.set(label, bucket); + } + return Array.from(byDay.entries()); + }, [events]); + + if (events.length === 0) { + return ; + } + + return ( +
+ {groups.map(([day, dayEvents]) => ( +
+
+ {day} +
+
+ {dayEvents.map((event) => ( + + ))} +
+
+ ))} +
+ ); +} + +export function HistorySection() { + const ctx = useRoutineDetail(); + const { + routine, + isEditDirty, + dirtyFields, + routineDefaults, + setEditDraft, + saveRoutine, + agentById, + projectById, + availableSecrets, + onHistoryRestoreSecretMaterials, + onHistoryRestored, + } = ctx; + + return ( + setEditDraft(routineDefaults)} + onSaveEdits={() => { + if (!saveRoutine.isPending && routine.title.trim()) { + saveRoutine.mutate(); + } + }} + agents={agentById} + projects={projectById} + secrets={availableSecrets} + onRestoreSecretMaterials={onHistoryRestoreSecretMaterials} + onRestored={onHistoryRestored} + /> + ); +} diff --git a/ui/src/components/ui/radio-card.tsx b/ui/src/components/ui/radio-card.tsx new file mode 100644 index 00000000..8e3295de --- /dev/null +++ b/ui/src/components/ui/radio-card.tsx @@ -0,0 +1,107 @@ +import * as React from "react"; +import { Check } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export type RadioCardOption = { + value: string; + title: string; + description?: string; +}; + +/** + * Selectable card primitive — a labelled ` + ); +} + +export function RadioCardGroup({ + value, + onValueChange, + options, + ariaLabel, + disabled, + className, +}: { + value: string; + onValueChange: (value: string) => void; + options: RadioCardOption[]; + ariaLabel: string; + disabled?: boolean; + className?: string; +}) { + const handleKeyDown = (event: React.KeyboardEvent) => { + if (disabled) return; + const idx = options.findIndex((option) => option.value === value); + if (idx === -1) return; + let nextIdx: number | null = null; + if (event.key === "ArrowDown" || event.key === "ArrowRight") { + nextIdx = (idx + 1) % options.length; + } else if (event.key === "ArrowUp" || event.key === "ArrowLeft") { + nextIdx = (idx - 1 + options.length) % options.length; + } + if (nextIdx !== null) { + event.preventDefault(); + onValueChange(options[nextIdx].value); + } + }; + + return ( +
+ {options.map((option) => ( + onValueChange(option.value)} + /> + ))} +
+ ); +} diff --git a/ui/src/lib/cron-readable.test.ts b/ui/src/lib/cron-readable.test.ts new file mode 100644 index 00000000..c41e9108 --- /dev/null +++ b/ui/src/lib/cron-readable.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { describeCron } from "./cron-readable"; + +describe("describeCron", () => { + it("describes a daily time", () => { + expect(describeCron("0 14 * * *")).toBe("Every day at 14:00"); + }); + + it("describes every-weekday", () => { + expect(describeCron("0 14 * * 1-5")).toBe("Every weekday at 14:00"); + }); + + it("describes a single day-of-week", () => { + expect(describeCron("30 9 * * 1")).toBe("Every Monday at 09:30"); + }); + + it("describes every N minutes", () => { + expect(describeCron("*/15 * * * *")).toBe("Every 15 minutes"); + }); + + it("describes hourly on a minute", () => { + expect(describeCron("5 * * * *")).toBe("Every hour at :05"); + }); + + it("returns null for empty or unparseable input", () => { + expect(describeCron("")).toBeNull(); + expect(describeCron(undefined)).toBeNull(); + expect(describeCron("not a cron")).toBeNull(); + expect(describeCron("1 2 3")).toBeNull(); + }); +}); diff --git a/ui/src/lib/cron-readable.ts b/ui/src/lib/cron-readable.ts new file mode 100644 index 00000000..b2f85b38 --- /dev/null +++ b/ui/src/lib/cron-readable.ts @@ -0,0 +1,79 @@ +/** + * Tiny best-effort cron → plain-English helper for the routine Triggers section. + * Not a full cron parser: it covers the common shapes Paperclip schedule triggers + * produce (every N minutes/hours, daily at HH:MM, weekday/weekend, day-of-week). + * Falls back to the raw expression when it can't confidently describe it. + */ + +const DOW_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +function pad2(value: number): string { + return value.toString().padStart(2, "0"); +} + +function describeTime(minute: string, hour: string): string | null { + const m = Number(minute); + const h = Number(hour); + if (!Number.isInteger(m) || !Number.isInteger(h)) return null; + if (m < 0 || m > 59 || h < 0 || h > 23) return null; + return `${pad2(h)}:${pad2(m)}`; +} + +function describeDayOfWeek(dow: string): string | null { + if (dow === "*" || dow === "?") return "every day"; + if (dow === "1-5") return "every weekday"; + if (dow === "0,6" || dow === "6,0" || dow === "0,7") return "every weekend"; + const parts = dow.split(",").map((part) => part.trim()); + const names = parts.map((part) => { + const n = Number(part); + if (!Number.isInteger(n)) return null; + return DOW_NAMES[n % 7]; + }); + if (names.some((name) => name === null)) return null; + if (names.length === 1) return `every ${names[0]}`; + return `every ${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`; +} + +export function describeCron(expression: string | null | undefined): string | null { + if (!expression) return null; + const trimmed = expression.trim(); + const fields = trimmed.split(/\s+/); + // Standard 5-field cron: minute hour day-of-month month day-of-week + if (fields.length !== 5) return null; + const [minute, hour, dom, month, dow] = fields; + + // Every N minutes + const everyMinutes = minute.match(/^\*\/(\d+)$/); + if (everyMinutes && hour === "*" && dom === "*" && month === "*" && dow === "*") { + return `Every ${everyMinutes[1]} minutes`; + } + + // Every N hours, on the minute + const everyHours = hour.match(/^\*\/(\d+)$/); + if (everyHours && /^\d+$/.test(minute) && dom === "*" && month === "*" && dow === "*") { + return `Every ${everyHours[1]} hours at :${pad2(Number(minute))}`; + } + + // Hourly + if (/^\d+$/.test(minute) && hour === "*" && dom === "*" && month === "*" && dow === "*") { + return `Every hour at :${pad2(Number(minute))}`; + } + + // Daily / weekly at a fixed time + if (/^\d+$/.test(minute) && /^\d+$/.test(hour) && month === "*") { + const time = describeTime(minute, hour); + if (!time) return null; + if (dom === "*" && (dow === "*" || dow === "?")) { + return `Every day at ${time}`; + } + if (dom === "*") { + const dowText = describeDayOfWeek(dow); + if (dowText) return `${dowText[0].toUpperCase()}${dowText.slice(1)} at ${time}`; + } + if (/^\d+$/.test(dom) && (dow === "*" || dow === "?")) { + return `Day ${dom} of every month at ${time}`; + } + } + + return null; +} diff --git a/ui/src/pages/RoutineDetail.tsx b/ui/src/pages/RoutineDetail.tsx index 51d96025..9b4a69c1 100644 --- a/ui/src/pages/RoutineDetail.tsx +++ b/ui/src/pages/RoutineDetail.tsx @@ -1,32 +1,17 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { Link, useLocation, useNavigate, useParams } from "@/lib/router"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Navigate, useNavigate, useParams } from "@/lib/router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { - Activity as ActivityIcon, - ChevronDown, - ChevronRight, - Clock3, - Copy, - History as HistoryIcon, - KeyRound, - Play, - RefreshCw, - Repeat, - Save, - Trash2, - Webhook, - Zap, -} from "lucide-react"; +import { AlertCircle, Repeat, Sparkles } from "lucide-react"; import { ApiError } from "../api/client"; -import { routinesApi, type RoutineTriggerResponse, type RotateRoutineTriggerResponse, type RestoreRoutineRevisionResponse } from "../api/routines"; -import { secretsApi } from "../api/secrets"; -import { EnvVarEditor } from "../components/EnvVarEditor"; import { - RoutineHistoryTab, - type RoutineHistoryDirtyFieldDescriptor, -} from "../components/RoutineHistoryTab"; + routinesApi, + type RoutineTriggerResponse, + type RotateRoutineTriggerResponse, + type RestoreRoutineRevisionResponse, +} from "../api/routines"; +import { secretsApi } from "../api/secrets"; +import { type RoutineHistoryDirtyFieldDescriptor } from "../components/RoutineHistoryTab"; import { heartbeatsApi } from "../api/heartbeats"; -import { LiveRunWidget } from "../components/LiveRunWidget"; import { agentsApi } from "../api/agents"; import { projectsApi } from "../api/projects"; import { accessApi } from "../api/access"; @@ -34,76 +19,100 @@ import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useToastActions } from "../context/ToastContext"; import { queryKeys } from "../lib/queryKeys"; -import { buildRoutineTriggerPatch } from "../lib/routine-trigger-patch"; import { buildMarkdownMentionOptions } from "../lib/company-members"; -import { timeAgo } from "../lib/timeAgo"; import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; -import { AgentIcon } from "../components/AgentIconPicker"; -import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector"; -import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "../components/MarkdownEditor"; +import { type InlineEntityOption } from "../components/InlineEntitySelector"; +import { type MarkdownEditorRef, type MentionOption } from "../components/MarkdownEditor"; import { RoutineRunVariablesDialog, type RoutineRunDialogSubmitData, } from "../components/RoutineRunVariablesDialog"; -import { RoutineVariablesEditor, RoutineVariablesHint } from "../components/RoutineVariablesEditor"; -import { ScheduleEditor, describeSchedule } from "../components/ScheduleEditor"; import { RunButton } from "../components/AgentActionButtons"; import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees"; import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects"; -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 { + RoutineSubSidebar, + RoutineSectionPicker, +} from "../components/RoutineSubSidebar"; +import { RoutineSaveBar } from "../components/RoutineSaveBar"; +import { + EDITABLE_SECTIONS, + ROUTINE_SECTION_KEYS, + SECTION_FIELD_KEYS, + RoutineDetailContext, + type RoutineDetailContextValue, + type RoutineEditDraft, + type RoutineSectionKey, + type SecretMessage, +} from "../components/routine-sections/context"; +import { + OverviewSection, + TriggersSection, + VariablesSection, + SecretsSection, + DeliverySection, +} from "../components/routine-sections/editable-sections"; +import { + RunsSection, + ActivitySection, + HistorySection, +} from "../components/routine-sections/operate-sections"; import type { - EnvBinding, RoutineDetail as RoutineDetailType, RoutineEnvConfig, - RoutineTrigger, RoutineVariable, } 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"]; -const signingModes = ["bearer", "hmac_sha256", "github_hmac", "none"]; -const routineTabs = ["triggers", "runs", "activity", "secrets", "history"] as const; -const concurrencyPolicyDescriptions: Record = { - 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 = { - skip_missed: "Ignore schedule windows that were missed while the routine or scheduler was paused.", - enqueue_missed_with_cap: "Catch up missed schedule windows in capped batches after recovery.", -}; -const signingModeDescriptions: Record = { - 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.", - github_hmac: "Accept GitHub-style X-Hub-Signature-256 header (HMAC over raw body, no timestamp).", - none: "No authentication — the webhook URL itself acts as a shared secret.", -}; -const SIGNING_MODES_WITHOUT_REPLAY_WINDOW = new Set(["github_hmac", "none"]); +const LAST_SECTION_STORAGE_KEY = "paperclip.routineLastSection"; -type RoutineTab = (typeof routineTabs)[number]; +const SECTION_TITLES: Record = { + overview: "Overview", + triggers: "Triggers", + variables: "Variables", + secrets: "Secrets", + delivery: "Delivery", + runs: "Runs", + activity: "Activity", + history: "History", +}; -type SecretMessage = { - title: string; - entries: Array<{ - webhookUrl: string; - webhookSecret: string; - }>; +function isRoutineSection(value: string | undefined | null): value is RoutineSectionKey { + return value != null && ROUTINE_SECTION_KEYS.includes(value as RoutineSectionKey); +} + +function readLastSection(routineId: string): RoutineSectionKey | null { + try { + const raw = localStorage.getItem(LAST_SECTION_STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as Record; + const stored = parsed[routineId]; + return isRoutineSection(stored) ? stored : null; + } catch { + return null; + } +} + +function writeLastSection(routineId: string, section: RoutineSectionKey) { + try { + const raw = localStorage.getItem(LAST_SECTION_STORAGE_KEY); + const parsed = raw ? (JSON.parse(raw) as Record) : {}; + parsed[routineId] = section; + localStorage.setItem(LAST_SECTION_STORAGE_KEY, JSON.stringify(parsed)); + } catch { + /* ignore storage failures */ + } +} + +/** Back-compat: `?tab=x` query param maps to the new section sub-routes. */ +const LEGACY_TAB_TO_SECTION: Record = { + triggers: "triggers", + runs: "runs", + activity: "activity", + secrets: "secrets", + history: "history", }; function autoResizeTextarea(element: HTMLTextAreaElement | null) { @@ -112,27 +121,6 @@ function autoResizeTextarea(element: HTMLTextAreaElement | null) { 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; @@ -141,17 +129,7 @@ function getLocalTimezone(): string { } } -function buildRoutineMutationPayload(input: { - title: string; - description: string; - projectId: string; - assigneeAgentId: string; - priority: string; - concurrencyPolicy: string; - catchUpPolicy: string; - variables: RoutineVariable[]; - env: RoutineEnvConfig | null; -}) { +function buildRoutineMutationPayload(input: RoutineEditDraft) { return { ...input, description: input.description.trim() || null, @@ -161,135 +139,12 @@ function buildRoutineMutationPayload(input: { }; } -function TriggerEditor({ - trigger, - onSave, - onRotate, - onDelete, -}: { - trigger: RoutineTrigger; - onSave: (id: string, patch: Record) => void; - onRotate: (id: string) => void; - onDelete: (id: string) => void; -}) { - const [draft, setDraft] = useState({ - label: trigger.label ?? "", - cronExpression: trigger.cronExpression ?? "", - signingMode: trigger.signingMode ?? "bearer", - replayWindowSec: String(trigger.replayWindowSec ?? 300), - }); - - useEffect(() => { - setDraft({ - label: trigger.label ?? "", - cronExpression: trigger.cronExpression ?? "", - signingMode: trigger.signingMode ?? "bearer", - replayWindowSec: String(trigger.replayWindowSec ?? 300), - }); - }, [trigger]); - - return ( -
-
-
- {trigger.kind === "schedule" ? : trigger.kind === "webhook" ? : } - {trigger.label ?? trigger.kind} -
- - {trigger.kind === "schedule" && trigger.nextRunAt - ? `Next: ${new Date(trigger.nextRunAt).toLocaleString()}` - : trigger.kind === "webhook" - ? "Webhook" - : "API"} - -
- -
-
- - setDraft((current) => ({ ...current, label: event.target.value }))} - /> -
- {trigger.kind === "schedule" && ( -
- - setDraft((current) => ({ ...current, cronExpression }))} - /> -
- )} - {trigger.kind === "webhook" && ( - <> -
- - -
- {!SIGNING_MODES_WITHOUT_REPLAY_WINDOW.has(draft.signingMode) && ( -
- - setDraft((current) => ({ ...current, replayWindowSec: event.target.value }))} - /> -
- )} - - )} -
- -
- {trigger.lastResult && Last: {trigger.lastResult}} -
- {trigger.kind === "webhook" && ( - - )} - - -
-
-
- ); -} - export function RoutineDetail() { - const { routineId } = useParams<{ routineId: string }>(); + const { routineId, section: sectionParam } = useParams<{ routineId: string; section?: string }>(); const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); const navigate = useNavigate(); - const location = useLocation(); const { pushToast } = useToastActions(); const hydratedRoutineIdRef = useRef(null); const titleInputRef = useRef(null); @@ -297,7 +152,6 @@ export function RoutineDetail() { const assigneeSelectorRef = useRef(null); const projectSelectorRef = useRef(null); const [secretMessage, setSecretMessage] = useState(null); - const [advancedOpen, setAdvancedOpen] = useState(false); const [saveConflict, setSaveConflict] = useState(false); const [runVariablesOpen, setRunVariablesOpen] = useState(false); const [newTrigger, setNewTrigger] = useState({ @@ -306,17 +160,7 @@ export function RoutineDetail() { signingMode: "bearer", replayWindowSec: "300", }); - const [editDraft, setEditDraft] = useState<{ - title: string; - description: string; - projectId: string; - assigneeAgentId: string; - priority: string; - concurrencyPolicy: string; - catchUpPolicy: string; - variables: RoutineVariable[]; - env: RoutineEnvConfig | null; - }>({ + const [editDraft, setEditDraft] = useState({ title: "", description: "", projectId: "", @@ -327,7 +171,17 @@ export function RoutineDetail() { variables: [], env: null, }); - const activeTab = useMemo(() => getRoutineTabFromSearch(location.search), [location.search]); + + const section: RoutineSectionKey = isRoutineSection(sectionParam) ? sectionParam : "overview"; + + const navigateToSection = useCallback( + (next: RoutineSectionKey, options?: { replace?: boolean }) => { + if (!routineId) return; + writeLastSection(routineId, next); + navigate(`/routines/${routineId}/${next}`, { replace: options?.replace ?? true }); + }, + [navigate, routineId], + ); const { data: routine, isLoading, error } = useQuery({ queryKey: queryKeys.routines.detail(routineId!), @@ -395,7 +249,7 @@ export function RoutineDetail() { }, }); - const routineDefaults = useMemo( + const routineDefaults = useMemo( () => routine ? { @@ -444,11 +298,38 @@ export function RoutineDetail() { }, [editDraft, routineDefaults]); const isEditDirty = dirtyFields.length > 0; + const sectionDirtyFields = useCallback( + (target: RoutineSectionKey) => { + const keys = SECTION_FIELD_KEYS[target]; + if (!keys) return []; + return dirtyFields.filter((field) => keys.includes(field.key)); + }, + [dirtyFields], + ); + const isSectionDirty = useCallback( + (target: RoutineSectionKey) => sectionDirtyFields(target).length > 0, + [sectionDirtyFields], + ); + const discardSection = useCallback( + (target: RoutineSectionKey) => { + if (!routineDefaults) return; + const keys = SECTION_FIELD_KEYS[target]; + if (!keys) return; + setEditDraft((current) => { + const next = { ...current } as Record; + for (const key of keys) { + next[key] = (routineDefaults as Record)[key]; + } + return next as RoutineEditDraft; + }); + }, + [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); @@ -460,36 +341,28 @@ export function RoutineDetail() { 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", - }); + // Persist the section the user lands on so a bare /routines/:id remembers it. + useEffect(() => { + if (routineId && isRoutineSection(sectionParam)) { + writeLastSection(routineId, sectionParam); } - }; + }, [routineId, sectionParam]); - 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 copySecretValue = useCallback( + async (label: string, value: string) => { + try { + await navigator.clipboard.writeText(value); + pushToast({ title: `${label} copied`, tone: "success" }); + } catch (copyError) { + pushToast({ + title: `Failed to copy ${label.toLowerCase()}`, + body: copyError instanceof Error ? copyError.message : "Clipboard access was denied.", + tone: "error", + }); + } + }, + [pushToast], + ); const saveRoutine = useMutation({ mutationFn: () => { @@ -509,8 +382,8 @@ export function RoutineDetail() { queryClient.invalidateQueries({ queryKey: queryKeys.routines.revisions(routineId!) }), ]); }, - onError: (error) => { - if (error instanceof ApiError && error.status === 409) { + onError: (mutationError) => { + if (mutationError instanceof ApiError && mutationError.status === 409) { setSaveConflict(true); pushToast({ title: "Routine changed", @@ -521,7 +394,7 @@ export function RoutineDetail() { } pushToast({ title: "Failed to save routine", - body: error instanceof Error ? error.message : "Paperclip could not save the routine.", + body: mutationError instanceof Error ? mutationError.message : "Paperclip could not save the routine.", tone: "error", }); }, @@ -544,7 +417,7 @@ export function RoutineDetail() { onSuccess: async () => { pushToast({ title: "Routine run started", tone: "success" }); setRunVariablesOpen(false); - setActiveTab("runs"); + navigateToSection("runs"); await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }), queryClient.invalidateQueries({ queryKey: queryKeys.routines.runs(routineId!) }), @@ -552,10 +425,10 @@ export function RoutineDetail() { queryClient.invalidateQueries({ queryKey: queryKeys.routines.activity(selectedCompanyId!, routineId!) }), ]); }, - onError: (error) => { + onError: (runError) => { pushToast({ title: "Routine run failed", - body: error instanceof Error ? error.message : "Paperclip could not start the routine run.", + body: runError instanceof Error ? runError.message : "Paperclip could not start the routine run.", tone: "error", }); }, @@ -574,10 +447,10 @@ export function RoutineDetail() { queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }), ]); }, - onError: (error) => { + onError: (statusError) => { pushToast({ title: "Failed to update routine", - body: error instanceof Error ? error.message : "Paperclip could not update the routine.", + body: statusError instanceof Error ? statusError.message : "Paperclip could not update the routine.", tone: "error", }); }, @@ -594,10 +467,7 @@ export function RoutineDetail() { ? { cronExpression: newTrigger.cronExpression.trim(), timezone: getLocalTimezone() } : {}), ...(newTrigger.kind === "webhook" - ? { - signingMode: newTrigger.signingMode, - replayWindowSec: Number(newTrigger.replayWindowSec || "300"), - } + ? { signingMode: newTrigger.signingMode, replayWindowSec: Number(newTrigger.replayWindowSec || "300") } : {}), }); }, @@ -605,17 +475,10 @@ export function RoutineDetail() { if (result.secretMaterial) { setSecretMessage({ title: "Webhook trigger created", - entries: [{ - webhookUrl: result.secretMaterial.webhookUrl, - webhookSecret: result.secretMaterial.webhookSecret, - }], + entries: [{ webhookUrl: result.secretMaterial.webhookUrl, webhookSecret: result.secretMaterial.webhookSecret }], }); } else { - pushToast({ - title: "Trigger added", - body: "The routine schedule was saved.", - tone: "success", - }); + pushToast({ title: "Trigger added", body: "The routine schedule was saved.", tone: "success" }); } await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }), @@ -623,10 +486,10 @@ export function RoutineDetail() { queryClient.invalidateQueries({ queryKey: queryKeys.routines.activity(selectedCompanyId!, routineId!) }), ]); }, - onError: (error) => { + onError: (triggerError) => { pushToast({ title: "Failed to add trigger", - body: error instanceof Error ? error.message : "Paperclip could not create the trigger.", + body: triggerError instanceof Error ? triggerError.message : "Paperclip could not create the trigger.", tone: "error", }); }, @@ -635,21 +498,17 @@ export function RoutineDetail() { const updateTrigger = useMutation({ mutationFn: ({ id, patch }: { id: string; patch: Record }) => routinesApi.updateTrigger(id, patch), onSuccess: async () => { - pushToast({ - title: "Trigger saved", - body: "The routine cadence update was saved.", - tone: "success", - }); + pushToast({ title: "Trigger saved", body: "The routine cadence update was saved.", tone: "success" }); 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) => { + onError: (triggerError) => { pushToast({ title: "Failed to update trigger", - body: error instanceof Error ? error.message : "Paperclip could not update the trigger.", + body: triggerError instanceof Error ? triggerError.message : "Paperclip could not update the trigger.", tone: "error", }); }, @@ -658,20 +517,17 @@ export function RoutineDetail() { const deleteTrigger = useMutation({ mutationFn: (id: string) => routinesApi.deleteTrigger(id), onSuccess: async () => { - pushToast({ - title: "Trigger deleted", - tone: "success", - }); + pushToast({ title: "Trigger deleted", tone: "success" }); 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) => { + onError: (triggerError) => { pushToast({ title: "Failed to delete trigger", - body: error instanceof Error ? error.message : "Paperclip could not delete the trigger.", + body: triggerError instanceof Error ? triggerError.message : "Paperclip could not delete the trigger.", tone: "error", }); }, @@ -682,33 +538,24 @@ export function RoutineDetail() { onSuccess: async (result) => { setSecretMessage({ title: "Webhook secret rotated", - entries: [{ - webhookUrl: result.secretMaterial.webhookUrl, - webhookSecret: result.secretMaterial.webhookSecret, - }], + entries: [{ 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) => { + onError: (triggerError) => { pushToast({ title: "Failed to rotate webhook secret", - body: error instanceof Error ? error.message : "Paperclip could not rotate the webhook secret.", + body: triggerError instanceof Error ? triggerError.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 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 recentProjectIds = useMemo(() => getRecentProjectIds(), [routine?.id]); const assigneeOptions = useMemo( @@ -732,613 +579,310 @@ export function RoutineDetail() { })), [projects], ); - const mentionOptions = useMemo(() => { - return buildMarkdownMentionOptions({ - agents, - projects, - members: companyMembers?.users, + const mentionOptions = useMemo( + () => buildMarkdownMentionOptions({ agents, projects, members: companyMembers?.users }), + [agents, companyMembers?.users, projects], + ); + + // Wrap track-recent side-effects so the section components stay declarative. + const setEditDraftTracked: typeof setEditDraft = useCallback((updater) => { + setEditDraft((current) => { + const next = typeof updater === "function" ? (updater as (c: RoutineEditDraft) => RoutineEditDraft)(current) : updater; + if (next.assigneeAgentId && next.assigneeAgentId !== current.assigneeAgentId) { + trackRecentAssignee(next.assigneeAgentId); + } + if (next.projectId && next.projectId !== current.projectId) { + trackRecentProject(next.projectId); + } + return next; }); - }, [agents, companyMembers?.users, projects]); + }, []); + const currentAssignee = editDraft.assigneeAgentId ? agentById.get(editDraft.assigneeAgentId) ?? null : null; const currentProject = editDraft.projectId ? projectById.get(editDraft.projectId) ?? null : null; + const reloadLatest = useCallback(() => { + setSaveConflict(false); + if (routineDefaults) setEditDraft(routineDefaults); + queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }); + }, [queryClient, routineDefaults, routineId]); + + const onHistoryRestoreSecretMaterials = useCallback((response: RestoreRoutineRevisionResponse) => { + if (response.secretMaterials.length > 0) { + setSecretMessage({ + title: + response.secretMaterials.length === 1 + ? "Webhook trigger restored" + : `${response.secretMaterials.length} webhook triggers restored`, + entries: response.secretMaterials.map((recreated) => ({ + webhookUrl: recreated.webhookUrl, + webhookSecret: recreated.webhookSecret, + })), + }); + } + }, []); + + const onHistoryRestored = useCallback( + (response: RestoreRoutineRevisionResponse) => { + setSaveConflict(false); + queryClient.setQueryData( + queryKeys.routines.detail(routineId!), + (prev) => + prev + ? { + ...prev, + ...response.routine, + latestRevisionId: response.revision.id, + latestRevisionNumber: response.revision.revisionNumber, + } + : prev, + ); + setEditDraft({ + title: response.routine.title, + description: response.routine.description ?? "", + projectId: response.routine.projectId ?? "", + assigneeAgentId: response.routine.assigneeAgentId ?? "", + priority: response.routine.priority, + concurrencyPolicy: response.routine.concurrencyPolicy, + catchUpPolicy: response.routine.catchUpPolicy, + variables: response.routine.variables as RoutineVariable[], + env: (response.routine.env ?? null) as RoutineEnvConfig | null, + }); + hydratedRoutineIdRef.current = response.routine.id; + }, + [queryClient, routineId], + ); + if (!selectedCompanyId) { return ; } + // Back-compat redirect: `?tab=x` → `/routines/:id/x`. + const legacyTab = new URLSearchParams(window.location.search).get("tab"); + if (routineId && legacyTab && LEGACY_TAB_TO_SECTION[legacyTab]) { + return ; + } + + // Bare /routines/:id → remembered section or overview. + if (routineId && !sectionParam) { + const landing = readLastSection(routineId) ?? "overview"; + return ; + } + // Unknown section → overview. + if (routineId && sectionParam && !isRoutineSection(sectionParam)) { + return ; + } + if (isLoading) { return ; } - if (error || !routine) { + if (error || !routine || !routineDefaults) { return ( -

- {error instanceof Error ? error.message : "Routine not found"} -

+ ); } const automationEnabled = routine.status === "active"; - const selectedProject = routine.projectId ? (projects?.find((project) => project.id === routine.projectId) ?? null) : null; const automationToggleDisabled = updateRoutineStatus.isPending || routine.status === "archived"; - const automationLabel = routine.status === "archived" - ? "Archived" - : !routine.assigneeAgentId - ? "Draft" + const automationLabel = + routine.status === "archived" + ? "Archived" + : !routine.assigneeAgentId + ? "Draft" + : automationEnabled + ? "Active" + : "Paused"; + const automationLabelClassName = + routine.status === "archived" + ? "text-muted-foreground" : automationEnabled - ? "Active" - : "Paused"; - const automationLabelClassName = routine.status === "archived" - ? "text-muted-foreground" - : automationEnabled - ? "text-emerald-400" - : "text-muted-foreground"; + ? "text-emerald-400" + : "text-muted-foreground"; + + const contextValue: RoutineDetailContextValue = { + routine, + routineId: routineId!, + companyId: routine.companyId, + editDraft, + setEditDraft: setEditDraftTracked, + routineDefaults, + dirtyFields, + isEditDirty, + sectionDirtyFields, + isSectionDirty, + discardSection, + saveRoutine, + saveConflict, + reloadLatest, + automationEnabled, + automationLabel, + automationLabelClassName, + automationToggleDisabled, + onToggleAutomation: () => { + if (!automationEnabled && !routine.assigneeAgentId) { + pushToast({ + title: "Default agent required", + body: "Set a default agent before enabling routine automation.", + tone: "warn", + }); + return; + } + updateRoutineStatus.mutate(automationEnabled ? "paused" : "active"); + }, + onOpenRunDialog: () => setRunVariablesOpen(true), + runRoutinePending: runRoutine.isPending, + newTrigger, + setNewTrigger, + createTrigger, + updateTrigger, + deleteTrigger, + rotateTrigger, + secretMessage, + setSecretMessage, + copySecretValue, + availableSecrets, + createSecret, + agents: agents ?? [], + projects: projects ?? [], + agentById, + projectById, + assigneeOptions, + projectOptions, + recentAssigneeIds, + recentProjectIds, + mentionOptions, + currentAssignee, + currentProject, + routineRuns, + activity, + hasLiveRun, + activeIssueId, + titleInputRef, + descriptionEditorRef, + assigneeSelectorRef, + projectSelectorRef, + onHistoryRestoreSecretMaterials, + onHistoryRestored, + navigateToSection, + }; + + const isEditableSection = EDITABLE_SECTIONS.includes(section); return ( -
- {/* Header: editable title + actions */} -
-
-