forked from farhoodlabs/paperclip
fix(ui): prevent lossy cron rewrites + redesign routine triggers tab (#3569)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Humans configure when those agents run via **routines**, which are driven by cron-backed triggers > - The routine detail page exposed triggers through an always-visible inline add form and per-row inline editor, with a ScheduleEditor that only understood a narrow set of cron shapes > - That combination was actively lossy: pasting `0 9,13,17 * * *` silently collapsed to `0 10 * * *` on save, and common shapes (every-N-minutes within a window, multiple times per day, monthly on several dates) had no first-class UI > - This pull request rebuilds the triggers tab around a list of cards + add/edit modal, teaches ScheduleEditor the cron shapes users actually want, and prevents cron round-trips from dropping data > - It also *optionally* tucks the Triggers/Runs/Activity tabs into the shared right-hand PropertiesPanel (same pattern as Issues and Goals) so they stay in view alongside the routine instead of being hidden below the main content > - The benefit is that routine scheduling becomes non-destructive and legible — operators can see, describe, and edit real-world schedules without dropping into raw cron and without fear that saving will silently rewrite their trigger ## What Changed **Core fixes + redesign (required):** - **ScheduleEditor correctness** — `parseCronToPreset` now detects comma lists, ranges, steps, and unknown tokens across every cron field and routes anything it can't round-trip losslessly to the `custom` preset (except `dow === "1-5"` → `weekdays`). Fixes the `0 9,13,17 * * *` → `0 10 * * *` regression. - **ScheduleEditor presets** — adds first-class support for every-N-minutes (with optional hour window + weekdays-only), every-N-hours, hourly at minute offset, daily with multiple times/day, selected-days-of-week with multiple times, and monthly on multiple dates. `describeSchedule` unfolds multi-value hour/day lists into readable sentences. - **ScheduleEditor polish** — swaps raw `<input type=\"checkbox\">` for the shadcn `Checkbox` primitive so hour-window and weekdays-only toggles match the rest of the app. - **Triggers tab redesign** — replaces the inline add form + inline editor with a header + \"Add trigger\" button, compact `TriggerListCard` entries, and a `TriggerDialog` add/edit modal. Enable/disable is now a single-click switch on each card; delete goes through a `ConfirmDialog`. - **Webhook trigger gating** — webhook kind is visible but disabled with \"— COMING SOON\" in the add dialog, matching the old inline form's production behaviour. Editing existing webhook triggers still works. - **Tests** — adds `ScheduleEditor.test.ts` covering the regression cron strings (`0 9,13,17 * * *`, `0 */4 * * *`, `0 10,16 * * *`) plus existing preset patterns as regression guards in the other direction. **Optional layout change (commit `145a86b5` — can be dropped without affecting the rest):** - Moves Triggers/Runs/Activity into the shared right-hand `PropertiesPanel` (persisted open/close, header toggle button), mirroring `IssueDetail` and `GoalDetail`. The reasoning: these tabs are the primary way a human *operates* a routine, and keeping them docked on the right means they're always in view next to the routine content rather than hidden below the fold. Mobile parity is preserved by rendering the same tabs inline below `md`. Trigger cards and run/activity rows were restructured into vertical stacks so they fit the 320px panel without overflow, and the last-result badge became a wrapping inline chip so long error strings no longer fill the card width. - **If reviewers prefer to keep the tabs inline below the routine, this commit can be reverted cleanly without touching any of the fixes above.** ## Screenshots: Old: <img width="721" height="707" alt="triggers-old" src="https://github.com/user-attachments/assets/260bb682-32cb-4dff-b038-d55e45824b04" /> New: <img width="1410" height="1325" alt="Screenshot 2026-04-13 at 12 25 00" src="https://github.com/user-attachments/assets/d70dd35b-e72f-4fc6-bb21-be9b0d92b3b1" /> New Add Trigger modal: <img width="1408" height="1321" alt="Screenshot 2026-04-13 at 12 25 07" src="https://github.com/user-attachments/assets/0f23a83d-ba2c-47ed-9efa-829e777dcdf5" /> Commit 145a86b5 Properties panel: <img width="1409" height="830" alt="commit-145a86b51265e326160cb8c48e0874cb36d86f37" src="https://github.com/user-attachments/assets/f1d42f07-7cd3-4614-8e93-5b585affd4bf" /> ## Verification - `cd ui && npm test -- ScheduleEditor` — new cron parser/describer cases pass. - Full UI test suite + typecheck green locally. - Manual: 1. Open a routine → Triggers tab → verify cards render with enable switch, edit, and delete (confirm dialog). 2. Create a schedule trigger with each preset (every-N-min with window, every-N-hours, hourly@offset, daily multi-time, weekly multi-time, monthly multi-date) → save → reopen → preset + values round-trip intact. 3. Paste `0 9,13,17 * * *` into an existing trigger → editor routes to Custom with the raw cron preserved → save → value unchanged. 4. Try to add a webhook trigger → kind option shows \"— COMING SOON\" and is disabled; edit an existing webhook trigger still works. 5. Toggle the properties panel via header button → state persists across reload. Resize below `md` → tabs render inline. - **Before/after screenshots:** attached in PR description (inline triggers tab → list+modal; raw-cron save hazard → custom preset preservation; bottom-of-page tabs → right-hand PropertiesPanel). ## Risks - **Medium-low.** UI-only change; no API, schema, or migration impact. - `parseCronToPreset` / `describeSchedule` signatures are preserved, but their *behaviour* shifts: more cron strings now resolve to `custom` than before. Any external caller relying on the old (lossy) classification would see different preset tags — none known in-repo. - PropertiesPanel reuse (optional commit) depends on the existing localStorage key behaviour; if two routes ever write conflicting open/close state under the same key, one could clobber the other. Mirrors the established `IssueDetail`/`GoalDetail` pattern, so risk is bounded. Reverting `145a86b5` removes this risk entirely while keeping the fixes. - Webhook kind is disabled in the add dialog only; existing webhook triggers remain editable, so no data is stranded. ## Model Used - **Authoring / PR drafting:** Anthropic Claude — `claude-opus-4-6` (1M context window), via Claude Code CLI. Used for diff review and PR description drafting. Code authored by @aronprins. - **Post-hoc audit:** OpenAI Codex — `gpt-5.4` (high reasoning). Audited the completed work after implementation; found no issues. ## Checklist - [x] Thinking path traces from project context to this change - [x] Model used specified with version + capability details - [x] Tests run locally and pass - [x] Added/updated tests (`ScheduleEditor.test.ts`) - [x] Before/after screenshots attached - [ ] Documentation updated — none required (internal UI only) - [x] Risks documented - [x] Will address all Greptile + reviewer comments before merge
This commit is contained in:
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{description && <DialogDescription>{description}</DialogDescription>}
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)} disabled={busy}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
variant={destructive ? "destructive" : "default"}
|
||||
size="sm"
|
||||
onClick={onConfirm}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? "Working…" : confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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 <Select> couldn't render. Saving the
|
||||
// form then rebuilt the cron as `0 10 * * *`, silently collapsing three
|
||||
// daily fires into one.
|
||||
expect(parseCronToPreset("0 9,13,17 * * *").preset).toBe("custom");
|
||||
expect(parseCronToPreset("0 10,16 * * *").preset).toBe("custom");
|
||||
});
|
||||
|
||||
it("routes step expressions to custom", () => {
|
||||
expect(parseCronToPreset("0 */4 * * *").preset).toBe("custom");
|
||||
expect(parseCronToPreset("*/15 * * * *").preset).toBe("custom");
|
||||
expect(parseCronToPreset("0 9-17/2 * * *").preset).toBe("custom");
|
||||
});
|
||||
|
||||
it("routes range expressions (other than weekday 1-5) to custom", () => {
|
||||
expect(parseCronToPreset("0 9-17 * * *").preset).toBe("custom");
|
||||
expect(parseCronToPreset("15-45 * * * *").preset).toBe("custom");
|
||||
expect(parseCronToPreset("0 9 1-15 * *").preset).toBe("custom");
|
||||
});
|
||||
|
||||
it("routes comma-separated day-of-week to custom", () => {
|
||||
expect(parseCronToPreset("0 9 * * 1,3,5").preset).toBe("custom");
|
||||
});
|
||||
|
||||
it("routes hourly-on-weekdays schedules to custom to preserve the stored expression", () => {
|
||||
expect(getScheduleEditorPresetForTest("5 * * * 1-5")).toBe("custom");
|
||||
expect(roundTripCronForTest("5 * * * 1-5")).toBe("5 * * * 1-5");
|
||||
});
|
||||
|
||||
it("routes non-wildcard month field to custom", () => {
|
||||
// None of the presets encode a month, so even a single numeric month
|
||||
// must fall through to custom to avoid being silently dropped.
|
||||
expect(parseCronToPreset("0 9 1 1 *").preset).toBe("custom");
|
||||
});
|
||||
|
||||
it("routes unknown tokens to custom", () => {
|
||||
expect(parseCronToPreset("0 MON * * *").preset).toBe("custom");
|
||||
expect(parseCronToPreset("@daily").preset).toBe("custom");
|
||||
});
|
||||
|
||||
it("routes malformed crons to custom", () => {
|
||||
expect(parseCronToPreset("not a cron").preset).toBe("custom");
|
||||
expect(parseCronToPreset("0 9 *").preset).toBe("custom");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeSchedule", () => {
|
||||
it("describes simple presets in plain English", () => {
|
||||
expect(describeSchedule("0 9 * * *")).toContain("Every day");
|
||||
expect(describeSchedule("0 9 * * 1-5")).toContain("weekday");
|
||||
expect(describeSchedule("0 9 * * 1")).toContain("Monday");
|
||||
});
|
||||
|
||||
it("describes multi-value hour lists (these previously collapsed silently)", () => {
|
||||
// Regression guard. Pre-fix, these crons round-tripped to "Every day at …"
|
||||
// with a silently-wrong single hour. Post-fix they rendered as the raw
|
||||
// cron string. Now that the editor can represent multi-value hour lists
|
||||
// first-class, describeSchedule unfolds them into a readable sentence.
|
||||
expect(describeSchedule("0 9,13,17 * * *")).toBe("Every day at 09:00, 13:00 and 17:00");
|
||||
expect(describeSchedule("0 10,16 * * *")).toBe("Every day at 10:00 and 16:00");
|
||||
});
|
||||
|
||||
it("describes step expressions in plain English", () => {
|
||||
expect(describeSchedule("0 */4 * * *")).toBe("Every 4 hours at :00");
|
||||
expect(describeSchedule("*/15 * * * *")).toBe("Every 15 minutes");
|
||||
expect(describeSchedule("*/15 9-17 * * 1-5")).toContain("between 09:00 and 17:00");
|
||||
});
|
||||
|
||||
it("describes multi-day weekday selections", () => {
|
||||
expect(describeSchedule("0 9 * * 1,3,5")).toBe("Every Mon, Wed, Fri at 09:00");
|
||||
});
|
||||
|
||||
it("describes multi-date monthly selections with ordinals", () => {
|
||||
expect(describeSchedule("0 9 1,15 * *")).toBe("On the 1st, 15th of the month at 09:00");
|
||||
});
|
||||
|
||||
it("falls back to the raw cron string for expressions it can't confidently describe", () => {
|
||||
// Named tokens and exotic forms still round-trip as the raw cron.
|
||||
expect(describeSchedule("0 MON * * *")).toBe("0 MON * * *");
|
||||
expect(describeSchedule("@daily")).toBe("@daily");
|
||||
expect(describeSchedule("not a cron")).toBe("not a cron");
|
||||
});
|
||||
|
||||
it("falls back to the default 10:00 preset for an empty cron", () => {
|
||||
expect(describeSchedule("")).toBe("Every day at 10:00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getScheduleEditorPresetForTest", () => {
|
||||
it("keeps malformed hour fields in custom mode instead of coercing them into daily", () => {
|
||||
expect(getScheduleEditorPresetForTest("0 foo * * *")).toBe("custom");
|
||||
expect(getScheduleEditorPresetForTest("0 9-foo * * *")).toBe("custom");
|
||||
});
|
||||
|
||||
it("keeps multi-minute daily schedules in custom mode because one trigger cannot represent arbitrary time pairs", () => {
|
||||
expect(getScheduleEditorPresetForTest("15,45 9,13 * * *")).toBe("custom");
|
||||
});
|
||||
});
|
||||
|
||||
describe("roundTripCronForTest", () => {
|
||||
it("preserves weekday ranges instead of normalizing them to a comma list", () => {
|
||||
expect(roundTripCronForTest("0 9 * * 1-5")).toBe("0 9 * * 1-5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasSingleMinuteAcrossTimesForTest", () => {
|
||||
it("accepts same-minute multi-time selections", () => {
|
||||
expect(hasSingleMinuteAcrossTimesForTest(["09:00", "13:00", "17:00"])).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects mixed-minute selections that would expand into extra cron runs", () => {
|
||||
expect(hasSingleMinuteAcrossTimesForTest(["09:15", "13:45"])).toBe(false);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<string>(["github_hmac", "none"]);
|
||||
const signingModeDescriptions: Record<string, string> = {
|
||||
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<string, unknown>;
|
||||
}) => 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<TriggerDialogState>(() => 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<string, unknown> = {
|
||||
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<string, unknown> = {
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? "Edit trigger" : "Add trigger"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure when and how this routine fires.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5 pt-1">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="trigger-label" className="text-xs">Label</Label>
|
||||
<Input
|
||||
id="trigger-label"
|
||||
placeholder="e.g. Morning digest"
|
||||
value={draft.label}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, label: e.target.value }))}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Optional — shown in the trigger list.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Kind</Label>
|
||||
<Select
|
||||
value={draft.kind}
|
||||
onValueChange={(kind) => setDraft((d) => ({ ...d, kind: kind as TriggerKind }))}
|
||||
disabled={isEdit}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{triggerKinds.map((kind) => (
|
||||
<SelectItem
|
||||
key={kind}
|
||||
value={kind}
|
||||
disabled={!isEdit && kind === "webhook"}
|
||||
>
|
||||
{kind}
|
||||
{!isEdit && kind === "webhook" ? " — COMING SOON" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isEdit && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Kind can't be changed after creation.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showScheduleFields && (
|
||||
<ScheduleEditor
|
||||
value={draft.cronExpression}
|
||||
onChange={(cronExpression) => setDraft((d) => ({ ...d, cronExpression }))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showWebhookFields && (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Signing mode</Label>
|
||||
<Select
|
||||
value={draft.signingMode}
|
||||
onValueChange={(signingMode) => setDraft((d) => ({ ...d, signingMode }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{signingModes.map((mode) => (
|
||||
<SelectItem key={mode} value={mode}>
|
||||
{mode}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{signingModeDescriptions[draft.signingMode]}
|
||||
</p>
|
||||
</div>
|
||||
{!SIGNING_MODES_WITHOUT_REPLAY_WINDOW.has(draft.signingMode) && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Replay window (seconds)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={draft.replayWindowSec}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({ ...d, replayWindowSec: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
{isEdit && (
|
||||
<label className="flex items-center gap-2 cursor-pointer text-sm mr-auto">
|
||||
<ToggleSwitch
|
||||
checked={draft.enabled}
|
||||
onCheckedChange={(enabled) => setDraft((d) => ({ ...d, enabled }))}
|
||||
/>
|
||||
<span>{draft.enabled ? "Enabled" : "Paused"}</span>
|
||||
</label>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? "Saving…" : isEdit ? "Save changes" : "Add trigger"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
className={`rounded-lg border border-border p-3 transition-colors ${trigger.enabled ? "bg-card" : "bg-muted/40"}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<span className={`text-sm font-medium truncate flex-1 min-w-0 ${trigger.enabled ? "" : "text-muted-foreground"}`}>
|
||||
{trigger.label || (isSchedule ? "Schedule" : isWebhook ? "Webhook" : "Trigger")}
|
||||
</span>
|
||||
<ToggleSwitch
|
||||
checked={trigger.enabled}
|
||||
onCheckedChange={onToggleEnabled}
|
||||
disabled={togglePending}
|
||||
aria-label={trigger.enabled ? "Disable trigger" : "Enable trigger"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 flex-wrap mt-2">
|
||||
<Badge variant="outline" className="text-[11px]">
|
||||
{trigger.kind}
|
||||
</Badge>
|
||||
{!trigger.enabled && (
|
||||
<Badge variant="secondary" className="text-[11px] text-muted-foreground">
|
||||
paused
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-sm break-words">{summary}</div>
|
||||
{isSchedule && trigger.cronExpression && (
|
||||
<div className="text-xs text-muted-foreground mt-1 font-mono break-all">
|
||||
{trigger.cronExpression}
|
||||
{trigger.timezone ? ` · ${trigger.timezone}` : ""}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<dl className="mt-3 space-y-2 text-xs">
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<dt className="text-muted-foreground">Next run</dt>
|
||||
<dd className="break-words">{nextRun}</dd>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<dt className="text-muted-foreground">Last fired</dt>
|
||||
<dd className="break-words">{lastFired}</dd>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<dt className="text-muted-foreground">Last result</dt>
|
||||
<dd className="min-w-0">
|
||||
{trigger.lastResult ? (
|
||||
<span
|
||||
className={`inline-block rounded px-1.5 py-0.5 text-[11px] break-words ${
|
||||
resultIsError
|
||||
? "bg-destructive/15 text-destructive"
|
||||
: "bg-secondary text-secondary-foreground"
|
||||
}`}
|
||||
title={trigger.lastResult}
|
||||
>
|
||||
{trigger.lastResult}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="mt-3 flex items-center justify-end gap-1 border-t border-border pt-2">
|
||||
{isWebhook && onRotateSecret && (
|
||||
<Button variant="ghost" size="xs" onClick={onRotateSecret} title="Rotate secret">
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="xs" onClick={onEdit} title="Edit">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={onDelete}
|
||||
title="Delete"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+326
-381
@@ -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<string, string> = {
|
||||
coalesce_if_active: "Keep one follow-up run queued while an active run is still working.",
|
||||
@@ -79,13 +78,6 @@ const catchUpPolicyDescriptions: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
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<string, unknown>) => 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 (
|
||||
<div className="rounded-lg border border-border p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
{trigger.kind === "schedule" ? <Clock3 className="h-3.5 w-3.5" /> : trigger.kind === "webhook" ? <Webhook className="h-3.5 w-3.5" /> : <Zap className="h-3.5 w-3.5" />}
|
||||
{trigger.label ?? trigger.kind}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{trigger.kind === "schedule" && trigger.nextRunAt
|
||||
? `Next: ${new Date(trigger.nextRunAt).toLocaleString()}`
|
||||
: trigger.kind === "webhook"
|
||||
? "Webhook"
|
||||
: "API"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Label</Label>
|
||||
<Input
|
||||
value={draft.label}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, label: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
{trigger.kind === "schedule" && (
|
||||
<div className="md:col-span-2 space-y-1.5">
|
||||
<Label className="text-xs">Schedule</Label>
|
||||
<ScheduleEditor
|
||||
value={draft.cronExpression}
|
||||
onChange={(cronExpression) => setDraft((current) => ({ ...current, cronExpression }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{trigger.kind === "webhook" && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Signing mode</Label>
|
||||
<Select
|
||||
value={draft.signingMode}
|
||||
onValueChange={(signingMode) => setDraft((current) => ({ ...current, signingMode }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{signingModes.map((mode) => (
|
||||
<SelectItem key={mode} value={mode}>{mode}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{!SIGNING_MODES_WITHOUT_REPLAY_WINDOW.has(draft.signingMode) && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Replay window (seconds)</Label>
|
||||
<Input
|
||||
value={draft.replayWindowSec}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, replayWindowSec: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{trigger.lastResult && <span className="text-xs text-muted-foreground">Last: {trigger.lastResult}</span>}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{trigger.kind === "webhook" && (
|
||||
<Button variant="outline" size="sm" onClick={() => onRotate(trigger.id)}>
|
||||
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
|
||||
Rotate secret
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onSave(trigger.id, buildRoutineTriggerPatch(trigger, draft, getLocalTimezone()))}
|
||||
>
|
||||
<Save className="mr-1.5 h-3.5 w-3.5" />
|
||||
Save trigger
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => onDelete(trigger.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(null);
|
||||
const titleInputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const descriptionEditorRef = useRef<MarkdownEditorRef>(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<RoutineTrigger | null>(null);
|
||||
const [triggerPendingDelete, setTriggerPendingDelete] = useState<RoutineTrigger | null>(null);
|
||||
const [togglingTriggerId, setTogglingTriggerId] = useState<string | null>(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<RoutineTriggerResponse> => {
|
||||
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<string, unknown>): Promise<RoutineTriggerResponse> => {
|
||||
// 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 (
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-3">
|
||||
<TabsList variant="line" className="w-full justify-start gap-1">
|
||||
<TabsTrigger value="triggers" className="gap-1.5">
|
||||
<Clock3 className="h-3.5 w-3.5" />
|
||||
Triggers
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="runs" className="gap-1.5">
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
Runs
|
||||
{hasLiveRun && <span className="h-2 w-2 rounded-full bg-blue-500 animate-pulse" />}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="activity" className="gap-1.5">
|
||||
<ActivityIcon className="h-3.5 w-3.5" />
|
||||
Activity
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="history" className="gap-1.5">
|
||||
<HistoryIcon className="h-3.5 w-3.5" />
|
||||
History
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="triggers" className="space-y-4">
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
setEditingTrigger(null);
|
||||
setTriggerDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||
Add trigger
|
||||
</Button>
|
||||
|
||||
{routine.triggers.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border bg-muted/30 p-8 text-center">
|
||||
<p className="text-sm font-medium">No triggers yet</p>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-4">
|
||||
Triggers fire this routine on a schedule or via webhook.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingTrigger(null);
|
||||
setTriggerDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||
Add your first trigger
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{routine.triggers.map((trigger) => (
|
||||
<TriggerListCard
|
||||
key={trigger.id}
|
||||
trigger={trigger}
|
||||
onEdit={() => {
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="runs" className="space-y-4">
|
||||
{hasLiveRun && activeIssueId && routine && (
|
||||
<LiveRunWidget issueId={activeIssueId} companyId={routine.companyId} />
|
||||
)}
|
||||
{(routineRuns ?? []).length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No runs yet.</p>
|
||||
) : (
|
||||
<div className="border border-border rounded-lg divide-y divide-border">
|
||||
{(routineRuns ?? []).map((run) => (
|
||||
<div key={run.id} className="flex flex-col gap-1.5 px-3 py-2 text-sm min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<Badge variant="outline" className="text-[11px]">{run.source}</Badge>
|
||||
<Badge variant={run.status === "failed" ? "destructive" : "secondary"} className="text-[11px]">
|
||||
{run.status.replaceAll("_", " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
{(run.trigger || run.linkedIssue) && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap text-xs min-w-0">
|
||||
{run.trigger && (
|
||||
<span className="text-muted-foreground truncate">{run.trigger.label ?? run.trigger.kind}</span>
|
||||
)}
|
||||
{run.linkedIssue && (
|
||||
<Link to={`/issues/${run.linkedIssue.identifier ?? run.linkedIssue.id}`} className="text-muted-foreground hover:underline truncate">
|
||||
{run.linkedIssue.identifier ?? run.linkedIssue.id.slice(0, 8)}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-[11px] text-muted-foreground">{timeAgo(run.triggeredAt)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="activity">
|
||||
{(activity ?? []).length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No activity yet.</p>
|
||||
) : (
|
||||
<div className="border border-border rounded-lg divide-y divide-border">
|
||||
{(activity ?? []).map((event) => (
|
||||
<div key={event.id} className="flex flex-col gap-1 px-3 py-2 text-xs min-w-0">
|
||||
<span className="font-medium text-foreground/90">{event.action.replaceAll(".", " ")}</span>
|
||||
{event.details && Object.keys(event.details).length > 0 && (
|
||||
<div className="text-muted-foreground break-words">
|
||||
{Object.entries(event.details).slice(0, 3).map(([key, value], i) => (
|
||||
<span key={key}>
|
||||
{i > 0 && <span className="mx-1 text-border">·</span>}
|
||||
<span className="text-muted-foreground/70">{key.replaceAll("_", " ")}:</span>{" "}
|
||||
{formatActivityDetailValue(value)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-muted-foreground/60">{timeAgo(event.createdAt)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="history">
|
||||
<RoutineHistoryTab
|
||||
routine={routine}
|
||||
isEditDirty={isEditDirty}
|
||||
dirtyFields={dirtyFields}
|
||||
onDiscardEdits={() => {
|
||||
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<RoutineDetailType | undefined>(
|
||||
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;
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}, [
|
||||
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 <EmptyState icon={Repeat} message="Select a company to view routines." />;
|
||||
}
|
||||
@@ -811,6 +920,18 @@ export function RoutineDetail() {
|
||||
<span className={`min-w-[3.75rem] text-sm font-medium ${automationLabelClassName}`}>
|
||||
{automationLabel}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className={cn(
|
||||
"hidden md:inline-flex shrink-0 transition-opacity duration-200",
|
||||
panelVisible ? "opacity-0 pointer-events-none w-0 overflow-hidden" : "opacity-100",
|
||||
)}
|
||||
onClick={() => setPanelVisible(true)}
|
||||
title="Show triggers, runs and activity"
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1064,225 +1185,12 @@ export function RoutineDetail() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<Separator className="md:hidden" />
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-3">
|
||||
<TabsList variant="line" className="w-full justify-start gap-1">
|
||||
<TabsTrigger value="triggers" className="gap-1.5">
|
||||
<Clock3 className="h-3.5 w-3.5" />
|
||||
Triggers
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="runs" className="gap-1.5">
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
Runs
|
||||
{hasLiveRun && <span className="h-2 w-2 rounded-full bg-blue-500 animate-pulse" />}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="activity" className="gap-1.5">
|
||||
<ActivityIcon className="h-3.5 w-3.5" />
|
||||
Activity
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="history" className="gap-1.5">
|
||||
<HistoryIcon className="h-3.5 w-3.5" />
|
||||
History
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="triggers" className="space-y-4">
|
||||
{/* Add trigger form */}
|
||||
<div className="rounded-lg border border-border p-4 space-y-3">
|
||||
<p className="text-sm font-medium">Add trigger</p>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Kind</Label>
|
||||
<Select value={newTrigger.kind} onValueChange={(kind) => setNewTrigger((current) => ({ ...current, kind }))}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{triggerKinds.map((kind) => (
|
||||
<SelectItem key={kind} value={kind} disabled={kind === "webhook"}>
|
||||
{kind}{kind === "webhook" ? " — COMING SOON" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{newTrigger.kind === "schedule" && (
|
||||
<div className="md:col-span-2 space-y-1.5">
|
||||
<Label className="text-xs">Schedule</Label>
|
||||
<ScheduleEditor
|
||||
value={newTrigger.cronExpression}
|
||||
onChange={(cronExpression) => setNewTrigger((current) => ({ ...current, cronExpression }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{newTrigger.kind === "webhook" && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Signing mode</Label>
|
||||
<Select value={newTrigger.signingMode} onValueChange={(signingMode) => setNewTrigger((current) => ({ ...current, signingMode }))}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{signingModes.map((mode) => (
|
||||
<SelectItem key={mode} value={mode}>{mode}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{signingModeDescriptions[newTrigger.signingMode]}</p>
|
||||
</div>
|
||||
{!SIGNING_MODES_WITHOUT_REPLAY_WINDOW.has(newTrigger.signingMode) && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Replay window (seconds)</Label>
|
||||
<Input value={newTrigger.replayWindowSec} onChange={(event) => setNewTrigger((current) => ({ ...current, replayWindowSec: event.target.value }))} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
<Button size="sm" onClick={() => createTrigger.mutate()} disabled={createTrigger.isPending}>
|
||||
{createTrigger.isPending ? "Adding..." : "Add trigger"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Existing triggers */}
|
||||
{routine.triggers.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No triggers configured yet.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{routine.triggers.map((trigger) => (
|
||||
<TriggerEditor
|
||||
key={trigger.id}
|
||||
trigger={trigger}
|
||||
onSave={(id, patch) => updateTrigger.mutate({ id, patch })}
|
||||
onRotate={(id) => rotateTrigger.mutate(id)}
|
||||
onDelete={(id) => deleteTrigger.mutate(id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="runs" className="space-y-4">
|
||||
{hasLiveRun && activeIssueId && routine && (
|
||||
<LiveRunWidget issueId={activeIssueId} companyId={routine.companyId} />
|
||||
)}
|
||||
{(routineRuns ?? []).length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No runs yet.</p>
|
||||
) : (
|
||||
<div className="border border-border rounded-lg divide-y divide-border">
|
||||
{(routineRuns ?? []).map((run) => (
|
||||
<div key={run.id} className="flex items-center justify-between px-3 py-2 text-sm">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Badge variant="outline" className="shrink-0">{run.source}</Badge>
|
||||
<Badge variant={run.status === "failed" ? "destructive" : "secondary"} className="shrink-0">
|
||||
{run.status.replaceAll("_", " ")}
|
||||
</Badge>
|
||||
{run.trigger && (
|
||||
<span className="text-muted-foreground truncate">{run.trigger.label ?? run.trigger.kind}</span>
|
||||
)}
|
||||
{run.linkedIssue && (
|
||||
<Link to={`/issues/${run.linkedIssue.identifier ?? run.linkedIssue.id}`} className="text-muted-foreground hover:underline truncate">
|
||||
{run.linkedIssue.identifier ?? run.linkedIssue.id.slice(0, 8)}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground shrink-0 ml-2">{timeAgo(run.triggeredAt)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="activity">
|
||||
{(activity ?? []).length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No activity yet.</p>
|
||||
) : (
|
||||
<div className="border border-border rounded-lg divide-y divide-border">
|
||||
{(activity ?? []).map((event) => (
|
||||
<div key={event.id} className="flex items-center justify-between px-3 py-2 text-xs gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="font-medium text-foreground/90 shrink-0">{event.action.replaceAll(".", " ")}</span>
|
||||
{event.details && Object.keys(event.details).length > 0 && (
|
||||
<span className="text-muted-foreground truncate">
|
||||
{Object.entries(event.details).slice(0, 3).map(([key, value], i) => (
|
||||
<span key={key}>
|
||||
{i > 0 && <span className="mx-1 text-border">·</span>}
|
||||
<span className="text-muted-foreground/70">{key.replaceAll("_", " ")}:</span>{" "}
|
||||
{formatActivityDetailValue(value)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground/60 shrink-0">{timeAgo(event.createdAt)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="history">
|
||||
<RoutineHistoryTab
|
||||
routine={routine}
|
||||
isEditDirty={isEditDirty}
|
||||
dirtyFields={dirtyFields}
|
||||
onDiscardEdits={() => {
|
||||
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<RoutineDetailType | undefined>(
|
||||
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;
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{/* Tabs (mobile only — desktop renders in the right properties panel) */}
|
||||
<div className="md:hidden">
|
||||
{activityTabsPanel}
|
||||
</div>
|
||||
|
||||
<RoutineRunVariablesDialog
|
||||
open={runVariablesOpen}
|
||||
@@ -1297,6 +1205,43 @@ export function RoutineDetail() {
|
||||
isPending={runRoutine.isPending}
|
||||
onSubmit={(data) => runRoutine.mutate(data)}
|
||||
/>
|
||||
|
||||
<TriggerDialog
|
||||
open={triggerDialogOpen}
|
||||
onOpenChange={(next) => {
|
||||
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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!triggerPendingDelete}
|
||||
onOpenChange={(next) => {
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user