1import { useAtomValue } from "@effect/atom-react";
2import {
3 AuthOrchestrationOperateScope,
4 resolveProjectProviderInstanceEnabled,
5} from "@t3tools/contracts";
6import { readEnvironmentScope } from "../../state/session";
7import type {
8 EnvironmentId,
9 ProjectId,
10 ScheduledTask,
11 ScheduledTaskUpsertInput,
12} from "@t3tools/contracts";
13import {
14 MAX_WEBHOOK_DELIVERY_AGE_MINUTES,
15 resolveEnvironmentMachineKind,
16} from "@t3tools/contracts";
17import type { MenuAction } from "@react-native-menu/menu";
18import { DateTimePicker } from "@expo/ui/community/datetime-picker";
19import {
20 isAtomCommandInterrupted,
21 squashAtomCommandFailure,
22 type AtomCommandResult,
23} from "@t3tools/client-runtime/state/runtime";
24import {
25 DEFAULT_WEBHOOK_PROMPT,
26 parseMaxDeliveryAge,
27} from "@t3tools/client-runtime/scheduled-task-webhook";
28import {
29 useCallback,
30 useEffect,
31 useLayoutEffect,
32 useMemo,
33 useRef,
34 useState,
35 type ReactNode,
36} from "react";
37import { useFocusEffect, useNavigation, usePreventRemove } from "@react-navigation/native";
38import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
39import { Alert, AppState, Platform, Pressable, TextInput as RNTextInput, View } from "react-native";
40import { useSafeAreaInsets } from "react-native-safe-area-context";
41
42import { AppText as Text } from "../../components/AppText";
43import { SymbolView } from "../../components/AppSymbol";
44import {
45 EnvironmentMachineSymbol,
46 ENVIRONMENT_MACHINE_SYMBOLS,
47} from "../../components/EnvironmentMachineSymbol";
48import { ControlPillMenu } from "../../components/ControlPill";
49import type { ComposerEditorSelection } from "../../components/ComposerEditor";
50import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
51import { SegmentedControl } from "../../components/SegmentedControl";
52import { ThemedSwitch } from "../../components/ThemedSwitch";
53import { webhookAddress } from "@t3tools/client-runtime/webhook-address";
54import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic";
55import { usePreparedConnection } from "../../state/session";
56import { buildModelOptions } from "../../lib/modelOptions";
57import { NativeStackScreenOptions } from "../../native/StackHeader";
58import { useProjects, useEnvironmentServerConfig } from "../../state/entities";
59import { useEnvironmentQuery } from "../../state/query";
60import { serverEnvironment } from "../../state/server";
61import { useAtomCommand } from "../../state/use-atom-command";
62import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
63import { resolveNewTaskBranchLabel } from "../threads/new-task-context-presentation";
64import { useVoiceInputController } from "../voice-input/useVoiceInputController";
65import { ScheduledTaskPromptField } from "./components/ScheduledTaskPromptField";
66import {
67 AndroidSettingsEnvironmentFilter,
68 SettingsEnvironmentFilterHeader,
69} from "./components/SettingsEnvironmentFilterHeader";
70import { SettingsScreen } from "./components/SettingsScreen";
71import { SettingsSection } from "./components/SettingsSection";
72import { useSettingsEnvironmentFilter, type SettingsTarget } from "./settings-environment-filter";
73import {
74 editDraft,
75 scheduledTaskDefaultModel,
76 scheduleFromDraft,
77 type ScheduledTaskDraft as Draft,
78} from "./scheduledTaskDraft";
79import { settingsTargetsForProject } from "./settings-environment-filter.logic";
80import { useScheduledTaskEditor } from "./scheduled-task-editor";
81import { scheduledTaskEditorSessionAtom } from "./scheduled-task-editor-state";
82import { appAtomRegistry } from "../../state/atom-registry";
83import {
84 formatNextScheduledTaskRun,
85 formatScheduledTaskInterval,
86} from "./scheduledTaskPresentation";
87
88type ScheduledTaskRoutes = {
89 SettingsScheduledTaskNew: undefined;
90 SettingsScheduledTaskEdit: undefined;
91 SettingsScheduledTaskModel: undefined;
92 SettingsScheduledTaskBranch: undefined;
93};
94
95const 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
105function 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
112function 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
120function 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
132function 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
143function 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
174function 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
231function 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
265export 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
376export 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
388export function SettingsScheduledTaskEditRouteScreen() {
389 return <SettingsScheduledTaskEditorScreen title="Edit scheduled task" />;
390}
391
392function 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
557function 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
1034function 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
1128function 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}