| 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { ThreadDetailsControl } from "./ThreadDetailsControl"; |
| 3 | import { useNavigate } from "@tanstack/react-router"; |
| 4 | import { CalendarClockIcon, PencilIcon, PlayIcon, Settings2Icon } from "lucide-react"; |
| 5 | import { useState } from "react"; |
| 6 | import { |
| 7 | AuthOrchestrationOperateScope, |
| 8 | type EnvironmentId, |
| 9 | type ScheduledTask, |
| 10 | type ThreadId, |
| 11 | } from "@t3tools/contracts"; |
| 12 | import { |
| 13 | isAtomCommandInterrupted, |
| 14 | squashAtomCommandFailure, |
| 15 | } from "@t3tools/client-runtime/state/runtime"; |
| 16 | |
| 17 | import { ThreadDetailsSection } from "./ThreadDetailsSection"; |
| 18 | import { cn } from "../../lib/utils"; |
| 19 | import { relativeLabel, scheduleLabel } from "../settings/ScheduledTasksSettings"; |
| 20 | import { useEnvironmentQuery } from "../../state/query"; |
| 21 | import { serverEnvironment } from "../../state/server"; |
| 22 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 23 | |
| 24 | import { readEnvironmentScope } from "../../state/session"; |
| 25 | import { Switch } from "../ui/switch"; |
| 26 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 27 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 28 | import { |
| 29 | THREAD_DETAILS_PANEL_ICON_CLASS, |
| 30 | THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS, |
| 31 | } from "./threadDetailsPanelStyles"; |
| 32 | |
| 33 | const STATUS_DOT_CLASS: Record<ScheduledTask["lastRunStatus"], string> = { |
| 34 | never: "bg-muted-foreground/40", |
| 35 | running: "bg-sky-500", |
| 36 | succeeded: "bg-emerald-500", |
| 37 | failed: "bg-destructive", |
| 38 | }; |
| 39 | |
| 40 | /** |
| 41 | * Thread details panel section listing the automations (scheduled tasks) bound |
| 42 | * to this thread. Fed by the live scheduled-task subscription, so run status |
| 43 | * and next-run times update as the scheduler works — no manual refresh. |
| 44 | * Renders nothing when the thread has no bound automations. |
| 45 | */ |
| 46 | export function ThreadAutomationsPanel(props: { |
| 47 | readonly environmentId: EnvironmentId; |
| 48 | readonly threadId: ThreadId; |
| 49 | }) { |
| 50 | const canOperate = useAtomValue( |
| 51 | serverEnvironment.runScheduledTaskNow.permissionAtom(props.environmentId), |
| 52 | ); |
| 53 | const tasksQuery = useEnvironmentQuery( |
| 54 | serverEnvironment.scheduledTasksLive({ environmentId: props.environmentId, input: {} }), |
| 55 | ); |
| 56 | const setTaskEnabled = useAtomCommand(serverEnvironment.setScheduledTaskEnabled, { |
| 57 | label: "thread automation toggle", |
| 58 | }); |
| 59 | const runTaskNow = useAtomCommand(serverEnvironment.runScheduledTaskNow, { |
| 60 | label: "thread automation run now", |
| 61 | }); |
| 62 | const navigate = useNavigate(); |
| 63 | const [busyTaskId, setBusyTaskId] = useState<string | null>(null); |
| 64 | |
| 65 | const boundTasks = (tasksQuery.data?.tasks ?? []).filter( |
| 66 | (task) => task.threadId === props.threadId, |
| 67 | ); |
| 68 | // A load error must not look like "no automations" — this thread may have |
| 69 | // tasks whose controls would silently vanish. Only hide the section when we |
| 70 | // positively know there is nothing bound to it. |
| 71 | if (tasksQuery.error === null && boundTasks.length === 0) return null; |
| 72 | |
| 73 | const reportFailure = (title: string, error: unknown) => { |
| 74 | toastManager.add( |
| 75 | stackedThreadToast({ |
| 76 | type: "error", |
| 77 | title, |
| 78 | description: error instanceof Error ? error.message : String(error), |
| 79 | }), |
| 80 | ); |
| 81 | }; |
| 82 | |
| 83 | const toggleEnabled = async (task: ScheduledTask, enabled: boolean) => { |
| 84 | if ( |
| 85 | busyTaskId !== null || |
| 86 | !readEnvironmentScope(props.environmentId, AuthOrchestrationOperateScope) |
| 87 | ) |
| 88 | return; |
| 89 | setBusyTaskId(task.id); |
| 90 | // Partial update: only the enabled flag changes, so a toggle can never |
| 91 | // revert concurrent edits made to the task elsewhere. |
| 92 | const result = await setTaskEnabled({ |
| 93 | environmentId: props.environmentId, |
| 94 | input: { id: task.id, enabled }, |
| 95 | }); |
| 96 | setBusyTaskId(null); |
| 97 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
| 98 | reportFailure("Could not update automation", squashAtomCommandFailure(result)); |
| 99 | } |
| 100 | }; |
| 101 | |
| 102 | const runNow = async (task: ScheduledTask) => { |
| 103 | if ( |
| 104 | busyTaskId !== null || |
| 105 | !readEnvironmentScope(props.environmentId, AuthOrchestrationOperateScope) |
| 106 | ) |
| 107 | return; |
| 108 | setBusyTaskId(task.id); |
| 109 | const result = await runTaskNow({ |
| 110 | environmentId: props.environmentId, |
| 111 | input: { id: task.id }, |
| 112 | }); |
| 113 | setBusyTaskId(null); |
| 114 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
| 115 | reportFailure("Could not run automation", squashAtomCommandFailure(result)); |
| 116 | } |
| 117 | }; |
| 118 | |
| 119 | return ( |
| 120 | <ThreadDetailsSection |
| 121 | headingId="thread-details-automations-heading" |
| 122 | title="Automations" |
| 123 | data-thread-automations-panel |
| 124 | actions={ |
| 125 | <Tooltip> |
| 126 | <TooltipTrigger |
| 127 | render={ |
| 128 | <ThreadDetailsControl |
| 129 | size="icon-xs" |
| 130 | variant="ghost" |
| 131 | part="icon" |
| 132 | aria-label="Manage scheduled tasks" |
| 133 | onClick={() => |
| 134 | void navigate({ |
| 135 | to: "/settings/scheduled-tasks", |
| 136 | search: { environmentId: props.environmentId }, |
| 137 | }) |
| 138 | } |
| 139 | > |
| 140 | <Settings2Icon className="size-3.5" /> |
| 141 | </ThreadDetailsControl> |
| 142 | } |
| 143 | /> |
| 144 | <TooltipPopup>Manage scheduled tasks</TooltipPopup> |
| 145 | </Tooltip> |
| 146 | } |
| 147 | > |
| 148 | {tasksQuery.error !== null ? ( |
| 149 | <p className="px-2.5 py-1.5 text-2xs text-destructive"> |
| 150 | Could not load automations: {tasksQuery.error} |
| 151 | </p> |
| 152 | ) : null} |
| 153 | |
| 154 | <ul className="m-0 list-none p-0"> |
| 155 | {boundTasks.map((task) => ( |
| 156 | <li |
| 157 | key={task.id} |
| 158 | className={cn( |
| 159 | "group flex items-center rounded-lg py-1.5", |
| 160 | THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS, |
| 161 | )} |
| 162 | > |
| 163 | <span className="relative inline-flex size-4 shrink-0 items-center justify-center"> |
| 164 | <CalendarClockIcon className={THREAD_DETAILS_PANEL_ICON_CLASS} /> |
| 165 | <span |
| 166 | className={cn( |
| 167 | "absolute -right-1 -top-1 size-1.5 rounded-full", |
| 168 | STATUS_DOT_CLASS[task.lastRunStatus], |
| 169 | )} |
| 170 | aria-hidden |
| 171 | /> |
| 172 | </span> |
| 173 | <div className="min-w-0 flex-1"> |
| 174 | <span className="block truncate text-sm font-medium text-foreground/80"> |
| 175 | {task.title} |
| 176 | </span> |
| 177 | <p className="truncate text-2xs text-muted-foreground"> |
| 178 | {scheduleLabel(task.schedule)} |
| 179 | {task.enabled && task.nextRunAt !== null |
| 180 | ? ` · next ${relativeLabel(task.nextRunAt)}` |
| 181 | : task.enabled |
| 182 | ? "" |
| 183 | : " · paused"} |
| 184 | </p> |
| 185 | </div> |
| 186 | <Tooltip> |
| 187 | <TooltipTrigger |
| 188 | render={ |
| 189 | <ThreadDetailsControl |
| 190 | size="icon-xs" |
| 191 | variant="ghost" |
| 192 | part="icon" |
| 193 | aria-label={`Edit ${task.title}`} |
| 194 | onClick={() => |
| 195 | void navigate({ |
| 196 | to: "/settings/scheduled-tasks", |
| 197 | search: { environmentId: props.environmentId, taskId: task.id }, |
| 198 | }) |
| 199 | } |
| 200 | > |
| 201 | <PencilIcon className="size-3.5" /> |
| 202 | </ThreadDetailsControl> |
| 203 | } |
| 204 | /> |
| 205 | <TooltipPopup>Edit automation</TooltipPopup> |
| 206 | </Tooltip> |
| 207 | {/* A webhook task runs from its URL; there is no request to run it with. */} |
| 208 | {task.schedule.type === "webhook" ? null : ( |
| 209 | <Tooltip> |
| 210 | <TooltipTrigger |
| 211 | render={ |
| 212 | <ThreadDetailsControl |
| 213 | size="icon-xs" |
| 214 | variant="ghost" |
| 215 | part="icon" |
| 216 | aria-label={`Run ${task.title} now`} |
| 217 | disabled={ |
| 218 | !canOperate || busyTaskId !== null || task.lastRunStatus === "running" |
| 219 | } |
| 220 | onClick={() => void runNow(task)} |
| 221 | > |
| 222 | <PlayIcon className="size-3.5" /> |
| 223 | </ThreadDetailsControl> |
| 224 | } |
| 225 | /> |
| 226 | <TooltipPopup>Run now</TooltipPopup> |
| 227 | </Tooltip> |
| 228 | )} |
| 229 | <Switch |
| 230 | checked={task.enabled} |
| 231 | disabled={!canOperate || busyTaskId !== null} |
| 232 | aria-label={task.enabled ? `Pause ${task.title}` : `Resume ${task.title}`} |
| 233 | onCheckedChange={(enabled) => void toggleEnabled(task, enabled)} |
| 234 | /> |
| 235 | </li> |
| 236 | ))} |
| 237 | </ul> |
| 238 | </ThreadDetailsSection> |
| 239 | ); |
| 240 | } |