From 6d725f1ccbe6bca2809b4ed59b18f688fbe854a7 Mon Sep 17 00:00:00 2001 From: tiammomo <26957354+tiammomo@users.noreply.github.com> Date: Fri, 18 Sep 2026 08:06:31 +0800 Subject: [PATCH] fix(scheduled-tasks): preserve unchanged one-time execution instants (#5330) * fix(scheduled-tasks): preserve unchanged one-time execution instants Signed-off-by: tiammomo <26957354+tiammomo@users.noreply.github.com> * fix(scheduled-tasks): reset edit state before task remounts Signed-off-by: tiammomo <26957354+tiammomo@users.noreply.github.com> * test(scheduled-tasks): exercise timezone fallback on UTC runners Signed-off-by: tiammomo <26957354+tiammomo@users.noreply.github.com> --------- Signed-off-by: tiammomo <26957354+tiammomo@users.noreply.github.com> --- README.md | 2 + frontend/AGENTS.md | 2 + .../app/workspace/scheduled-tasks/page.tsx | 53 ++-- .../scheduled-task-schedule-input.tsx | 32 ++- frontend/tests/e2e/scheduled-tasks.spec.ts | 115 +++++++++ .../schedule-edit-instant.dom.test.tsx | 233 ++++++++++++++++++ 6 files changed, 404 insertions(+), 33 deletions(-) create mode 100644 frontend/tests/unit/components/workspace/schedule-edit-instant.dom.test.tsx diff --git a/README.md b/README.md index 79bb158c4..f4691424e 100644 --- a/README.md +++ b/README.md @@ -1777,6 +1777,8 @@ Deleting a project moves its entire shelf to trash in the same step. DeerFlow now includes a first-class scheduled-task MVP in the workspace. +Editing a one-time task's title or prompt preserves its original execution time, including seconds and the selected occurrence during a daylight-saving clock rollback. Changing its date, time, or timezone recalculates the execution time. Switching tasks while editing loads the selected task's own title, prompt, and schedule. + Current MVP capabilities: - Manage tasks at `/workspace/scheduled-tasks` diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 3850fd9fe..1ee1d215f 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -95,6 +95,8 @@ do not use HTML `maxLength`, which counts UTF-16 code units instead. - **Path alias**: `@/*` maps to `src/*`. - **Components**: `ui/` and `ai-elements/` are generated from registries (Shadcn, MagicUI, React Bits, Vercel AI SDK) — don't manually edit these. +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. + ## Environment Scheduled-task interval forms preserve the initial `every_seconds` on mount, diff --git a/frontend/src/app/workspace/scheduled-tasks/page.tsx b/frontend/src/app/workspace/scheduled-tasks/page.tsx index 4afd85152..968ac79d7 100644 --- a/frontend/src/app/workspace/scheduled-tasks/page.tsx +++ b/frontend/src/app/workspace/scheduled-tasks/page.tsx @@ -145,6 +145,7 @@ export default function ScheduledTasksPage() { >("all"); const [formError, setFormError] = useState(null); const [editing, setEditing] = useState(false); + const [editTaskId, setEditTaskId] = useState(undefined); const [editTitle, setEditTitle] = useState(""); const [editPrompt, setEditPrompt] = useState(""); const [editAssistantId, setEditAssistantId] = useState(DEFAULT_ASSISTANT_ID); @@ -264,35 +265,35 @@ export default function ScheduledTasksPage() { } }, [filteredData, selectedTaskId]); - useEffect(() => { + // Reset before children commit so the keyed input captures this task. + // Same-id refetches retain the in-progress draft. + if (editTaskId !== selectedTask?.id) { + setEditTaskId(selectedTask?.id); if (!selectedTask) { setEditing(false); - return; + } else { + setEditTitle(selectedTask.title); + setEditPrompt(selectedTask.prompt); + setEditAssistantId(selectedTask.assistant_id ?? DEFAULT_ASSISTANT_ID); + const spec = selectedTask.schedule_spec as { + cron?: string; + run_at?: string; + every_seconds?: number; + }; + setEditSchedule({ + schedule_type: selectedTask.schedule_type, + schedule_spec: { + cron: typeof spec.cron === "string" ? spec.cron : undefined, + run_at: typeof spec.run_at === "string" ? spec.run_at : undefined, + every_seconds: + typeof spec.every_seconds === "number" + ? spec.every_seconds + : undefined, + }, + timezone: selectedTask.timezone || "UTC", + }); } - setEditTitle(selectedTask.title); - setEditPrompt(selectedTask.prompt); - setEditAssistantId(selectedTask.assistant_id ?? DEFAULT_ASSISTANT_ID); - const spec = selectedTask.schedule_spec as { - cron?: string; - run_at?: string; - every_seconds?: number; - }; - setEditSchedule({ - schedule_type: selectedTask.schedule_type, - schedule_spec: { - cron: typeof spec.cron === "string" ? spec.cron : undefined, - run_at: typeof spec.run_at === "string" ? spec.run_at : undefined, - every_seconds: - typeof spec.every_seconds === "number" - ? spec.every_seconds - : undefined, - }, - timezone: selectedTask.timezone || "UTC", - }); - // Depend on id only so a background refetch (same task, new object reference) - // does not wipe edits in progress. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedTask?.id]); + } return ( diff --git a/frontend/src/components/workspace/scheduled-task-schedule-input.tsx b/frontend/src/components/workspace/scheduled-task-schedule-input.tsx index fef7006e5..2d0c15709 100644 --- a/frontend/src/components/workspace/scheduled-task-schedule-input.tsx +++ b/frontend/src/components/workspace/scheduled-task-schedule-input.tsx @@ -118,16 +118,27 @@ export function ScheduledTaskScheduleInput({ const [parts, setParts] = useState( () => parseCron(initial.schedule_spec.cron ?? "0 9 * * *").parts, ); + const [timezone, setTimezone] = useState( + () => initial.timezone || detectBrowserTimezone(), + ); const [runAtLocal, setRunAtLocal] = useState( initial.schedule_type === "once" && initial.schedule_spec.run_at - ? utcToZonedLocalInput( - initial.schedule_spec.run_at, - initial.timezone || "UTC", - ) + ? utcToZonedLocalInput(initial.schedule_spec.run_at, timezone) : "", ); - const [timezone, setTimezone] = useState( - initial.timezone || detectBrowserTimezone(), + + // Minute-precision wall time cannot retain seconds or identify the later + // occurrence of a repeated DST time. Keep the mounted task's original + // instant while its schedule fields match, even if the parent echoes edits + // back through initial. Task switches remount this component with a key. + const [initialOnce] = useState(() => + initial.schedule_type === "once" && initial.schedule_spec.run_at + ? { + runAt: initial.schedule_spec.run_at, + local: runAtLocal, + timezone, + } + : null, ); const initialInterval = parseInitialInterval(initial.schedule_spec); const [intervalAmount, setIntervalAmount] = useState(initialInterval.amount); @@ -151,7 +162,13 @@ export function ScheduledTaskScheduleInput({ // value always matches what the user sees in the preview. useEffect(() => { if (scheduleType === "once") { - const runAt = runAtLocal ? zonedLocalToUtcIso(runAtLocal, timezone) : ""; + const unchanged = + runAtLocal === initialOnce?.local && timezone === initialOnce.timezone; + const runAt = unchanged + ? initialOnce.runAt + : runAtLocal + ? zonedLocalToUtcIso(runAtLocal, timezone) + : ""; onChangeRef.current({ schedule_type: "once", schedule_spec: runAt ? { run_at: runAt } : {}, @@ -188,6 +205,7 @@ export function ScheduledTaskScheduleInput({ parts, runAtLocal, timezone, + initialOnce, intervalAmount, intervalUnit, intervalEdited, diff --git a/frontend/tests/e2e/scheduled-tasks.spec.ts b/frontend/tests/e2e/scheduled-tasks.spec.ts index 0362cba30..054279754 100644 --- a/frontend/tests/e2e/scheduled-tasks.spec.ts +++ b/frontend/tests/e2e/scheduled-tasks.spec.ts @@ -458,3 +458,118 @@ test("edit omits assistant_id when the agent is unchanged", async ({ expect(patchBody).toMatchObject({ title: "Renamed digest" }); expect(patchBody).not.toHaveProperty("assistant_id"); }); + +for (const runAt of ["2026-11-01T06:30:00Z", "2027-06-01T12:30:45Z"]) { + test(`editing title and prompt preserves one-time instant ${runAt}`, async ({ + page, + }) => { + mockLangGraphAPI(page, { + threads: [], + scheduledTasks: [ + { + id: "task-instant", + thread_id: null, + context_mode: "fresh_thread_per_run", + title: "Original title", + prompt: "Original prompt", + schedule_type: "once", + schedule_spec: { run_at: runAt }, + timezone: "America/New_York", + status: "enabled", + next_run_at: runAt, + last_run_at: null, + last_run_id: null, + last_error: null, + run_count: 0, + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-01T00:00:00Z", + }, + ], + }); + await page.goto("/workspace/scheduled-tasks"); + const detail = page.getByTestId("scheduled-task-detail"); + await detail.getByRole("button", { name: "Edit", exact: true }).click(); + await detail.getByPlaceholder("Edit title").fill("Renamed task"); + await detail.getByPlaceholder("Edit prompt").fill("Updated prompt"); + const submitted = page.waitForRequest( + (request) => + request.method() === "PATCH" && + new URL(request.url()).pathname === "/api/scheduled-tasks/task-instant", + ); + await detail + .getByRole("button", { name: "Save edit", exact: true }) + .click(); + const request = await submitted; + expect(request.postDataJSON()).toMatchObject({ + title: "Renamed task", + prompt: "Updated prompt", + schedule_spec: { run_at: runAt }, + timezone: "America/New_York", + }); + }); +} + +test("switching tasks during editing saves only the selected task's schedule", async ({ + page, +}) => { + const tasks = [ + { + id: "switch-a", + title: "Task A", + prompt: "Prompt A", + timezone: "America/New_York", + runAt: "2026-03-01T05:00:00+00:00", + }, + { + id: "switch-b", + title: "Task B", + prompt: "Prompt B", + timezone: "Asia/Shanghai", + runAt: "2027-06-01T12:30:45+00:00", + }, + ]; + mockLangGraphAPI(page, { + threads: [], + scheduledTasks: tasks.map((task) => ({ + id: task.id, + title: task.title, + prompt: task.prompt, + timezone: task.timezone, + thread_id: null, + context_mode: "fresh_thread_per_run", + schedule_type: "once", + schedule_spec: { run_at: task.runAt }, + status: "enabled", + next_run_at: task.runAt, + last_run_at: null, + last_run_id: null, + last_error: null, + run_count: 0, + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-01T00:00:00Z", + })), + }); + await page.goto("/workspace/scheduled-tasks"); + await page.getByTestId("scheduled-task-item-switch-a").click(); + const detail = page.getByTestId("scheduled-task-detail"); + await detail.getByRole("button", { name: "Edit", exact: true }).click(); + await detail.getByPlaceholder("Edit title").fill("Unsaved A"); + await page.getByTestId("scheduled-task-item-switch-b").click(); + await expect(detail.getByPlaceholder("Edit title")).toHaveValue("Task B"); + await expect(detail.getByPlaceholder("Edit prompt")).toHaveValue("Prompt B"); + await expect(detail.getByLabel("Run at")).toHaveValue("2027-06-01T20:30"); + await detail.getByLabel("Run at").fill("2027-06-01T21:30"); + await detail.getByLabel("Run at").fill("2027-06-01T20:30"); + const submitted = page.waitForRequest( + (request) => + request.method() === "PATCH" && + new URL(request.url()).pathname === "/api/scheduled-tasks/switch-b", + ); + await detail.getByRole("button", { name: "Save edit", exact: true }).click(); + expect((await submitted).postDataJSON()).toMatchObject({ + title: "Task B", + prompt: "Prompt B", + timezone: "Asia/Shanghai", + schedule_spec: { run_at: tasks[1]!.runAt }, + }); +}); diff --git a/frontend/tests/unit/components/workspace/schedule-edit-instant.dom.test.tsx b/frontend/tests/unit/components/workspace/schedule-edit-instant.dom.test.tsx new file mode 100644 index 000000000..190e65314 --- /dev/null +++ b/frontend/tests/unit/components/workspace/schedule-edit-instant.dom.test.tsx @@ -0,0 +1,233 @@ +import { afterEach, expect, rs, test } from "@rstest/core"; +import { cleanup, fireEvent, render } from "@testing-library/react"; +import { useState, type ReactNode } from "react"; + +import { + ScheduledTaskScheduleInput, + type ScheduleValue, +} from "@/components/workspace/scheduled-task-schedule-input"; +import { enUS } from "@/core/i18n/locales/en-US"; + +rs.mock("@/core/i18n/hooks", () => ({ + useI18n: () => ({ locale: "en-US", t: enUS }), +})); + +// Exercise schedule state with native selects; the page E2E uses real Radix UI. +rs.mock("@/components/ui/select", () => ({ + Select: ({ + value, + onValueChange, + children, + }: { + value: string; + onValueChange: (value: string) => void; + children: ReactNode; + }) => ( + + ), + SelectTrigger: () => null, + SelectValue: () => null, + SelectContent: ({ children }: { children: ReactNode }) => <>{children}, + SelectItem: ({ value, children }: { value: string; children: ReactNode }) => ( + + ), +})); + +afterEach(cleanup); + +function once(runAt: string, timezone = "America/New_York"): ScheduleValue { + return { schedule_type: "once", schedule_spec: { run_at: runAt }, timezone }; +} + +test.each([ + "2026-11-01T06:30:00Z", + "2027-06-01T12:30:45Z", + "2027-06-01T12:30:45.123Z", + "2027-06-01T08:30:00-04:00", +])("preserves the original one-time timestamp on mount: %s", (runAt) => { + const initial = once(runAt); + const onChange = rs.fn(); + render( + , + ); + expect(onChange).toHaveBeenLastCalledWith(initial); +}); + +test("parent feedback and title-only rerenders retain the original instant", () => { + const original = once("2026-11-01T06:30:00Z"); + function Editor() { + const [schedule, setSchedule] = useState(original); + const [title, setTitle] = useState("Original"); + return ( + <> + setTitle(event.target.value)} + /> + setSchedule(next)} + scheduleTypeLocked + /> + {schedule.schedule_spec.run_at} + + ); + } + const ui = render(); + fireEvent.change(ui.getByLabelText("Title"), { + target: { value: "Renamed" }, + }); + expect(ui.getByRole("status").textContent).toBe( + original.schedule_spec.run_at, + ); + fireEvent.change(ui.getByLabelText("Run at"), { + target: { value: "2026-11-01T03:30" }, + }); + expect(ui.getByRole("status").textContent).toBe("2026-11-01T08:30:00+00:00"); + // Returning to the original fields must use the original snapshot, not the + // latest value passed back through initial by the parent. + fireEvent.change(ui.getByLabelText("Run at"), { + target: { value: "2026-11-01T01:30" }, + }); + expect(ui.getByRole("status").textContent).toBe( + original.schedule_spec.run_at, + ); +}); + +test("changing timezone recomputes the instant and reverting restores seconds", () => { + const initial = once("2027-06-01T12:30:45Z"); + const onChange = rs.fn(); + const ui = render( + , + ); + fireEvent.change(ui.getByRole("combobox"), { + target: { value: "Asia/Shanghai" }, + }); + expect(onChange).toHaveBeenLastCalledWith( + once("2027-06-01T00:30:00+00:00", "Asia/Shanghai"), + ); + fireEvent.change(ui.getByRole("combobox"), { + target: { value: "America/New_York" }, + }); + expect(onChange).toHaveBeenLastCalledWith(initial); +}); + +test("clearing the date removes run_at", () => { + const onChange = rs.fn(); + const ui = render( + , + ); + fireEvent.change(ui.getByLabelText("Run at"), { target: { value: "" } }); + expect(onChange).toHaveBeenLastCalledWith({ + schedule_type: "once", + schedule_spec: {}, + timezone: "America/New_York", + }); +}); + +test("empty create form converts a newly entered date", () => { + const onChange = rs.fn(); + const ui = render( + , + ); + expect(onChange).toHaveBeenLastCalledWith({ + schedule_type: "once", + schedule_spec: {}, + timezone: "Asia/Shanghai", + }); + fireEvent.change(ui.getByLabelText("Run at"), { + target: { value: "2027-06-01T09:30" }, + }); + expect(onChange).toHaveBeenLastCalledWith( + once("2027-06-01T01:30:00+00:00", "Asia/Shanghai"), + ); +}); + +test("switching schedule type preserves cron behavior and an unchanged once value", () => { + const initial = once("2027-06-01T12:30:45Z"); + const onChange = rs.fn(); + const ui = render( + , + ); + fireEvent.click(ui.getByRole("button", { name: "Recurring" })); + expect(onChange).toHaveBeenLastCalledWith({ + schedule_type: "cron", + schedule_spec: { cron: "0 9 * * *" }, + timezone: "America/New_York", + }); + fireEvent.click(ui.getByRole("button", { name: "One-time" })); + expect(onChange).toHaveBeenLastCalledWith(initial); +}); + +test("a keyed task switch captures the next task's instant", () => { + const onChange = rs.fn(); + const ui = render( + , + ); + const next = once("2027-06-01T12:30:45Z"); + ui.rerender( + , + ); + expect(onChange).toHaveBeenLastCalledWith(next); +}); + +test("empty timezone uses a non-UTC browser zone without changing the instant", () => { + const timezone = "Asia/Shanghai"; + const browserOptions = Intl.DateTimeFormat().resolvedOptions(); + const detectedZone = rs + .spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions") + .mockReturnValue({ ...browserOptions, timeZone: timezone }); + try { + const initial = once("2026-11-01T06:30:00Z", ""); + const onChange = rs.fn(); + const ui = render( + , + ); + expect(detectedZone).toHaveBeenCalled(); + expect(onChange).toHaveBeenLastCalledWith({ ...initial, timezone }); + expect((ui.getByLabelText("Run at") as HTMLInputElement).value).toBe( + "2026-11-01T14:30", + ); + } finally { + detectedZone.mockRestore(); + } +});