)}
- {trigger.next_run_at && (
+ {/* A disabled trigger keeps the next_run_at it had — the dispatcher
+ filters on `enabled` instead of clearing it — so the row would
+ otherwise carry the Disabled badge and a promise to run at 09:00
+ in the same breath. The badge is the true one. */}
+ {trigger.next_run_at && trigger.enabled && (
+ )}
{ if (!v && !deleting) setConfirmOpen(false); }}>
@@ -433,6 +461,12 @@ function TriggerRow({ trigger, autopilotId, canWrite }: { trigger: AutopilotTrig
+ { if (!v && !rotateToken.isPending) setRotateOpen(false); }}>
diff --git a/packages/views/autopilots/components/autopilot-dialog.schedule.test.tsx b/packages/views/autopilots/components/autopilot-dialog.schedule.test.tsx
index 8f3acfa37..432c7bbad 100644
--- a/packages/views/autopilots/components/autopilot-dialog.schedule.test.tsx
+++ b/packages/views/autopilots/components/autopilot-dialog.schedule.test.tsx
@@ -262,3 +262,66 @@ describe("AutopilotDialog schedule section on an autopilot that has one", () =>
expect(mockCreateTrigger).not.toHaveBeenCalled();
});
});
+
+// Regression cover for MUL-7478: the schedule panel counted triggers of every
+// kind, so a 1 schedule + 1 webhook autopilot — where exactly one row can take
+// a cron and the write below names it — was locked out of schedule editing
+// entirely, with a notice pointing at a detail page that had no editor either.
+describe("AutopilotDialog schedule section on an autopilot with several triggers", () => {
+ beforeEach(() => {
+ mockUpdateAutopilot.mockReset().mockResolvedValue({ id: AUTOPILOT_ID });
+ mockCreateTrigger.mockReset().mockResolvedValue({ id: "trg-new" });
+ mockUpdateTrigger.mockReset().mockResolvedValue({ id: "trg-sched" });
+ });
+
+ it("edits the one schedule of an autopilot that also has a webhook", async () => {
+ const user = userEvent.setup();
+ renderEditDialog([
+ trigger({ id: "trg-sched" }),
+ trigger({ id: "trg-hook", kind: "webhook", cron_expression: null, timezone: null }),
+ ]);
+
+ // The stored schedule, live — not the locked notice a second trigger of any
+ // kind used to produce.
+ expect(screen.getByTestId("timezone-picker")).toHaveTextContent("Asia/Shanghai");
+ expect(screen.queryByText(/Close this dialog/)).not.toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: "At an interval" }));
+ await user.click(saveButton());
+
+ await waitFor(() => expect(mockUpdateTrigger).toHaveBeenCalledTimes(1));
+ // The schedule row, never the webhook one: the API rejects a cron on any
+ // other kind, and rotating the webhook's URL out from under its callers
+ // would be the wrong write to guess at.
+ expect(mockUpdateTrigger.mock.calls[0]?.[0]).toMatchObject({
+ autopilotId: AUTOPILOT_ID,
+ triggerId: "trg-sched",
+ });
+ expect(mockCreateTrigger).not.toHaveBeenCalled();
+ });
+
+ it("states which schedules to go and edit instead of showing the first of them", async () => {
+ const user = userEvent.setup();
+ renderEditDialog([
+ trigger({ id: "trg-morning" }),
+ trigger({ id: "trg-evening", cron_expression: "TZ=Asia/Shanghai 0 18 * * *" }),
+ ]);
+
+ expect(
+ screen.getByText(
+ "This autopilot has 2 schedules. Close this dialog and edit each one under Triggers below.",
+ ),
+ ).toBeInTheDocument();
+ // A single-value editor cannot show two schedules, and a disabled one
+ // showing the first is still the half-truth a reader would set a clock by.
+ expect(screen.queryByTestId("timezone-picker")).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "At an interval" })).not.toBeInTheDocument();
+
+ await user.click(saveButton());
+
+ // Other fields still save; the schedules are left to the trigger rows.
+ await waitFor(() => expect(mockUpdateAutopilot).toHaveBeenCalledTimes(1));
+ expect(mockUpdateTrigger).not.toHaveBeenCalled();
+ expect(mockCreateTrigger).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/views/autopilots/components/autopilot-dialog.tsx b/packages/views/autopilots/components/autopilot-dialog.tsx
index c6ba1fbc4..1c761da6a 100644
--- a/packages/views/autopilots/components/autopilot-dialog.tsx
+++ b/packages/views/autopilots/components/autopilot-dialog.tsx
@@ -227,14 +227,22 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
// above. Null means there is none to patch, so the write creates one.
const scheduleTriggerIdRef = useRef(existingSchedule?.id ?? null);
- const triggerCount = isCreate ? 0 : props.triggers.length;
- const schedulePillDisabled = !isCreate && triggerCount >= 2;
+ // Only SCHEDULE rows can make this panel ambiguous, so only they are counted.
+ // Counting every kind locked a 1 schedule + 1 webhook autopilot (MUL-7478),
+ // where `existingSchedule` above names exactly one row and the write below
+ // has nowhere else to land. Two schedules is the real ambiguity: this editor
+ // holds one `ScheduleConfig`, so it would show the first row as if it were
+ // the whole story and save would silently rewrite that one alone.
+ const scheduleTriggerCount = isCreate
+ ? 0
+ : props.triggers.filter((trig) => trig.kind === "schedule").length;
+ const schedulePillDisabled = !isCreate && scheduleTriggerCount >= 2;
// The manual-autopilot empty state, and the only path to a first schedule
- // from this dialog. Skipped when the panel is locked (2+ triggers), which
- // keeps that case rendering exactly the disabled editor it always has.
+ // from this dialog. A locked panel never reaches it: locking means two
+ // schedules exist, so `existingSchedule` is non-null whenever it is true.
const showScheduleEmptyState =
- !isCreate && existingSchedule === null && !scheduleAdded && !schedulePillDisabled;
+ !isCreate && existingSchedule === null && !scheduleAdded;
const selectedAssignee = useMemo(() => {
if (!assigneeId) return null;
@@ -649,7 +657,9 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
{triggerKind === "schedule" ? (
{t(($) => $.dialog.section_schedule)}
- {showScheduleEmptyState ? (
+ {schedulePillDisabled ? (
+
+ ) : showScheduleEmptyState ? (
setScheduleAdded(true)} />
) : (
/* No `onValidityChange` / `clearRejection` here, unlike the
@@ -666,12 +676,7 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
// over the network and then writes the schedule it read before
// that round trip, so an edit made in between would be dropped
// on the floor with a success toast over it.
- disabled={schedulePillDisabled || submitting}
- disabledReason={
- schedulePillDisabled
- ? t(($) => $.dialog.schedule_disabled_reason)
- : undefined
- }
+ disabled={submitting}
/>
)}
@@ -964,6 +969,22 @@ function SubscribersSection({
}
+// The panel cannot speak for two schedules, and a disabled editor showing the
+// first of them is still half a truth — the one the reader would set their
+// clock by. It says what it cannot do and where the reader can: this dialog
+// only ever opens from the detail page, so the Triggers list is already on
+// screen behind it.
+function ScheduleMultipleNotice({ count }: { count: number }) {
+ const { t } = useT("autopilots");
+ return (
+
+ );
+}
+
// The schedule section of an autopilot that has none. Mirrors the detail
// page's trigger empty state — a dashed card that states the autopilot is
// manual — so the two surfaces agree on what "no schedule" looks like instead
diff --git a/packages/views/autopilots/components/edit-schedule-trigger-dialog.test.tsx b/packages/views/autopilots/components/edit-schedule-trigger-dialog.test.tsx
new file mode 100644
index 000000000..352cead43
--- /dev/null
+++ b/packages/views/autopilots/components/edit-schedule-trigger-dialog.test.tsx
@@ -0,0 +1,252 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import type { AutopilotTrigger } from "@multica/core/types";
+import { renderWithI18n } from "../../test/i18n";
+
+// The editor a trigger row opens (MUL-7478). Before it, an existing schedule
+// could only be deleted and recreated: the autopilot dialog's panel speaks for
+// one schedule, and the detail page listed triggers read-only.
+
+const mockUpdateTrigger = vi.hoisted(() => vi.fn());
+
+vi.mock("@multica/core/hooks", () => ({ useWorkspaceId: () => "ws-test" }));
+
+// The submit path validates over the network before it writes. Parking that
+// round trip holds the dialog mid-flight, in the window a label typed after
+// Save used to fall into.
+const preview = vi.hoisted(() => ({ release: null as null | (() => void), hold: false }));
+
+vi.mock("@multica/core/autopilots/queries", () => ({
+ cronPreviewOptions: (wsId: string, expr: string, tz: string) => ({
+ queryKey: ["cron-preview", wsId, expr, tz],
+ queryFn: async () => {
+ if (preview.hold) {
+ await new Promise((resolve) => {
+ preview.release = resolve;
+ });
+ }
+ return { next_runs: ["2126-07-14T01:00:00Z"] };
+ },
+ retry: false,
+ }),
+}));
+
+vi.mock("@multica/core/autopilots/mutations", () => ({
+ useUpdateAutopilotTrigger: () => ({ mutateAsync: mockUpdateTrigger }),
+}));
+
+vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
+
+vi.mock("./pickers/timezone-picker", () => ({
+ TimezonePicker: ({ value }: { value: string }) =>
{value}
,
+}));
+
+import { EditScheduleTriggerDialog } from "./edit-schedule-trigger-dialog";
+
+const AUTOPILOT_ID = "ap-1";
+
+function trigger(overrides: Partial = {}): AutopilotTrigger {
+ return {
+ id: "trg-evening",
+ autopilot_id: AUTOPILOT_ID,
+ kind: "schedule",
+ enabled: true,
+ cron_expression: "TZ=Asia/Bangkok 0 */3 * * *",
+ timezone: "Asia/Bangkok",
+ next_run_at: null,
+ webhook_token: null,
+ label: null,
+ last_fired_at: null,
+ created_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:00:00Z",
+ ...overrides,
+ };
+}
+
+function renderDialog(trig: AutopilotTrigger = trigger()) {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ const onOpenChange = vi.fn();
+ const tree = (next: AutopilotTrigger) => (
+
+
+
+ );
+ const result = renderWithI18n(tree(trig));
+ // The detail query refreshing under an open dialog: new props, same mount.
+ return { ...result, onOpenChange, refreshProps: (next: AutopilotTrigger) => result.rerender(tree(next)) };
+}
+
+const saveButton = () => screen.getByRole("button", { name: "Save" });
+const labelInput = () => screen.getByPlaceholderText("e.g. Weekday morning");
+const enabledSwitch = () => screen.getByRole("switch", { name: "Enabled" });
+
+beforeEach(() => {
+ mockUpdateTrigger.mockReset().mockResolvedValue({ id: "trg-evening" });
+ preview.hold = false;
+ preview.release = null;
+});
+
+describe("EditScheduleTriggerDialog", () => {
+ it("opens on the schedule the row already runs, not on a default", () => {
+ renderDialog();
+
+ // The stored zone and interval, read back from the row — seeding the editor
+ // with its own 09:00 default would be a proposal dressed as the trigger's
+ // state, which is how MUL-5649 lost a save under a success toast.
+ expect(screen.getByTestId("timezone-picker")).toHaveTextContent("Asia/Bangkok");
+ expect(screen.getByRole("button", { name: "At an interval", pressed: true })).toBeInTheDocument();
+ expect(screen.getByDisplayValue("3")).toBeInTheDocument();
+ });
+
+ it("patches this trigger alone, carrying the zone with the expression", async () => {
+ const user = userEvent.setup();
+ const { onOpenChange } = renderDialog();
+
+ await user.click(screen.getByRole("button", { name: "At a time" }));
+ await user.click(saveButton());
+
+ await waitFor(() => expect(mockUpdateTrigger).toHaveBeenCalledTimes(1));
+ const patch = mockUpdateTrigger.mock.calls[0]?.[0];
+ expect(patch).toMatchObject({
+ autopilotId: AUTOPILOT_ID,
+ triggerId: "trg-evening",
+ timezone: "Asia/Bangkok",
+ });
+ expect(patch.cron_expression).toContain("Asia/Bangkok");
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+
+ it("keeps the dialog open when the write fails, with the server's reason", async () => {
+ const user = userEvent.setup();
+ const { onOpenChange } = renderDialog();
+ mockUpdateTrigger.mockRejectedValueOnce(new Error("cron_expression is invalid"));
+
+ await user.click(screen.getByRole("button", { name: "At a time" }));
+ await user.click(saveButton());
+
+ await waitFor(() => expect(mockUpdateTrigger).toHaveBeenCalledTimes(1));
+ const { toast } = await import("sonner");
+ expect(toast.error).toHaveBeenCalledWith("cron_expression is invalid");
+ expect(onOpenChange).not.toHaveBeenCalledWith(false);
+ });
+});
+
+// The PATCH is partial on purpose. A changed cron / timezone / enabled reads
+// server-side as a substantive edit: it republishes the rule version and moves
+// this trigger's accountability to whoever saved (`UpdateAutopilotTrigger` in
+// server/internal/handler/autopilot.go, MUL-4302). Since `parseCron` → `toCron`
+// hands an untouched schedule back normalized — `TZ=` prefix and all, textually
+// different from the stored row — resending it would make a rename look like a
+// schedule change and carry that responsibility along with it.
+describe("EditScheduleTriggerDialog sends only what the user changed", () => {
+ it("sends the label alone when only the label was touched", async () => {
+ const user = userEvent.setup();
+ // Stored without the prefix the editor adds back, so a resend would be
+ // visibly a different string to the server.
+ renderDialog(trigger({ cron_expression: "0 */3 * * *", label: "Old name" }));
+
+ await user.clear(labelInput());
+ await user.type(labelInput(), "Evening sweep");
+ await user.click(saveButton());
+
+ await waitFor(() => expect(mockUpdateTrigger).toHaveBeenCalledTimes(1));
+ expect(mockUpdateTrigger.mock.calls[0]?.[0]).toEqual({
+ autopilotId: AUTOPILOT_ID,
+ triggerId: "trg-evening",
+ label: "Evening sweep",
+ });
+ });
+
+ it("pauses a schedule without resending the schedule", async () => {
+ const user = userEvent.setup();
+ renderDialog(trigger({ cron_expression: "0 */3 * * *" }));
+
+ await user.click(enabledSwitch());
+ await user.click(saveButton());
+
+ await waitFor(() => expect(mockUpdateTrigger).toHaveBeenCalledTimes(1));
+ // The cron stays where it is — pausing is not an edit to it, and the row
+ // keeps running the same schedule if it is switched back on.
+ expect(mockUpdateTrigger.mock.calls[0]?.[0]).toEqual({
+ autopilotId: AUTOPILOT_ID,
+ triggerId: "trg-evening",
+ enabled: false,
+ });
+ });
+
+ it("has nothing to save until something changes", async () => {
+ const user = userEvent.setup();
+ renderDialog();
+
+ // A no-op PATCH is not free: the server recomputes and rewrites the row's
+ // next_run_at from whatever it is sent, so opening and saving a dialog the
+ // user never edited would still move a reading they never touched.
+ expect(saveButton()).toBeDisabled();
+
+ await user.click(enabledSwitch());
+ expect(saveButton()).toBeEnabled();
+
+ await user.click(enabledSwitch());
+ expect(saveButton()).toBeDisabled();
+ expect(mockUpdateTrigger).not.toHaveBeenCalled();
+ });
+
+ it("takes no label the in-flight write could not carry", async () => {
+ const user = userEvent.setup();
+ renderDialog();
+ preview.hold = true;
+
+ await user.click(screen.getByRole("button", { name: "At a time" }));
+ await user.click(saveButton());
+
+ // Parked mid-validation: submit has already read the label it will send, so
+ // the input locks rather than accepting one this write cannot carry and the
+ // closing dialog would swallow.
+ await waitFor(() => expect(labelInput()).toBeDisabled());
+ expect(mockUpdateTrigger).not.toHaveBeenCalled();
+
+ preview.release?.();
+ await waitFor(() => expect(mockUpdateTrigger).toHaveBeenCalledTimes(1));
+ expect(mockUpdateTrigger.mock.calls[0]?.[0].label).toBeUndefined();
+ });
+
+ it("does not call a stored label with stray whitespace an edit", () => {
+ renderDialog(trigger({ label: " Morning sweep " }));
+
+ // The baseline is trimmed the way submit trims, so opening the dialog on a
+ // historical label does not by itself arm Save.
+ expect(saveButton()).toBeDisabled();
+ });
+
+ it("leaves alone a field a teammate changed under the open dialog", async () => {
+ const user = userEvent.setup();
+ const { refreshProps } = renderDialog(trigger({ label: "Old name" }));
+
+ // A teammate renames this row and pauses it; the detail query refreshes and
+ // the dialog takes the new props without remounting, so its untouched
+ // controls still hold what it opened on.
+ refreshProps(trigger({ label: "Renamed by teammate", enabled: false }));
+
+ // This user has edited nothing, so there is nothing of theirs to save.
+ expect(saveButton()).toBeDisabled();
+
+ // And when they do edit one field, only that field travels: the rename and
+ // the pause stay as the teammate left them instead of being reverted to
+ // what this dialog happened to be showing.
+ await user.click(screen.getByRole("button", { name: "At a time" }));
+ await user.click(saveButton());
+
+ await waitFor(() => expect(mockUpdateTrigger).toHaveBeenCalledTimes(1));
+ const patch = mockUpdateTrigger.mock.calls[0]?.[0];
+ expect(patch.label).toBeUndefined();
+ expect(patch.enabled).toBeUndefined();
+ expect(patch.cron_expression).toBeDefined();
+ });
+});
diff --git a/packages/views/autopilots/components/edit-schedule-trigger-dialog.tsx b/packages/views/autopilots/components/edit-schedule-trigger-dialog.tsx
new file mode 100644
index 000000000..d8caaae90
--- /dev/null
+++ b/packages/views/autopilots/components/edit-schedule-trigger-dialog.tsx
@@ -0,0 +1,206 @@
+"use client";
+
+import { useRef, useState } from "react";
+import { useUpdateAutopilotTrigger } from "@multica/core/autopilots/mutations";
+import { useWorkspaceId } from "@multica/core/hooks";
+import { Button } from "@multica/ui/components/ui/button";
+import { Switch } from "@multica/ui/components/ui/switch";
+import { Dialog, DialogContent, DialogTitle } from "@multica/ui/components/ui/dialog";
+import { toast } from "sonner";
+import type { AutopilotTrigger } from "@multica/core/types";
+import { ScheduleEditor } from "./schedule-editor/schedule-editor";
+import { parseCron, toCron } from "./schedule-editor/cron-mapping";
+import { useScheduleSubmitGate } from "./schedule-editor/validate";
+import type { ScheduleConfig } from "./schedule-editor/model";
+import { useT } from "../../i18n";
+
+// The only place in the UI where an existing schedule can be changed. The
+// autopilot dialog's panel speaks for the autopilot's one schedule; a trigger
+// row speaks for itself, which is what an autopilot carrying several of them
+// needs (MUL-7478). Mounted per open so the editor always hydrates from the
+// row as it stands now — a stale snapshot here would write back a cron the
+// user never saw.
+export function EditScheduleTriggerDialog({
+ open,
+ onOpenChange,
+ autopilotId,
+ trigger,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ autopilotId: string;
+ trigger: AutopilotTrigger;
+}) {
+ if (!open) return null;
+ return (
+
+ );
+}
+
+function EditScheduleTriggerDialogBody({
+ onOpenChange,
+ autopilotId,
+ trigger,
+}: {
+ onOpenChange: (open: boolean) => void;
+ autopilotId: string;
+ trigger: AutopilotTrigger;
+}) {
+ const { t } = useT("autopilots");
+ const wsId = useWorkspaceId();
+ const updateTrigger = useUpdateAutopilotTrigger();
+ // `parseCron` round-trips anything the server stored: an expression outside
+ // the structured model comes back as an advanced config holding the raw
+ // fields, which the editor renders in its expression row. So every schedule
+ // row is editable here, not only the ones the pickers can describe.
+ const initialCfg = parseCron(trigger.cron_expression ?? "", trigger.timezone ?? "UTC");
+ const [config, setConfig] = useState(initialCfg);
+ const [label, setLabel] = useState(trigger.label ?? "");
+ const [enabled, setEnabled] = useState(trigger.enabled);
+ const [submitting, setSubmitting] = useState(false);
+ const scheduleGate = useScheduleSubmitGate(wsId);
+
+ // What "changed" is measured against, snapshotted at mount — never read live
+ // off `trigger`. The detail query refreshes under an open dialog (a teammate
+ // saving this same row), and a prop moving under an untouched control would
+ // read as this user's edit: Save would then send the value they never set,
+ // back over the one that had just landed.
+ //
+ // The cron baseline is the editor's own rendering of the stored expression,
+ // not the stored text: `parseCron` → `toCron` normalizes (a bare cron on a
+ // zoned row comes back carrying its `TZ=` prefix), so comparing against the
+ // stored string would call an untouched schedule edited. Server-side that
+ // reads as a substantive change — republishing the rule version and moving
+ // this trigger's accountability to whoever opened the dialog, which MUL-4302
+ // settled must not happen on a label-only or no-op save.
+ const baseline = useRef({
+ cron: toCron(initialCfg),
+ timezone: initialCfg.timezone,
+ // Trimmed like the value submit sends, so a stored label carrying stray
+ // whitespace is not already an edit the moment the dialog opens.
+ label: (trigger.label ?? "").trim(),
+ enabled: trigger.enabled,
+ });
+ const scheduleDirty =
+ toCron(config) !== baseline.current.cron ||
+ config.timezone !== baseline.current.timezone;
+ const labelDirty = label.trim() !== baseline.current.label;
+ const enabledDirty = enabled !== baseline.current.enabled;
+ const dirty = scheduleDirty || labelDirty || enabledDirty;
+ // The cron gate only stands between the user and a write that carries a cron.
+ // A row whose stored expression the server can no longer preview is exactly
+ // the one a user reaches for this dialog to switch OFF, and a rejection of an
+ // expression they are not sending must not be what stops them.
+ const canSubmit = !submitting && dirty && (!scheduleDirty || scheduleGate.scheduleValid);
+
+ const handleSubmit = async () => {
+ if (!canSubmit) return;
+ setSubmitting(true);
+ try {
+ let cronExpr: string | null = null;
+ if (scheduleDirty) {
+ if (!(await scheduleGate.ensureAccepted(config))) {
+ setSubmitting(false);
+ return;
+ }
+ cronExpr = toCron(config);
+ if (!cronExpr.trim()) {
+ setSubmitting(false);
+ return;
+ }
+ }
+ // Only the fields that moved. The PATCH preserves everything it is not
+ // sent, so a field left out here keeps whatever the row has now — which
+ // is also what makes this dialog safe to have open while someone else
+ // edits the same trigger: it can only overwrite what its user touched.
+ await updateTrigger.mutateAsync({
+ autopilotId,
+ triggerId: trigger.id,
+ ...(cronExpr !== null
+ ? { cron_expression: cronExpr, timezone: config.timezone || undefined }
+ : {}),
+ ...(labelDirty ? { label: label.trim() } : {}),
+ ...(enabledDirty ? { enabled } : {}),
+ });
+ toast.success(t(($) => $.edit_trigger_dialog.toast_updated));
+ onOpenChange(false);
+ } catch (err) {
+ toast.error(
+ err instanceof Error && err.message
+ ? err.message
+ : t(($) => $.edit_trigger_dialog.toast_update_failed),
+ );
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/packages/views/autopilots/components/schedule-editor/schedule-editor.tsx b/packages/views/autopilots/components/schedule-editor/schedule-editor.tsx
index b456faae2..7b0ebba9b 100644
--- a/packages/views/autopilots/components/schedule-editor/schedule-editor.tsx
+++ b/packages/views/autopilots/components/schedule-editor/schedule-editor.tsx
@@ -66,7 +66,6 @@ export interface ScheduleEditorProps {
onChange: (value: ScheduleConfig) => void;
wsId: string;
disabled?: boolean;
- disabledReason?: string;
/** Fires when the server accepts or rejects the current expression, so the
* owning dialog can keep its submit button in step with the inline error. */
onValidityChange?: (valid: boolean) => void;
@@ -238,7 +237,6 @@ export function ScheduleEditor({
onChange,
wsId,
disabled,
- disabledReason,
onValidityChange,
}: ScheduleEditorProps) {
const { t, i18n } = useT("autopilots");
@@ -862,9 +860,6 @@ export function ScheduleEditor({
)}
- {disabled === true && disabledReason !== undefined && (
-