From afbea9fd779bc555989d60c6ad1c062e1889d063 Mon Sep 17 00:00:00 2001 From: Xi Zhang Date: Sat, 27 Jun 2026 01:21:51 +0100 Subject: [PATCH] feat: add scheduled tasks functionality and UI integration - Introduced a new ScheduledTasksPanel component to manage and display scheduled tasks. - Implemented useScheduledTasks hook for fetching, creating, and deleting scheduled tasks. - Added cronUtils for handling cron expressions and scheduling logic. - Updated ThreadList component to include a button for accessing the scheduled tasks view. - Enhanced the useAvailableModels hook for better code readability. --- src/app/components/ScheduledTasksPanel.tsx | 1036 ++++++++++++++++++++ src/app/components/ThreadList.tsx | 23 + src/app/hooks/useAvailableModels.ts | 9 +- src/app/hooks/useScheduledTasks.ts | 141 +++ src/app/page.tsx | 2 + src/lib/cronUtils.ts | 155 +++ 6 files changed, 1362 insertions(+), 4 deletions(-) create mode 100644 src/app/components/ScheduledTasksPanel.tsx create mode 100644 src/app/hooks/useScheduledTasks.ts create mode 100644 src/lib/cronUtils.ts diff --git a/src/app/components/ScheduledTasksPanel.tsx b/src/app/components/ScheduledTasksPanel.tsx new file mode 100644 index 0000000..df74e8b --- /dev/null +++ b/src/app/components/ScheduledTasksPanel.tsx @@ -0,0 +1,1036 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Activity, + ArrowLeft, + Calendar, + CalendarClock, + ChevronRight, + ClipboardList, + Clock, + FlaskConical, + Loader2, + Play, + Plus, + RefreshCw, + Repeat2, + Search, + Trash2, + X, + type LucideIcon, +} from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Textarea } from "@/components/ui/textarea"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import { + createScheduledTask, + deleteScheduledTask, + runScheduledTaskNow, + useScheduledTasks, + type ScheduledTask, +} from "@/app/hooks/useScheduledTasks"; +import { + cronLabel, + cronToSpec, + DAY_NAMES, + DEFAULT_SCHEDULE_SPEC, + nextRunLabel, + specToCron, + validateCronExpression, + type Frequency, + type ScheduleSpec, +} from "@/lib/cronUtils"; + +interface Template { + icon: LucideIcon; + label: string; + description: string; + name: string; + prompt: string; + schedule: string; +} + +const TEMPLATES: Template[] = [ + { + icon: ClipboardList, + label: "Daily Papers", + description: "Track new ML papers against your research preferences.", + name: "Daily Papers", + prompt: + "Summarise the latest ML papers from arXiv according to my research preferences with the paper-navigator skill. Focus on papers that are relevant to my current projects, explain why each one matters, and save the summary to /memories/daily-papers.md.", + schedule: "0 9 * * *", + }, + { + icon: Repeat2, + label: "Weekly Research Review", + description: + "Summarise this week's research progress and future direction.", + name: "Weekly Research Review", + prompt: + "Summarise this week's research progress across my active projects. Highlight key results, decisions, blockers, open questions, and what changed in my understanding. Then propose future research directions and concrete next steps. Save the review to /memories/weekly-research-review.md and update any relevant project memory.", + schedule: "0 17 * * 5", + }, + { + icon: Activity, + label: "Weekly Research Plan", + description: "Draft a Monday plan for the week's research priorities.", + name: "Weekly Research Plan", + prompt: + "Draft this week's research plan based on my active projects, recent progress, project memories, and open questions. Prioritise the most important research goals, propose concrete experiments or reading tasks, identify risks, and write a practical schedule for the week. Save the plan to /memories/weekly-research-plan.md.", + schedule: "0 8 * * 1", + }, + { + icon: FlaskConical, + label: "Experiment Backlog", + description: "Convert open questions into testable experiment ideas.", + name: "Experiment Backlog", + prompt: + "Review my active project memories, recent research notes, and open questions. Turn the most important unresolved ideas into a prioritised experiment backlog with hypotheses, expected signal, required data or code, estimated effort, and success criteria. Save it to /memories/experiment-backlog.md.", + schedule: "0 10 * * 2", + }, +]; + +function formatAbsoluteDate(iso: string | null): string { + if (!iso) return "Not scheduled"; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return "Unknown"; + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(date); +} + +function formatLongDate(iso: string | null): string { + if (!iso) return ""; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return ""; + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(date); +} + +function formatCreatedDate(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return "Unknown"; + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }).format(date); +} + +function taskSearchText(task: ScheduledTask): string { + return [ + task.name, + task.prompt, + task.schedule, + cronLabel(task.schedule), + task.next_run_date ? formatAbsoluteDate(task.next_run_date) : "", + ] + .join(" ") + .toLowerCase(); +} + +function sortTasks(tasks: ScheduledTask[]): ScheduledTask[] { + return [...tasks].sort((a, b) => { + const aNext = a.next_run_date ? new Date(a.next_run_date).getTime() : 0; + const bNext = b.next_run_date ? new Date(b.next_run_date).getTime() : 0; + if (aNext && bNext && aNext !== bNext) return aNext - bNext; + if (aNext && !bNext) return -1; + if (!aNext && bNext) return 1; + return a.name.localeCompare(b.name); + }); +} + +function FieldLabel({ + children, + htmlFor, +}: { + children: React.ReactNode; + htmlFor?: string; +}) { + return ( + + ); +} + +function ScheduleBuilder({ + value, + onChange, + error, +}: { + value: ScheduleSpec; + onChange: (s: ScheduleSpec) => void; + error?: string | null; +}) { + const set = (patch: Partial) => + onChange({ ...value, ...patch }); + + const selectClass = + "h-9 rounded-md border border-input bg-background py-1.5 pl-2.5 pr-8 text-sm outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"; + const frequencySelectClass = cn(selectClass, "w-full sm:min-w-40"); + const daySelectClass = cn(selectClass, "w-full sm:w-28"); + + return ( +
+
+ + + {value.frequency !== "custom" && ( + <> + {value.frequency === "weekly" && ( + + )} + + {value.frequency === "monthly" && ( + + )} + + + at + +
+ set({ hour: Number(e.target.value) })} + className="h-9 text-center font-mono tabular-nums" + /> + : + set({ minute: Number(e.target.value) })} + className="h-9 text-center font-mono tabular-nums" + /> +
+ + )} +
+ + {value.frequency === "custom" && ( + set({ custom: e.target.value })} + placeholder="0 9 * * 1-5" + spellCheck={false} + aria-invalid={Boolean(error)} + aria-describedby="schedule-cron-help" + className="h-9 font-mono text-sm" + /> + )} + +
+
+
+ ); +} + +interface TemplateButtonProps { + template: Template; + onSelect: (template: Template) => void; + compact?: boolean; +} + +function TemplateButton({ template, onSelect, compact }: TemplateButtonProps) { + const Icon = template.icon; + + return ( + + ); +} + +interface CreateFormProps { + initialTemplate?: Template; + onCreated: () => void; + onCancel: () => void; +} + +function CreateForm({ initialTemplate, onCreated, onCancel }: CreateFormProps) { + const [name, setName] = useState(initialTemplate?.name ?? ""); + const [prompt, setPrompt] = useState(initialTemplate?.prompt ?? ""); + const [spec, setSpec] = useState(() => + initialTemplate + ? cronToSpec(initialTemplate.schedule) + : { ...DEFAULT_SCHEDULE_SPEC } + ); + const [saving, setSaving] = useState(false); + const nameRef = useRef(null); + + const cron = specToCron(spec); + const cronError = + spec.frequency === "custom" ? validateCronExpression(cron) : null; + const canSave = Boolean(name.trim() && prompt.trim() && !cronError); + + useEffect(() => { + nameRef.current?.focus(); + }, []); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + if (!name.trim()) { + toast.error("Task name is required."); + return; + } + if (!prompt.trim()) { + toast.error("Task description is required."); + return; + } + if (cronError) { + toast.error(cronError); + return; + } + + setSaving(true); + try { + await createScheduledTask({ + name: name.trim(), + prompt: prompt.trim(), + schedule: cron, + }); + toast.success(`"${name.trim()}" scheduled.`); + onCreated(); + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to create scheduled task." + ); + } finally { + setSaving(false); + } + }; + + return ( +
+
+ +
+

New scheduled task

+

+ EvoScientist will run this task description unattended. +

+
+
+ + +
+
+ Task name + setName(e.target.value)} + placeholder="Daily Briefing" + autoComplete="off" + /> +
+ +
+ + Task description + +