diff --git a/ui/src/components/ConfirmDialog.tsx b/ui/src/components/ConfirmDialog.tsx deleted file mode 100644 index 3a8ed9a5..00000000 --- a/ui/src/components/ConfirmDialog.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; - -interface ConfirmDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - title: string; - description?: string; - confirmLabel?: string; - cancelLabel?: string; - destructive?: boolean; - onConfirm: () => void; - busy?: boolean; -} - -export function ConfirmDialog({ - open, - onOpenChange, - title, - description, - confirmLabel = "Confirm", - cancelLabel = "Cancel", - destructive, - onConfirm, - busy, -}: ConfirmDialogProps) { - return ( - - - - {title} - {description && {description}} - - - - - - - - ); -} diff --git a/ui/src/components/ScheduleEditor.test.ts b/ui/src/components/ScheduleEditor.test.ts deleted file mode 100644 index e0f8853a..00000000 --- a/ui/src/components/ScheduleEditor.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - describeSchedule, - getScheduleEditorPresetForTest, - hasSingleMinuteAcrossTimesForTest, - parseCronToPreset, - roundTripCronForTest, -} from "./ScheduleEditor"; - -describe("parseCronToPreset", () => { - describe("simple single-value crons map to presets", () => { - it("maps `* * * * *` to every_minute", () => { - expect(parseCronToPreset("* * * * *").preset).toBe("every_minute"); - }); - - it("maps `0 * * * *` to every_hour", () => { - const parsed = parseCronToPreset("0 * * * *"); - expect(parsed.preset).toBe("every_hour"); - expect(parsed.minute).toBe("0"); - }); - - it("maps `0 9 * * *` to every_day at 09:00", () => { - const parsed = parseCronToPreset("0 9 * * *"); - expect(parsed.preset).toBe("every_day"); - expect(parsed.hour).toBe("9"); - expect(parsed.minute).toBe("0"); - }); - - it("maps `0 9 * * 1-5` to weekdays", () => { - const parsed = parseCronToPreset("0 9 * * 1-5"); - expect(parsed.preset).toBe("weekdays"); - expect(parsed.hour).toBe("9"); - }); - - it("maps `0 9 * * 1` to weekly on Monday", () => { - const parsed = parseCronToPreset("0 9 * * 1"); - expect(parsed.preset).toBe("weekly"); - expect(parsed.dayOfWeek).toBe("1"); - expect(parsed.hour).toBe("9"); - }); - - it("maps `0 9 1 * *` to monthly on the 1st", () => { - const parsed = parseCronToPreset("0 9 1 * *"); - expect(parsed.preset).toBe("monthly"); - expect(parsed.dayOfMonth).toBe("1"); - expect(parsed.hour).toBe("9"); - }); - }); - - describe("complex crons round-trip via custom preset (regression: comma lists were silently coerced into every_day)", () => { - it("routes comma-separated hours to custom", () => { - // Regression: `0 9,13,17 * * *` used to be parsed as `every_day` with - // hour `"9,13,17"`, which the hour { - const next = times.slice(); - const value = e.target.value || "00:00"; - next[i] = value; - if (next.length > 1) { - const parsed = parseTimeParts(value); - if (parsed) { - for (let idx = 0; idx < next.length; idx += 1) { - const current = parseTimeParts(next[idx] ?? ""); - const hour = idx === i ? parsed.hour : (current?.hour ?? 0); - next[idx] = `${pad(hour)}:${pad(parsed.minute)}`; - } - } - } - onChange(next); - }} - /> - - - ))} - - - ); +function ordinalSuffix(n: number): string { + const s = ["th", "st", "nd", "rd"]; + const v = n % 100; + return s[(v - 20) % 10] || s[v] || s[0]; } -function DayOfWeekPicker({ - days, - onChange, -}: { - days: number[]; - onChange: (next: number[]) => void; -}) { - const letters = ["S", "M", "T", "W", "T", "F", "S"]; - return ( -
- {letters.map((l, i) => { - const active = days.includes(i); - return ( - - ); - })} -
- ); -} - -function DayOfMonthPicker({ - domDays, - onChange, -}: { - domDays: number[]; - onChange: (next: number[]) => void; -}) { - return ( -
- {Array.from({ length: 31 }, (_, i) => i + 1).map((d) => { - const active = domDays.includes(d); - return ( - - ); - })} -
- ); -} - -// --------------------------------------------------------------------------- -// ScheduleEditor component (rich) -// --------------------------------------------------------------------------- +export { describeSchedule }; export function ScheduleEditor({ value, @@ -643,247 +153,68 @@ export function ScheduleEditor({ value: string; onChange: (cron: string) => void; }) { - const [state, setState] = useState(() => parseCronToEditorState(value)); + const parsed = useMemo(() => parseCronToPreset(value), [value]); + const [preset, setPreset] = useState(parsed.preset); + const [hour, setHour] = useState(parsed.hour); + const [minute, setMinute] = useState(parsed.minute); + const [dayOfWeek, setDayOfWeek] = useState(parsed.dayOfWeek); + const [dayOfMonth, setDayOfMonth] = useState(parsed.dayOfMonth); + const [customCron, setCustomCron] = useState(preset === "custom" ? value : ""); - // Sync when external value changes and isn't the same cron we just emitted. + // Sync from external value changes useEffect(() => { - const currentCron = buildCronFromState(state); - if (currentCron !== value) { - setState(parseCronToEditorState(value)); - } - // eslint-disable-next-line react-hooks/exhaustive-deps + const p = parseCronToPreset(value); + setPreset(p.preset); + setHour(p.hour); + setMinute(p.minute); + setDayOfWeek(p.dayOfWeek); + setDayOfMonth(p.dayOfMonth); + if (p.preset === "custom") setCustomCron(value); }, [value]); - const emitState = useCallback( - (next: EditorState) => { - setState(next); - onChange(buildCronFromState(next)); + const emitChange = useCallback( + (p: SchedulePreset, h: string, m: string, dow: string, dom: string, custom: string) => { + if (p === "custom") { + onChange(custom); + } else { + onChange(buildCron(p, h, m, dow, dom)); + } }, [onChange], ); - const update = useCallback( - (patch: Pick | Partial) => { - emitState({ ...state, ...patch }); - }, - [emitState, state], - ); - - const { preset } = state; + const handlePresetChange = (newPreset: SchedulePreset) => { + setPreset(newPreset); + if (newPreset === "custom") { + setCustomCron(value); + } else { + emitChange(newPreset, hour, minute, dayOfWeek, dayOfMonth, customCron); + } + }; return ( -
- {/* Preset */} -
- - -
+
+ - {preset === "every_minute" && ( -

- No options — runs every minute, around the clock. -

- )} - - {preset === "every_n_minutes" && ( -
-
- -
- update({ n: clamp(+e.target.value || 1, 1, 59) })} - /> - minutes -
-
- {[1, 5, 10, 15, 20, 30].map((v) => ( - update({ n: v })} - > - {v} - - ))} -
-
- -
- )} - - {preset === "hourly" && ( + {preset === "custom" ? (
- update({ minutePast: clamp(+e.target.value || 0, 0, 59) })} - /> -

- Runs once an hour at :{pad(state.minutePast)} -

-
- )} - - {preset === "every_n_hours" && ( -
-
- -
- update({ n: clamp(+e.target.value || 1, 1, 23) })} - /> - hours -
-
- {[1, 2, 3, 4, 6, 8, 12].map((v) => ( - update({ n: v })} - > - {v} - - ))} -
-
-
- - update({ minutePast: clamp(+e.target.value || 0, 0, 59) })} - /> -
- -
- )} - - {preset === "daily" && ( -
- - update({ times })} /> - {state.times.length > 1 && ( -

- All times in one schedule share the same minute. Changing one minute updates them all. -

- )} -
- )} - - {preset === "weekdays" && ( -
-
- - update({ days })} /> -
- {( - [ - ["Weekdays", [1, 2, 3, 4, 5]], - ["Weekends", [0, 6]], - ["All days", [0, 1, 2, 3, 4, 5, 6]], - ["Mon · Wed · Fri", [1, 3, 5]], - ["Tue · Thu", [2, 4]], - ] as const - ).map(([label, days]) => ( - update({ days: [...days] })} - > - {label} - - ))} -
-
-
- - update({ times })} /> - {state.times.length > 1 && ( -

- All times in one schedule share the same minute. Changing one minute updates them all. -

- )} -
-
- )} - - {preset === "monthly" && ( -
-
- - update({ domDays })} /> -
- {( - [ - ["1st only", [1]], - ["15th only", [15]], - ["1st & 15th", [1, 15]], - ["Last day (28th)", [28]], - ] as const - ).map(([label, days]) => ( - update({ domDays: [...days] })} - > - {label} - - ))} -
-

- Days 29–31 are skipped in months that don't have them. -

-
-
- - update({ times })} /> - {state.times.length > 1 && ( -

- All times in one schedule share the same minute. Changing one minute updates them all. -

- )} -
-
- )} - - {preset === "custom" && ( -
- - update({ custom: e.target.value })} + value={customCron} + onChange={(e) => { + setCustomCron(e.target.value); + emitChange("custom", hour, minute, dayOfWeek, dayOfMonth, e.target.value); + }} placeholder="0 10 * * *" className="font-mono text-sm" /> @@ -891,150 +222,123 @@ export function ScheduleEditor({ Five fields: minute hour day-of-month month day-of-week

- )} + ) : ( +
+ {preset !== "every_minute" && preset !== "every_hour" && ( + <> + at + + : + + + )} - + {preset === "every_hour" && ( + <> + at minute + + + )} -
-
- Summary — - {describeSchedule(buildCronFromState(state))} + {preset === "weekly" && ( + <> + on +
+ {DAYS_OF_WEEK.map((d) => ( + + ))} +
+ + )} + + {preset === "monthly" && ( + <> + on day + + + )}
- - {buildCronFromState(state)} - -
+ )}
); } - -function WindowAndWeekdaysToggles({ - state, - update, -}: { - state: EditorState; - update: (patch: Partial) => void; -}) { - return ( - <> -
- - {state.windowEnabled && ( -
- - to - -
- )} -
- - - ); -} - -function WeekdaysOnlyToggle({ - state, - update, -}: { - state: EditorState; - update: (patch: Partial) => void; -}) { - return ( - - ); -} - -function changePreset(state: EditorState, next: EditorPreset): EditorState { - // Reset ambiguous sub-state when switching presets so we don't carry - // over a stale weekdaysOnly / windowEnabled from a sibling preset. - switch (next) { - case "every_minute": - return { ...state, preset: next }; - case "every_n_minutes": - return { ...state, preset: next, n: 15, windowEnabled: false, weekdaysOnly: false }; - case "hourly": - return { ...state, preset: next }; - case "every_n_hours": - return { ...state, preset: next, n: 2, weekdaysOnly: false }; - case "daily": - return { ...state, preset: next, times: state.times.length ? state.times : ["09:00"] }; - case "weekdays": - return { - ...state, - preset: next, - days: state.days.length ? state.days : [1, 2, 3, 4, 5], - times: state.times.length ? state.times : ["09:00"], - }; - case "monthly": - return { - ...state, - preset: next, - domDays: state.domDays.length ? state.domDays : [1], - times: state.times.length ? state.times : ["09:00"], - }; - case "custom": - return { ...state, preset: next, custom: state.custom || buildCronFromState(state) }; - } -} - -function clamp(v: number, lo: number, hi: number): number { - return Math.min(Math.max(v, lo), hi); -} - -export function getScheduleEditorPresetForTest(cron: string): EditorPreset { - return parseCronToEditorState(cron).preset; -} - -export function hasSingleMinuteAcrossTimesForTest(times: string[]): boolean { - return hasSingleMinuteAcrossTimes(times); -} - -export function roundTripCronForTest(cron: string): string { - return buildCronFromState(parseCronToEditorState(cron)); -} diff --git a/ui/src/components/TriggerDialog.tsx b/ui/src/components/TriggerDialog.tsx deleted file mode 100644 index e9490a5b..00000000 --- a/ui/src/components/TriggerDialog.tsx +++ /dev/null @@ -1,268 +0,0 @@ -import { useEffect, useState } from "react"; -import type { RoutineTrigger } from "@paperclipai/shared"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { ToggleSwitch } from "@/components/ui/toggle-switch"; -import { ScheduleEditor } from "./ScheduleEditor"; - -const triggerKinds = ["schedule", "webhook"] as const; -const signingModes = ["bearer", "hmac_sha256", "github_hmac", "none"] as const; -const SIGNING_MODES_WITHOUT_REPLAY_WINDOW = new Set(["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.", -}; - -type TriggerKind = (typeof triggerKinds)[number]; - -export interface TriggerDialogState { - label: string; - kind: TriggerKind; - cronExpression: string; - signingMode: string; - replayWindowSec: string; - enabled: boolean; -} - -interface TriggerDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - /** When editing an existing trigger, pass it here. Null for create. */ - trigger: RoutineTrigger | null; - /** Timezone to use when creating a new schedule trigger (the detail page uses the browser's zone). */ - fallbackTimezone: string; - /** Called when the user submits. For updates `id` is non-null. */ - onSubmit: (payload: { - id: string | null; - kind: TriggerKind; - // For create: full body. For update: partial patch ready to send. - body: Record; - }) => void; - submitting?: boolean; -} - -const BLANK: TriggerDialogState = { - label: "", - kind: "schedule", - cronExpression: "0 9 * * 1-5", - signingMode: "bearer", - replayWindowSec: "300", - enabled: true, -}; - -function draftFromTrigger(trigger: RoutineTrigger | null): TriggerDialogState { - if (!trigger) return { ...BLANK }; - return { - label: trigger.label ?? "", - kind: (trigger.kind as TriggerKind) ?? "schedule", - cronExpression: trigger.cronExpression ?? "0 9 * * 1-5", - signingMode: trigger.signingMode ?? "bearer", - replayWindowSec: String(trigger.replayWindowSec ?? 300), - enabled: trigger.enabled, - }; -} - -function parseReplayWindowSec(raw: string): number { - const parsed = Number(raw); - if (!Number.isFinite(parsed) || parsed < 1) return 300; - return Math.trunc(parsed); -} - -export function TriggerDialog({ - open, - onOpenChange, - trigger, - fallbackTimezone, - onSubmit, - submitting, -}: TriggerDialogProps) { - const isEdit = !!trigger; - const [draft, setDraft] = useState(() => draftFromTrigger(trigger)); - - // Reset the draft whenever the dialog opens with a different trigger. - useEffect(() => { - if (open) setDraft(draftFromTrigger(trigger)); - }, [open, trigger]); - - const handleSubmit = () => { - const labelTrimmed = draft.label.trim(); - - if (isEdit && trigger) { - // Build a PATCH body. Match the fields the backend accepts on - // PATCH /routine-triggers/:id (see updateRoutineTriggerSchema). - const patch: Record = { - label: labelTrimmed || null, - enabled: draft.enabled, - }; - if (trigger.kind === "schedule") { - patch.cronExpression = draft.cronExpression.trim(); - patch.timezone = trigger.timezone ?? fallbackTimezone; - } - if (trigger.kind === "webhook") { - patch.signingMode = draft.signingMode; - patch.replayWindowSec = parseReplayWindowSec(draft.replayWindowSec); - } - onSubmit({ id: trigger.id, kind: trigger.kind as TriggerKind, body: patch }); - return; - } - - // Create body: match POST /routines/:id/triggers (createRoutineTriggerSchema). - const body: Record = { - kind: draft.kind, - label: labelTrimmed || draft.kind, - }; - if (draft.kind === "schedule") { - body.cronExpression = draft.cronExpression.trim(); - body.timezone = fallbackTimezone; - } - if (draft.kind === "webhook") { - body.signingMode = draft.signingMode; - body.replayWindowSec = parseReplayWindowSec(draft.replayWindowSec); - } - onSubmit({ id: null, kind: draft.kind, body }); - }; - - const showWebhookFields = draft.kind === "webhook"; - const showScheduleFields = draft.kind === "schedule"; - - return ( - - - - {isEdit ? "Edit trigger" : "Add trigger"} - - Configure when and how this routine fires. - - - -
-
- - setDraft((d) => ({ ...d, label: e.target.value }))} - /> -

- Optional — shown in the trigger list. -

-
- -
- - - {isEdit && ( -

- Kind can't be changed after creation. -

- )} -
- - {showScheduleFields && ( - setDraft((d) => ({ ...d, cronExpression }))} - /> - )} - - {showWebhookFields && ( -
-
- - -

- {signingModeDescriptions[draft.signingMode]} -

-
- {!SIGNING_MODES_WITHOUT_REPLAY_WINDOW.has(draft.signingMode) && ( -
- - - setDraft((d) => ({ ...d, replayWindowSec: e.target.value })) - } - /> -
- )} -
- )} -
- - - {isEdit && ( - - )} - - - -
-
- ); -} diff --git a/ui/src/components/TriggerListCard.tsx b/ui/src/components/TriggerListCard.tsx deleted file mode 100644 index 6ca4b0ed..00000000 --- a/ui/src/components/TriggerListCard.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { Clock3, Pencil, RefreshCw, Trash2, Webhook, Zap } from "lucide-react"; -import type { RoutineTrigger } from "@paperclipai/shared"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { ToggleSwitch } from "@/components/ui/toggle-switch"; -import { describeSchedule } from "./ScheduleEditor"; -import { timeAgo } from "../lib/timeAgo"; - -interface TriggerListCardProps { - trigger: RoutineTrigger; - onEdit: () => void; - onDelete: () => void; - onToggleEnabled: (enabled: boolean) => void; - onRotateSecret?: () => void; - togglePending?: boolean; -} - -export function TriggerListCard({ - trigger, - onEdit, - onDelete, - onToggleEnabled, - onRotateSecret, - togglePending, -}: TriggerListCardProps) { - const isSchedule = trigger.kind === "schedule"; - const isWebhook = trigger.kind === "webhook"; - const Icon = isSchedule ? Clock3 : isWebhook ? Webhook : Zap; - - const summary = isSchedule && trigger.cronExpression - ? describeSchedule(trigger.cronExpression) - : isWebhook - ? `Webhook${trigger.publicId ? ` · ${trigger.publicId}` : ""}` - : "API trigger"; - - const nextRun = isSchedule && trigger.enabled && trigger.nextRunAt - ? new Date(trigger.nextRunAt).toLocaleString(undefined, { - weekday: "short", - day: "numeric", - month: "short", - hour: "2-digit", - minute: "2-digit", - }) - : trigger.enabled ? "—" : "Disabled"; - - const lastFired = trigger.lastFiredAt ? timeAgo(trigger.lastFiredAt) : "Never"; - - const resultIsError = typeof trigger.lastResult === "string" && /error|fail/i.test(trigger.lastResult); - - return ( -
-
-
- -
- - {trigger.label || (isSchedule ? "Schedule" : isWebhook ? "Webhook" : "Trigger")} - - -
- -
- - {trigger.kind} - - {!trigger.enabled && ( - - paused - - )} -
- -
{summary}
- {isSchedule && trigger.cronExpression && ( -
- {trigger.cronExpression} - {trigger.timezone ? ` · ${trigger.timezone}` : ""} -
- )} - -
-
-
Next run
-
{nextRun}
-
-
-
Last fired
-
{lastFired}
-
-
-
Last result
-
- {trigger.lastResult ? ( - - {trigger.lastResult} - - ) : ( - - )} -
-
-
- -
- {isWebhook && onRotateSecret && ( - - )} - - -
-
- ); -} diff --git a/ui/src/pages/RoutineDetail.tsx b/ui/src/pages/RoutineDetail.tsx index 076bf518..a8a0e9d0 100644 --- a/ui/src/pages/RoutineDetail.tsx +++ b/ui/src/pages/RoutineDetail.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Link, useLocation, useNavigate, useParams } from "@/lib/router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -9,16 +9,15 @@ import { Copy, History as HistoryIcon, Play, - Plus, + RefreshCw, Repeat, Save, - SlidersHorizontal, + Trash2, + Webhook, + Zap, } from "lucide-react"; import { ApiError } from "../api/client"; import { routinesApi, type RoutineTriggerResponse, type RotateRoutineTriggerResponse, type RestoreRoutineRevisionResponse } from "../api/routines"; -import { TriggerListCard } from "../components/TriggerListCard"; -import { TriggerDialog } from "../components/TriggerDialog"; -import { ConfirmDialog } from "../components/ConfirmDialog"; import { RoutineHistoryTab, type RoutineHistoryDirtyFieldDescriptor, @@ -30,10 +29,9 @@ import { projectsApi } from "../api/projects"; import { accessApi } from "../api/access"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; -import { usePanel } from "../context/PanelContext"; import { useToastActions } from "../context/ToastContext"; -import { cn } from "../lib/utils"; 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"; @@ -47,6 +45,7 @@ import { 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"; @@ -68,6 +67,8 @@ import type { RoutineDetail as RoutineDetailType, RoutineTrigger, RoutineVariabl 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", "history"] as const; const concurrencyPolicyDescriptions: Record = { coalesce_if_active: "Keep one follow-up run queued while an active run is still working.", @@ -78,6 +79,13 @@ 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"]); type RoutineTab = (typeof routineTabs)[number]; @@ -142,6 +150,128 @@ 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 { selectedCompanyId } = useCompany(); @@ -150,7 +280,6 @@ export function RoutineDetail() { const navigate = useNavigate(); const location = useLocation(); const { pushToast } = useToastActions(); - const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel(); const hydratedRoutineIdRef = useRef(null); const titleInputRef = useRef(null); const descriptionEditorRef = useRef(null); @@ -160,10 +289,12 @@ export function RoutineDetail() { const [advancedOpen, setAdvancedOpen] = useState(false); const [saveConflict, setSaveConflict] = useState(false); const [runVariablesOpen, setRunVariablesOpen] = useState(false); - const [triggerDialogOpen, setTriggerDialogOpen] = useState(false); - const [editingTrigger, setEditingTrigger] = useState(null); - const [triggerPendingDelete, setTriggerPendingDelete] = useState(null); - const [togglingTriggerId, setTogglingTriggerId] = useState(null); + const [newTrigger, setNewTrigger] = useState({ + kind: "schedule", + cronExpression: "0 10 * * *", + signingMode: "bearer", + replayWindowSec: "300", + }); const [editDraft, setEditDraft] = useState<{ title: string; description: string; @@ -310,7 +441,7 @@ export function RoutineDetail() { } }; - const setActiveTab = useCallback((value: string) => { + const setActiveTab = (value: string) => { if (!routineId || !isRoutineTab(value)) return; const params = new URLSearchParams(location.search); if (value === "triggers") { @@ -326,7 +457,7 @@ export function RoutineDetail() { }, { replace: true }, ); - }, [location.pathname, location.search, navigate, routineId]); + }; const saveRoutine = useMutation({ mutationFn: () => { @@ -363,11 +494,6 @@ export function RoutineDetail() { }); }, }); - const saveRoutineRef = useRef(saveRoutine); - - useEffect(() => { - saveRoutineRef.current = saveRoutine; - }, [saveRoutine]); const runRoutine = useMutation({ mutationFn: (data?: RoutineRunDialogSubmitData) => @@ -426,23 +552,24 @@ export function RoutineDetail() { }); const createTrigger = useMutation({ - mutationFn: async (body: Record): Promise => { - // Auto-label when the caller didn't provide one (e.g. dialog left the - // Label field blank). Keeps the existing "schedule-2"-style numbering - // behaviour so existing routines keep unique-ish labels. - const kind = String(body.kind ?? "schedule"); - const trimmedLabel = typeof body.label === "string" ? body.label.trim() : ""; - let finalLabel: string; - if (trimmedLabel.length > 0 && trimmedLabel !== kind) { - finalLabel = trimmedLabel; - } else { - const existingOfKind = (routine?.triggers ?? []).filter((t) => t.kind === kind).length; - finalLabel = existingOfKind > 0 ? `${kind}-${existingOfKind + 1}` : kind; - } - return routinesApi.createTrigger(routineId!, { ...body, label: finalLabel }); + mutationFn: async (): Promise => { + const existingOfKind = (routine?.triggers ?? []).filter((t) => t.kind === newTrigger.kind).length; + const autoLabel = existingOfKind > 0 ? `${newTrigger.kind}-${existingOfKind + 1}` : newTrigger.kind; + return routinesApi.createTrigger(routineId!, { + kind: newTrigger.kind, + label: autoLabel, + ...(newTrigger.kind === "schedule" + ? { cronExpression: newTrigger.cronExpression.trim(), timezone: getLocalTimezone() } + : {}), + ...(newTrigger.kind === "webhook" + ? { + signingMode: newTrigger.signingMode, + replayWindowSec: Number(newTrigger.replayWindowSec || "300"), + } + : {}), + }); }, onSuccess: async (result) => { - setTriggerDialogOpen(false); if (result.secretMaterial) { setSecretMessage({ title: "Webhook trigger created", @@ -478,10 +605,9 @@ export function RoutineDetail() { onSuccess: async () => { pushToast({ title: "Trigger saved", + body: "The routine cadence update was saved.", tone: "success", }); - setTriggerDialogOpen(false); - setEditingTrigger(null); await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }), queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }), @@ -495,9 +621,6 @@ export function RoutineDetail() { tone: "error", }); }, - onSettled: () => { - setTogglingTriggerId(null); - }, }); const deleteTrigger = useMutation({ @@ -507,7 +630,6 @@ export function RoutineDetail() { title: "Trigger deleted", tone: "success", }); - setTriggerPendingDelete(null); await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }), queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }), @@ -588,237 +710,6 @@ export function RoutineDetail() { const currentAssignee = editDraft.assigneeAgentId ? agentById.get(editDraft.assigneeAgentId) ?? null : null; const currentProject = editDraft.projectId ? projectById.get(editDraft.projectId) ?? null : null; - const activityTabsPanel = useMemo(() => { - if (!routine) return null; - return ( - - - - - Triggers - - - - Runs - {hasLiveRun && } - - - - Activity - - - - History - - - - - - - {routine.triggers.length === 0 ? ( -
-

No triggers yet

-

- Triggers fire this routine on a schedule or via webhook. -

- -
- ) : ( -
- {routine.triggers.map((trigger) => ( - { - setEditingTrigger(trigger); - setTriggerDialogOpen(true); - }} - onDelete={() => setTriggerPendingDelete(trigger)} - onToggleEnabled={(enabled) => { - setTogglingTriggerId(trigger.id); - updateTrigger.mutate({ id: trigger.id, patch: { enabled } }); - }} - onRotateSecret={ - trigger.kind === "webhook" - ? () => rotateTrigger.mutate(trigger.id) - : undefined - } - togglePending={togglingTriggerId === trigger.id} - /> - ))} -
- )} -
- - - {hasLiveRun && activeIssueId && routine && ( - - )} - {(routineRuns ?? []).length === 0 ? ( -

No runs yet.

- ) : ( -
- {(routineRuns ?? []).map((run) => ( -
-
- {run.source} - - {run.status.replaceAll("_", " ")} - -
- {(run.trigger || run.linkedIssue) && ( -
- {run.trigger && ( - {run.trigger.label ?? run.trigger.kind} - )} - {run.linkedIssue && ( - - {run.linkedIssue.identifier ?? run.linkedIssue.id.slice(0, 8)} - - )} -
- )} - {timeAgo(run.triggeredAt)} -
- ))} -
- )} -
- - - {(activity ?? []).length === 0 ? ( -

No activity yet.

- ) : ( -
- {(activity ?? []).map((event) => ( -
- {event.action.replaceAll(".", " ")} - {event.details && Object.keys(event.details).length > 0 && ( -
- {Object.entries(event.details).slice(0, 3).map(([key, value], i) => ( - - {i > 0 && ·} - {key.replaceAll("_", " ")}:{" "} - {formatActivityDetailValue(value)} - - ))} -
- )} - {timeAgo(event.createdAt)} -
- ))} -
- )} -
- - - { - if (routineDefaults) setEditDraft(routineDefaults); - }} - onSaveEdits={() => { - const currentSave = saveRoutineRef.current; - if (!currentSave.isPending && editDraft.title.trim()) { - currentSave.mutate(); - } - }} - agents={agentById} - projects={projectById} - onRestoreSecretMaterials={(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, - })), - }); - } - }} - onRestored={(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, - }); - hydratedRoutineIdRef.current = response.routine.id; - }} - /> - -
- ); - }, [ - activeIssueId, - activeTab, - activity, - agentById, - dirtyFields, - editDraft.title, - hasLiveRun, - isEditDirty, - projectById, - queryClient, - rotateTrigger.mutate, - routine, - routineDefaults, - routineRuns, - routineId, - setActiveTab, - togglingTriggerId, - updateTrigger.mutate, - ]); - - useEffect(() => { - if (!activityTabsPanel) { - closePanel(); - return; - } - openPanel(activityTabsPanel); - return () => closePanel(); - }, [activityTabsPanel, closePanel, openPanel]); - if (!selectedCompanyId) { return ; } @@ -920,18 +811,6 @@ export function RoutineDetail() { {automationLabel} -
@@ -1185,12 +1064,225 @@ export function RoutineDetail() { - + - {/* Tabs (mobile only — desktop renders in the right properties panel) */} -
- {activityTabsPanel} -
+ {/* Tabs */} + + + + + Triggers + + + + Runs + {hasLiveRun && } + + + + Activity + + + + History + + + + + {/* 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 ? ( +

No triggers configured yet.

+ ) : ( +
+ {routine.triggers.map((trigger) => ( + updateTrigger.mutate({ id, patch })} + onRotate={(id) => rotateTrigger.mutate(id)} + onDelete={(id) => deleteTrigger.mutate(id)} + /> + ))} +
+ )} +
+ + + {hasLiveRun && activeIssueId && routine && ( + + )} + {(routineRuns ?? []).length === 0 ? ( +

No runs yet.

+ ) : ( +
+ {(routineRuns ?? []).map((run) => ( +
+
+ {run.source} + + {run.status.replaceAll("_", " ")} + + {run.trigger && ( + {run.trigger.label ?? run.trigger.kind} + )} + {run.linkedIssue && ( + + {run.linkedIssue.identifier ?? run.linkedIssue.id.slice(0, 8)} + + )} +
+ {timeAgo(run.triggeredAt)} +
+ ))} +
+ )} +
+ + + {(activity ?? []).length === 0 ? ( +

No activity yet.

+ ) : ( +
+ {(activity ?? []).map((event) => ( +
+
+ {event.action.replaceAll(".", " ")} + {event.details && Object.keys(event.details).length > 0 && ( + + {Object.entries(event.details).slice(0, 3).map(([key, value], i) => ( + + {i > 0 && ·} + {key.replaceAll("_", " ")}:{" "} + {formatActivityDetailValue(value)} + + ))} + + )} +
+ {timeAgo(event.createdAt)} +
+ ))} +
+ )} +
+ + + { + if (routineDefaults) setEditDraft(routineDefaults); + }} + onSaveEdits={() => { + if (!saveRoutine.isPending && editDraft.title.trim()) { + saveRoutine.mutate(); + } + }} + agents={agentById} + projects={projectById} + onRestoreSecretMaterials={(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, + })), + }); + } + }} + onRestored={(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, + }); + hydratedRoutineIdRef.current = response.routine.id; + }} + /> + +
runRoutine.mutate(data)} /> - - { - setTriggerDialogOpen(next); - if (!next) setEditingTrigger(null); - }} - trigger={editingTrigger} - fallbackTimezone={getLocalTimezone()} - submitting={createTrigger.isPending || updateTrigger.isPending} - onSubmit={({ id, body }) => { - if (id) { - updateTrigger.mutate({ id, patch: body }); - } else { - createTrigger.mutate(body); - } - }} - /> - - { - if (!next) setTriggerPendingDelete(null); - }} - title="Delete trigger?" - description={ - triggerPendingDelete - ? `"${triggerPendingDelete.label ?? triggerPendingDelete.kind}" will be removed. This can't be undone.` - : undefined - } - confirmLabel="Delete" - destructive - busy={deleteTrigger.isPending} - onConfirm={() => { - if (triggerPendingDelete) deleteTrigger.mutate(triggerPendingDelete.id); - }} - /> ); }