+
- {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 (
-
- );
-}
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}
-