From e3b193577a5f9ab7c14d95be57723480021d9898 Mon Sep 17 00:00:00 2001 From: David Balderston Date: Sun, 21 Jun 2026 17:08:20 -0700 Subject: [PATCH 1/2] Improve automation editing controls --- .../settings/AutomationsSettings.tsx | 552 +++++++++++++++--- 1 file changed, 485 insertions(+), 67 deletions(-) diff --git a/apps/web/src/components/settings/AutomationsSettings.tsx b/apps/web/src/components/settings/AutomationsSettings.tsx index 09fc7505391..8bcc5c92977 100644 --- a/apps/web/src/components/settings/AutomationsSettings.tsx +++ b/apps/web/src/components/settings/AutomationsSettings.tsx @@ -12,6 +12,7 @@ import { type ScheduledTask, type ScheduledTaskRRuleConfig, type ScheduledTaskRun, + type ScheduledTaskUpdateInput, type ScheduledTaskWeekday, type ThreadId, } from "@t3tools/contracts"; @@ -21,18 +22,19 @@ import { useRouter } from "@tanstack/react-router"; import { ChevronDownIcon, ChevronRightIcon, + CirclePauseIcon, + CirclePlayIcon, Clock3Icon, FolderIcon, InfoIcon, MoreHorizontalIcon, - PauseCircleIcon, - PauseIcon, PlayIcon, PlusIcon, + SquarePenIcon, Trash2Icon, XIcon, } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useShallow } from "zustand/react/shallow"; import { useComposerDraftStore } from "~/composerDraftStore"; @@ -57,6 +59,15 @@ import { selectProjectsForEnvironment, selectThreadsForEnvironment, useStore } f import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Checkbox } from "../ui/checkbox"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; import { Dialog, DialogPanel, DialogPopup } from "../ui/dialog"; import { Input } from "../ui/input"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; @@ -357,6 +368,35 @@ function buildSchedule(form: AutomationFormState): { }; } +function formStateFromTask(task: ScheduledTask): AutomationFormState { + const config = parseRRuleConfig(task); + const cadence: Cadence = task.scheduleKind === "once" ? "once" : (config?.frequency ?? "daily"); + const startAtIso = task.scheduleKind === "once" ? task.scheduleValue : config?.dtStart; + const startAtDate = startAtIso ? new Date(startAtIso) : new Date(); + const fallbackStartAt = Number.isNaN(startAtDate.getTime()) + ? defaultStartAt() + : toDatetimeLocalInputValue(startAtDate); + const monthDay = + config?.byMonthDay?.[0] ?? + (Number.isNaN(startAtDate.getTime()) ? new Date().getDate() : startAtDate.getDate()); + + return { + name: task.name, + kind: task.kind, + projectId: task.projectId, + targetThreadId: task.targetThreadId ?? "", + cadence, + startAt: fallbackStartAt, + interval: String(config?.interval ?? 1), + weekdays: config?.byDay ?? ["MO", "TU", "WE", "TH", "FR"], + monthDay: String(monthDay), + timezone: task.timezone, + prompt: task.prompt, + catchUp: task.catchUp, + modelSelection: task.modelSelection, + }; +} + function AutomationField({ label, className, @@ -523,6 +563,7 @@ function AutomationRow({ selected, busy, onSelect, + onEdit, onRun, onPauseResume, onDelete, @@ -533,6 +574,7 @@ function AutomationRow({ readonly selected: boolean; readonly busy: boolean; readonly onSelect: () => void; + readonly onEdit: () => void; readonly onRun: () => void; readonly onPauseResume: () => void; readonly onDelete: () => void; @@ -574,12 +616,19 @@ function AutomationRow({ {formatSchedule(task)} - + + + + - {active ? : } + {active ? ( + + ) : ( + + )} @@ -599,6 +648,7 @@ function AutomationListSection({ mutatingTaskId, onSelect, onMutate, + onDelete, }: { readonly title: string; readonly tasks: ReadonlyArray; @@ -607,7 +657,8 @@ function AutomationListSection({ readonly threadById: Map; readonly mutatingTaskId: string | null; readonly onSelect: (task: ScheduledTask) => void; - readonly onMutate: (task: ScheduledTask, action: "pause" | "resume" | "run" | "delete") => void; + readonly onMutate: (task: ScheduledTask, action: "pause" | "resume" | "run") => void; + readonly onDelete: (task: ScheduledTask) => void; }) { if (tasks.length === 0) return null; return ( @@ -629,9 +680,10 @@ function AutomationListSection({ selected={selectedTaskId === task.id} busy={busy} onSelect={() => onSelect(task)} + onEdit={() => onSelect(task)} onRun={() => onMutate(task, "run")} onPauseResume={() => onMutate(task, task.status === "active" ? "pause" : "resume")} - onDelete={() => onMutate(task, "delete")} + onDelete={() => onDelete(task)} /> ); })} @@ -660,8 +712,8 @@ function DetailRow({ function AutomationDetail({ task, runs, - projectName, - threadTitle, + projects, + threads, preferredSelection, providerEntries, modelOptionsByInstance, @@ -670,12 +722,17 @@ function AutomationDetail({ busy, onBack, onMutate, - onUpdateModelSelection, + onDelete, + onUpdate, }: { readonly task: ScheduledTask; readonly runs: ReadonlyArray; - readonly projectName: string; - readonly threadTitle: string | null; + readonly projects: ReadonlyArray<{ readonly id: ProjectId; readonly name: string }>; + readonly threads: ReadonlyArray<{ + readonly id: ThreadId; + readonly title: string; + readonly projectId: ProjectId; + }>; readonly preferredSelection: ModelSelection; readonly providerEntries: ReadonlyArray; readonly modelOptionsByInstance: ReadonlyMap>; @@ -683,21 +740,132 @@ function AutomationDetail({ readonly providers: ReadonlyArray[number]>; readonly busy: boolean; readonly onBack: () => void; - readonly onMutate: (task: ScheduledTask, action: "pause" | "resume" | "run" | "delete") => void; - readonly onUpdateModelSelection: (selection: ModelSelection) => void; + readonly onMutate: (task: ScheduledTask, action: "pause" | "resume" | "run") => void; + readonly onDelete: (task: ScheduledTask) => void; + readonly onUpdate: (task: ScheduledTask, patch: ScheduledTaskUpdateInput["patch"]) => void; }) { const active = task.status === "active"; + const [nameDraft, setNameDraft] = useState(task.name); + const [promptDraft, setPromptDraft] = useState(task.prompt); + const [scheduleDraft, setScheduleDraft] = useState(() => formStateFromTask(task)); + const scheduleDraftRef = useRef(scheduleDraft); + const projectName = + projects.find((project) => project.id === task.projectId)?.name ?? String(task.projectId); + const projectThreads = useMemo( + () => threads.filter((thread) => thread.projectId === task.projectId), + [task.projectId, threads], + ); + const threadTitle = task.targetThreadId + ? (threads.find((thread) => thread.id === task.targetThreadId)?.title ?? null) + : null; + + useEffect(() => { + setNameDraft(task.name); + setPromptDraft(task.prompt); + }, [task.id]); + + useEffect(() => { + const nextScheduleDraft = formStateFromTask(task); + setScheduleDraft(nextScheduleDraft); + scheduleDraftRef.current = nextScheduleDraft; + }, [task.catchUp, task.id, task.scheduleKind, task.scheduleValue, task.timezone]); + + const saveNameDraft = useCallback( + (value: string) => { + const name = value.trim(); + if (!name || name === task.name) return; + onUpdate(task, { name }); + }, + [onUpdate, task], + ); + + const savePromptDraft = useCallback( + (value: string) => { + const prompt = value.trim(); + if (!prompt || prompt === task.prompt) return; + onUpdate(task, { prompt }); + }, + [onUpdate, task], + ); + + useEffect(() => { + const handle = window.setTimeout(() => saveNameDraft(nameDraft), 600); + return () => window.clearTimeout(handle); + }, [nameDraft, saveNameDraft]); + + useEffect(() => { + const handle = window.setTimeout(() => savePromptDraft(promptDraft), 600); + return () => window.clearTimeout(handle); + }, [promptDraft, savePromptDraft]); + + const saveScheduleDraft = useCallback( + (next: AutomationFormState) => { + try { + onUpdate(task, { + ...buildSchedule(next), + timezone: next.timezone.trim() || task.timezone, + catchUp: next.catchUp, + }); + } catch { + return; + } + }, + [onUpdate, task], + ); + + const updateScheduleDraft = useCallback( + (updater: (current: AutomationFormState) => AutomationFormState) => { + const next = updater(scheduleDraftRef.current); + scheduleDraftRef.current = next; + setScheduleDraft(next); + saveScheduleDraft(next); + }, + [saveScheduleDraft], + ); + + const updateKind = useCallback( + (kind: ScheduledTask["kind"]) => { + if (kind === "standalone") { + onUpdate(task, { kind, targetThreadId: null }); + return; + } + const targetThreadId = task.targetThreadId ?? projectThreads[0]?.id; + if (!targetThreadId) return; + onUpdate(task, { kind, targetThreadId }); + }, + [onUpdate, projectThreads, task], + ); + + const updateProject = useCallback( + (projectId: ProjectId) => { + if (task.kind !== "thread") { + onUpdate(task, { projectId }); + return; + } + const projectThread = + threads.find( + (thread) => thread.projectId === projectId && thread.id === task.targetThreadId, + ) ?? threads.find((thread) => thread.projectId === projectId); + if (projectThread) { + onUpdate(task, { projectId, targetThreadId: projectThread.id }); + return; + } + onUpdate(task, { kind: "standalone", projectId, targetThreadId: null }); + }, + [onUpdate, task, threads], + ); + return (
onMutate(task, active ? "pause" : "resume")} > - {active ? : } + {active ? ( + + ) : ( + + )} - onMutate(task, "delete")}> + onDelete(task)}>