1import { useAtomValue } from "@effect/atom-react";
2import { ThreadDetailsControl } from "./ThreadDetailsControl";
3import { useNavigate } from "@tanstack/react-router";
4import { CalendarClockIcon, PencilIcon, PlayIcon, Settings2Icon } from "lucide-react";
5import { useState } from "react";
6import {
7 AuthOrchestrationOperateScope,
8 type EnvironmentId,
9 type ScheduledTask,
10 type ThreadId,
11} from "@t3tools/contracts";
12import {
13 isAtomCommandInterrupted,
14 squashAtomCommandFailure,
15} from "@t3tools/client-runtime/state/runtime";
16
17import { ThreadDetailsSection } from "./ThreadDetailsSection";
18import { cn } from "../../lib/utils";
19import { relativeLabel, scheduleLabel } from "../settings/ScheduledTasksSettings";
20import { useEnvironmentQuery } from "../../state/query";
21import { serverEnvironment } from "../../state/server";
22import { useAtomCommand } from "../../state/use-atom-command";
23
24import { readEnvironmentScope } from "../../state/session";
25import { Switch } from "../ui/switch";
26import { stackedThreadToast, toastManager } from "../ui/toast";
27import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
28import {
29 THREAD_DETAILS_PANEL_ICON_CLASS,
30 THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS,
31} from "./threadDetailsPanelStyles";
32
33const 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 */
46export 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}