1import { useAtomValue } from "@effect/atom-react";
2import {
3 Clock3Icon,
4 CopyIcon,
5 InboxIcon,
6 MoreHorizontalIcon,
7 PencilIcon,
8 PlayIcon,
9 PlusIcon,
10 Trash2Icon,
11} from "lucide-react";
12import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
13import type {
14 EnvironmentId,
15 ModelSelection,
16 OrchestrationV2ThreadLaunchWorkspaceStrategy,
17 ProjectId,
18 ScheduledTask,
19 ScheduledTaskId,
20 ScheduledTaskSchedule,
21 ScheduledTaskUpsertInput,
22 ScheduledTaskWebhookDeliveryOutcome,
23 ScheduledTaskWebhookDeliverySummary,
24 ThreadId,
25} from "@t3tools/contracts";
26import { DEFAULT_WEBHOOK_PROMPT } from "@t3tools/client-runtime/scheduled-task-webhook";
27import {
28 MAX_WEBHOOK_DELIVERY_AGE_MINUTES,
29 MIN_SCHEDULED_TASK_INTERVAL_MS,
30 ProviderInstanceId,
31 resolveEnvironmentMachineKind,
32} from "@t3tools/contracts";
33import {
34 isAtomCommandInterrupted,
35 squashAtomCommandFailure,
36} from "@t3tools/client-runtime/state/runtime";
37
38import { formatRelativeTime } from "../../timestampFormat";
39import { useEnvironmentSettings } from "../../hooks/useSettings";
40import { getCustomModelOptionsByInstance } from "../../modelSelection";
41import {
42 applyProviderInstanceSettings,
43 deriveProviderInstanceEntries,
44 sortProviderInstanceEntries,
45} from "../../providerInstances";
46import { usePrimaryCloudLinkState } from "../../cloud/primaryCloudLinkState";
47import { requestConfirmDialog } from "../../confirmDialog";
48import { webhookAddress } from "@t3tools/client-runtime/webhook-address";
49import { Link } from "@tanstack/react-router";
50import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
51import {
52 useEnvironment,
53 useEnvironmentHttpBaseUrl,
54 type EnvironmentPresentation,
55} from "../../state/environments";
56import { useProjects } from "../../state/entities";
57import { useEnvironmentQuery } from "../../state/query";
58import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server";
59import { useAtomCommand } from "../../state/use-atom-command";
60import { WorktreeBaseBranchPicker } from "../WorktreeBaseBranchPicker";
61import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
62import { AuthOrchestrationOperateScope } from "@t3tools/contracts";
63import { readEnvironmentScope } from "~/state/session";
64import { useSettingsScope } from "./SettingsScopeContext";
65import {
66 WEBHOOK_SIGNATURE_DEFAULTS,
67 matchesScheduledTaskScope,
68 scheduleFromDraft,
69 scheduledTaskDefaultModel,
70 taskToDraft,
71 type DraftState,
72 type ScheduleMode,
73 type WorkspaceMode,
74} from "./scheduledTasksSettings.logic";
75import { Label } from "../ui/label";
76import { Menu, MenuTrigger, MenuPopup, MenuItem, MenuSeparator } from "../ui/menu";
77import { ToggleGroup, Toggle } from "../ui/toggle-group";
78import { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyMedia } from "../ui/empty";
79import { ProviderModelPicker } from "../chat/ProviderModelPicker";
80import { Badge } from "../ui/badge";
81import { Button } from "../ui/button";
82import {
83 Dialog,
84 DialogClose,
85 DialogDescription,
86 DialogFooter,
87 DialogHeader,
88 DialogPanel,
89 DialogPopup,
90 DialogTitle,
91} from "../ui/dialog";
92import { Input } from "../ui/input";
93import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
94import { Switch } from "../ui/switch";
95import { Textarea } from "../ui/textarea";
96import { stackedThreadToast, toastManager } from "../ui/toast";
97import {
98 SettingsPageContainer,
99 SettingsRow,
100 SettingsSection,
101 SETTINGS_PICKER_TRIGGER_CLASSNAME,
102 useRelativeTimeTick,
103} from "./settingsLayout";
104
105/** JS day-of-week (0 = Sunday) rendered Monday-first, matching how people read a week. */
106const WEEKDAY_ORDER = [1, 2, 3, 4, 5, 6, 0] as const;
107const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
108const WEEKDAY_SHORT = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"] as const;
109
110const WORKSPACE_MODE_LABELS: Record<WorkspaceMode, string> = {
111 worktree: "Create a new worktree",
112 root: "Use the project checkout",
113 existing_worktree: "Use a specific checkout",
114};
115
116const EMPTY_DRAFT: DraftState = {
117 editingId: null,
118 title: "",
119 prompt: "",
120 enabled: true,
121 scheduleMode: "fixed",
122 intervalMinutes: "15",
123 timeOfDay: "09:00",
124 weekdays: new Set([1, 2, 3, 4, 5]),
125 projectId: "",
126 threadId: "",
127 workspaceMode: "worktree",
128 baseRef: "main",
129 startFromOrigin: true,
130 existingWorktreePath: "",
131 modelKey: "",
132 runtimeMode: "full-access",
133 interactionMode: "default",
134 baseModelSelection: null,
135 signatureEnabled: false,
136 ...WEBHOOK_SIGNATURE_DEFAULTS,
137 signatureSecret: "",
138 maxDeliveryAgeMinutes: "",
139};
140
141/** Labelled field: a caption sitting above its control. */
142function Field({
143 label,
144 hint,
145 htmlFor,
146 children,
147}: {
148 label: string;
149 hint?: string;
150 htmlFor?: string;
151 children: ReactNode;
152}) {
153 return (
154 <div className="space-y-1.5">
155 <Label className="flex items-baseline justify-between" htmlFor={htmlFor}>
156 <span>{label}</span>
157 {hint ? (
158 <span className="font-normal text-2xs text-muted-foreground/80">{hint}</span>
159 ) : null}
160 </Label>
161 {children}
162 </div>
163 );
164}
165
166function splitModelKey(value: string): ModelSelection | null {
167 const index = value.indexOf(":");
168 if (index <= 0 || index === value.length - 1) return null;
169 return {
170 instanceId: ProviderInstanceId.make(value.slice(0, index)),
171 model: value.slice(index + 1),
172 };
173}
174
175export function scheduleLabel(schedule: ScheduledTaskSchedule): string {
176 if (schedule.type === "webhook") return "On webhook";
177 if (schedule.type === "interval") {
178 const minutes = schedule.everyMs / 60_000;
179 return Number.isInteger(minutes)
180 ? `Every ${minutes} min`
181 : `Every ${Math.round(schedule.everyMs / 1000)} sec`;
182 }
183 const weekdays = schedule.weekdays ?? [];
184 const days =
185 weekdays.length === 0
186 ? "Daily"
187 : weekdays.length === 5 && weekdays.every((day) => day >= 1 && day <= 5)
188 ? "Weekdays"
189 : weekdays.map((day) => WEEKDAY_LABELS[day]).join(", ");
190 return `${days} at ${schedule.timeOfDay}`;
191}
192
193/**
194 * Human label for a run timestamp. `formatRelativeTime` only handles the
195 * past, and `nextRunAt` is a future instant — render "in 5m" style labels
196 * for upcoming runs instead of a misleading "just now".
197 */
198export function relativeLabel(value: string | null): string {
199 if (!value) return "Not scheduled";
200 const diffMs = new Date(value).getTime() - Date.now();
201 if (diffMs <= 0) {
202 const relative = formatRelativeTime(value);
203 if (!relative) return "Not scheduled";
204 return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value;
205 }
206 const minutes = Math.ceil(diffMs / 60_000);
207 if (minutes < 2) return "in under a minute";
208 if (minutes < 60) return `in ${minutes}m`;
209 const hours = Math.round(minutes / 60);
210 if (hours < 24) return `in ${hours}h`;
211 return `in ${Math.round(hours / 24)}d`;
212}
213
214const DELIVERY_OUTCOME_LABELS: Record<ScheduledTaskWebhookDeliveryOutcome, string> = {
215 accepted: "Ran",
216 dispatch_failed: "Run failed",
217 rejected_signature: "Bad signature",
218 disabled: "Task paused",
219 rate_limited: "Rate limited",
220 expired: "Too old",
221};
222
223function deliveryOutcomeVariant(outcome: ScheduledTaskWebhookDeliveryOutcome) {
224 if (outcome === "accepted") return "success";
225 if (outcome === "disabled" || outcome === "rate_limited" || outcome === "expired") {
226 return "warning";
227 }
228 return "error";
229}
230
231function statusVariant(status: ScheduledTask["lastRunStatus"]) {
232 if (status === "failed") return "error";
233 if (status === "succeeded") return "success";
234 if (status === "running") return "info";
235 return "outline";
236}
237
238export function ScheduledTasksSettings(target: {
239 readonly environmentId?: EnvironmentId;
240 readonly taskId?: ScheduledTaskId | undefined;
241}) {
242 const { scope, environments, connectedEnvironments, environment } = useSettingsScope();
243 const [editor, setEditor] = useState<{
244 environmentId: EnvironmentId;
245 task: ScheduledTask | null;
246 } | null>(null);
247 const openForEdit = useCallback((environmentId: EnvironmentId, task: ScheduledTask) => {
248 setEditor({ environmentId, task });
249 }, []);
250 const defaultEnvironment = environment ?? connectedEnvironments[0];
251 const canCreate = useAtomValue(
252 serverEnvironment.upsertScheduledTask.permissionAtom(defaultEnvironment?.environmentId ?? null),
253 );
254 return (
255 <SettingsPageContainer>
256 <SettingsSection
257 title="Scheduled tasks"
258 variant="plain"
259 headerAction={
260 <Button
261 size="xs"
262 variant="ghost-muted"
263 disabled={!defaultEnvironment || !canCreate}
264 onClick={() =>
265 defaultEnvironment &&
266 setEditor({ environmentId: defaultEnvironment.environmentId, task: null })
267 }
268 >
269 <PlusIcon className="size-3" />
270 New task
271 </Button>
272 }
273 >
274 {scope.kind === "unavailable" ? (
275 <SettingsSection title="Unavailable selection">
276 <SettingsRow title={scope.message} />
277 </SettingsSection>
278 ) : environments.length === 0 ? (
279 <Empty>
280 <EmptyHeader>
281 <EmptyMedia variant="icon">
282 <Clock3Icon />
283 </EmptyMedia>
284 <EmptyTitle>No environments available</EmptyTitle>
285 <EmptyDescription>Connect an environment to manage scheduled tasks.</EmptyDescription>
286 </EmptyHeader>
287 </Empty>
288 ) : (
289 <div className="space-y-8">
290 {environments.map((entry) => (
291 <ScheduledTaskEnvironmentSection
292 key={`${entry.environmentId}:${target.taskId ?? ""}`}
293 environment={entry}
294 showEnvironmentHeading={environments.length > 1}
295 taskId={
296 (target.environmentId ?? defaultEnvironment?.environmentId) ===
297 entry.environmentId
298 ? target.taskId
299 : undefined
300 }
301 onEdit={openForEdit}
302 />
303 ))}
304 </div>
305 )}
306 </SettingsSection>
307 {editor ? (
308 <ScheduledTaskEditorDialog
309 key={`${editor.environmentId}:${editor.task?.id ?? "new"}`}
310 initialEnvironmentId={editor.environmentId}
311 task={editor.task}
312 onClose={() => setEditor(null)}
313 />
314 ) : null}
315 </SettingsPageContainer>
316 );
317}
318
319function ScheduledTaskEnvironmentSection({
320 environment,
321 showEnvironmentHeading,
322 taskId,
323 onEdit,
324}: {
325 readonly environment: EnvironmentPresentation;
326 readonly showEnvironmentHeading: boolean;
327 readonly taskId?: ScheduledTaskId | undefined;
328 readonly onEdit: (environmentId: EnvironmentId, task: ScheduledTask) => void;
329}) {
330 const { scope } = useSettingsScope();
331 const connected =
332 environment.connection.phase === "connected" && environment.serverConfig !== null;
333 const tasksQuery = useEnvironmentQuery(
334 connected
335 ? serverEnvironment.scheduledTasksLive({
336 environmentId: environment.environmentId,
337 input: {},
338 })
339 : null,
340 );
341 const tasks = tasksQuery.data?.tasks.filter((task) =>
342 matchesScheduledTaskScope(scope, environment.environmentId, task.projectId),
343 );
344 const linkedTask = tasks?.find((task) => task.id === taskId);
345 const openedLink = useRef(false);
346 useEffect(() => {
347 if (!openedLink.current && linkedTask) {
348 openedLink.current = true;
349 onEdit(environment.environmentId, linkedTask);
350 }
351 }, [environment.environmentId, linkedTask, onEdit]);
352 useRelativeTimeTick(60_000);
353 return (
354 <SettingsSection
355 title={environment.label}
356 hideTitle={!showEnvironmentHeading}
357 icon={
358 <EnvironmentMachineIcon
359 kind={resolveEnvironmentMachineKind(environment.serverConfig)}
360 className="size-3.5"
361 />
362 }
363 >
364 {!connected ? (
365 <SettingsRow
366 title="Environment disconnected"
367 description={`Reconnect ${environment.label} to view its scheduled tasks.`}
368 />
369 ) : tasksQuery.error ? (
370 <SettingsRow title="Could not load scheduled tasks" description={tasksQuery.error} />
371 ) : !tasks ? (
372 <SettingsRow title="Loading scheduled tasks…" role="status" />
373 ) : (
374 <>
375 {taskId && !linkedTask ? (
376 <SettingsRow
377 title="Task unavailable"
378 description="This task no longer exists or is outside the selected project scope."
379 role="status"
380 />
381 ) : null}
382 {tasks.length === 0 ? (
383 <SettingsRow
384 title="No scheduled tasks"
385 description="No tasks match this environment and project selection."
386 />
387 ) : (
388 tasks.map((task) => (
389 <ScheduledTaskRow
390 key={task.id}
391 environmentId={environment.environmentId}
392 task={task}
393 onEdit={() => onEdit(environment.environmentId, task)}
394 />
395 ))
396 )}
397 </>
398 )}
399 </SettingsSection>
400 );
401}
402
403function ScheduledTaskRow({
404 environmentId,
405 task,
406 onEdit,
407}: {
408 readonly environmentId: EnvironmentId;
409 readonly task: ScheduledTask;
410 readonly onEdit: () => void;
411}) {
412 const canOperate = useAtomValue(
413 serverEnvironment.upsertScheduledTask.permissionAtom(environmentId),
414 );
415 const [busy, setBusy] = useState(false);
416 const [deliveriesOpen, setDeliveriesOpen] = useState(false);
417 const isWebhook = task.schedule.type === "webhook";
418 const toggle = useAtomCommand(serverEnvironment.setScheduledTaskEnabled, {
419 label: "scheduled task enabled",
420 });
421 const run = useAtomCommand(serverEnvironment.runScheduledTaskNow, {
422 label: "scheduled task run now",
423 });
424 const remove = useAtomCommand(serverEnvironment.deleteScheduledTask, {
425 label: "scheduled task delete",
426 });
427 const act = async (action: "toggle" | "run" | "delete") => {
428 if (busy || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return;
429 setBusy(true);
430 const result =
431 action === "toggle"
432 ? await toggle({ environmentId, input: { id: task.id, enabled: !task.enabled } })
433 : action === "run"
434 ? await run({ environmentId, input: { id: task.id } })
435 : await remove({ environmentId, input: { id: task.id } });
436 setBusy(false);
437 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
438 toastManager.add(
439 stackedThreadToast({
440 type: "error",
441 title: "Could not update scheduled task",
442 description: String(squashAtomCommandFailure(result)),
443 }),
444 );
445 }
446 };
447 return (
448 <SettingsRow
449 title={task.title}
450 description={<span className="line-clamp-2">{task.prompt}</span>}
451 status={
452 <div className="flex flex-wrap items-center gap-2">
453 <span>
454 {scheduleLabel(task.schedule)} ·{" "}
455 {!task.enabled
456 ? "Paused"
457 : isWebhook
458 ? "Listening"
459 : task.nextRunAt
460 ? `Next run ${relativeLabel(task.nextRunAt)}`
461 : "Not scheduled"}
462 </span>
463 {task.lastRunStatus !== "never" ? (
464 <Badge variant={statusVariant(task.lastRunStatus)}>{task.lastRunStatus}</Badge>
465 ) : null}
466 {task.lastRunError ? <span className="text-destructive">{task.lastRunError}</span> : null}
467 </div>
468 }
469 control={
470 <div className="flex items-center gap-2">
471 <Switch
472 checked={task.enabled}
473 disabled={busy || !canOperate}
474 aria-label={`Enable ${task.title}`}
475 onCheckedChange={() => void act("toggle")}
476 />
477 <Menu>
478 <MenuTrigger
479 render={
480 <Button
481 size="icon-sm"
482 variant="ghost"
483 disabled={busy || !canOperate}
484 aria-label={`Actions for ${task.title}`}
485 />
486 }
487 >
488 <MoreHorizontalIcon className="size-4" />
489 </MenuTrigger>
490 <MenuPopup align="end">
491 <MenuItem onClick={onEdit}>
492 <PencilIcon />
493 Edit
494 </MenuItem>
495 {isWebhook ? (
496 <MenuItem onClick={() => setDeliveriesOpen(true)}>
497 <InboxIcon />
498 Deliveries
499 </MenuItem>
500 ) : (
501 <MenuItem onClick={() => void act("run")}>
502 <PlayIcon />
503 Run now
504 </MenuItem>
505 )}
506 <MenuSeparator />
507 <MenuItem onClick={() => void act("delete")}>
508 <Trash2Icon />
509 Delete
510 </MenuItem>
511 </MenuPopup>
512 </Menu>
513 {deliveriesOpen ? (
514 <WebhookDeliveriesDialog
515 environmentId={environmentId}
516 task={task}
517 onClose={() => setDeliveriesOpen(false)}
518 />
519 ) : null}
520 </div>
521 }
522 />
523 );
524}
525
526function WebhookDeliveriesDialog({
527 environmentId,
528 task,
529 onClose,
530}: {
531 readonly environmentId: EnvironmentId;
532 readonly task: ScheduledTask;
533 readonly onClose: () => void;
534}) {
535 const deliveriesQuery = useEnvironmentQuery(
536 serverEnvironment.scheduledTaskWebhookDeliveries({ environmentId, input: { id: task.id } }),
537 );
538 const [selectedId, setSelectedId] = useState<ScheduledTaskWebhookDeliverySummary["id"] | null>(
539 null,
540 );
541 const selectedQuery = useEnvironmentQuery(
542 selectedId === null
543 ? null
544 : serverEnvironment.scheduledTaskWebhookDelivery({
545 environmentId,
546 input: { id: task.id, deliveryId: selectedId },
547 }),
548 );
549 const deliveries = deliveriesQuery.data?.deliveries ?? null;
550 const selected = selectedQuery.data?.delivery ?? null;
551 const error = selectedId === null ? deliveriesQuery.error : selectedQuery.error;
552
553 return (
554 <Dialog
555 open
556 onOpenChange={(next) => {
557 if (!next) onClose();
558 }}
559 >
560 <DialogPopup className="max-w-2xl">
561 <DialogHeader>
562 <DialogTitle>Deliveries · {task.title}</DialogTitle>
563 <DialogDescription>Recent requests to this task's webhook URL.</DialogDescription>
564 </DialogHeader>
565 <DialogPanel>
566 {error ? <p className="text-sm text-destructive">{error}</p> : null}
567 {selectedId !== null && selected === null && selectedQuery.error === null ? (
568 <p className="text-sm text-muted-foreground" role="status">
569 Loading delivery…
570 </p>
571 ) : selected ? (
572 <div className="space-y-4">
573 <div className="flex flex-wrap items-center gap-2 text-sm">
574 <Badge variant={deliveryOutcomeVariant(selected.outcome)}>
575 {DELIVERY_OUTCOME_LABELS[selected.outcome]}
576 </Badge>
577 <span>
578 {selected.method} · {relativeLabel(selected.receivedAt)}
579 </span>
580 {selected.signatureVerified ? (
581 <span className="text-muted-foreground">Signature verified</span>
582 ) : null}
583 </div>
584 {selected.error ? <p className="text-sm text-destructive">{selected.error}</p> : null}
585 <DeliveryBlock title="Prompt sent to the agent">
586 {selected.renderedPrompt ?? "No run was started for this request."}
587 </DeliveryBlock>
588 {selected.missingFields.length > 0 ? (
589 <p className="text-sm text-muted-foreground">
590 Empty placeholders: {selected.missingFields.join(", ")}
591 </p>
592 ) : null}
593 <DeliveryBlock title="Headers">
594 {Object.entries(selected.headers)
595 .map(([name, value]) => `${name}: ${value}`)
596 .join("\n")}
597 </DeliveryBlock>
598 {selected.query ? (
599 <DeliveryBlock title="Query">{selected.query}</DeliveryBlock>
600 ) : null}
601 <DeliveryBlock title={selected.bodyTruncated ? "Body (truncated)" : "Body"}>
602 {selected.body || "(empty)"}
603 </DeliveryBlock>
604 </div>
605 ) : selectedId !== null ? null : deliveries === null ? (
606 <p className="text-sm text-muted-foreground" role="status">
607 Loading deliveries…
608 </p>
609 ) : deliveries.length === 0 ? (
610 <p className="text-sm text-muted-foreground">No requests yet.</p>
611 ) : (
612 <ul className="divide-y divide-border">
613 {deliveries.map((delivery) => (
614 <li key={delivery.id}>
615 <button
616 type="button"
617 className="flex w-full flex-wrap items-center gap-2 py-2 text-left text-sm hover:bg-accent/40"
618 onClick={() => setSelectedId(delivery.id)}
619 >
620 <Badge variant={deliveryOutcomeVariant(delivery.outcome)}>
621 {DELIVERY_OUTCOME_LABELS[delivery.outcome]}
622 </Badge>
623 <span>{delivery.method}</span>
624 <span className="text-muted-foreground">
625 {relativeLabel(delivery.receivedAt)}
626 </span>
627 {delivery.missingFields.length > 0 ? (
628 <span className="text-muted-foreground">
629 {delivery.missingFields.length} empty placeholder
630 {delivery.missingFields.length === 1 ? "" : "s"}
631 </span>
632 ) : null}
633 </button>
634 </li>
635 ))}
636 </ul>
637 )}
638 </DialogPanel>
639 <DialogFooter>
640 {selectedId !== null ? (
641 <Button variant="outline" size="sm" onClick={() => setSelectedId(null)}>
642 Back
643 </Button>
644 ) : (
645 <Button
646 variant="outline"
647 size="sm"
648 disabled={deliveriesQuery.isPending}
649 onClick={deliveriesQuery.refresh}
650 >
651 Refresh
652 </Button>
653 )}
654 <DialogClose render={<Button size="sm" />}>Done</DialogClose>
655 </DialogFooter>
656 </DialogPopup>
657 </Dialog>
658 );
659}
660
661function DeliveryBlock({ title, children }: { title: string; children: ReactNode }) {
662 return (
663 <div className="space-y-1">
664 <Label>{title}</Label>
665 <pre className="max-h-64 overflow-auto rounded-md bg-muted p-2 text-xs whitespace-pre-wrap break-all">
666 {children}
667 </pre>
668 </div>
669 );
670}
671
672function WebhookEndpointField({
673 environmentId,
674 task,
675}: {
676 readonly environmentId: EnvironmentId;
677 readonly task: ScheduledTask | null;
678}) {
679 const canRotate = useAtomValue(
680 serverEnvironment.rotateScheduledTaskWebhookToken.permissionAtom(environmentId),
681 );
682 const httpBaseUrl = useEnvironmentHttpBaseUrl(environmentId);
683 const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "webhook URL" });
684 const rotate = useAtomCommand(serverEnvironment.rotateScheduledTaskWebhookToken, {
685 label: "scheduled task rotate webhook token",
686 });
687 const [rotating, setRotating] = useState(false);
688 const endpoint = task?.webhook;
689 if (!task || !endpoint) {
690 return <p className="text-sm text-muted-foreground">The URL appears after you save.</p>;
691 }
692 const { address: url, copyable, note } = webhookAddress(endpoint, httpBaseUrl);
693 const rotateUrl = async () => {
694 const confirmed =
695 (await requestConfirmDialog("Rotate this webhook URL?\nThe current URL stops working.", {
696 variant: "destructive",
697 })) ??
698 // No themed dialog host is mounted; fall back to the native prompt
699 // rather than rotating unasked.
700 window.confirm("Rotate this webhook URL? The current URL stops working.");
701 if (!confirmed) return;
702 setRotating(true);
703 const result = await rotate({ environmentId, input: { id: task.id } });
704 setRotating(false);
705 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
706 toastManager.add(
707 stackedThreadToast({
708 type: "error",
709 title: "Could not rotate webhook URL",
710 description: String(squashAtomCommandFailure(result)),
711 }),
712 );
713 }
714 };
715 return (
716 <div className="space-y-1.5">
717 <div className="flex items-center gap-2">
718 <Input
719 readOnly
720 aria-label="Webhook URL"
721 value={url}
722 onFocus={(event) => event.currentTarget.select()}
723 />
724 <Button
725 size="sm"
726 variant="outline"
727 type="button"
728 // A bare path is not a URL a sender can call.
729 disabled={!copyable}
730 onClick={() => copyToClipboard(url, undefined)}
731 >
732 <CopyIcon />
733 {isCopied ? "Copied" : "Copy"}
734 </Button>
735 <Button
736 size="sm"
737 variant="outline"
738 type="button"
739 disabled={rotating || !canRotate}
740 onClick={() => void rotateUrl()}
741 >
742 Rotate
743 </Button>
744 </div>
745 {note !== null ? <p className="text-xs text-muted-foreground">{note}</p> : null}
746 {endpoint.url !== null ? <WebhookDeliveryMode environmentId={environmentId} /> : null}
747 </div>
748 );
749}
750
751/**
752 * Whether T3 Connect forwards requests live or holds them while the
753 * environment is offline. The setting is per environment and only readable
754 * for this machine's own environment, so other environments show nothing.
755 */
756function WebhookDeliveryMode({ environmentId }: { readonly environmentId: EnvironmentId }) {
757 const cloudLink = usePrimaryCloudLinkState();
758 if (cloudLink.target?.environmentId !== environmentId || cloudLink.data === null) return null;
759 return (
760 <p className="text-xs text-muted-foreground">
761 {cloudLink.data.holdWebhooksWhileOffline
762 ? "Held for up to 24 hours while this environment is offline. "
763 : "Forwarded live. Requests fail while this environment is offline. "}
764 <Link to="/settings/connections" className="underline underline-offset-2">
765 Change in Connections
766 </Link>
767 </p>
768 );
769}
770
771function ScheduledTaskEditorDialog({
772 initialEnvironmentId,
773 task,
774 onClose,
775}: {
776 readonly initialEnvironmentId: EnvironmentId;
777 readonly task: ScheduledTask | null;
778 readonly onClose: () => void;
779}) {
780 const { scope, connectedEnvironments } = useSettingsScope();
781 const [environmentId, setEnvironmentId] = useState(initialEnvironmentId);
782 const environment = useEnvironment(environmentId);
783 const connected =
784 environment?.connection.phase === "connected" && environment.serverConfig !== null;
785 const tasksQuery = useEnvironmentQuery(
786 connected ? serverEnvironment.scheduledTasksLive({ environmentId, input: {} }) : null,
787 );
788 const allProjects = useProjects();
789 const projects = useMemo(
790 () =>
791 allProjects.filter(
792 (project) =>
793 project.environmentId === environmentId &&
794 matchesScheduledTaskScope(scope, environmentId, project.id),
795 ),
796 [allProjects, environmentId, scope],
797 );
798 const settings = useEnvironmentSettings(environmentId);
799 const providers =
800 useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS;
801 const canOperate = useAtomValue(
802 serverEnvironment.upsertScheduledTask.permissionAtom(environmentId),
803 );
804 const upsertTask = useAtomCommand(serverEnvironment.upsertScheduledTask, {
805 label: "scheduled task upsert",
806 });
807 const [draft, setDraft] = useState<DraftState>(() =>
808 task ? taskToDraft(task) : { ...EMPTY_DRAFT, projectId: projects[0]?.id ?? "" },
809 );
810 const [saving, setSaving] = useState(false);
811 const submissionPending = useRef(false);
812 const editingTaskMissing =
813 draft.editingId !== null &&
814 tasksQuery.data !== null &&
815 !tasksQuery.data.tasks.some((entry) => entry.id === draft.editingId);
816 // The live row, so a rotated URL shows up without reopening the dialog.
817 // Once the list has loaded, a missing task is gone; don't keep showing its URL.
818 const liveTask = tasksQuery.data
819 ? (tasksQuery.data.tasks.find((entry) => entry.id === draft.editingId) ?? null)
820 : task;
821 const selectedProjectId = draft.projectId || projects[0]?.id || "";
822 const selectedProject = projects.find((project) => project.id === selectedProjectId);
823 // The task runs in selectedProject, so its model picker should reflect that
824 // project's effective enablement, not just the machine's.
825 const instanceEntries = useMemo(
826 () =>
827 sortProviderInstanceEntries(
828 applyProviderInstanceSettings(
829 deriveProviderInstanceEntries(providers),
830 settings,
831 selectedProject?.id ?? null,
832 ),
833 ),
834 [providers, settings, selectedProject?.id],
835 );
836
837 // The real model picker is keyed by a `${instanceId}:${model}` string, which
838 // is exactly how the draft stores its selection.
839 const firstInstance = instanceEntries[0];
840 const draftSelection = draft.modelKey ? splitModelKey(draft.modelKey) : null;
841 const activeSelection =
842 draftSelection &&
843 instanceEntries.some((entry) => entry.instanceId === draftSelection.instanceId && entry.enabled)
844 ? draftSelection
845 : scheduledTaskDefaultModel(settings, selectedProject ?? null, instanceEntries);
846 const activeInstanceId =
847 activeSelection?.instanceId ?? firstInstance?.instanceId ?? ("" as ProviderInstanceId);
848 const activeModel = activeSelection?.model ?? "";
849 const modelOptionsByInstance = useMemo(
850 () => getCustomModelOptionsByInstance(settings, providers, activeInstanceId, activeModel),
851 [settings, providers, activeInstanceId, activeModel],
852 );
853
854 const reportFailure = (title: string, error: unknown) => {
855 toastManager.add(
856 stackedThreadToast({
857 type: "error",
858 title,
859 description: error instanceof Error ? error.message : String(error),
860 }),
861 );
862 };
863
864 const submit = async () => {
865 if (
866 !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) ||
867 submissionPending.current ||
868 saving ||
869 editingTaskMissing ||
870 !connected ||
871 tasksQuery.data === null
872 )
873 return;
874 const selection = activeSelection;
875 if (
876 !draft.title.trim() ||
877 !draft.prompt.trim() ||
878 !projects.some((project) => project.id === selectedProjectId) ||
879 selection === null
880 ) {
881 reportFailure("Scheduled task is incomplete", "Add a title, prompt, project, and model.");
882 return;
883 }
884 const schedule = scheduleFromDraft(draft);
885 if (schedule === null) {
886 reportFailure(
887 "Invalid age limit",
888 `Enter whole minutes from 1 to ${MAX_WEBHOOK_DELIVERY_AGE_MINUTES}, or leave it blank.`,
889 );
890 return;
891 }
892 if (
893 schedule.type === "webhook" &&
894 schedule.signature &&
895 (!schedule.signature.header ||
896 (!schedule.signature.secret &&
897 !(liveTask?.schedule.type === "webhook" && liveTask.webhook?.hasSecret)))
898 ) {
899 reportFailure("Signing secret is required", "Enter the signature header and secret.");
900 return;
901 }
902 if (
903 schedule.type === "interval" &&
904 (!Number.isSafeInteger(schedule.everyMs) || schedule.everyMs < MIN_SCHEDULED_TASK_INTERVAL_MS)
905 ) {
906 reportFailure("Invalid interval", "Enter an interval of at least one minute.");
907 return;
908 }
909 if (draft.workspaceMode === "existing_worktree" && !draft.existingWorktreePath.trim()) {
910 reportFailure("Checkout path is required", "Enter the path of the checkout to run in.");
911 return;
912 }
913 // Keep the original selection object (with provider options) when the
914 // picker still points at the same instance+model.
915 const modelSelection =
916 draft.baseModelSelection !== null &&
917 draft.baseModelSelection.instanceId === selection.instanceId &&
918 draft.baseModelSelection.model === selection.model
919 ? draft.baseModelSelection
920 : selection;
921 const workspaceStrategy: OrchestrationV2ThreadLaunchWorkspaceStrategy =
922 draft.workspaceMode === "root"
923 ? { type: "root" }
924 : draft.workspaceMode === "existing_worktree"
925 ? { type: "existing_worktree", worktreePath: draft.existingWorktreePath.trim() }
926 : {
927 type: "worktree",
928 baseRef: draft.baseRef.trim() || "main",
929 startFromOrigin: draft.startFromOrigin,
930 };
931 const input: ScheduledTaskUpsertInput = {
932 ...(draft.editingId ? { id: draft.editingId as ScheduledTaskId, requireExisting: true } : {}),
933 title: draft.title.trim(),
934 prompt: draft.prompt.trim(),
935 enabled: draft.enabled,
936 schedule,
937 projectId: selectedProjectId as ProjectId,
938 threadId: draft.threadId ? (draft.threadId as ThreadId) : null,
939 workspaceStrategy,
940 modelSelection,
941 runtimeMode: draft.runtimeMode,
942 interactionMode: draft.interactionMode,
943 creationSource: "web",
944 };
945 // Lock before React renders, and keep successful creates locked until the form closes.
946 submissionPending.current = true;
947 setSaving(true);
948 const result = await upsertTask({ environmentId, input });
949 setSaving(false);
950 if (result._tag === "Failure") {
951 submissionPending.current = false;
952 if (!isAtomCommandInterrupted(result)) {
953 reportFailure("Could not save scheduled task", squashAtomCommandFailure(result));
954 }
955 return;
956 }
957 onClose();
958 };
959
960 return (
961 <Dialog
962 open
963 onOpenChange={(open) => {
964 if (!open && !saving) onClose();
965 }}
966 >
967 <DialogPopup className="max-w-xl">
968 <DialogHeader>
969 <DialogTitle>{draft.editingId ? "Edit task" : "New task"}</DialogTitle>
970 <DialogDescription>
971 Run a prompt automatically — on an interval, at a fixed time, or when a webhook is
972 called.
973 </DialogDescription>
974 </DialogHeader>
975
976 <DialogPanel>
977 <fieldset disabled={saving} className="space-y-5">
978 {!connected ? (
979 <p className="text-sm text-destructive">Reconnect this environment before saving.</p>
980 ) : null}
981 <Field label="Runs on" htmlFor="scheduled-task-environment">
982 <Select
983 value={environmentId}
984 disabled={task !== null || saving}
985 onValueChange={(id) => {
986 const next = connectedEnvironments.find((entry) => entry.environmentId === id);
987 if (!next) return;
988 setEnvironmentId(next.environmentId);
989 setDraft((current) => ({
990 ...current,
991 projectId: "",
992 modelKey: "",
993 baseModelSelection: null,
994 baseRef: "main",
995 startFromOrigin: true,
996 existingWorktreePath: "",
997 }));
998 }}
999 >
1000 <SelectTrigger id="scheduled-task-environment" size="sm">
1001 <SelectValue>
1002 <span className="flex items-center gap-2">
1003 <EnvironmentMachineIcon
1004 kind={resolveEnvironmentMachineKind(environment?.serverConfig ?? null)}
1005 className="size-4"
1006 />
1007 {environment?.label ?? "Unavailable environment"}
1008 </span>
1009 </SelectValue>
1010 </SelectTrigger>
1011 <SelectPopup>
1012 {connectedEnvironments.map((entry) => (
1013 <SelectItem key={entry.environmentId} value={entry.environmentId}>
1014 <EnvironmentMachineIcon
1015 kind={resolveEnvironmentMachineKind(entry.serverConfig)}
1016 className="size-4"
1017 />
1018 {entry.label}
1019 </SelectItem>
1020 ))}
1021 </SelectPopup>
1022 </Select>
1023 </Field>
1024 {tasksQuery.error ? (
1025 <p className="text-sm text-destructive" role="status">
1026 {tasksQuery.error}
1027 </p>
1028 ) : null}
1029 {editingTaskMissing ? (
1030 <p className="text-xs text-destructive" role="status">
1031 This scheduled task no longer exists.
1032 </p>
1033 ) : null}
1034 <Field label="Name" htmlFor="scheduled-task-title">
1035 <Input
1036 id="scheduled-task-title"
1037 placeholder="e.g. Check for Sentry issues"
1038 value={draft.title}
1039 onChange={(event) =>
1040 setDraft((current) => ({ ...current, title: event.target.value }))
1041 }
1042 />
1043 </Field>
1044
1045 <div className="grid gap-3 sm:grid-cols-2">
1046 <Field label="Project" htmlFor="scheduled-task-project">
1047 <Select
1048 value={selectedProjectId}
1049 onValueChange={(projectId) =>
1050 setDraft((current) => ({ ...current, projectId: projectId ?? "" }))
1051 }
1052 >
1053 <SelectTrigger size="sm" id="scheduled-task-project">
1054 <SelectValue placeholder="Select a project">
1055 {selectedProject?.title}
1056 </SelectValue>
1057 </SelectTrigger>
1058 <SelectPopup>
1059 {projects.map((project) => (
1060 <SelectItem key={project.id} value={project.id}>
1061 {project.title}
1062 </SelectItem>
1063 ))}
1064 </SelectPopup>
1065 </Select>
1066 </Field>
1067
1068 <Field label="Workspace" htmlFor="scheduled-task-workspace">
1069 <Select
1070 value={draft.workspaceMode}
1071 onValueChange={(value) =>
1072 setDraft((current) => ({ ...current, workspaceMode: value as WorkspaceMode }))
1073 }
1074 >
1075 <SelectTrigger size="sm" id="scheduled-task-workspace">
1076 <SelectValue>{WORKSPACE_MODE_LABELS[draft.workspaceMode]}</SelectValue>
1077 </SelectTrigger>
1078 <SelectPopup>
1079 <SelectItem value="worktree">Create a new worktree</SelectItem>
1080 <SelectItem value="root">Use the project checkout</SelectItem>
1081 <SelectItem value="existing_worktree">Use a specific checkout</SelectItem>
1082 </SelectPopup>
1083 </Select>
1084 </Field>
1085 </div>
1086
1087 {draft.workspaceMode === "worktree" ? (
1088 <Field label="Base branch" htmlFor="scheduled-task-base-ref">
1089 <WorktreeBaseBranchPicker
1090 key={`${environmentId}:${selectedProjectId}`}
1091 id="scheduled-task-base-ref"
1092 environmentId={environmentId}
1093 cwd={selectedProject?.workspaceRoot ?? null}
1094 value={draft.baseRef}
1095 onValueChange={(baseRef) => setDraft((current) => ({ ...current, baseRef }))}
1096 startFromOrigin={draft.startFromOrigin}
1097 onStartFromOriginChange={(startFromOrigin) =>
1098 setDraft((current) => ({ ...current, startFromOrigin }))
1099 }
1100 disabled={saving || !connected}
1101 />
1102 </Field>
1103 ) : null}
1104 {draft.workspaceMode === "existing_worktree" ? (
1105 <Field label="Checkout path" htmlFor="scheduled-task-checkout">
1106 <Input
1107 id="scheduled-task-checkout"
1108 value={draft.existingWorktreePath}
1109 placeholder="/path/to/checkout"
1110 onChange={(event) =>
1111 setDraft((current) => ({
1112 ...current,
1113 existingWorktreePath: event.target.value,
1114 }))
1115 }
1116 />
1117 </Field>
1118 ) : null}
1119
1120 <Field label="Prompt" htmlFor="scheduled-task-prompt">
1121 <Textarea
1122 id="scheduled-task-prompt"
1123 className="max-h-64 overflow-y-auto"
1124 placeholder="What should the agent do each time this runs?"
1125 value={draft.prompt}
1126 onChange={(event) =>
1127 setDraft((current) => ({ ...current, prompt: event.target.value }))
1128 }
1129 />
1130 </Field>
1131
1132 <Field label="Model">
1133 <div>
1134 <ProviderModelPicker
1135 environmentId={environmentId}
1136 disabled={saving || !connected}
1137 activeInstanceId={activeInstanceId}
1138 model={activeModel}
1139 lockedProvider={null}
1140 instanceEntries={instanceEntries}
1141 modelOptionsByInstance={modelOptionsByInstance}
1142 isComposerOwned={false}
1143 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
1144 onInstanceModelChange={(instanceId, model) =>
1145 setDraft((current) => ({ ...current, modelKey: `${instanceId}:${model}` }))
1146 }
1147 />
1148 </div>
1149 </Field>
1150
1151 <div className="space-y-3">
1152 {task?.schedule.type === "interval" &&
1153 task.schedule.everyMs < MIN_SCHEDULED_TASK_INTERVAL_MS ? (
1154 <p className="text-sm text-muted-foreground" role="status">
1155 This task uses a legacy interval below one minute. Saving updates it to at least
1156 one minute.
1157 </p>
1158 ) : null}
1159 <div className="flex items-center justify-between gap-2">
1160 <Label>Schedule</Label>
1161 <ToggleGroup
1162 aria-label="Schedule type"
1163 value={[draft.scheduleMode]}
1164 onValueChange={(values) => {
1165 const mode = values[0] as ScheduleMode | undefined;
1166 if (mode === "fixed" || mode === "interval" || mode === "webhook")
1167 setDraft((current) => ({
1168 ...current,
1169 scheduleMode: mode,
1170 prompt:
1171 mode === "webhook" && !current.prompt.trim()
1172 ? DEFAULT_WEBHOOK_PROMPT
1173 : current.prompt,
1174 }));
1175 }}
1176 >
1177 <Toggle value="fixed">At a time</Toggle>
1178 <Toggle value="interval">Every interval</Toggle>
1179 <Toggle value="webhook">On webhook</Toggle>
1180 </ToggleGroup>
1181 </div>
1182
1183 {draft.scheduleMode === "webhook" ? (
1184 <div className="space-y-4">
1185 <WebhookEndpointField environmentId={environmentId} task={liveTask} />
1186 <p className="text-xs text-muted-foreground">
1187 {
1188 "Each request runs the prompt. Use {{body.path}}, {{headers.name}}, {{query.name}}, {{body}} or {{request}} in the prompt; only what it names reaches the agent."
1189 }
1190 </p>
1191 <Field
1192 label="Skip requests older than"
1193 hint="minutes, optional"
1194 htmlFor="scheduled-task-max-age"
1195 >
1196 <Input
1197 id="scheduled-task-max-age"
1198 type="number"
1199 nativeInput
1200 min={1}
1201 max={MAX_WEBHOOK_DELIVERY_AGE_MINUTES}
1202 placeholder="Run every request"
1203 value={draft.maxDeliveryAgeMinutes}
1204 onChange={(event) =>
1205 setDraft((current) => ({
1206 ...current,
1207 maxDeliveryAgeMinutes: event.target.value,
1208 }))
1209 }
1210 />
1211 </Field>
1212 <div className="flex items-center justify-between gap-4">
1213 <div className="min-w-0 space-y-1">
1214 <Label htmlFor="scheduled-task-signature">Require signature</Label>
1215 <p className="text-sm text-muted-foreground">
1216 Reject requests without a valid HMAC-SHA256 signature of the body.
1217 </p>
1218 </div>
1219 <Switch
1220 id="scheduled-task-signature"
1221 checked={draft.signatureEnabled}
1222 onCheckedChange={(signatureEnabled) =>
1223 setDraft((current) => ({ ...current, signatureEnabled }))
1224 }
1225 />
1226 </div>
1227 {draft.signatureEnabled ? (
1228 <div className="grid gap-3 sm:grid-cols-2">
1229 <Field label="Header" htmlFor="scheduled-task-signature-header">
1230 <Input
1231 id="scheduled-task-signature-header"
1232 value={draft.signatureHeader}
1233 onChange={(event) =>
1234 setDraft((current) => ({
1235 ...current,
1236 signatureHeader: event.target.value,
1237 }))
1238 }
1239 />
1240 </Field>
1241 <Field label="Prefix" htmlFor="scheduled-task-signature-prefix">
1242 <Input
1243 id="scheduled-task-signature-prefix"
1244 value={draft.signaturePrefix}
1245 placeholder="None"
1246 onChange={(event) =>
1247 setDraft((current) => ({
1248 ...current,
1249 signaturePrefix: event.target.value,
1250 }))
1251 }
1252 />
1253 </Field>
1254 <Field label="Encoding" htmlFor="scheduled-task-signature-encoding">
1255 <Select
1256 value={draft.signatureEncoding}
1257 onValueChange={(value) =>
1258 setDraft((current) => ({
1259 ...current,
1260 signatureEncoding: value === "base64" ? "base64" : "hex",
1261 }))
1262 }
1263 >
1264 <SelectTrigger size="sm" id="scheduled-task-signature-encoding">
1265 <SelectValue>{draft.signatureEncoding}</SelectValue>
1266 </SelectTrigger>
1267 <SelectPopup>
1268 <SelectItem value="hex">hex</SelectItem>
1269 <SelectItem value="base64">base64</SelectItem>
1270 </SelectPopup>
1271 </Select>
1272 </Field>
1273 <Field label="Secret" htmlFor="scheduled-task-signature-secret">
1274 <Input
1275 id="scheduled-task-signature-secret"
1276 type="password"
1277 autoComplete="off"
1278 value={draft.signatureSecret}
1279 placeholder={
1280 liveTask?.schedule.type === "webhook" && liveTask.webhook?.hasSecret
1281 ? "Unchanged"
1282 : "Shared secret"
1283 }
1284 onChange={(event) =>
1285 setDraft((current) => ({
1286 ...current,
1287 signatureSecret: event.target.value,
1288 }))
1289 }
1290 />
1291 </Field>
1292 </div>
1293 ) : null}
1294 </div>
1295 ) : draft.scheduleMode === "fixed" ? (
1296 <div className="flex flex-wrap items-center gap-3">
1297 <div className="flex items-center gap-2">
1298 <Label htmlFor="scheduled-task-time">Run at</Label>
1299 <Input
1300 type="time"
1301 id="scheduled-task-time"
1302 nativeInput
1303 className="w-32"
1304 value={draft.timeOfDay}
1305 onChange={(event) =>
1306 setDraft((current) => ({ ...current, timeOfDay: event.target.value }))
1307 }
1308 />
1309 <span className="text-xs text-muted-foreground">on</span>
1310 </div>
1311 <ToggleGroup
1312 multiple
1313 variant="outline"
1314 size="sm"
1315 aria-label="Days to run"
1316 value={[...draft.weekdays].map(String)}
1317 onValueChange={(values) => {
1318 if (values.length === 0) return;
1319 setDraft((current) => ({
1320 ...current,
1321 weekdays: new Set(values.map(Number)),
1322 }));
1323 }}
1324 >
1325 {WEEKDAY_ORDER.map((day) => (
1326 <Toggle key={day} value={String(day)} aria-label={WEEKDAY_LABELS[day]}>
1327 {WEEKDAY_SHORT[day]}
1328 </Toggle>
1329 ))}
1330 </ToggleGroup>
1331 </div>
1332 ) : (
1333 <div className="flex items-center gap-2">
1334 <Label htmlFor="scheduled-task-interval">Run every</Label>
1335 <Input
1336 type="number"
1337 id="scheduled-task-interval"
1338 nativeInput
1339 min={1}
1340 step="any"
1341 className="w-24"
1342 value={draft.intervalMinutes}
1343 onChange={(event) =>
1344 setDraft((current) => ({ ...current, intervalMinutes: event.target.value }))
1345 }
1346 />
1347 <span className="text-xs text-muted-foreground">minutes</span>
1348 </div>
1349 )}
1350 </div>
1351
1352 <div className="flex items-center justify-between gap-4">
1353 <div className="min-w-0 space-y-1">
1354 <Label htmlFor="scheduled-task-enabled">Enabled</Label>
1355 <p
1356 id="scheduled-task-enabled-description"
1357 className="text-sm text-muted-foreground"
1358 >
1359 Disabled tasks stay saved but do not run.
1360 </p>
1361 </div>
1362 <Switch
1363 id="scheduled-task-enabled"
1364 aria-describedby="scheduled-task-enabled-description"
1365 checked={draft.enabled}
1366 onCheckedChange={(enabled) => setDraft((current) => ({ ...current, enabled }))}
1367 />
1368 </div>
1369 </fieldset>
1370 </DialogPanel>
1371
1372 <DialogFooter>
1373 <DialogClose render={<Button variant="outline" size="sm" disabled={saving} />}>
1374 Cancel
1375 </DialogClose>
1376 <Button
1377 size="sm"
1378 disabled={!canOperate || saving || editingTaskMissing || !connected || !tasksQuery.data}
1379 onClick={() => void submit()}
1380 >
1381 {draft.editingId ? "Save task" : "Create task"}
1382 </Button>
1383 </DialogFooter>
1384 </DialogPopup>
1385 </Dialog>
1386 );
1387}