| 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { |
| 3 | AuthOrchestrationOperateScope, |
| 4 | resolveProjectProviderInstanceEnabled, |
| 5 | } from "@t3tools/contracts"; |
| 6 | import { readEnvironmentScope } from "../../state/session"; |
| 7 | import type { |
| 8 | EnvironmentId, |
| 9 | ProjectId, |
| 10 | ScheduledTask, |
| 11 | ScheduledTaskUpsertInput, |
| 12 | } from "@t3tools/contracts"; |
| 13 | import { |
| 14 | MAX_WEBHOOK_DELIVERY_AGE_MINUTES, |
| 15 | resolveEnvironmentMachineKind, |
| 16 | } from "@t3tools/contracts"; |
| 17 | import type { MenuAction } from "@react-native-menu/menu"; |
| 18 | import { DateTimePicker } from "@expo/ui/community/datetime-picker"; |
| 19 | import { |
| 20 | isAtomCommandInterrupted, |
| 21 | squashAtomCommandFailure, |
| 22 | type AtomCommandResult, |
| 23 | } from "@t3tools/client-runtime/state/runtime"; |
| 24 | import { |
| 25 | DEFAULT_WEBHOOK_PROMPT, |
| 26 | parseMaxDeliveryAge, |
| 27 | } from "@t3tools/client-runtime/scheduled-task-webhook"; |
| 28 | import { |
| 29 | useCallback, |
| 30 | useEffect, |
| 31 | useLayoutEffect, |
| 32 | useMemo, |
| 33 | useRef, |
| 34 | useState, |
| 35 | type ReactNode, |
| 36 | } from "react"; |
| 37 | import { useFocusEffect, useNavigation, usePreventRemove } from "@react-navigation/native"; |
| 38 | import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; |
| 39 | import { Alert, AppState, Platform, Pressable, TextInput as RNTextInput, View } from "react-native"; |
| 40 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 41 | |
| 42 | import { AppText as Text } from "../../components/AppText"; |
| 43 | import { SymbolView } from "../../components/AppSymbol"; |
| 44 | import { |
| 45 | EnvironmentMachineSymbol, |
| 46 | ENVIRONMENT_MACHINE_SYMBOLS, |
| 47 | } from "../../components/EnvironmentMachineSymbol"; |
| 48 | import { ControlPillMenu } from "../../components/ControlPill"; |
| 49 | import type { ComposerEditorSelection } from "../../components/ComposerEditor"; |
| 50 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; |
| 51 | import { SegmentedControl } from "../../components/SegmentedControl"; |
| 52 | import { ThemedSwitch } from "../../components/ThemedSwitch"; |
| 53 | import { webhookAddress } from "@t3tools/client-runtime/webhook-address"; |
| 54 | import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic"; |
| 55 | import { usePreparedConnection } from "../../state/session"; |
| 56 | import { buildModelOptions } from "../../lib/modelOptions"; |
| 57 | import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 58 | import { useProjects, useEnvironmentServerConfig } from "../../state/entities"; |
| 59 | import { useEnvironmentQuery } from "../../state/query"; |
| 60 | import { serverEnvironment } from "../../state/server"; |
| 61 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 62 | import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; |
| 63 | import { resolveNewTaskBranchLabel } from "../threads/new-task-context-presentation"; |
| 64 | import { useVoiceInputController } from "../voice-input/useVoiceInputController"; |
| 65 | import { ScheduledTaskPromptField } from "./components/ScheduledTaskPromptField"; |
| 66 | import { |
| 67 | AndroidSettingsEnvironmentFilter, |
| 68 | SettingsEnvironmentFilterHeader, |
| 69 | } from "./components/SettingsEnvironmentFilterHeader"; |
| 70 | import { SettingsScreen } from "./components/SettingsScreen"; |
| 71 | import { SettingsSection } from "./components/SettingsSection"; |
| 72 | import { useSettingsEnvironmentFilter, type SettingsTarget } from "./settings-environment-filter"; |
| 73 | import { |
| 74 | editDraft, |
| 75 | scheduledTaskDefaultModel, |
| 76 | scheduleFromDraft, |
| 77 | type ScheduledTaskDraft as Draft, |
| 78 | } from "./scheduledTaskDraft"; |
| 79 | import { settingsTargetsForProject } from "./settings-environment-filter.logic"; |
| 80 | import { useScheduledTaskEditor } from "./scheduled-task-editor"; |
| 81 | import { scheduledTaskEditorSessionAtom } from "./scheduled-task-editor-state"; |
| 82 | import { appAtomRegistry } from "../../state/atom-registry"; |
| 83 | import { |
| 84 | formatNextScheduledTaskRun, |
| 85 | formatScheduledTaskInterval, |
| 86 | } from "./scheduledTaskPresentation"; |
| 87 | |
| 88 | type ScheduledTaskRoutes = { |
| 89 | SettingsScheduledTaskNew: undefined; |
| 90 | SettingsScheduledTaskEdit: undefined; |
| 91 | SettingsScheduledTaskModel: undefined; |
| 92 | SettingsScheduledTaskBranch: undefined; |
| 93 | }; |
| 94 | |
| 95 | const DAYS = [ |
| 96 | { index: 1, label: "Mon" }, |
| 97 | { index: 2, label: "Tue" }, |
| 98 | { index: 3, label: "Wed" }, |
| 99 | { index: 4, label: "Thu" }, |
| 100 | { index: 5, label: "Fri" }, |
| 101 | { index: 6, label: "Sat" }, |
| 102 | { index: 0, label: "Sun" }, |
| 103 | ] as const; |
| 104 | |
| 105 | function describeSchedule(task: ScheduledTask): string { |
| 106 | if (task.schedule.type === "webhook") return "On webhook"; |
| 107 | if (task.schedule.type === "interval") return formatScheduledTaskInterval(task.schedule.everyMs); |
| 108 | const days = task.schedule.weekdays?.length ? repeatLabel(task.schedule.weekdays) : "Every day"; |
| 109 | return `${days} at ${formatTime(task.schedule.timeOfDay)}`; |
| 110 | } |
| 111 | |
| 112 | function formatTime(value: string): string { |
| 113 | const [hours, minutes] = value.split(":").map(Number); |
| 114 | if (!Number.isInteger(hours) || !Number.isInteger(minutes)) return value; |
| 115 | const time = new Date(); |
| 116 | time.setHours(hours ?? 9, minutes ?? 0, 0, 0); |
| 117 | return time.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); |
| 118 | } |
| 119 | |
| 120 | function timePickerValue(value: string): Date { |
| 121 | const [hours, minutes] = value.split(":").map(Number); |
| 122 | const time = new Date(); |
| 123 | time.setHours( |
| 124 | Number.isInteger(hours) ? (hours ?? 9) : 9, |
| 125 | Number.isInteger(minutes) ? (minutes ?? 0) : 0, |
| 126 | 0, |
| 127 | 0, |
| 128 | ); |
| 129 | return time; |
| 130 | } |
| 131 | |
| 132 | function repeatLabel(weekdays: ReadonlyArray<number>): string { |
| 133 | const days = new Set(weekdays); |
| 134 | if (days.size === 7) return "Every day"; |
| 135 | if (days.size === 5 && [1, 2, 3, 4, 5].every((day) => days.has(day))) return "Weekdays"; |
| 136 | return ( |
| 137 | DAYS.filter((day) => days.has(day.index)) |
| 138 | .map((day) => day.label) |
| 139 | .join(", ") || "Choose days" |
| 140 | ); |
| 141 | } |
| 142 | |
| 143 | function FormField(props: { |
| 144 | readonly label: string; |
| 145 | readonly value: string; |
| 146 | readonly onChange: (value: string) => void; |
| 147 | readonly keyboardType?: "decimal-pad" | "number-pad"; |
| 148 | readonly disabled?: boolean; |
| 149 | readonly placeholder?: string; |
| 150 | readonly borderTop?: boolean; |
| 151 | }) { |
| 152 | return ( |
| 153 | <View |
| 154 | className={ |
| 155 | props.borderTop ? "gap-2 border-t border-border-subtle px-4 py-3" : "gap-2 px-4 py-3" |
| 156 | } |
| 157 | > |
| 158 | <Text className="text-sm text-foreground-muted">{props.label}</Text> |
| 159 | <RNTextInput |
| 160 | accessibilityLabel={props.label} |
| 161 | value={props.value} |
| 162 | readOnly={props.disabled} |
| 163 | onChangeText={props.onChange} |
| 164 | textAlignVertical="center" |
| 165 | keyboardType={props.keyboardType} |
| 166 | placeholder={props.placeholder} |
| 167 | placeholderTextColorClassName="accent-foreground-muted" |
| 168 | className="min-h-8 font-sans text-base text-foreground" |
| 169 | /> |
| 170 | </View> |
| 171 | ); |
| 172 | } |
| 173 | |
| 174 | function SelectRow(props: { |
| 175 | readonly label: string; |
| 176 | readonly value: string; |
| 177 | readonly valueIcon?: ReactNode; |
| 178 | readonly actions: MenuAction[]; |
| 179 | readonly onSelect: (id: string) => void; |
| 180 | readonly borderTop?: boolean; |
| 181 | }) { |
| 182 | const value = ( |
| 183 | <View className="min-w-0 flex-1 flex-row items-center justify-end gap-2"> |
| 184 | {props.valueIcon} |
| 185 | <Text className="shrink text-right text-base text-foreground-muted" numberOfLines={1}> |
| 186 | {props.value} |
| 187 | </Text> |
| 188 | </View> |
| 189 | ); |
| 190 | if (props.actions.length === 0) { |
| 191 | return ( |
| 192 | <View |
| 193 | className={ |
| 194 | props.borderTop |
| 195 | ? "min-h-14 flex-row items-center gap-3 border-t border-border-subtle px-4 py-3" |
| 196 | : "min-h-14 flex-row items-center gap-3 px-4 py-3" |
| 197 | } |
| 198 | > |
| 199 | <Text className="text-lg text-foreground">{props.label}</Text> |
| 200 | {value} |
| 201 | </View> |
| 202 | ); |
| 203 | } |
| 204 | return ( |
| 205 | <ControlPillMenu |
| 206 | actions={props.actions} |
| 207 | onPressAction={({ nativeEvent }) => props.onSelect(nativeEvent.event)} |
| 208 | > |
| 209 | <Pressable |
| 210 | accessibilityRole="button" |
| 211 | accessibilityLabel={`${props.label}, ${props.value}`} |
| 212 | className={ |
| 213 | props.borderTop |
| 214 | ? "min-h-14 flex-row items-center gap-3 border-t border-border-subtle px-4 py-3 active:opacity-70" |
| 215 | : "min-h-14 flex-row items-center gap-3 px-4 py-3 active:opacity-70" |
| 216 | } |
| 217 | > |
| 218 | <Text className="text-lg text-foreground">{props.label}</Text> |
| 219 | {value} |
| 220 | <SymbolView |
| 221 | name="chevron.down" |
| 222 | size={14} |
| 223 | tintColorClassName="accent-chevron" |
| 224 | type="monochrome" |
| 225 | /> |
| 226 | </Pressable> |
| 227 | </ControlPillMenu> |
| 228 | ); |
| 229 | } |
| 230 | |
| 231 | function PickerRow(props: { |
| 232 | readonly label: string; |
| 233 | readonly value: string; |
| 234 | readonly onPress: () => void; |
| 235 | readonly disabled?: boolean; |
| 236 | readonly borderTop?: boolean; |
| 237 | }) { |
| 238 | return ( |
| 239 | <Pressable |
| 240 | accessibilityRole="button" |
| 241 | accessibilityLabel={`${props.label}, ${props.value}`} |
| 242 | accessibilityState={{ disabled: props.disabled }} |
| 243 | disabled={props.disabled} |
| 244 | onPress={props.onPress} |
| 245 | className={ |
| 246 | props.borderTop |
| 247 | ? "min-h-14 flex-row items-center gap-3 border-t border-border-subtle px-4 py-3 active:opacity-70 disabled:opacity-50" |
| 248 | : "min-h-14 flex-row items-center gap-3 px-4 py-3 active:opacity-70 disabled:opacity-50" |
| 249 | } |
| 250 | > |
| 251 | <Text className="text-lg text-foreground">{props.label}</Text> |
| 252 | <Text className="min-w-0 flex-1 text-right text-base text-foreground-muted" numberOfLines={1}> |
| 253 | {props.value} |
| 254 | </Text> |
| 255 | <SymbolView |
| 256 | name="chevron.right" |
| 257 | size={14} |
| 258 | tintColorClassName="accent-chevron" |
| 259 | type="monochrome" |
| 260 | /> |
| 261 | </Pressable> |
| 262 | ); |
| 263 | } |
| 264 | |
| 265 | export function SettingsScheduledTasksRouteScreen() { |
| 266 | const [now, setNow] = useState(Date.now); |
| 267 | useFocusEffect( |
| 268 | useCallback(() => { |
| 269 | const updateNow = () => setNow(Date.now()); |
| 270 | updateNow(); |
| 271 | const timer = setInterval(updateNow, 60_000); |
| 272 | const subscription = AppState.addEventListener("change", (state) => { |
| 273 | if (state === "active") updateNow(); |
| 274 | }); |
| 275 | return () => { |
| 276 | clearInterval(timer); |
| 277 | subscription.remove(); |
| 278 | }; |
| 279 | }, []), |
| 280 | ); |
| 281 | const { availableTargets, selectedTargets, selectedProjectKey, projectGroups } = |
| 282 | useSettingsEnvironmentFilter(); |
| 283 | const selectedGroup = projectGroups.find((group) => group.key === selectedProjectKey); |
| 284 | const visibleEnvironments = settingsTargetsForProject( |
| 285 | selectedTargets, |
| 286 | selectedProjectKey === null ? null : selectedGroup, |
| 287 | ); |
| 288 | const { startEditor, resetEditor } = useScheduledTaskEditor(); |
| 289 | const navigation = useNavigation<NativeStackNavigationProp<ScheduledTaskRoutes>>(); |
| 290 | const insets = useSafeAreaInsets(); |
| 291 | const newTask = () => { |
| 292 | if (visibleEnvironments.length === 0) return; |
| 293 | resetEditor(); |
| 294 | navigation.navigate("SettingsScheduledTaskNew"); |
| 295 | }; |
| 296 | |
| 297 | return ( |
| 298 | <> |
| 299 | <SettingsEnvironmentFilterHeader |
| 300 | trailingItems={[ |
| 301 | withNativeGlassHeaderItem({ |
| 302 | type: "button", |
| 303 | label: "", |
| 304 | accessibilityLabel: "New task", |
| 305 | icon: { type: "sfSymbol", name: "plus" } as const, |
| 306 | disabled: visibleEnvironments.length === 0, |
| 307 | onPress: newTask, |
| 308 | }), |
| 309 | ]} |
| 310 | /> |
| 311 | <SettingsScreen |
| 312 | title="Scheduled Tasks" |
| 313 | trailing={ |
| 314 | <View className="flex-row items-center"> |
| 315 | <AndroidSettingsEnvironmentFilter /> |
| 316 | <Pressable |
| 317 | accessibilityRole="button" |
| 318 | accessibilityLabel="New task" |
| 319 | accessibilityState={{ disabled: visibleEnvironments.length === 0 }} |
| 320 | disabled={visibleEnvironments.length === 0} |
| 321 | onPress={newTask} |
| 322 | className="size-11 items-center justify-center rounded-full disabled:opacity-50" |
| 323 | > |
| 324 | <SymbolView name="plus" size={22} tintColorClassName="accent-icon" /> |
| 325 | </Pressable> |
| 326 | </View> |
| 327 | } |
| 328 | > |
| 329 | <ScrollView |
| 330 | keyboardShouldPersistTaps="handled" |
| 331 | keyboardDismissMode="on-drag" |
| 332 | contentInsetAdjustmentBehavior="automatic" |
| 333 | showsVerticalScrollIndicator={false} |
| 334 | className="flex-1" |
| 335 | contentContainerClassName="gap-5 px-5 pt-4" |
| 336 | contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} |
| 337 | > |
| 338 | {visibleEnvironments.length > 0 ? ( |
| 339 | visibleEnvironments.map((environment) => ( |
| 340 | <EnvironmentTasks |
| 341 | key={environment.environmentId} |
| 342 | environment={environment} |
| 343 | now={now} |
| 344 | projectIds={ |
| 345 | selectedProjectKey === null |
| 346 | ? null |
| 347 | : (selectedGroup?.members |
| 348 | .filter( |
| 349 | (member) => member.project.environmentId === environment.environmentId, |
| 350 | ) |
| 351 | .map((member) => member.project.id) ?? []) |
| 352 | } |
| 353 | onEdit={(task) => { |
| 354 | startEditor({ |
| 355 | environmentId: environment.environmentId, |
| 356 | environmentLabel: environment.label, |
| 357 | draft: editDraft(task), |
| 358 | }); |
| 359 | navigation.navigate("SettingsScheduledTaskEdit"); |
| 360 | }} |
| 361 | /> |
| 362 | )) |
| 363 | ) : ( |
| 364 | <Text className="px-2 text-base text-foreground-muted"> |
| 365 | {availableTargets.length === 0 |
| 366 | ? "Connect an environment to view and create scheduled tasks." |
| 367 | : "No environments match these filters. Change the filter above."} |
| 368 | </Text> |
| 369 | )} |
| 370 | </ScrollView> |
| 371 | </SettingsScreen> |
| 372 | </> |
| 373 | ); |
| 374 | } |
| 375 | |
| 376 | export function SettingsScheduledTaskNewRouteScreen() { |
| 377 | const { resetEditor } = useScheduledTaskEditor(); |
| 378 | const { availableTargets } = useSettingsEnvironmentFilter(); |
| 379 | const initialized = useRef(false); |
| 380 | useLayoutEffect(() => { |
| 381 | if (initialized.current || availableTargets.length === 0) return; |
| 382 | initialized.current = true; |
| 383 | resetEditor(); |
| 384 | }, [availableTargets.length, resetEditor]); |
| 385 | return <SettingsScheduledTaskEditorScreen title="New scheduled task" />; |
| 386 | } |
| 387 | |
| 388 | export function SettingsScheduledTaskEditRouteScreen() { |
| 389 | return <SettingsScheduledTaskEditorScreen title="Edit scheduled task" />; |
| 390 | } |
| 391 | |
| 392 | function SettingsScheduledTaskEditorScreen({ title }: { readonly title: string }) { |
| 393 | const { |
| 394 | editor, |
| 395 | voiceOwnerKey, |
| 396 | readEditor, |
| 397 | setEditor, |
| 398 | startEditor, |
| 399 | hasChanges, |
| 400 | draftForEnvironment, |
| 401 | } = useScheduledTaskEditor(); |
| 402 | const { availableTargets } = useSettingsEnvironmentFilter(); |
| 403 | const navigation = useNavigation(); |
| 404 | const insets = useSafeAreaInsets(); |
| 405 | const [saving, setSaving] = useState(false); |
| 406 | const [saved, setSaved] = useState(false); |
| 407 | const ownerKey = editor ? `${voiceOwnerKey}:${editor.environmentId}` : null; |
| 408 | const prompt = editor?.draft.prompt ?? ""; |
| 409 | const [selectionState, setSelectionState] = useState<{ |
| 410 | readonly ownerKey: string | null; |
| 411 | readonly selection: ComposerEditorSelection; |
| 412 | } | null>(null); |
| 413 | const selection = |
| 414 | selectionState?.ownerKey === ownerKey |
| 415 | ? selectionState.selection |
| 416 | : { start: prompt.length, end: prompt.length }; |
| 417 | const setSelection = (next: ComposerEditorSelection) => |
| 418 | setSelectionState({ ownerKey, selection: next }); |
| 419 | const setPrompt = (next: string) => |
| 420 | setEditor((current) => |
| 421 | current |
| 422 | ? { |
| 423 | ...current, |
| 424 | draft: { ...current.draft, prompt: next }, |
| 425 | } |
| 426 | : current, |
| 427 | ); |
| 428 | const voiceInput = useVoiceInputController({ |
| 429 | ownerKey, |
| 430 | label: editor?.draft.title.trim() || title, |
| 431 | subscribeToDraftChanges: (onChange) => |
| 432 | appAtomRegistry.subscribe(scheduledTaskEditorSessionAtom, onChange), |
| 433 | selection, |
| 434 | readDraftMessage: () => { |
| 435 | const current = readEditor(); |
| 436 | return current?.environmentId === editor?.environmentId |
| 437 | ? (current?.draft.prompt ?? null) |
| 438 | : null; |
| 439 | }, |
| 440 | onChangeSelection: setSelection, |
| 441 | onChangeDraftMessage: (text) => { |
| 442 | if (readEditor()?.environmentId === editor?.environmentId) setPrompt(text); |
| 443 | }, |
| 444 | disabled: saving, |
| 445 | }); |
| 446 | const preventRemove = !saved && (hasChanges || saving); |
| 447 | usePreventRemove(preventRemove, ({ data }) => { |
| 448 | if (saving) { |
| 449 | Alert.alert("Saving task", "Wait for the task to finish saving before leaving."); |
| 450 | return; |
| 451 | } |
| 452 | Alert.alert("Discard changes?", "Your unsaved changes will be lost.", [ |
| 453 | { text: "Keep editing", style: "cancel" }, |
| 454 | { |
| 455 | text: "Discard changes", |
| 456 | style: "destructive", |
| 457 | onPress: () => { |
| 458 | if (voiceInput.isBusy) voiceInput.cancel(); |
| 459 | startEditor(null); |
| 460 | navigation.dispatch(data.action); |
| 461 | }, |
| 462 | }, |
| 463 | ]); |
| 464 | }); |
| 465 | useEffect(() => { |
| 466 | if (!saved) return; |
| 467 | // Let the native removal guard turn off before popping the saved form. |
| 468 | const frame = requestAnimationFrame(() => { |
| 469 | if (navigation.isFocused()) navigation.goBack(); |
| 470 | }); |
| 471 | return () => cancelAnimationFrame(frame); |
| 472 | }, [navigation, saved]); |
| 473 | return ( |
| 474 | <SettingsScreen title={title}> |
| 475 | {Platform.OS === "ios" ? ( |
| 476 | <NativeStackScreenOptions |
| 477 | options={{ |
| 478 | headerBackVisible: false, |
| 479 | gestureEnabled: !preventRemove, |
| 480 | // The system back button begins its native pop before the removal |
| 481 | // guard runs. Dispatch from a bar action so the guard runs first. |
| 482 | unstable_headerLeftItems: () => [ |
| 483 | withNativeGlassHeaderItem({ |
| 484 | type: "button", |
| 485 | label: "", |
| 486 | accessibilityLabel: "Back", |
| 487 | icon: { type: "sfSymbol", name: "chevron.backward" }, |
| 488 | onPress: () => navigation.goBack(), |
| 489 | }), |
| 490 | ], |
| 491 | }} |
| 492 | /> |
| 493 | ) : null} |
| 494 | <ScrollView |
| 495 | keyboardShouldPersistTaps="handled" |
| 496 | keyboardDismissMode="on-drag" |
| 497 | contentInsetAdjustmentBehavior="automatic" |
| 498 | showsVerticalScrollIndicator={false} |
| 499 | className="flex-1" |
| 500 | contentContainerClassName="gap-5 px-5 pt-4" |
| 501 | contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} |
| 502 | > |
| 503 | {editor ? ( |
| 504 | <TaskForm |
| 505 | key={editor.environmentId} |
| 506 | environmentId={editor.environmentId} |
| 507 | environmentLabel={ |
| 508 | availableTargets.find((target) => target.environmentId === editor.environmentId) |
| 509 | ?.label ?? editor.environmentLabel |
| 510 | } |
| 511 | availableTargets={availableTargets} |
| 512 | draft={editor.draft} |
| 513 | saving={saving} |
| 514 | setSaving={setSaving} |
| 515 | dictationPending={voiceInput.blocksSubmission} |
| 516 | promptField={ |
| 517 | <ScheduledTaskPromptField |
| 518 | value={prompt} |
| 519 | onChange={setPrompt} |
| 520 | selection={selection} |
| 521 | onChangeSelection={setSelection} |
| 522 | disabled={saving} |
| 523 | voiceInput={voiceInput} |
| 524 | /> |
| 525 | } |
| 526 | setDraft={(draft) => { |
| 527 | if (!saving) setEditor({ ...editor, draft }); |
| 528 | }} |
| 529 | onSaved={() => setSaved(true)} |
| 530 | onChangeEnvironment={(target) => { |
| 531 | if (target.environmentId === editor.environmentId) return; |
| 532 | setEditor({ |
| 533 | environmentId: target.environmentId, |
| 534 | environmentLabel: target.label, |
| 535 | draft: { |
| 536 | ...draftForEnvironment(target.environmentId), |
| 537 | title: editor.draft.title, |
| 538 | prompt: editor.draft.prompt, |
| 539 | schedule: editor.draft.schedule, |
| 540 | enabled: editor.draft.enabled, |
| 541 | }, |
| 542 | }); |
| 543 | }} |
| 544 | /> |
| 545 | ) : ( |
| 546 | <Text className="px-2 text-base text-foreground-muted"> |
| 547 | {availableTargets.length === 0 |
| 548 | ? "Connect an environment to create a scheduled task." |
| 549 | : "No environments match the current filters. Change the filters to create a task."} |
| 550 | </Text> |
| 551 | )} |
| 552 | </ScrollView> |
| 553 | </SettingsScreen> |
| 554 | ); |
| 555 | } |
| 556 | |
| 557 | function TaskForm({ |
| 558 | environmentId, |
| 559 | environmentLabel, |
| 560 | availableTargets, |
| 561 | draft, |
| 562 | saving, |
| 563 | setSaving, |
| 564 | dictationPending, |
| 565 | promptField, |
| 566 | setDraft, |
| 567 | onSaved, |
| 568 | onChangeEnvironment, |
| 569 | }: { |
| 570 | readonly environmentId: EnvironmentId; |
| 571 | readonly environmentLabel: string; |
| 572 | readonly availableTargets: readonly SettingsTarget[]; |
| 573 | readonly draft: Draft; |
| 574 | readonly saving: boolean; |
| 575 | readonly setSaving: (saving: boolean) => void; |
| 576 | readonly dictationPending: boolean; |
| 577 | readonly promptField: ReactNode; |
| 578 | readonly setDraft: (draft: Draft) => void; |
| 579 | readonly onSaved: () => void; |
| 580 | readonly onChangeEnvironment: (target: SettingsTarget) => void; |
| 581 | }) { |
| 582 | const navigation = useNavigation<NativeStackNavigationProp<ScheduledTaskRoutes>>(); |
| 583 | const tasks = useEnvironmentQuery( |
| 584 | serverEnvironment.scheduledTasksLive({ environmentId, input: {} }), |
| 585 | ); |
| 586 | const projects = useProjects().filter((project) => project.environmentId === environmentId); |
| 587 | const config = useEnvironmentServerConfig(environmentId); |
| 588 | const modelOptions = useMemo( |
| 589 | () => buildModelOptions(config, null, undefined, draft.projectId), |
| 590 | [config, draft.projectId], |
| 591 | ); |
| 592 | const canOperate = useAtomValue( |
| 593 | serverEnvironment.upsertScheduledTask.permissionAtom(environmentId), |
| 594 | ); |
| 595 | const upsert = useAtomCommand(serverEnvironment.upsertScheduledTask, { |
| 596 | label: "scheduled task upsert", |
| 597 | reportFailure: false, |
| 598 | }); |
| 599 | const submissionPending = useRef(false); |
| 600 | const [timePickerOpen, setTimePickerOpen] = useState(false); |
| 601 | const taskMissing = |
| 602 | draft.task !== null && |
| 603 | tasks.data !== null && |
| 604 | !tasks.data.tasks.some((task) => task.id === draft.task?.id); |
| 605 | const environmentUnavailable = !availableTargets.some( |
| 606 | (target) => target.environmentId === environmentId, |
| 607 | ); |
| 608 | |
| 609 | const failure = (title: string, result: AtomCommandResult<unknown, unknown>) => { |
| 610 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
| 611 | Alert.alert(title, String(squashAtomCommandFailure(result))); |
| 612 | } |
| 613 | }; |
| 614 | |
| 615 | const save = async () => { |
| 616 | if ( |
| 617 | !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) || |
| 618 | submissionPending.current || |
| 619 | saving || |
| 620 | dictationPending || |
| 621 | taskMissing || |
| 622 | environmentUnavailable |
| 623 | ) |
| 624 | return; |
| 625 | // Signatures are edited on desktop and web; send the task's current one, |
| 626 | // not the copy taken when this form opened, so a newer edit survives. |
| 627 | const liveTask = tasks.data?.tasks.find((task) => task.id === draft.task?.id); |
| 628 | const schedule = scheduleFromDraft( |
| 629 | draft.schedule.mode === "webhook" && liveTask?.schedule.type === "webhook" |
| 630 | ? { ...draft.schedule, signature: liveTask.schedule.signature } |
| 631 | : draft.schedule, |
| 632 | ); |
| 633 | if ( |
| 634 | draft.schedule.mode === "webhook" && |
| 635 | parseMaxDeliveryAge(draft.schedule.maxDeliveryAgeMinutes) === undefined |
| 636 | ) { |
| 637 | Alert.alert( |
| 638 | "Invalid age limit", |
| 639 | `Enter whole minutes from 1 to ${MAX_WEBHOOK_DELIVERY_AGE_MINUTES}, or leave it blank.`, |
| 640 | ); |
| 641 | return; |
| 642 | } |
| 643 | if ( |
| 644 | !draft.title.trim() || |
| 645 | !draft.prompt.trim() || |
| 646 | !draft.projectId || |
| 647 | !draft.modelSelection || |
| 648 | !schedule || |
| 649 | (draft.workspace === "existing_worktree" && !draft.checkoutPath.trim()) |
| 650 | ) { |
| 651 | Alert.alert( |
| 652 | "Incomplete task", |
| 653 | "Add a name, prompt, project, model, valid schedule, and checkout path if needed.", |
| 654 | ); |
| 655 | return; |
| 656 | } |
| 657 | if (!projects.some((project) => project.id === draft.projectId)) { |
| 658 | Alert.alert("Project unavailable", "Choose a project in this environment."); |
| 659 | return; |
| 660 | } |
| 661 | const input: ScheduledTaskUpsertInput = { |
| 662 | ...(draft.task ? { id: draft.task.id, requireExisting: true } : {}), |
| 663 | title: draft.title.trim(), |
| 664 | prompt: draft.prompt.trim(), |
| 665 | projectId: draft.projectId, |
| 666 | modelSelection: draft.modelSelection, |
| 667 | schedule, |
| 668 | enabled: draft.enabled, |
| 669 | threadId: draft.task?.threadId ?? null, |
| 670 | workspaceStrategy: |
| 671 | draft.workspace === "root" |
| 672 | ? { type: "root" } |
| 673 | : draft.workspace === "existing_worktree" |
| 674 | ? { type: "existing_worktree", worktreePath: draft.checkoutPath.trim() } |
| 675 | : { |
| 676 | type: "worktree", |
| 677 | baseRef: draft.baseRef.trim() || "main", |
| 678 | startFromOrigin: draft.startFromOrigin, |
| 679 | }, |
| 680 | runtimeMode: draft.runtimeMode, |
| 681 | interactionMode: draft.task?.interactionMode ?? "default", |
| 682 | creationSource: draft.task?.creationSource ?? "mobile", |
| 683 | }; |
| 684 | // Lock before React renders, and keep successful creates locked until the form closes. |
| 685 | submissionPending.current = true; |
| 686 | setSaving(true); |
| 687 | const result = await upsert({ environmentId, input }); |
| 688 | setSaving(false); |
| 689 | if (result._tag === "Failure") { |
| 690 | submissionPending.current = false; |
| 691 | failure("Could not save task", result); |
| 692 | return; |
| 693 | } |
| 694 | onSaved(); |
| 695 | }; |
| 696 | |
| 697 | return ( |
| 698 | <View |
| 699 | className="gap-5" |
| 700 | pointerEvents={saving ? "none" : "auto"} |
| 701 | accessibilityElementsHidden={saving} |
| 702 | importantForAccessibility={saving ? "no-hide-descendants" : "auto"} |
| 703 | > |
| 704 | {taskMissing ? ( |
| 705 | <Text className="px-1 text-base text-danger-foreground">This task no longer exists.</Text> |
| 706 | ) : null} |
| 707 | |
| 708 | {environmentUnavailable ? ( |
| 709 | <Text className="px-1 text-base text-danger-foreground"> |
| 710 | This environment is disconnected. Reconnect before saving. |
| 711 | </Text> |
| 712 | ) : null} |
| 713 | <SettingsSection> |
| 714 | <SelectRow |
| 715 | label="Runs on" |
| 716 | value={environmentLabel} |
| 717 | valueIcon={ |
| 718 | <EnvironmentMachineSymbol |
| 719 | kind={resolveEnvironmentMachineKind(config)} |
| 720 | size={18} |
| 721 | tintColorClassName="accent-foreground-muted" |
| 722 | /> |
| 723 | } |
| 724 | actions={ |
| 725 | draft.task || saving || dictationPending |
| 726 | ? [] |
| 727 | : availableTargets.map((target) => ({ |
| 728 | id: target.environmentId, |
| 729 | title: target.label, |
| 730 | image: |
| 731 | ENVIRONMENT_MACHINE_SYMBOLS[resolveEnvironmentMachineKind(target.serverConfig)], |
| 732 | state: target.environmentId === environmentId ? "on" : undefined, |
| 733 | })) |
| 734 | } |
| 735 | onSelect={(id) => { |
| 736 | const target = availableTargets.find((entry) => entry.environmentId === id); |
| 737 | if (target) onChangeEnvironment(target); |
| 738 | }} |
| 739 | /> |
| 740 | </SettingsSection> |
| 741 | <SettingsSection title="Task"> |
| 742 | <FormField |
| 743 | label="Name" |
| 744 | disabled={saving} |
| 745 | value={draft.title} |
| 746 | placeholder="Check for issues" |
| 747 | onChange={(title) => setDraft({ ...draft, title })} |
| 748 | /> |
| 749 | {promptField} |
| 750 | </SettingsSection> |
| 751 | |
| 752 | <SettingsSection title="Context"> |
| 753 | <SelectRow |
| 754 | label="Project" |
| 755 | value={ |
| 756 | projects.find((project) => project.id === draft.projectId)?.title ?? |
| 757 | (projects.length ? "Choose project" : "No projects available") |
| 758 | } |
| 759 | actions={projects.map((project) => ({ |
| 760 | id: project.id, |
| 761 | title: project.title, |
| 762 | state: project.id === draft.projectId ? "on" : undefined, |
| 763 | }))} |
| 764 | onSelect={(id) => { |
| 765 | const project = projects.find((item) => item.id === id); |
| 766 | if (!project) return; |
| 767 | const keepsExplicitModel = |
| 768 | draft.modelSelectionIsExplicit && |
| 769 | (draft.modelSelection === null || |
| 770 | !config?.settings || |
| 771 | resolveProjectProviderInstanceEnabled( |
| 772 | config.settings, |
| 773 | project.id, |
| 774 | draft.modelSelection.instanceId, |
| 775 | )); |
| 776 | setDraft({ |
| 777 | ...draft, |
| 778 | projectId: project.id, |
| 779 | modelSelection: keepsExplicitModel |
| 780 | ? draft.modelSelection |
| 781 | : scheduledTaskDefaultModel(config, project), |
| 782 | modelSelectionIsExplicit: keepsExplicitModel, |
| 783 | }); |
| 784 | }} |
| 785 | /> |
| 786 | <PickerRow |
| 787 | label="Model" |
| 788 | borderTop |
| 789 | value={ |
| 790 | modelOptions.find( |
| 791 | (option) => |
| 792 | option.selection.instanceId === draft.modelSelection?.instanceId && |
| 793 | option.selection.model === draft.modelSelection?.model, |
| 794 | )?.label ?? |
| 795 | draft.modelSelection?.model ?? |
| 796 | (modelOptions.length ? "Choose model" : "No models available") |
| 797 | } |
| 798 | onPress={() => navigation.navigate("SettingsScheduledTaskModel")} |
| 799 | disabled={saving || dictationPending || environmentUnavailable} |
| 800 | /> |
| 801 | </SettingsSection> |
| 802 | |
| 803 | <SettingsSection title="Workspace"> |
| 804 | <SelectRow |
| 805 | label="Run in" |
| 806 | value={ |
| 807 | draft.workspace === "worktree" |
| 808 | ? "New worktree" |
| 809 | : draft.workspace === "root" |
| 810 | ? "Project checkout" |
| 811 | : "Specific checkout" |
| 812 | } |
| 813 | actions={[ |
| 814 | { |
| 815 | id: "worktree", |
| 816 | title: "New worktree", |
| 817 | state: draft.workspace === "worktree" ? "on" : undefined, |
| 818 | }, |
| 819 | { |
| 820 | id: "root", |
| 821 | title: "Project checkout", |
| 822 | state: draft.workspace === "root" ? "on" : undefined, |
| 823 | }, |
| 824 | { |
| 825 | id: "existing_worktree", |
| 826 | title: "Specific checkout", |
| 827 | state: draft.workspace === "existing_worktree" ? "on" : undefined, |
| 828 | }, |
| 829 | ]} |
| 830 | onSelect={(id) => { |
| 831 | if (id === "worktree" || id === "root" || id === "existing_worktree") |
| 832 | setDraft({ ...draft, workspace: id }); |
| 833 | }} |
| 834 | /> |
| 835 | {draft.workspace === "worktree" ? ( |
| 836 | <PickerRow |
| 837 | label="Base branch" |
| 838 | value={resolveNewTaskBranchLabel({ |
| 839 | branchName: draft.baseRef, |
| 840 | startFromOrigin: draft.startFromOrigin, |
| 841 | workspaceMode: "worktree", |
| 842 | })} |
| 843 | borderTop |
| 844 | disabled={!draft.projectId || saving || dictationPending || environmentUnavailable} |
| 845 | onPress={() => navigation.navigate("SettingsScheduledTaskBranch")} |
| 846 | /> |
| 847 | ) : null} |
| 848 | {draft.workspace === "existing_worktree" ? ( |
| 849 | <FormField |
| 850 | label="Checkout path" |
| 851 | disabled={saving} |
| 852 | value={draft.checkoutPath} |
| 853 | borderTop |
| 854 | onChange={(checkoutPath) => setDraft({ ...draft, checkoutPath })} |
| 855 | /> |
| 856 | ) : null} |
| 857 | </SettingsSection> |
| 858 | |
| 859 | <SettingsSection title="Schedule"> |
| 860 | <View className="px-4 py-3"> |
| 861 | <SegmentedControl |
| 862 | options={[ |
| 863 | { value: "fixed_time", label: "At a time" }, |
| 864 | { value: "interval", label: "Interval" }, |
| 865 | { value: "webhook", label: "On webhook" }, |
| 866 | ]} |
| 867 | selected={draft.schedule.mode} |
| 868 | onSelect={(mode) => { |
| 869 | setTimePickerOpen(false); |
| 870 | setDraft({ |
| 871 | ...draft, |
| 872 | prompt: |
| 873 | mode === "webhook" && !draft.prompt.trim() |
| 874 | ? DEFAULT_WEBHOOK_PROMPT |
| 875 | : draft.prompt, |
| 876 | schedule: { ...draft.schedule, mode }, |
| 877 | }); |
| 878 | }} |
| 879 | /> |
| 880 | </View> |
| 881 | {draft.schedule.mode === "fixed_time" ? ( |
| 882 | <> |
| 883 | <Pressable |
| 884 | accessibilityRole="button" |
| 885 | accessibilityLabel={`Time, ${formatTime(draft.schedule.timeOfDay)}`} |
| 886 | onPress={() => setTimePickerOpen((open) => !open)} |
| 887 | className="min-h-14 flex-row items-center gap-3 border-t border-border-subtle px-4 py-3 active:opacity-70" |
| 888 | > |
| 889 | <Text className="text-lg text-foreground">Time</Text> |
| 890 | <Text className="min-w-0 flex-1 text-right text-base text-foreground-muted"> |
| 891 | {formatTime(draft.schedule.timeOfDay)} |
| 892 | </Text> |
| 893 | <SymbolView |
| 894 | name="chevron.right" |
| 895 | size={14} |
| 896 | tintColorClassName="accent-chevron" |
| 897 | type="monochrome" |
| 898 | /> |
| 899 | </Pressable> |
| 900 | {timePickerOpen ? ( |
| 901 | <DateTimePicker |
| 902 | value={timePickerValue(draft.schedule.timeOfDay)} |
| 903 | mode="time" |
| 904 | display={Platform.OS === "ios" ? "spinner" : "default"} |
| 905 | onDismiss={() => setTimePickerOpen(false)} |
| 906 | onValueChange={(_, selected) => { |
| 907 | const timeOfDay = `${String(selected.getHours()).padStart(2, "0")}:${String(selected.getMinutes()).padStart(2, "0")}`; |
| 908 | setDraft({ ...draft, schedule: { ...draft.schedule, timeOfDay } }); |
| 909 | }} |
| 910 | /> |
| 911 | ) : null} |
| 912 | <SelectRow |
| 913 | label="Repeat" |
| 914 | value={repeatLabel(draft.schedule.weekdays)} |
| 915 | borderTop |
| 916 | actions={[ |
| 917 | { |
| 918 | id: "every_day", |
| 919 | title: "Every day", |
| 920 | state: draft.schedule.weekdays.length === 7 ? "on" : undefined, |
| 921 | }, |
| 922 | { |
| 923 | id: "weekdays", |
| 924 | title: "Weekdays", |
| 925 | state: repeatLabel(draft.schedule.weekdays) === "Weekdays" ? "on" : undefined, |
| 926 | }, |
| 927 | ...DAYS.map((day) => ({ |
| 928 | id: String(day.index), |
| 929 | title: day.label, |
| 930 | attributes: { keepsMenuPresented: true }, |
| 931 | state: draft.schedule.weekdays.includes(day.index) ? ("on" as const) : undefined, |
| 932 | })), |
| 933 | ]} |
| 934 | onSelect={(id) => { |
| 935 | const weekdays = |
| 936 | id === "every_day" |
| 937 | ? DAYS.map((day) => day.index) |
| 938 | : id === "weekdays" |
| 939 | ? [1, 2, 3, 4, 5] |
| 940 | : (() => { |
| 941 | const day = DAYS.find((item) => String(item.index) === id); |
| 942 | if (!day) return draft.schedule.weekdays; |
| 943 | return draft.schedule.weekdays.includes(day.index) |
| 944 | ? draft.schedule.weekdays.filter((index) => index !== day.index) |
| 945 | : [...draft.schedule.weekdays, day.index]; |
| 946 | })(); |
| 947 | setDraft({ ...draft, schedule: { ...draft.schedule, weekdays } }); |
| 948 | }} |
| 949 | /> |
| 950 | </> |
| 951 | ) : draft.schedule.mode === "webhook" ? ( |
| 952 | <> |
| 953 | <WebhookScheduleDetails |
| 954 | environmentId={environmentId} |
| 955 | // The live row, so a rotated URL shows up without reopening the form. |
| 956 | // Once the list has loaded, a missing task is gone; don't keep showing its URL. |
| 957 | task={ |
| 958 | tasks.data |
| 959 | ? (tasks.data.tasks.find((task) => task.id === draft.task?.id) ?? null) |
| 960 | : draft.task |
| 961 | } |
| 962 | signatureConfigured={draft.schedule.signature !== null} |
| 963 | disabled={!canOperate || saving || environmentUnavailable} |
| 964 | /> |
| 965 | <FormField |
| 966 | label="Skip requests older than (minutes)" |
| 967 | value={draft.schedule.maxDeliveryAgeMinutes} |
| 968 | placeholder="Run every request" |
| 969 | keyboardType="number-pad" |
| 970 | disabled={saving} |
| 971 | borderTop |
| 972 | onChange={(maxDeliveryAgeMinutes) => |
| 973 | setDraft({ ...draft, schedule: { ...draft.schedule, maxDeliveryAgeMinutes } }) |
| 974 | } |
| 975 | /> |
| 976 | </> |
| 977 | ) : ( |
| 978 | <> |
| 979 | <FormField |
| 980 | label="Minutes between runs" |
| 981 | value={draft.schedule.intervalMinutes} |
| 982 | keyboardType="decimal-pad" |
| 983 | disabled={saving} |
| 984 | borderTop |
| 985 | onChange={(intervalMinutes) => |
| 986 | setDraft({ ...draft, schedule: { ...draft.schedule, intervalMinutes } }) |
| 987 | } |
| 988 | /> |
| 989 | {Number(draft.schedule.intervalMinutes) < 1 ? ( |
| 990 | <Text className="px-4 pb-3 text-sm text-danger-foreground"> |
| 991 | Intervals must be at least 1 minute. Update this interval before saving. |
| 992 | </Text> |
| 993 | ) : draft.task?.schedule.type === "interval" && draft.task.schedule.everyMs < 60_000 ? ( |
| 994 | <Text className="px-4 pb-3 text-sm text-foreground-muted"> |
| 995 | This task previously ran more than once per minute. Saving requires an interval of |
| 996 | at least 1 minute. |
| 997 | </Text> |
| 998 | ) : null} |
| 999 | </> |
| 1000 | )} |
| 1001 | <View className="min-h-14 flex-row items-center gap-3 border-t border-border-subtle px-4 py-3"> |
| 1002 | <Text className="min-w-0 flex-1 text-lg text-foreground">Enabled</Text> |
| 1003 | <ThemedSwitch |
| 1004 | accessibilityLabel="Task enabled" |
| 1005 | value={draft.enabled} |
| 1006 | onValueChange={(enabled) => setDraft({ ...draft, enabled })} |
| 1007 | /> |
| 1008 | </View> |
| 1009 | </SettingsSection> |
| 1010 | {draft.schedule.mode === "fixed_time" ? ( |
| 1011 | <Text className="px-2 text-sm text-foreground-muted"> |
| 1012 | Time uses the environment's time zone, which may differ from your phone's. |
| 1013 | </Text> |
| 1014 | ) : null} |
| 1015 | <Pressable |
| 1016 | accessibilityRole="button" |
| 1017 | accessibilityState={{ |
| 1018 | disabled: saving || dictationPending || taskMissing || environmentUnavailable, |
| 1019 | }} |
| 1020 | disabled={ |
| 1021 | !canOperate || saving || dictationPending || taskMissing || environmentUnavailable |
| 1022 | } |
| 1023 | onPress={() => void save()} |
| 1024 | className="min-h-12 items-center justify-center rounded-[14px] bg-primary px-4 disabled:opacity-50" |
| 1025 | > |
| 1026 | <Text className="text-base font-t3-medium text-primary-foreground"> |
| 1027 | {saving ? "Saving…" : draft.task ? "Save changes" : "Create task"} |
| 1028 | </Text> |
| 1029 | </Pressable> |
| 1030 | </View> |
| 1031 | ); |
| 1032 | } |
| 1033 | |
| 1034 | function WebhookScheduleDetails({ |
| 1035 | environmentId, |
| 1036 | task, |
| 1037 | signatureConfigured, |
| 1038 | disabled, |
| 1039 | }: { |
| 1040 | readonly environmentId: EnvironmentId; |
| 1041 | readonly task: ScheduledTask | null; |
| 1042 | readonly signatureConfigured: boolean; |
| 1043 | readonly disabled: boolean; |
| 1044 | }) { |
| 1045 | const [rotating, setRotating] = useState(false); |
| 1046 | const rotate = useAtomCommand(serverEnvironment.rotateScheduledTaskWebhookToken, { |
| 1047 | label: "scheduled task rotate webhook token", |
| 1048 | reportFailure: false, |
| 1049 | }); |
| 1050 | const preparedConnection = usePreparedConnection(environmentId); |
| 1051 | const httpBaseUrl = |
| 1052 | preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null; |
| 1053 | const webhook = task?.schedule.type === "webhook" ? task.webhook : undefined; |
| 1054 | // Without T3 Connect, the path is resolved on the address this phone uses. |
| 1055 | const resolved = webhook ? webhookAddress(webhook, httpBaseUrl) : null; |
| 1056 | return ( |
| 1057 | <View className="gap-2 border-t border-border-subtle px-4 py-3"> |
| 1058 | <Text className="text-sm text-foreground-muted"> |
| 1059 | { |
| 1060 | "The prompt can use {{body.a.b}}, {{headers.name}}, {{query.name}}, {{body}} and {{request}}. The filled-in prompt is all the agent sees." |
| 1061 | } |
| 1062 | </Text> |
| 1063 | {task === null || resolved === null ? ( |
| 1064 | <Text className="text-sm text-foreground-muted">Save the task to get its webhook URL.</Text> |
| 1065 | ) : ( |
| 1066 | <> |
| 1067 | <Pressable |
| 1068 | accessibilityRole="button" |
| 1069 | accessibilityLabel="Copy webhook URL" |
| 1070 | accessibilityHint="Copies the URL to the clipboard" |
| 1071 | // A bare path is not something a sender can call, so only full URLs copy. |
| 1072 | disabled={!resolved.copyable} |
| 1073 | onPress={() => void tryCopyTextWithHaptic(resolved.address)} |
| 1074 | className="gap-1 active:opacity-70" |
| 1075 | > |
| 1076 | <Text className="text-lg text-foreground"> |
| 1077 | {resolved.copyable ? "Webhook URL" : "Webhook path"} |
| 1078 | </Text> |
| 1079 | <Text className="text-sm text-foreground-muted" numberOfLines={2} selectable> |
| 1080 | {resolved.address} |
| 1081 | </Text> |
| 1082 | </Pressable> |
| 1083 | {resolved.note !== null ? ( |
| 1084 | <Text className="text-sm text-foreground-muted">{resolved.note}</Text> |
| 1085 | ) : null} |
| 1086 | <Pressable |
| 1087 | accessibilityRole="button" |
| 1088 | accessibilityState={{ disabled: disabled || rotating }} |
| 1089 | disabled={disabled || rotating} |
| 1090 | onPress={() => |
| 1091 | Alert.alert("Rotate URL?", "The current URL stops working immediately.", [ |
| 1092 | { text: "Cancel", style: "cancel" }, |
| 1093 | { |
| 1094 | text: "Rotate", |
| 1095 | style: "destructive", |
| 1096 | onPress: () => { |
| 1097 | setRotating(true); |
| 1098 | void rotate({ environmentId, input: { id: task.id } }).then((result) => { |
| 1099 | setRotating(false); |
| 1100 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
| 1101 | Alert.alert( |
| 1102 | "Could not rotate URL", |
| 1103 | String(squashAtomCommandFailure(result)), |
| 1104 | ); |
| 1105 | } |
| 1106 | }); |
| 1107 | }, |
| 1108 | }, |
| 1109 | ]) |
| 1110 | } |
| 1111 | className="min-h-11 justify-center active:opacity-70 disabled:opacity-50" |
| 1112 | > |
| 1113 | <Text className="text-base text-danger-foreground"> |
| 1114 | {rotating ? "Rotating…" : "Rotate URL"} |
| 1115 | </Text> |
| 1116 | </Pressable> |
| 1117 | </> |
| 1118 | )} |
| 1119 | {signatureConfigured ? ( |
| 1120 | <Text className="text-sm text-foreground-muted"> |
| 1121 | Signature check configured on desktop/web. |
| 1122 | </Text> |
| 1123 | ) : null} |
| 1124 | </View> |
| 1125 | ); |
| 1126 | } |
| 1127 | |
| 1128 | function EnvironmentTasks({ |
| 1129 | environment, |
| 1130 | now, |
| 1131 | projectIds, |
| 1132 | onEdit, |
| 1133 | }: { |
| 1134 | readonly environment: SettingsTarget; |
| 1135 | readonly now: number; |
| 1136 | readonly projectIds: readonly ProjectId[] | null; |
| 1137 | readonly onEdit: (task: ScheduledTask) => void; |
| 1138 | }) { |
| 1139 | const environmentId = environment.environmentId; |
| 1140 | const tasks = useEnvironmentQuery( |
| 1141 | serverEnvironment.scheduledTasksLive({ environmentId, input: {} }), |
| 1142 | ); |
| 1143 | const visibleTasks = tasks.data?.tasks.filter( |
| 1144 | (task) => projectIds === null || projectIds.includes(task.projectId), |
| 1145 | ); |
| 1146 | const canOperate = useAtomValue( |
| 1147 | serverEnvironment.upsertScheduledTask.permissionAtom(environmentId), |
| 1148 | ); |
| 1149 | const setEnabled = useAtomCommand(serverEnvironment.setScheduledTaskEnabled, { |
| 1150 | label: "scheduled task enabled", |
| 1151 | reportFailure: false, |
| 1152 | }); |
| 1153 | const runNow = useAtomCommand(serverEnvironment.runScheduledTaskNow, { |
| 1154 | label: "scheduled task run", |
| 1155 | reportFailure: false, |
| 1156 | }); |
| 1157 | const remove = useAtomCommand(serverEnvironment.deleteScheduledTask, { |
| 1158 | label: "scheduled task delete", |
| 1159 | reportFailure: false, |
| 1160 | }); |
| 1161 | const failure = (title: string, result: AtomCommandResult<unknown, unknown>) => { |
| 1162 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
| 1163 | Alert.alert(title, String(squashAtomCommandFailure(result))); |
| 1164 | } |
| 1165 | }; |
| 1166 | |
| 1167 | const act = async (task: ScheduledTask, action: "run" | "toggle" | "delete") => { |
| 1168 | if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return; |
| 1169 | const result = |
| 1170 | action === "run" |
| 1171 | ? await runNow({ environmentId, input: { id: task.id } }) |
| 1172 | : action === "toggle" |
| 1173 | ? await setEnabled({ environmentId, input: { id: task.id, enabled: !task.enabled } }) |
| 1174 | : await remove({ environmentId, input: { id: task.id } }); |
| 1175 | failure(`Could not ${action === "toggle" ? "update" : action} task`, result); |
| 1176 | }; |
| 1177 | |
| 1178 | return ( |
| 1179 | <SettingsSection |
| 1180 | title={environment.label} |
| 1181 | titleIcon={ |
| 1182 | <EnvironmentMachineSymbol |
| 1183 | kind={resolveEnvironmentMachineKind(environment.serverConfig)} |
| 1184 | size={16} |
| 1185 | tintColorClassName={ |
| 1186 | Platform.OS === "android" ? "accent-primary" : "accent-foreground-muted" |
| 1187 | } |
| 1188 | /> |
| 1189 | } |
| 1190 | > |
| 1191 | {tasks.error ? ( |
| 1192 | <Text className="p-4 text-base text-danger-foreground">{tasks.error}</Text> |
| 1193 | ) : !tasks.data ? ( |
| 1194 | <Text className="p-4 text-base text-foreground-muted">Loading tasks…</Text> |
| 1195 | ) : visibleTasks?.length === 0 ? ( |
| 1196 | <Text className="p-4 text-base text-foreground-muted"> |
| 1197 | {projectIds === null ? "No scheduled tasks yet." : "No tasks in this project."} |
| 1198 | </Text> |
| 1199 | ) : ( |
| 1200 | visibleTasks?.map((task, index) => ( |
| 1201 | <View |
| 1202 | key={task.id} |
| 1203 | className={ |
| 1204 | index === 0 |
| 1205 | ? "flex-row items-start gap-1 px-4 py-4" |
| 1206 | : "flex-row items-start gap-1 border-t border-border-subtle px-4 py-4" |
| 1207 | } |
| 1208 | > |
| 1209 | <Pressable |
| 1210 | accessibilityRole="button" |
| 1211 | accessibilityLabel={`Edit ${task.title}`} |
| 1212 | onPress={() => { |
| 1213 | onEdit(task); |
| 1214 | }} |
| 1215 | className="min-w-0 flex-1 gap-1 active:opacity-70" |
| 1216 | > |
| 1217 | <Text className="text-lg font-t3-medium text-foreground" numberOfLines={1}> |
| 1218 | {task.title} |
| 1219 | </Text> |
| 1220 | <Text className="text-sm text-foreground-muted" numberOfLines={2}> |
| 1221 | {describeSchedule(task)} |
| 1222 | {!task.enabled |
| 1223 | ? " · Paused" |
| 1224 | : task.nextRunAt |
| 1225 | ? ` · ${formatNextScheduledTaskRun(task.nextRunAt, now)}` |
| 1226 | : ""} |
| 1227 | </Text> |
| 1228 | {task.lastRunError ? ( |
| 1229 | <Text className="text-sm text-danger-foreground" numberOfLines={2}> |
| 1230 | Last run failed: {task.lastRunError} |
| 1231 | </Text> |
| 1232 | ) : null} |
| 1233 | </Pressable> |
| 1234 | <ControlPillMenu |
| 1235 | actions={[ |
| 1236 | { id: "edit", title: "Edit" }, |
| 1237 | { |
| 1238 | id: "toggle", |
| 1239 | title: task.enabled ? "Pause" : "Resume", |
| 1240 | attributes: { disabled: !canOperate }, |
| 1241 | }, |
| 1242 | ...(task.schedule.type === "webhook" |
| 1243 | ? [] |
| 1244 | : [{ id: "run", title: "Run now", attributes: { disabled: !canOperate } }]), |
| 1245 | { |
| 1246 | id: "delete", |
| 1247 | title: "Delete", |
| 1248 | attributes: { destructive: true, disabled: !canOperate }, |
| 1249 | }, |
| 1250 | ]} |
| 1251 | onPressAction={({ nativeEvent }) => { |
| 1252 | const action = nativeEvent.event; |
| 1253 | if (action === "edit") { |
| 1254 | onEdit(task); |
| 1255 | } else if (action === "delete") { |
| 1256 | Alert.alert("Delete task?", task.title, [ |
| 1257 | { text: "Cancel", style: "cancel" }, |
| 1258 | { |
| 1259 | text: "Delete", |
| 1260 | style: "destructive", |
| 1261 | onPress: () => void act(task, "delete"), |
| 1262 | }, |
| 1263 | ]); |
| 1264 | } else if (action === "toggle" || action === "run") { |
| 1265 | void act(task, action); |
| 1266 | } |
| 1267 | }} |
| 1268 | > |
| 1269 | <Pressable |
| 1270 | accessibilityRole="button" |
| 1271 | accessibilityLabel={`Actions for ${task.title}`} |
| 1272 | className="h-11 w-11 items-center justify-center" |
| 1273 | > |
| 1274 | <SymbolView |
| 1275 | name="ellipsis" |
| 1276 | size={18} |
| 1277 | tintColorClassName="accent-icon" |
| 1278 | type="monochrome" |
| 1279 | /> |
| 1280 | </Pressable> |
| 1281 | </ControlPillMenu> |
| 1282 | </View> |
| 1283 | )) |
| 1284 | )} |
| 1285 | </SettingsSection> |
| 1286 | ); |
| 1287 | } |