From 338f8819a462d5157805b10141b3b1ddbdd3abd5 Mon Sep 17 00:00:00 2001 From: haotianqi666 Date: Mon, 12 Oct 2026 20:04:48 +0800 Subject: [PATCH] fix(frontend): preserve schedule when switching to custom cron (#6749) Co-authored-by: haotianqi666 <250384991+haotianqi666@users.noreply.github.com> --- README.md | 2 + frontend/AGENTS.md | 6 ++ .../scheduled-task-schedule-input.tsx | 5 +- .../e2e/scheduled-task-cron-preset.spec.ts | 61 ++++++++++++++++++ ...scheduled-task-schedule-input.dom.test.tsx | 64 +++++++++++++++++++ 5 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 frontend/tests/e2e/scheduled-task-cron-preset.spec.ts diff --git a/README.md b/README.md index f708371b6e..3a630e1baf 100644 --- a/README.md +++ b/README.md @@ -2796,6 +2796,8 @@ Current MVP limits: Enable background polling with `config.yaml -> scheduler.enabled`. Manual trigger uses the same scheduled-task resource and execution path. +In the task form, switching an hourly, daily, weekly, or monthly schedule to **Custom cron** starts from its current timing, including selected weekdays or day of the month. Switching back to Custom cron after preset edits uses those edits rather than an older expression. + ### Lifecycle, safety caps and stop conditions - The tasks page and the REST API (`POST` / `PATCH /api/scheduled-tasks`) accept the same per-run goal (`goal_objective`), safety cap (`max_runs`, `end_at`) and stop condition (`stop_condition`) as a conversation. Sending `null` in a PATCH clears any of these four; an `end_at` without a UTC offset is wall-clock time in the task's timezone. diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 9a7fd27d11..0b90a0fd9d 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -140,6 +140,12 @@ matching in `core/scheduled-tasks/search.ts`; clearing search retains other filt Single-run schedule edits retain the mounted task's original `run_at` while its wall time and timezone match. The parent echoes edits through `initial`; retain a stable snapshot and reset the parent draft during render before remounting with a task key when switching tasks. Use the resolved timezone consistently for the snapshot and displayed wall time. Component and scheduled-task E2E tests cover DST folds and timestamp precision. +The scheduled-task schedule input initializes Custom cron from the current +preset and its current fields on every transition into the custom editor. +Do not substitute a daily schedule or restore stale `parts.raw` after preset +edits. Component tests cover emitted specs; browser tests pin unchanged +schedules through the task form's PATCH payload on desktop and mobile. + ## Environment Scheduled-task interval forms preserve the initial `every_seconds` on mount, diff --git a/frontend/src/components/workspace/scheduled-task-schedule-input.tsx b/frontend/src/components/workspace/scheduled-task-schedule-input.tsx index 5f07c7be59..1ace3c974e 100644 --- a/frontend/src/components/workspace/scheduled-task-schedule-input.tsx +++ b/frontend/src/components/workspace/scheduled-task-schedule-input.tsx @@ -223,8 +223,9 @@ export function ScheduledTaskScheduleInput({ if (next === "monthly" && merged.dayOfMonth == null) { merged.dayOfMonth = 1; } - if (next === "custom" && !merged.raw) { - merged.raw = serializeCron("daily", prev); + if (next === "custom" && preset !== "custom") { + // Open the current schedule, not a daily fallback or an older draft. + merged.raw = serializeCron(preset, prev); } return merged; }); diff --git a/frontend/tests/e2e/scheduled-task-cron-preset.spec.ts b/frontend/tests/e2e/scheduled-task-cron-preset.spec.ts new file mode 100644 index 0000000000..96653898f0 --- /dev/null +++ b/frontend/tests/e2e/scheduled-task-cron-preset.spec.ts @@ -0,0 +1,61 @@ +import { expect, test } from "@playwright/test"; + +import { mockLangGraphAPI } from "./utils/mock-api"; + +for (const width of [1280, 375]) { + for (const cron of ["30 14 * * 1,3,5", "30 14 21 * *"]) { + test(`custom cron preserves ${cron} through saving at width ${width}`, async ({ + page, + }) => { + await page.setViewportSize({ width, height: 900 }); + const patches: Record[] = []; + page.on("request", (request) => { + if ( + request.method() === "PATCH" && + new URL(request.url()).pathname === "/api/scheduled-tasks/cron-task" + ) { + patches.push(request.postDataJSON() as Record); + } + }); + mockLangGraphAPI(page, { + threads: [], + scheduledTasks: [ + { + id: "cron-task", + thread_id: null, + title: "Scheduled report", + prompt: "Prepare the report", + schedule_type: "cron", + schedule_spec: { cron }, + timezone: "UTC", + status: "enabled", + next_run_at: "2099-01-21T14:30:00Z", + last_run_at: null, + last_run_id: null, + last_error: null, + run_count: 0, + created_at: "2026-10-01T00:00:00Z", + updated_at: "2026-10-01T00:00:00Z", + }, + ], + }); + + await page.goto("/workspace/scheduled-tasks"); + await page + .getByTestId("scheduled-task-detail") + .getByRole("button", { name: "Edit", exact: true }) + .click(); + const form = page.getByTestId("scheduled-task-form"); + await form.getByTestId("schedule-preset").click(); + await page.getByRole("option", { name: "Custom cron" }).click(); + await expect(form.getByLabel("Cron expression")).toHaveValue(cron); + + await form.getByLabel("Title", { exact: true }).fill("Renamed report"); + await form.getByRole("button", { name: "Save changes" }).click(); + + await expect.poll(() => patches.length).toBe(1); + expect(patches[0]).toEqual({ title: "Renamed report" }); + await expect(form).toHaveCount(0); + }); + } +} diff --git a/frontend/tests/unit/components/workspace/scheduled-task-schedule-input.dom.test.tsx b/frontend/tests/unit/components/workspace/scheduled-task-schedule-input.dom.test.tsx index 94616130b3..d7d88a47e2 100644 --- a/frontend/tests/unit/components/workspace/scheduled-task-schedule-input.dom.test.tsx +++ b/frontend/tests/unit/components/workspace/scheduled-task-schedule-input.dom.test.tsx @@ -10,6 +10,70 @@ import { I18nProvider } from "@/core/i18n/context"; afterEach(cleanup); describe("ScheduledTaskScheduleInput", () => { + async function selectPreset(name: string) { + fireEvent.keyDown(screen.getByTestId("schedule-preset"), { + key: "ArrowDown", + }); + fireEvent.click(await screen.findByRole("option", { name })); + } + + function renderCron(cron: string) { + const emitted: ScheduleValue[] = []; + render( + + emitted.push(value)} + /> + , + ); + return emitted; + } + + test.each(["15 * * * *", "30 14 * * *", "30 14 * * 1,3,5", "30 14 21 * *"])( + "keeps %s when opening the custom cron editor", + async (cron) => { + const emitted = renderCron(cron); + + await selectPreset("Custom cron"); + + expect( + screen.getByRole("textbox", { name: "Cron expression" }), + ).toHaveProperty("value", cron); + expect(emitted.at(-1)?.schedule_spec).toEqual({ cron }); + }, + ); + + test("reopening custom cron uses the current preset instead of a stale expression", async () => { + const emitted = renderCron("*/10 * * * *"); + + await selectPreset("Weekly"); + fireEvent.change(screen.getByLabelText("Time"), { + target: { value: "16:45" }, + }); + expect(emitted.at(-1)?.schedule_spec).toEqual({ cron: "45 16 * * 1" }); + + await selectPreset("Custom cron"); + + expect( + screen.getByRole("textbox", { name: "Cron expression" }), + ).toHaveProperty("value", "45 16 * * 1"); + expect(emitted.at(-1)?.schedule_spec).toEqual({ cron: "45 16 * * 1" }); + + fireEvent.change(screen.getByRole("textbox", { name: "Cron expression" }), { + target: { value: "*/10 * * * *" }, + }); + expect(emitted.at(-1)).toEqual({ + schedule_type: "cron", + schedule_spec: { cron: "*/10 * * * *" }, + timezone: "UTC", + }); + }); + test.each([1, 30, 59])( "preserves an existing %s-second interval until explicitly edited", (everySeconds) => {