fix(ui): stabilize routine schedule editor and interrupted run labels (#8333)

## Thinking Path

> - Paperclip is the open source control plane people use to manage AI
agents for work.
> - This change touches the board UI surfaces for issue run timelines
and routine schedule editing.
> - Operators need cancelled runs to distinguish ordinary cancellation
from human interruption, otherwise the run history reads as more severe
than it is.
> - Routine schedule editing also needs to preserve user-entered cron
values while rendering common schedules in a stable, understandable
editor.
> - This pull request keeps the editor state tied to explicit schedule
values, adds coverage for routine editable sections, and makes
interrupted run copy more precise.
> - The benefit is less surprising routine editing and clearer issue run
history for operators.

## Linked Issues or Issue Description

No public GitHub issue is filed for this exact branch. Related public
PRs:

- Refs #3581, which addresses a narrower schedule reset case.
- Refs #1803, which is another open schedule editor UI improvement.

Problem description:

Routine trigger schedules can be edited through the board UI, but the
previous schedule editor path could normalize or reset cron state in
ways that made unsaved edits fragile. Issue run history also labeled
operator-interrupted cancelled runs like ordinary cancellations.
Reviewers should treat this PR as a combined UI stabilization pass for
those two visible operator workflows.

## What Changed

- Added a more stable routine schedule editor flow that preserves
explicit cron values and handles custom/common schedule transitions.
- Wired routine editable-section state so schedule drafts do not get
overwritten by unrelated section refreshes.
- Added tests for schedule editor behavior, routine editable sections,
and routine service schedule preservation.
- Updated issue run timeline copy so operator-interrupted cancelled runs
display as interrupted, while ordinary cancelled runs remain cancelled.
- Kept the classic issue thread run label behavior aligned with the
current issue thread surface.

## Verification

- `NODE_ENV=development pnpm run preflight:workspace-links &&
NODE_ENV=development pnpm exec vitest run
server/src/__tests__/routines-service.test.ts
ui/src/components/IssueChatThread.test.tsx
ui/src/components/ScheduleEditor.test.tsx
ui/src/components/routine-sections/editable-sections.test.tsx` — 103
tests passed.

Note: direct `pnpm exec vitest ...` without `NODE_ENV=development`
loaded a React build where `React.act` is undefined in this workspace.
The same targeted tests pass under the development React build.

## Risks

Low to medium risk. The changes are UI-focused but touch routine
schedule editing, which is a high-frequency operator workflow. The main
risk is that an uncommon cron expression could render as custom when a
user expected a preset; the added tests cover preservation and explicit
custom handling.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex, GPT-5-class coding agent. Exact hosted runtime model ID
and context window were not exposed in this session. Tool use and local
command execution were used for inspection, verification, GitHub PR
creation, and Paperclip issue updates.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-06-19 13:01:17 -05:00
committed by GitHub
parent 76ffa5023f
commit aeea5f9195
9 changed files with 475 additions and 63 deletions
@@ -495,6 +495,26 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
expect(restoredTrigger?.publicId).not.toBe(created.trigger.publicId);
});
it("persists custom schedule cron expressions exactly", async () => {
const { companyId, routine, svc } = await seedFixture();
const cronExpression = "0 8-18/2 * * 1-5";
const created = await svc.createTrigger(routine.id, {
kind: "schedule",
label: "Business hours",
cronExpression,
timezone: "UTC",
}, {});
expect(created.trigger.cronExpression).toBe(cronExpression);
const storedTrigger = await svc.getTrigger(created.trigger.id);
expect(storedTrigger?.cronExpression).toBe(cronExpression);
const [listed] = await svc.list(companyId);
expect(listed?.triggers[0]?.cronExpression).toBe(cronExpression);
});
it("blocks agents from restoring routine revisions assigned to another agent", async () => {
const { companyId, routine, svc } = await seedFixture();
const otherAgentId = randomUUID();
@@ -353,6 +353,57 @@ describe("IssueChatThread", () => {
});
});
it("labels operator-interrupted cancelled runs as interrupted while preserving plain cancelled runs", () => {
const root = createRoot(container);
const linkedRuns: IssueChatLinkedRun[] = [
{
runId: "run-interrupted",
status: "cancelled",
agentId: "agent-1",
agentName: "CodexCoder",
createdAt: new Date("2026-04-06T12:00:00.000Z"),
startedAt: new Date("2026-04-06T12:00:00.000Z"),
finishedAt: new Date("2026-04-06T12:01:00.000Z"),
errorCode: "operator_interrupted",
resultJson: { operatorInterrupted: true, interruptionSource: "issue_comment_interrupt" },
},
{
runId: "run-cancelled",
status: "cancelled",
agentId: "agent-1",
agentName: "CodexCoder",
createdAt: new Date("2026-04-06T12:02:00.000Z"),
startedAt: new Date("2026-04-06T12:02:00.000Z"),
finishedAt: new Date("2026-04-06T12:03:00.000Z"),
resultJson: { stopReason: "cancelled" },
},
];
act(() => {
root.render(
<MemoryRouter>
<IssueChatThread
comments={[]}
linkedRuns={linkedRuns}
timelineEvents={[]}
liveRuns={[]}
onAdd={async () => {}}
showComposer={false}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
expect(container.textContent).toContain("interrupted by board after 1 minute");
expect(container.textContent).toContain("cancelled after 1 minute");
expect(container.textContent).not.toContain("run interrupted");
act(() => {
root.unmount();
});
});
it("falls back to execCommand for comment copy actions in insecure contexts", async () => {
const clipboardWrite = vi.fn(async () => {
throw new Error("Clipboard API blocked");
+5 -33
View File
@@ -107,6 +107,7 @@ import { Identity } from "./Identity";
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
import { IssueThreadInteractionCardClassic } from "./IssueThreadInteractionCardClassic";
import { AgentIcon } from "./AgentIconPicker";
import { RunStatusBadge } from "./interrupt-handoff/InterruptHandoffViews";
import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft";
import {
captureComposerViewportSnapshot,
@@ -793,36 +794,6 @@ export function resolveIssueChatHumanAuthor(args: {
};
}
function formatRunStatusLabel(status: string) {
switch (status) {
case "timed_out":
return "timed out";
default:
return status.replace(/_/g, " ");
}
}
function runStatusClass(status: string) {
switch (status) {
case "succeeded":
return "text-green-700 dark:text-green-300";
case "failed":
case "error":
return "text-red-700 dark:text-red-300";
case "timed_out":
return "text-orange-700 dark:text-orange-300";
case "running":
return "text-cyan-700 dark:text-cyan-300";
case "queued":
case "pending":
return "text-amber-700 dark:text-amber-300";
case "cancelled":
return "text-muted-foreground";
default:
return "text-foreground";
}
}
function toolCountSummary(toolParts: ToolCallMessagePart[]): string | null {
if (toolParts.length === 0) return null;
let commands = 0;
@@ -2717,9 +2688,10 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
>
{runId.slice(0, 8)}
</Link>
<span className={cn("font-medium", runStatusClass(runStatus))}>
{formatRunStatusLabel(runStatus)}
</span>
<RunStatusBadge
status={runStatus}
operatorInterrupted={custom.runOperatorInterrupted === true}
/>
<a
href={anchorId ? `#${anchorId}` : undefined}
className="text-xs text-muted-foreground transition-colors hover:text-foreground hover:underline"
+133
View File
@@ -0,0 +1,133 @@
// @vitest-environment jsdom
import { useState } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
ScheduleEditor,
buildCron,
getScheduleCronValidation,
parseCronToPreset,
} from "./ScheduleEditor";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
function Harness({
initial,
onChange,
onValidityChange,
}: {
initial: string;
onChange?: (value: string) => void;
onValidityChange?: (valid: boolean) => void;
}) {
const [value, setValue] = useState(initial);
return (
<ScheduleEditor
value={value}
onValidityChange={onValidityChange}
onChange={(cron) => {
setValue(cron);
onChange?.(cron);
}}
/>
);
}
function typeCron(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
function act(callback: () => void) {
flushSync(callback);
}
describe("ScheduleEditor cron helpers", () => {
it("classifies unknown valid 5-field cron expressions as custom", () => {
expect(parseCronToPreset("0 8-18/2 * * 1-5").preset).toBe("custom");
expect(getScheduleCronValidation("0 8-18/2 * * 1-5").valid).toBe(true);
});
it("still recognizes supported presets and emits their expected cron", () => {
expect(parseCronToPreset("0 10 * * 1-5")).toMatchObject({
preset: "weekdays",
hour: "10",
minute: "0",
});
expect(buildCron("weekdays", "8", "15", "1", "1")).toBe("15 8 * * 1-5");
});
it("reports partial cron edits as invalid without treating them as presets", () => {
const validation = getScheduleCronValidation("0 8-18/2 *");
expect(validation.valid).toBe(false);
expect(validation.message).toContain("5 fields");
});
});
describe("ScheduleEditor", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
});
function cronInput() {
return container.querySelector<HTMLInputElement>('input[aria-label="Cron expression"]');
}
it("renders unknown valid cron expressions in Custom with the original text", () => {
const root = createRoot(container);
act(() => {
root.render(<Harness initial="0 8-18/2 * * 1-5" />);
});
expect(container.textContent).toContain("Custom (cron)");
expect(cronInput()?.value).toBe("0 8-18/2 * * 1-5");
expect(container.textContent).toContain("Valid cron.");
act(() => root.unmount());
});
it("keeps Custom open while partial edits and pasted valid cron values round-trip through parent state", () => {
const onChange = vi.fn();
const onValidityChange = vi.fn();
const root = createRoot(container);
act(() => {
root.render(
<Harness
initial="0 8-18/2 * * 1-5"
onChange={onChange}
onValidityChange={onValidityChange}
/>,
);
});
act(() => {
typeCron(cronInput()!, "0 8-18/2 *");
});
expect(cronInput()?.value).toBe("0 8-18/2 *");
expect(cronInput()?.getAttribute("aria-invalid")).toBe("true");
expect(container.textContent).toContain("Use exactly 5 fields");
expect(onChange).not.toHaveBeenCalledWith("0 8-18/2 *");
expect(onValidityChange).toHaveBeenLastCalledWith(false);
act(() => {
typeCron(cronInput()!, "0 8-18/2 * * 1-5");
});
expect(cronInput()?.value).toBe("0 8-18/2 * * 1-5");
expect(cronInput()?.getAttribute("aria-invalid")).toBe("false");
expect(onChange).toHaveBeenLastCalledWith("0 8-18/2 * * 1-5");
expect(onValidityChange).toHaveBeenLastCalledWith(true);
act(() => root.unmount());
});
});
+85 -19
View File
@@ -2,9 +2,9 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { ChevronDown, ChevronRight } from "lucide-react";
import { nextCronFires, parseCronExpression } from "../lib/cron-fires";
type SchedulePreset = "every_minute" | "every_hour" | "every_day" | "weekdays" | "weekly" | "monthly" | "custom";
export type SchedulePreset = "every_minute" | "every_hour" | "every_day" | "weekdays" | "weekly" | "monthly" | "custom";
const PRESETS: { value: SchedulePreset; label: string }[] = [
{ value: "every_minute", label: "Every minute" },
@@ -41,7 +41,11 @@ const DAYS_OF_MONTH = Array.from({ length: 31 }, (_, i) => ({
label: String(i + 1),
}));
function parseCronToPreset(cron: string): {
function hasOption(options: Array<{ value: string }>, value: string): boolean {
return options.some((option) => option.value === value);
}
export function parseCronToPreset(cron: string): {
preset: SchedulePreset;
hour: string;
minute: string;
@@ -59,42 +63,44 @@ function parseCronToPreset(cron: string): {
return { preset: "custom", ...defaults };
}
const [min, hr, dom, , dow] = parts;
const [min, hr, dom, month, dow] = parts;
const selectableMinute = hasOption(MINUTES, min);
const selectableHour = hasOption(HOURS, hr);
// Every minute: "* * * * *"
if (min === "*" && hr === "*" && dom === "*" && dow === "*") {
if (min === "*" && hr === "*" && dom === "*" && month === "*" && dow === "*") {
return { preset: "every_minute", ...defaults };
}
// Every hour: "0 * * * *"
if (hr === "*" && dom === "*" && dow === "*") {
return { preset: "every_hour", ...defaults, minute: min === "*" ? "0" : min };
if (hr === "*" && dom === "*" && month === "*" && dow === "*" && selectableMinute) {
return { preset: "every_hour", ...defaults, minute: min };
}
// Every day: "M H * * *"
if (dom === "*" && dow === "*" && hr !== "*") {
return { preset: "every_day", ...defaults, hour: hr, minute: min === "*" ? "0" : min };
if (dom === "*" && month === "*" && dow === "*" && selectableHour && selectableMinute) {
return { preset: "every_day", ...defaults, hour: hr, minute: min };
}
// Weekdays: "M H * * 1-5"
if (dom === "*" && dow === "1-5" && hr !== "*") {
return { preset: "weekdays", ...defaults, hour: hr, minute: min === "*" ? "0" : min };
if (dom === "*" && month === "*" && dow === "1-5" && selectableHour && selectableMinute) {
return { preset: "weekdays", ...defaults, hour: hr, minute: min };
}
// Weekly: "M H * * D" (single day)
if (dom === "*" && /^\d$/.test(dow) && hr !== "*") {
return { preset: "weekly", ...defaults, hour: hr, minute: min === "*" ? "0" : min, dayOfWeek: dow };
if (dom === "*" && month === "*" && hasOption(DAYS_OF_WEEK, dow) && selectableHour && selectableMinute) {
return { preset: "weekly", ...defaults, hour: hr, minute: min, dayOfWeek: dow };
}
// Monthly: "M H D * *"
if (/^\d{1,2}$/.test(dom) && dow === "*" && hr !== "*") {
return { preset: "monthly", ...defaults, hour: hr, minute: min === "*" ? "0" : min, dayOfMonth: dom };
if (month === "*" && hasOption(DAYS_OF_MONTH, dom) && dow === "*" && selectableHour && selectableMinute) {
return { preset: "monthly", ...defaults, hour: hr, minute: min, dayOfMonth: dom };
}
return { preset: "custom", ...defaults };
}
function buildCron(preset: SchedulePreset, hour: string, minute: string, dayOfWeek: string, dayOfMonth: string): string {
export function buildCron(preset: SchedulePreset, hour: string, minute: string, dayOfWeek: string, dayOfMonth: string): string {
switch (preset) {
case "every_minute":
return "* * * * *";
@@ -146,12 +152,53 @@ function ordinalSuffix(n: number): string {
export { describeSchedule };
export function getScheduleCronValidation(cron: string): {
valid: boolean;
message: string;
nextFires: Date[];
} {
const trimmed = cron.trim();
if (!trimmed) {
return {
valid: false,
message: "Enter a 5-field cron expression.",
nextFires: [],
};
}
const fields = trimmed.split(/\s+/);
if (fields.length !== 5) {
return {
valid: false,
message: `Use exactly 5 fields; this has ${fields.length}.`,
nextFires: [],
};
}
if (!parseCronExpression(trimmed)) {
return {
valid: false,
message: "Cron fields must use valid numbers, ranges, lists, wildcards, or steps.",
nextFires: [],
};
}
const nextFires = nextCronFires(trimmed, 3, { timeZone: "UTC" });
return {
valid: true,
message: nextFires.length > 0 ? "Valid cron." : "Valid cron, but no upcoming fires were found.",
nextFires,
};
}
export function ScheduleEditor({
value,
onChange,
onValidityChange,
}: {
value: string;
onChange: (cron: string) => void;
onValidityChange?: (valid: boolean) => void;
}) {
const parsed = useMemo(() => parseCronToPreset(value), [value]);
const [preset, setPreset] = useState<SchedulePreset>(parsed.preset);
@@ -160,6 +207,11 @@ export function ScheduleEditor({
const [dayOfWeek, setDayOfWeek] = useState(parsed.dayOfWeek);
const [dayOfMonth, setDayOfMonth] = useState(parsed.dayOfMonth);
const [customCron, setCustomCron] = useState(preset === "custom" ? value : "");
const customValidation = useMemo(() => getScheduleCronValidation(customCron), [customCron]);
useEffect(() => {
onValidityChange?.(preset !== "custom" || customValidation.valid);
}, [customValidation.valid, onValidityChange, preset]);
// Sync from external value changes
useEffect(() => {
@@ -195,7 +247,7 @@ export function ScheduleEditor({
return (
<div className="space-y-3">
<Select value={preset} onValueChange={(v) => handlePresetChange(v as SchedulePreset)}>
<SelectTrigger className="w-full">
<SelectTrigger className="w-full" aria-label="Schedule frequency">
<SelectValue placeholder="Choose frequency..." />
</SelectTrigger>
<SelectContent>
@@ -212,15 +264,29 @@ export function ScheduleEditor({
<Input
value={customCron}
onChange={(e) => {
setCustomCron(e.target.value);
emitChange("custom", hour, minute, dayOfWeek, dayOfMonth, e.target.value);
const nextCron = e.target.value;
setCustomCron(nextCron);
if (getScheduleCronValidation(nextCron).valid) {
emitChange("custom", hour, minute, dayOfWeek, dayOfMonth, nextCron);
}
}}
placeholder="0 10 * * *"
aria-label="Cron expression"
aria-invalid={!customValidation.valid}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Five fields: minute hour day-of-month month day-of-week
</p>
<p
className={customValidation.valid ? "text-xs text-muted-foreground" : "text-xs text-destructive"}
aria-live="polite"
>
{customValidation.message}
{customValidation.valid && customValidation.nextFires.length > 0
? ` Next: ${customValidation.nextFires.map((fire) => fire.toLocaleString()).join(", ")}.`
: null}
</p>
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
@@ -67,6 +67,15 @@ export type NewTriggerDraft = {
replayWindowSec: string;
};
export function createDefaultNewTrigger(): NewTriggerDraft {
return {
kind: "schedule",
cronExpression: "0 10 * * *",
signingMode: "bearer",
replayWindowSec: "300",
};
}
export type SecretMessage = {
title: string;
entries: Array<{ webhookUrl: string; webhookSecret: string }>;
@@ -0,0 +1,145 @@
// @vitest-environment jsdom
import { useState } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TriggersSection } from "./editable-sections";
import {
RoutineDetailContext,
createDefaultNewTrigger,
type NewTriggerDraft,
type RoutineDetailContextValue,
} from "./context";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
vi.mock("../MarkdownEditor", () => ({
MarkdownEditor: () => null,
}));
function act(callback: () => void) {
flushSync(callback);
}
function buttonByText(container: HTMLElement, label: string): HTMLButtonElement {
const button = [...container.querySelectorAll("button")].find(
(candidate) => candidate.textContent?.trim() === label,
);
if (!button) throw new Error(`Button not found: ${label}`);
return button as HTMLButtonElement;
}
function typeCron(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
function Harness({ createMutate }: { createMutate: ReturnType<typeof vi.fn> }) {
const [newTrigger, setNewTrigger] = useState<NewTriggerDraft>({
...createDefaultNewTrigger(),
cronExpression: "0 8-18/2 * * 1-5",
});
const value = {
routine: {
id: "routine-1",
triggers: [],
},
newTrigger,
setNewTrigger,
createTrigger: {
isPending: false,
mutate: createMutate,
},
updateTrigger: { mutate: vi.fn() },
deleteTrigger: { mutate: vi.fn() },
rotateTrigger: { mutate: vi.fn() },
} as unknown as RoutineDetailContextValue;
return (
<RoutineDetailContext.Provider value={value}>
<TriggersSection />
</RoutineDetailContext.Provider>
);
}
describe("TriggersSection", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
});
it("closes the add-trigger composer and resets the draft after a successful create", () => {
const createMutate = vi.fn((_variables, options?: { onSuccess?: () => void }) => {
options?.onSuccess?.();
});
const root = createRoot(container);
act(() => {
root.render(<Harness createMutate={createMutate} />);
});
act(() => {
buttonByText(container, "New trigger").dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.querySelector('input[aria-label="Cron expression"]')).not.toBeNull();
act(() => {
buttonByText(container, "Add trigger").dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(createMutate).toHaveBeenCalledTimes(1);
expect(container.querySelector('input[aria-label="Cron expression"]')).toBeNull();
expect(buttonByText(container, "New trigger")).not.toBeNull();
act(() => {
buttonByText(container, "New trigger").dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.querySelector('input[aria-label="Cron expression"]')).toBeNull();
expect(container.textContent).toContain("Every day");
act(() => root.unmount());
});
it("disables add trigger while the custom cron draft is invalid locally", () => {
const createMutate = vi.fn();
const root = createRoot(container);
act(() => {
root.render(<Harness createMutate={createMutate} />);
});
act(() => {
buttonByText(container, "New trigger").dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
const input = container.querySelector<HTMLInputElement>('input[aria-label="Cron expression"]');
expect(input).not.toBeNull();
expect(buttonByText(container, "Add trigger").disabled).toBe(false);
act(() => {
typeCron(input!, "0 8-18/2 *");
});
expect(input?.value).toBe("0 8-18/2 *");
expect(container.textContent).toContain("Use exactly 5 fields");
expect(buttonByText(container, "Add trigger").disabled).toBe(true);
act(() => {
buttonByText(container, "Add trigger").dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(createMutate).not.toHaveBeenCalled();
act(() => root.unmount());
});
});
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import {
ArrowRight,
Braces,
@@ -29,11 +29,11 @@ import { EmptyState } from "../EmptyState";
import { InlineEntitySelector } from "../InlineEntitySelector";
import { AgentIcon } from "../AgentIconPicker";
import { MarkdownEditor } from "../MarkdownEditor";
import { ScheduleEditor } from "../ScheduleEditor";
import { ScheduleEditor, getScheduleCronValidation } from "../ScheduleEditor";
import { RoutineVariablesEditor, RoutineVariablesHint } from "../RoutineVariablesEditor";
import { RoutineTriggerCard } from "../RoutineTriggerCard";
import { EnvVarEditor } from "../EnvVarEditor";
import { useRoutineDetail } from "./context";
import { createDefaultNewTrigger, useRoutineDetail } from "./context";
import type { EnvBinding, RoutineDetail as RoutineDetailType } from "@paperclipai/shared";
const concurrencyPolicyOptions = [
@@ -341,6 +341,18 @@ export function TriggersSection() {
const ctx = useRoutineDetail();
const { routine, newTrigger, setNewTrigger, createTrigger, updateTrigger, deleteTrigger, rotateTrigger } = ctx;
const [addOpen, setAddOpen] = useState(false);
const [newScheduleEditorValid, setNewScheduleEditorValid] = useState(true);
const newScheduleValidation = useMemo(
() => newTrigger.kind === "schedule" ? getScheduleCronValidation(newTrigger.cronExpression) : null,
[newTrigger.cronExpression, newTrigger.kind],
);
const addDisabled =
createTrigger.isPending ||
(newScheduleValidation ? !newScheduleValidation.valid || !newScheduleEditorValid : false);
useEffect(() => {
if (newTrigger.kind !== "schedule") setNewScheduleEditorValid(true);
}, [newTrigger.kind]);
return (
<div className="space-y-4">
@@ -403,6 +415,7 @@ export function TriggersSection() {
onChange={(cronExpression) =>
setNewTrigger((current) => ({ ...current, cronExpression }))
}
onValidityChange={setNewScheduleEditorValid}
/>
</div>
)}
@@ -451,8 +464,15 @@ export function TriggersSection() {
</Button>
<Button
size="sm"
onClick={() => createTrigger.mutate(undefined, { onSuccess: () => setAddOpen(false) })}
disabled={createTrigger.isPending}
onClick={() =>
createTrigger.mutate(undefined, {
onSuccess: () => {
setNewTrigger(createDefaultNewTrigger());
setAddOpen(false);
},
})
}
disabled={addDisabled}
>
{createTrigger.isPending ? "Adding..." : "Add trigger"}
</Button>
+2 -6
View File
@@ -43,6 +43,7 @@ import {
ROUTINE_SECTION_KEYS,
SECTION_FIELD_KEYS,
RoutineDetailContext,
createDefaultNewTrigger,
type RoutineDetailContextValue,
type RoutineEditDraft,
type RoutineSectionKey,
@@ -154,12 +155,7 @@ export function RoutineDetail() {
const [secretMessage, setSecretMessage] = useState<SecretMessage | null>(null);
const [saveConflict, setSaveConflict] = useState(false);
const [runVariablesOpen, setRunVariablesOpen] = useState(false);
const [newTrigger, setNewTrigger] = useState({
kind: "schedule",
cronExpression: "0 10 * * *",
signingMode: "bearer",
replayWindowSec: "300",
});
const [newTrigger, setNewTrigger] = useState(createDefaultNewTrigger);
const [editDraft, setEditDraft] = useState<RoutineEditDraft>({
title: "",
description: "",