feat(frontend): duplicate scheduled tasks (#5064)

* docs: design scheduled task duplication

* docs: plan scheduled task duplication

* feat(frontend): build scheduled task duplicate drafts

* feat(frontend): duplicate scheduled tasks into create form

* docs: document scheduled task duplication

* test(frontend): verify duplicate schedule preview

* refactor(frontend): simplify scheduled task duplication
This commit is contained in:
Ryker_Feng 2026-08-27 22:22:50 +08:00 committed by GitHub
parent 846c716523
commit 848eea289c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 100 additions and 2 deletions

View File

@ -1372,6 +1372,7 @@ Current MVP capabilities:
- Manage tasks at `/workspace/scheduled-tasks`
- Choose whether each scheduled task reuses a thread and its conversation history or creates a fresh thread per run
- Duplicate an existing task into the create form as an editable draft without copying its run history
- Support `once` and `cron` schedules
- Run background scheduled executions as non-interactive DeerFlow runs (`ask_clarification` is not exposed there)
- Persist a due execution as `queued` when its reused thread or the global execution budget is busy, then launch it when capacity is available; queued occurrences survive Gateway restarts and fail after `scheduler.queue_timeout_seconds`

View File

@ -730,6 +730,7 @@ DeerFlow 现在在 workspace 里内置了一个一等的定时任务scheduled
- 在 `/workspace/scheduled-tasks` 管理任务
- 每个定时任务可以选择复用同一个 thread 及其历史对话,也可以选择每次运行新建一个 thread
- 将现有任务复制到创建表单中作为可编辑草稿,不复制运行历史
- 支持 `once``cron` 两种调度方式
- 后台定时执行以非交互式 DeerFlow run 运行(那里不会暴露 `ask_clarification`
- 当所复用的 thread 或全局执行配额正忙时,到期执行会持久化为 `queued`,并在可用后启动;队列项在 Gateway 重启后保留,超过 `scheduler.queue_timeout_seconds` 后标记为失败

View File

@ -1,8 +1,8 @@
"use client";
import { TriangleAlertIcon } from "lucide-react";
import { CopyIcon, TriangleAlertIcon } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
@ -118,6 +118,8 @@ export default function ScheduledTasksPage() {
timezone: "UTC",
});
const [createNonce, setCreateNonce] = useState(0);
const createFormRef = useRef<HTMLDivElement>(null);
const createTitleRef = useRef<HTMLInputElement>(null);
const filteredData = (data ?? []).filter((task) => {
const statusPass = statusFilter === "all" || task.status === statusFilter;
const typePass = typeFilter === "all" || task.schedule_type === typeFilter;
@ -163,6 +165,24 @@ export default function ScheduledTasksPage() {
setContextMode("fresh_thread_per_run");
setCreateNonce((n) => n + 1);
};
const duplicateTask = (task: ScheduledTask) => {
setTitle(`${task.title}${st.actions.duplicateTitleSuffix}`);
setPrompt(task.prompt);
setContextMode(task.context_mode);
setTargetThreadId(task.thread_id ?? "");
setCreateSchedule({
schedule_type: task.schedule_type,
schedule_spec: { ...task.schedule_spec },
timezone: task.timezone,
});
setFormError(null);
setCreateNonce((nonce) => nonce + 1);
createFormRef.current?.scrollIntoView({
behavior: "smooth",
block: "start",
});
createTitleRef.current?.focus();
};
useEffect(() => {
document.title = `${t.sidebar.scheduledTasks} - ${t.pages.appName}`;
@ -212,6 +232,7 @@ export default function ScheduledTasksPage() {
<div className="mx-auto flex w-full max-w-(--container-width-md) flex-col gap-4 p-6">
<h1 className="text-2xl font-semibold">{t.sidebar.scheduledTasks}</h1>
<div
ref={createFormRef}
className="grid gap-2 rounded-lg border p-4"
data-testid="scheduled-task-create-form"
>
@ -267,6 +288,7 @@ export default function ScheduledTasksPage() {
</>
)}
<Input
ref={createTitleRef}
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder={st.create.taskTitle}
@ -546,6 +568,14 @@ export default function ScheduledTasksPage() {
>
{st.actions.trigger}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => duplicateTask(selectedTask)}
>
<CopyIcon />
{st.actions.duplicate}
</Button>
<Button
variant="destructive"
size="sm"

View File

@ -458,6 +458,8 @@ export const enUS: Translations = {
pause: "Pause",
resume: "Resume",
trigger: "Trigger now",
duplicate: "Duplicate",
duplicateTitleSuffix: " (Copy)",
delete: "Delete",
},
deleteConfirm:

View File

@ -375,6 +375,8 @@ export interface Translations {
pause: string;
resume: string;
trigger: string;
duplicate: string;
duplicateTitleSuffix: string;
delete: string;
};
deleteConfirm: string;

View File

@ -440,6 +440,8 @@ export const zhCN: Translations = {
pause: "暂停",
resume: "恢复",
trigger: "立即触发",
duplicate: "复制",
duplicateTitleSuffix: "(副本)",
delete: "删除",
},
deleteConfirm: "确定要删除该定时任务吗?此操作不可撤销。",

View File

@ -94,6 +94,66 @@ test("user can create a scheduled task from the page", async ({ page }) => {
).toBeVisible();
});
test("duplicate fills the create form without creating a task", async ({
page,
}) => {
let createRequests = 0;
page.on("request", (request) => {
if (
request.method() === "POST" &&
new URL(request.url()).pathname.endsWith("/api/scheduled-tasks")
) {
createRequests += 1;
}
});
mockLangGraphAPI(page, {
threads: [],
scheduledTasks: [
{
id: "task-copy-source",
thread_id: "thread-copy-source",
context_mode: "reuse_thread",
title: "Daily summary",
prompt: "Summarize this conversation",
schedule_type: "cron",
schedule_spec: { cron: "0 18 * * *" },
timezone: "UTC",
status: "enabled",
next_run_at: "2026-08-28T18:00:00Z",
last_run_at: null,
last_run_id: null,
last_thread_id: null,
last_error: null,
run_count: 0,
created_at: "2026-08-01T00:00:00Z",
updated_at: "2026-08-01T00:00:00Z",
},
],
});
await page.goto("/workspace/scheduled-tasks");
await page
.getByTestId("scheduled-task-detail")
.getByRole("button", { name: "Duplicate" })
.click();
const createForm = page.getByTestId("scheduled-task-create-form");
await expect(createForm.getByPlaceholder("Task title")).toHaveValue(
"Daily summary (Copy)",
);
await expect(createForm.getByPlaceholder("Prompt")).toHaveValue(
"Summarize this conversation",
);
await expect(createForm.getByPlaceholder("Thread ID")).toHaveValue(
"thread-copy-source",
);
await expect(createForm.getByTestId("schedule-preview")).toHaveText(
"Every day at 18:00 (UTC)",
);
await expect(createForm.getByPlaceholder("Task title")).toBeFocused();
expect(createRequests).toBe(0);
});
test("reuse-thread tasks explain their context and busy-thread queue behavior", async ({
page,
}) => {