diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 12f3e152..52c4a7f2 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -130,6 +130,7 @@ function boardRoutes() { ) : null} } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/components/RoutineActivityRow.tsx b/ui/src/components/RoutineActivityRow.tsx new file mode 100644 index 00000000..1da8b4a6 --- /dev/null +++ b/ui/src/components/RoutineActivityRow.tsx @@ -0,0 +1,78 @@ +import { useState } from "react"; +import { ChevronRight } from "lucide-react"; +import type { ActivityEvent } from "@paperclipai/shared"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; + +export type RoutineActivityEvent = Pick; + +function formatTime(value: string | Date): string { + try { + return new Date(value).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + } catch { + return String(value); + } +} + +function summarizeDetails(details: Record | null | undefined): string { + if (!details) return ""; + const entries = Object.entries(details).slice(0, 3); + return entries + .map(([key, value]) => `${key.replaceAll("_", " ")}: ${formatDetailValue(value)}`) + .join(" · "); +} + +function formatDetailValue(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(formatDetailValue).join(", "); + try { + return JSON.stringify(value); + } catch { + return "[unserializable]"; + } +} + +/** Activity log row with an expandable JSON payload (§3.7). */ +export function RoutineActivityRow({ event }: { event: RoutineActivityEvent }) { + const [expanded, setExpanded] = useState(false); + const hasPayload = event.details != null && Object.keys(event.details).length > 0; + + return ( +
+ + {expanded && hasPayload ? ( +
+          {JSON.stringify(event.details, null, 2)}
+        
+ ) : null} +
+ ); +} diff --git a/ui/src/components/RoutineSaveBar.test.tsx b/ui/src/components/RoutineSaveBar.test.tsx new file mode 100644 index 00000000..7c387aaa --- /dev/null +++ b/ui/src/components/RoutineSaveBar.test.tsx @@ -0,0 +1,90 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { RoutineSaveBar } from "./RoutineSaveBar"; +import type { RoutineHistoryDirtyFieldDescriptor } from "./RoutineHistoryTab"; + +let container: HTMLDivElement; +let root: ReturnType; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); +}); + +const DIRTY: RoutineHistoryDirtyFieldDescriptor[] = [ + { key: "title", label: "the title" }, + { key: "description", label: "the description" }, +]; + +function renderBar(props: Partial>) { + const handlers = { + onSave: vi.fn(), + onDiscard: vi.fn(), + onReload: vi.fn(), + }; + act(() => { + root.render( + , + ); + }); + return handlers; +} + +describe("RoutineSaveBar", () => { + it("renders nothing when clean and no conflict", () => { + renderBar({ dirtyFields: [] }); + expect(container.textContent).toBe(""); + }); + + it("shows the unsaved change count when dirty", () => { + renderBar({ dirtyFields: DIRTY }); + expect(container.textContent).toContain("2 unsaved changes"); + }); + + it("invokes onSave on Cmd/Ctrl+S while dirty", () => { + const handlers = renderBar({ dirtyFields: DIRTY }); + act(() => { + window.dispatchEvent(new KeyboardEvent("keydown", { key: "s", metaKey: true })); + }); + expect(handlers.onSave).toHaveBeenCalledTimes(1); + }); + + it("does not invoke onSave on Cmd+S when clean", () => { + const handlers = renderBar({ dirtyFields: [] }); + act(() => { + window.dispatchEvent(new KeyboardEvent("keydown", { key: "s", metaKey: true })); + }); + expect(handlers.onSave).not.toHaveBeenCalled(); + }); + + it("surfaces the conflict recovery actions on saveConflict", () => { + const handlers = renderBar({ dirtyFields: DIRTY, saveConflict: true }); + expect(container.textContent).toContain("Routine changed elsewhere"); + const buttons = Array.from(container.querySelectorAll("button")); + const reload = buttons.find((button) => button.textContent?.includes("Reload latest")); + const overwrite = buttons.find((button) => button.textContent?.includes("Overwrite anyway")); + expect(reload).toBeTruthy(); + expect(overwrite).toBeTruthy(); + act(() => { + reload!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(handlers.onReload).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/src/components/RoutineSaveBar.tsx b/ui/src/components/RoutineSaveBar.tsx new file mode 100644 index 00000000..1a5b35ad --- /dev/null +++ b/ui/src/components/RoutineSaveBar.tsx @@ -0,0 +1,203 @@ +import { useEffect, useState } from "react"; +import { AlertTriangle, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import type { RoutineHistoryDirtyFieldDescriptor } from "./RoutineHistoryTab"; + +/** + * Per-section sticky save bar (§1.4–§1.5). Hidden when clean; reveals on dirty. + * On a 409 it swaps to the conflict-recovery surface ("Reload latest" / + * "Overwrite anyway"). Wires ⌘/Ctrl+S → save and Esc → discard-with-confirm. + */ +export function RoutineSaveBar({ + dirtyFields, + isSaving, + saveConflict, + onSave, + onDiscard, + onReload, + disabled, +}: { + dirtyFields: RoutineHistoryDirtyFieldDescriptor[]; + isSaving: boolean; + saveConflict: boolean; + onSave: () => void; + onDiscard: () => void; + onReload: () => void; + disabled?: boolean; +}) { + const dirtyCount = dirtyFields.length; + const isDirty = dirtyCount > 0; + const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false); + + useEffect(() => { + if (!isDirty && !saveConflict) return; + const handler = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") { + event.preventDefault(); + if (!isSaving && !disabled) onSave(); + } else if (event.key === "Escape" && isDirty) { + event.preventDefault(); + setConfirmDiscardOpen(true); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [isDirty, saveConflict, isSaving, disabled, onSave]); + + if (!isDirty && !saveConflict) return null; + + return ( + <> +
+ {saveConflict ? ( +
+ + Routine changed elsewhere. Reload to merge. +
+ ) : ( + + + + + +

+ Pending changes +

+
    + {dirtyFields.map((field) => ( +
  • + + {field.label} +
  • + ))} +
+
+
+ )} + +
+ {saveConflict ? ( + <> + + + + + + + + Replaces the newer revision with your local edits. + + + + + ) : ( + <> + + + + )} +
+
+ + + + + Discard changes? + + This will revert {dirtyCount} unsaved{" "} + {dirtyCount === 1 ? "change" : "changes"} in this section. + + + + + + + + + + ); +} + +/** Read-only strip for non-owners on editable sections (§1.6). */ +export function RoutineReadOnlyStrip() { + return ( +
+ Read-only — you don't own this routine. +
+ ); +} diff --git a/ui/src/components/RoutineSubSidebar.test.tsx b/ui/src/components/RoutineSubSidebar.test.tsx new file mode 100644 index 00000000..f0b029b7 --- /dev/null +++ b/ui/src/components/RoutineSubSidebar.test.tsx @@ -0,0 +1,112 @@ +// @vitest-environment jsdom + +import { act, type AnchorHTMLAttributes, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/router", () => ({ + Link: ({ + to, + children, + ...props + }: AnchorHTMLAttributes & { to: string; children: ReactNode; replace?: boolean }) => { + const { replace: _replace, ...rest } = props as Record; + return ( + )}> + {children} + + ); + }, +})); + +import { RoutineSubSidebar } from "./RoutineSubSidebar"; +import type { RoutineSectionKey } from "./routine-sections/context"; + +let container: HTMLDivElement; +let root: ReturnType; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); +}); + +function renderSidebar(overrides?: { + activeSection?: RoutineSectionKey; + dirty?: RoutineSectionKey[]; + hasLiveRun?: boolean; + onNavigate?: (section: RoutineSectionKey) => void; +}) { + const dirty = new Set(overrides?.dirty ?? []); + const onNavigate = overrides?.onNavigate ?? vi.fn(); + act(() => { + root.render( + `/routines/r1/${section}`} + isSectionDirty={(section) => dirty.has(section)} + hasLiveRun={overrides?.hasLiveRun ?? false} + onNavigate={onNavigate} + />, + ); + }); + return { onNavigate }; +} + +describe("RoutineSubSidebar", () => { + it("renders all eight sections grouped under ROUTINE and OPERATE", () => { + renderSidebar(); + const links = Array.from(container.querySelectorAll("a")); + const labels = links.map((link) => link.textContent?.trim()); + expect(labels).toEqual([ + "Overview", + "Triggers", + "Variables", + "Secrets", + "Delivery", + "Runs", + "Activity", + "History", + ]); + const groupLabels = Array.from(container.querySelectorAll("p")).map((p) => p.textContent); + expect(groupLabels).toContain("Routine"); + expect(groupLabels).toContain("Operate"); + }); + + it("marks the active section with aria-current=page", () => { + renderSidebar({ activeSection: "secrets" }); + const active = container.querySelector('a[aria-current="page"]'); + expect(active?.textContent?.trim()).toBe("Secrets"); + }); + + it("links each section to its section URL", () => { + renderSidebar(); + const variables = Array.from(container.querySelectorAll("a")).find( + (link) => link.textContent?.trim() === "Variables", + ); + expect(variables?.getAttribute("href")).toBe("/routines/r1/variables"); + }); + + it("shows a dirty marker only on dirty editable sections", () => { + renderSidebar({ dirty: ["overview", "delivery"] }); + const dirtyMarkers = container.querySelectorAll('[aria-label="Unsaved changes"]'); + expect(dirtyMarkers.length).toBe(2); + }); + + it("fires onNavigate when a section is clicked", () => { + const { onNavigate } = renderSidebar(); + const triggers = Array.from(container.querySelectorAll("a")).find( + (link) => link.textContent?.trim() === "Triggers", + ) as HTMLAnchorElement; + act(() => { + triggers.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(onNavigate).toHaveBeenCalledWith("triggers"); + }); +}); diff --git a/ui/src/components/RoutineSubSidebar.tsx b/ui/src/components/RoutineSubSidebar.tsx new file mode 100644 index 00000000..1b8fc2cb --- /dev/null +++ b/ui/src/components/RoutineSubSidebar.tsx @@ -0,0 +1,214 @@ +import { useRef } from "react"; +import { + Activity as ActivityIcon, + Circle, + Clock3, + History as HistoryIcon, + KeyRound, + LayoutGrid, + Play, + Send, + SlidersHorizontal, +} from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import { Link } from "@/lib/router"; +import { cn } from "@/lib/utils"; +import { + ROUTINE_SECTION_KEYS, + type RoutineSectionKey, +} from "./routine-sections/context"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +type NavItem = { + key: RoutineSectionKey; + label: string; + icon: LucideIcon; +}; + +type NavGroup = { + label: string; + items: NavItem[]; +}; + +const NAV_GROUPS: NavGroup[] = [ + { + label: "Routine", + items: [ + { key: "overview", label: "Overview", icon: Circle }, + { key: "triggers", label: "Triggers", icon: Clock3 }, + { key: "variables", label: "Variables", icon: LayoutGrid }, + { key: "secrets", label: "Secrets", icon: KeyRound }, + { key: "delivery", label: "Delivery", icon: Send }, + ], + }, + { + label: "Operate", + items: [ + { key: "runs", label: "Runs", icon: Play }, + { key: "activity", label: "Activity", icon: ActivityIcon }, + { key: "history", label: "History", icon: HistoryIcon }, + ], + }, +]; + +const ALL_ITEMS: NavItem[] = NAV_GROUPS.flatMap((group) => group.items); + +export function RoutineSubSidebar({ + activeSection, + hrefFor, + isSectionDirty, + hasLiveRun, + onNavigate, +}: { + activeSection: RoutineSectionKey; + hrefFor: (section: RoutineSectionKey) => string; + isSectionDirty: (section: RoutineSectionKey) => boolean; + hasLiveRun: boolean; + onNavigate: (section: RoutineSectionKey) => void; +}) { + const itemRefs = useRef>([]); + + const focusItem = (index: number) => { + const clamped = (index + ALL_ITEMS.length) % ALL_ITEMS.length; + itemRefs.current[clamped]?.focus(); + onNavigate(ALL_ITEMS[clamped].key); + }; + + const handleKeyDown = (event: React.KeyboardEvent, index: number) => { + switch (event.key) { + case "ArrowDown": + event.preventDefault(); + focusItem(index + 1); + break; + case "ArrowUp": + event.preventDefault(); + focusItem(index - 1); + break; + case "Home": + event.preventDefault(); + focusItem(0); + break; + case "End": + event.preventDefault(); + focusItem(ALL_ITEMS.length - 1); + break; + default: + break; + } + }; + + let flatIndex = -1; + + return ( + + ); +} + +/** Mobile section picker — collapses the sub-sidebar into a grouped ` { + if (ROUTINE_SECTION_KEYS.includes(value as RoutineSectionKey)) { + onNavigate(value as RoutineSectionKey); + } + }} + > + + + + + {NAV_GROUPS.map((group) => ( + + + {group.label} + + {group.items.map((item) => ( + + + + {item.label} + {isSectionDirty(item.key) ? ( + + ) : null} + + + ))} + + ))} + + + + ); +} + +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 */} -
-
-