| 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { |
| 3 | Clock3Icon, |
| 4 | CopyIcon, |
| 5 | InboxIcon, |
| 6 | MoreHorizontalIcon, |
| 7 | PencilIcon, |
| 8 | PlayIcon, |
| 9 | PlusIcon, |
| 10 | Trash2Icon, |
| 11 | } from "lucide-react"; |
| 12 | import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 13 | import 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"; |
| 26 | import { DEFAULT_WEBHOOK_PROMPT } from "@t3tools/client-runtime/scheduled-task-webhook"; |
| 27 | import { |
| 28 | MAX_WEBHOOK_DELIVERY_AGE_MINUTES, |
| 29 | MIN_SCHEDULED_TASK_INTERVAL_MS, |
| 30 | ProviderInstanceId, |
| 31 | resolveEnvironmentMachineKind, |
| 32 | } from "@t3tools/contracts"; |
| 33 | import { |
| 34 | isAtomCommandInterrupted, |
| 35 | squashAtomCommandFailure, |
| 36 | } from "@t3tools/client-runtime/state/runtime"; |
| 37 | |
| 38 | import { formatRelativeTime } from "../../timestampFormat"; |
| 39 | import { useEnvironmentSettings } from "../../hooks/useSettings"; |
| 40 | import { getCustomModelOptionsByInstance } from "../../modelSelection"; |
| 41 | import { |
| 42 | applyProviderInstanceSettings, |
| 43 | deriveProviderInstanceEntries, |
| 44 | sortProviderInstanceEntries, |
| 45 | } from "../../providerInstances"; |
| 46 | import { usePrimaryCloudLinkState } from "../../cloud/primaryCloudLinkState"; |
| 47 | import { requestConfirmDialog } from "../../confirmDialog"; |
| 48 | import { webhookAddress } from "@t3tools/client-runtime/webhook-address"; |
| 49 | import { Link } from "@tanstack/react-router"; |
| 50 | import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; |
| 51 | import { |
| 52 | useEnvironment, |
| 53 | useEnvironmentHttpBaseUrl, |
| 54 | type EnvironmentPresentation, |
| 55 | } from "../../state/environments"; |
| 56 | import { useProjects } from "../../state/entities"; |
| 57 | import { useEnvironmentQuery } from "../../state/query"; |
| 58 | import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; |
| 59 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 60 | import { WorktreeBaseBranchPicker } from "../WorktreeBaseBranchPicker"; |
| 61 | import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; |
| 62 | import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; |
| 63 | import { readEnvironmentScope } from "~/state/session"; |
| 64 | import { useSettingsScope } from "./SettingsScopeContext"; |
| 65 | import { |
| 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"; |
| 75 | import { Label } from "../ui/label"; |
| 76 | import { Menu, MenuTrigger, MenuPopup, MenuItem, MenuSeparator } from "../ui/menu"; |
| 77 | import { ToggleGroup, Toggle } from "../ui/toggle-group"; |
| 78 | import { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyMedia } from "../ui/empty"; |
| 79 | import { ProviderModelPicker } from "../chat/ProviderModelPicker"; |
| 80 | import { Badge } from "../ui/badge"; |
| 81 | import { Button } from "../ui/button"; |
| 82 | import { |
| 83 | Dialog, |
| 84 | DialogClose, |
| 85 | DialogDescription, |
| 86 | DialogFooter, |
| 87 | DialogHeader, |
| 88 | DialogPanel, |
| 89 | DialogPopup, |
| 90 | DialogTitle, |
| 91 | } from "../ui/dialog"; |
| 92 | import { Input } from "../ui/input"; |
| 93 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; |
| 94 | import { Switch } from "../ui/switch"; |
| 95 | import { Textarea } from "../ui/textarea"; |
| 96 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 97 | import { |
| 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. */ |
| 106 | const WEEKDAY_ORDER = [1, 2, 3, 4, 5, 6, 0] as const; |
| 107 | const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const; |
| 108 | const WEEKDAY_SHORT = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"] as const; |
| 109 | |
| 110 | const 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 | |
| 116 | const 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. */ |
| 142 | function 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 | |
| 166 | function 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 | |
| 175 | export 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 | */ |
| 198 | export 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 | |
| 214 | const 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 | |
| 223 | function 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 | |
| 231 | function 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 | |
| 238 | export 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 | |
| 319 | function 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 | |
| 403 | function 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 | |
| 526 | function 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 | |
| 661 | function 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 | |
| 672 | function 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 | */ |
| 756 | function 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 | |
| 771 | function 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 | } |