diff --git a/ui/src/components/ConfirmDialog.tsx b/ui/src/components/ConfirmDialog.tsx new file mode 100644 index 00000000..3a8ed9a5 --- /dev/null +++ b/ui/src/components/ConfirmDialog.tsx @@ -0,0 +1,57 @@ +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 new file mode 100644 index 00000000..e0f8853a --- /dev/null +++ b/ui/src/components/ScheduleEditor.test.ts @@ -0,0 +1,166 @@ +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 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 function ScheduleEditor({ value, @@ -153,68 +643,247 @@ export function ScheduleEditor({ value: string; onChange: (cron: string) => void; }) { - 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 : ""); + const [state, setState] = useState(() => parseCronToEditorState(value)); - // Sync from external value changes + // Sync when external value changes and isn't the same cron we just emitted. useEffect(() => { - 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); + const currentCron = buildCronFromState(state); + if (currentCron !== value) { + setState(parseCronToEditorState(value)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [value]); - 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)); - } + const emitState = useCallback( + (next: EditorState) => { + setState(next); + onChange(buildCronFromState(next)); }, [onChange], ); - const handlePresetChange = (newPreset: SchedulePreset) => { - setPreset(newPreset); - if (newPreset === "custom") { - setCustomCron(value); - } else { - emitChange(newPreset, hour, minute, dayOfWeek, dayOfMonth, customCron); - } - }; + const update = useCallback( + (patch: Pick | Partial) => { + emitState({ ...state, ...patch }); + }, + [emitState, state], + ); + + const { preset } = state; return ( -
- +
+ {/* Preset */} +
+ + +
- {preset === "custom" ? ( + {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" && (
+ { - setCustomCron(e.target.value); - emitChange("custom", hour, minute, dayOfWeek, dayOfMonth, e.target.value); - }} + type="number" + min={0} + max={59} + className="w-24 font-mono" + value={state.minutePast} + onChange={(e) => 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 })} placeholder="0 10 * * *" className="font-mono text-sm" /> @@ -222,123 +891,150 @@ 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 - - - )} - - {preset === "weekly" && ( - <> - on -
- {DAYS_OF_WEEK.map((d) => ( - - ))} -
- - )} - - {preset === "monthly" && ( - <> - on day - - - )} -
)} + + + +
+
+ Summary — + {describeSchedule(buildCronFromState(state))} +
+ + {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 new file mode 100644 index 00000000..e9490a5b --- /dev/null +++ b/ui/src/components/TriggerDialog.tsx @@ -0,0 +1,268 @@ +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 new file mode 100644 index 00000000..6ca4b0ed --- /dev/null +++ b/ui/src/components/TriggerListCard.tsx @@ -0,0 +1,139 @@ +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 a8a0e9d0..076bf518 100644 --- a/ui/src/pages/RoutineDetail.tsx +++ b/ui/src/pages/RoutineDetail.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, useLocation, useNavigate, useParams } from "@/lib/router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -9,15 +9,16 @@ import { Copy, History as HistoryIcon, Play, - RefreshCw, + Plus, Repeat, Save, - Trash2, - Webhook, - Zap, + SlidersHorizontal, } 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, @@ -29,9 +30,10 @@ 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"; @@ -45,7 +47,6 @@ 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"; @@ -67,8 +68,6 @@ 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.", @@ -79,13 +78,6 @@ 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]; @@ -150,128 +142,6 @@ 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(); @@ -280,6 +150,7 @@ 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); @@ -289,12 +160,10 @@ export function RoutineDetail() { const [advancedOpen, setAdvancedOpen] = useState(false); const [saveConflict, setSaveConflict] = useState(false); const [runVariablesOpen, setRunVariablesOpen] = useState(false); - const [newTrigger, setNewTrigger] = useState({ - kind: "schedule", - cronExpression: "0 10 * * *", - signingMode: "bearer", - replayWindowSec: "300", - }); + const [triggerDialogOpen, setTriggerDialogOpen] = useState(false); + const [editingTrigger, setEditingTrigger] = useState(null); + const [triggerPendingDelete, setTriggerPendingDelete] = useState(null); + const [togglingTriggerId, setTogglingTriggerId] = useState(null); const [editDraft, setEditDraft] = useState<{ title: string; description: string; @@ -441,7 +310,7 @@ export function RoutineDetail() { } }; - const setActiveTab = (value: string) => { + const setActiveTab = useCallback((value: string) => { if (!routineId || !isRoutineTab(value)) return; const params = new URLSearchParams(location.search); if (value === "triggers") { @@ -457,7 +326,7 @@ export function RoutineDetail() { }, { replace: true }, ); - }; + }, [location.pathname, location.search, navigate, routineId]); const saveRoutine = useMutation({ mutationFn: () => { @@ -494,6 +363,11 @@ export function RoutineDetail() { }); }, }); + const saveRoutineRef = useRef(saveRoutine); + + useEffect(() => { + saveRoutineRef.current = saveRoutine; + }, [saveRoutine]); const runRoutine = useMutation({ mutationFn: (data?: RoutineRunDialogSubmitData) => @@ -552,24 +426,23 @@ export function RoutineDetail() { }); const createTrigger = useMutation({ - 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"), - } - : {}), - }); + 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 }); }, onSuccess: async (result) => { + setTriggerDialogOpen(false); if (result.secretMaterial) { setSecretMessage({ title: "Webhook trigger created", @@ -605,9 +478,10 @@ 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!) }), @@ -621,6 +495,9 @@ export function RoutineDetail() { tone: "error", }); }, + onSettled: () => { + setTogglingTriggerId(null); + }, }); const deleteTrigger = useMutation({ @@ -630,6 +507,7 @@ 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!) }), @@ -710,6 +588,237 @@ 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 ; } @@ -811,6 +920,18 @@ export function RoutineDetail() { {automationLabel} +
@@ -1064,225 +1185,12 @@ export function RoutineDetail() { - + - {/* 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; - }} - /> - -
+ {/* Tabs (mobile only — desktop renders in the right properties panel) */} +
+ {activityTabsPanel} +
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); + }} + /> ); }