import equal from "fast-deep-equal"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactElement, type ReactNode, } from "react"; import { Text, View } from "react-native"; import { Brain, Folder, GitBranch } from "lucide-react-native"; import { StyleSheet } from "react-native-unistyles"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import type { ScheduleCadence, ScheduleSummary } from "@getpaseo/protocol/schedule/types"; import { useStoreWithEqualityFn } from "zustand/traditional"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { ComboboxItem } from "@/components/ui/combobox"; import { Button } from "@/components/ui/button"; import { CombinedModelSelector } from "@/components/combined-model-selector"; import { useIsCompactFormFactor } from "@/constants/layout"; import { HostStatusDotSlot } from "@/components/hosts/host-picker"; import { createControlGeometry, type FieldControlSize } from "@/components/ui/control-geometry"; import { Field, FormTextInput } from "@/components/ui/form-field"; import { Switch } from "@/components/ui/switch"; import { getProviderIcon } from "@/components/provider-icons"; import { CadenceEditor } from "@/components/schedules/cadence-editor"; import { SelectField, SelectFieldTrigger, type SelectFieldDisplay, type SelectFieldOption, type SelectFieldRenderOptionInput, } from "@/components/ui/select-field"; import { formatThinkingOptionLabel } from "@/agent-controls/labels"; import { mergeProviderPreferences, useFormPreferences, type FormPreferences, } from "@/hooks/use-form-preferences"; import { useScheduleMutations } from "@/hooks/use-schedule-mutations"; import { useAggregatedAgents } from "@/hooks/use-aggregated-agents"; import { useProjects } from "@/hooks/use-projects"; import { useHosts } from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; import { buildScheduleProjectTargets } from "@/schedules/schedule-project-targets"; import { useScheduleFormModel } from "@/schedules/use-schedule-form-model"; import { useScheduleFormProviderSnapshot } from "@/schedules/use-schedule-form-provider-snapshot"; import type { ScheduleFormDisplay, ScheduleFormHost, ScheduleFormModel, ScheduleFormSnapshot, ScheduleFormState, } from "@/schedules/schedule-form-model"; import { validateCron } from "@/utils/schedule-format"; import { toErrorMessage } from "@/utils/error-messages"; import { getDeviceTimeZone } from "@/utils/device-timezone"; export interface ScheduleFormSheetProps { serverId?: string; visible: boolean; onClose: () => void; mode: "create" | "edit"; schedule?: ScheduleSummary; } function parseMaxRuns(raw: string): number | null { const parsed = Number.parseInt(raw, 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : null; } function requireCronCadence( cadence: Extract | undefined, ): Extract { if (!cadence) { throw new Error("Choose a cron cadence before creating this schedule"); } return cadence; } function resolveCreateServerId(input: { mode: "create" | "edit"; serverId: string | null | undefined; hosts: readonly ScheduleFormHost[]; }): string | null { if (input.mode === "edit") { return input.serverId ?? null; } if (input.serverId !== undefined) { return input.serverId; } if (input.hosts.length === 1) { return input.hosts[0]?.serverId ?? null; } return null; } function buildScheduleHostOptionTestId(serverId: string): string { return `schedule-host-option-${serverId}`; } function buildThinkingOptionTestId(optionId: string): string { return `schedule-thinking-option-${optionId}`; } function openKey(props: ScheduleFormSheetProps): string { if (props.mode === "edit") { return `edit:${props.serverId ?? ""}:${props.schedule?.id ?? ""}`; } return `create:${props.serverId ?? ""}`; } function selectScheduleHosts( hosts: readonly { serverId: string; label: string }[], ): (state: ReturnType) => ScheduleFormHost[] { return (state) => hosts.map((host) => ({ serverId: host.serverId, label: host.label, supportsWorkspaceMultiplicity: state.sessions[host.serverId]?.serverInfo?.features?.workspaceMultiplicity === true, })); } function buildSnapshot(input: { mode: "create" | "edit"; serverId: string | undefined; schedule: ScheduleSummary | undefined; hosts: readonly ScheduleFormHost[]; projectTargets: ReturnType; preferences: FormPreferences; timezone: string; }): ScheduleFormSnapshot { const schedule = input.schedule ? { ...input.schedule, serverId: input.serverId, serverName: undefined } : undefined; return { mode: input.mode, schedule, hosts: input.hosts, defaults: { serverId: resolveCreateServerId({ mode: input.mode, serverId: input.serverId, hosts: input.hosts, }), projectTargets: input.projectTargets, preferences: input.preferences, timezone: input.timezone, }, }; } function updateSelectionPreferences(input: { preferences: FormPreferences; provider: AgentProvider; model: string; mode: string; thinkingOptionId: string; isolation: "local" | "worktree"; }): FormPreferences { const model = input.model.trim(); const mode = input.mode.trim(); const thinkingOptionId = input.thinkingOptionId.trim(); return { ...mergeProviderPreferences({ preferences: input.preferences, provider: input.provider, updates: { model: model || undefined, mode: mode || undefined, ...(model && thinkingOptionId ? { thinkingByModel: { [model]: thinkingOptionId } } : {}), }, }), isolation: input.isolation, }; } export function ScheduleFormSheet(props: ScheduleFormSheetProps): ReactElement | null { const [renderedProps, setRenderedProps] = useState(() => props.visible ? props : null, ); const [sheetVisible, setSheetVisible] = useState(props.visible); const livePropsRef = useRef(props); const closeRequestedRef = useRef(false); livePropsRef.current = props; useEffect(() => { if (props.visible) { if (closeRequestedRef.current) { return; } setRenderedProps(props); setSheetVisible(true); return; } if (renderedProps) { setSheetVisible(false); } }, [props, renderedProps]); const requestClose = useCallback(() => { closeRequestedRef.current = true; setSheetVisible(false); }, []); const handleDismiss = useCallback(() => { const dismissedProps = livePropsRef.current; closeRequestedRef.current = false; setRenderedProps(null); setSheetVisible(false); if (dismissedProps.visible) { dismissedProps.onClose(); } }, []); if (!renderedProps) { return null; } return ( ); } function OpenScheduleFormSheet({ serverId, visible, onClose, onDismiss, mode, schedule, }: ScheduleFormSheetProps & { onDismiss: () => void }): ReactElement { const controlSize: FieldControlSize = useIsCompactFormFactor() ? "md" : "sm"; const { projects } = useProjects(); const hostProfiles = useHosts(); const hosts = useStoreWithEqualityFn( useSessionStore, useMemo(() => selectScheduleHosts(hostProfiles), [hostProfiles]), equal, ); const { preferences, updatePreferences } = useFormPreferences(); const projectTargets = useMemo(() => buildScheduleProjectTargets(projects), [projects]); const timezone = useMemo(getDeviceTimeZone, []); const snapshot = useMemo( () => buildSnapshot({ mode, serverId, schedule, hosts, projectTargets, preferences, timezone, }), [hosts, mode, preferences, projectTargets, schedule, serverId, timezone], ); const model = useScheduleFormModel(snapshot); const state = useSyncExternalStore(model.subscribe, model.getState, model.getState); const providerSnapshot = useScheduleFormProviderSnapshot(model, state); const { agents } = useAggregatedAgents({ includeArchived: true }); const mutationServerId = state.selectedServerId ?? serverId ?? ""; const { createSchedule, updateSchedule, isCreating, isUpdating } = useScheduleMutations({ serverId: mutationServerId, }); const isSubmitting = isCreating || isUpdating; const cadenceError = state.cadence.type === "cron" ? validateCron(state.cadence.expression) : null; const canSubmit = state.canSubmit && cadenceError === null && !isSubmitting; const agentTargetLabel = useMemo(() => { if (!schedule || schedule.target.type !== "agent") { return null; } const { agentId } = schedule.target; const agent = agents.find( (entry) => entry.serverId === (state.selectedServerId ?? serverId) && entry.id === agentId, ); if (!agent) { return "Agent unavailable"; } return agent.title?.trim() || "Untitled agent"; }, [agents, schedule, serverId, state.selectedServerId]); const persistPreferences = useCallback(async () => { const provider = state.selectedProvider; if (!provider) { return; } await updatePreferences((current) => updateSelectionPreferences({ preferences: current, provider, model: state.selectedModel, mode: state.selectedMode, thinkingOptionId: state.selectedThinkingOptionId, isolation: state.isolation, }), ); }, [ state.isolation, state.selectedMode, state.selectedModel, state.selectedProvider, state.selectedThinkingOptionId, updatePreferences, ]); const submitAgentTarget = useCallback(async (): Promise => { if (!schedule || !state.submitCadence) { return false; } await updateSchedule({ id: schedule.id, cadence: state.submitCadence, }); return true; }, [schedule, state.submitCadence, updateSchedule]); const submitNewAgent = useCallback(async (): Promise => { const provider = state.selectedProvider; const cwd = state.workingDir.trim(); if (!provider || !cwd) { return false; } await persistPreferences(); const maxRuns = parseMaxRuns(state.maxRuns); if (mode === "edit" && schedule) { await updateSchedule({ id: schedule.id, name: state.name.trim() || null, prompt: state.prompt.trim(), ...(state.submitCadence ? { cadence: state.submitCadence } : {}), newAgentConfig: { provider, model: state.selectedModel || null, modeId: state.selectedMode || null, thinkingOptionId: state.selectedThinkingOptionId || null, cwd, ...(state.submitArchiveOnFinish !== undefined ? { archiveOnFinish: state.submitArchiveOnFinish } : {}), ...(state.submitIsolation !== undefined ? { isolation: state.submitIsolation } : {}), }, maxRuns, }); return true; } await createSchedule({ prompt: state.prompt.trim(), name: state.name.trim() || undefined, cadence: requireCronCadence(state.submitCadence), target: { type: "new-agent", config: { provider, cwd, model: state.selectedModel || undefined, modeId: state.selectedMode || undefined, thinkingOptionId: state.selectedThinkingOptionId || undefined, ...(state.submitArchiveOnFinish !== undefined ? { archiveOnFinish: state.submitArchiveOnFinish } : {}), ...(state.submitIsolation !== undefined ? { isolation: state.submitIsolation } : {}), title: state.name.trim() || undefined, }, }, ...(maxRuns != null ? { maxRuns } : {}), }); return true; }, [createSchedule, mode, persistPreferences, schedule, state, updateSchedule]); const handleSubmit = useCallback(async () => { if (!canSubmit) { return; } model.setSubmitError(null); try { const submitted = state.targetKind === "agent" ? await submitAgentTarget() : await submitNewAgent(); if (submitted) { onClose(); } } catch (error) { model.setSubmitError(toErrorMessage(error)); } }, [canSubmit, model, onClose, state.targetKind, submitAgentTarget, submitNewAgent]); const handleSubmitPress = useCallback(() => { void handleSubmit(); }, [handleSubmit]); const header = useMemo(() => { if (mode !== "edit") { return { title: "New schedule" }; } return { title: schedule?.target.type === "agent" ? "Edit heartbeat" : "Edit schedule" }; }, [mode, schedule?.target.type]); const footer = useMemo( () => ( ), [canSubmit, handleSubmitPress, isSubmitting, mode, onClose], ); return ( ); } interface ScheduleFormFieldsProps { model: ScheduleFormModel; state: ScheduleFormState; providerSnapshot: ReturnType; agentTargetLabel: string | null; controlSize: FieldControlSize; cadenceError: string | null; mutationServerId: string; } function ScheduleFormFields({ model, state, providerSnapshot, agentTargetLabel, controlSize, cadenceError, mutationServerId, }: ScheduleFormFieldsProps): ReactElement { if (state.targetKind === "agent") { return ( <> {state.submitError ? {state.submitError} : null} ); } return ( <> {state.submitError ? {state.submitError} : null} ); } interface ScheduleTargetFieldsProps { model: ScheduleFormModel; state: ScheduleFormState; providerSnapshot: ReturnType; agentTargetLabel: string | null; controlSize: FieldControlSize; mutationServerId: string; } function ScheduleTargetFields({ model, state, providerSnapshot, agentTargetLabel, controlSize, mutationServerId, }: ScheduleTargetFieldsProps): ReactElement { const hostOptions = useMemo[]>( () => state.hosts.map((host) => ({ id: host.serverId, value: host.serverId, label: host.label, testID: buildScheduleHostOptionTestId(host.serverId), })), [state.hosts], ); const selectedHost = state.hosts.find((host) => host.serverId === state.selectedServerId) ?? null; const selectedHostDisplay = useMemo(() => { if (selectedHost) { return { label: selectedHost.label }; } if (state.selectedServerId) { return { label: state.selectedServerId }; } return null; }, [selectedHost, state.selectedServerId]); const projectOptions = state.projectOptions; const modeOptions = useMemo[]>( () => state.modeOptions.map((option) => ({ id: option.id, value: option.id, label: option.label, })), [state.modeOptions], ); const thinkingOptions = useMemo[]>( () => state.availableThinkingOptions.map((option) => ({ id: option.id, value: option.id, label: formatThinkingOptionLabel(option), testID: buildThinkingOptionTestId(option.id), })), [state.availableThinkingOptions], ); const handleSelectHost = useCallback( (nextServerId: string) => { model.setHost(nextServerId); }, [model], ); const handleSelectProject = useCallback( (optionId: string, display: ScheduleFormDisplay) => { model.setProject(optionId, display); }, [model], ); const handleSelectModel = useCallback( (provider: AgentProvider, modelId: string) => { model.setModel(provider, modelId); }, [model], ); const handleSelectMode = useCallback( (modeId: string) => { model.setSessionMode(modeId); }, [model], ); const handleSelectThinking = useCallback( (thinkingOptionId: string) => { model.setThinking(thinkingOptionId); }, [model], ); const handleModelOpen = useCallback(() => { providerSnapshot.refetchIfStale(state.selectedProvider); }, [providerSnapshot, state.selectedProvider]); const handleRetryProvider = useCallback( (provider: AgentProvider) => { void providerSnapshot.refresh([provider]); }, [providerSnapshot], ); const renderHostOption = useCallback( (input: SelectFieldRenderOptionInput) => , [], ); const renderProjectOption = useCallback( (input: SelectFieldRenderOptionInput) => , [], ); const renderThinkingOption = useCallback( (input: SelectFieldRenderOptionInput) => , [], ); const modelTriggerLeading = useMemo( () => , [state.selectedProvider], ); const renderModelTrigger = useCallback( ({ selectedModelLabel, disabled, isOpen, hovered, pressed, }: { selectedModelLabel: string; onPress: () => void; disabled: boolean; isOpen: boolean; hovered: boolean; pressed: boolean; }): ReactNode => { const displayLabel = state.selectedModelDisplay?.label ?? selectedModelLabel; return ( ); }, [controlSize, modelTriggerLeading, state.selectedModel, state.selectedModelDisplay], ); if (state.targetKind === "agent") { return ; } return ( <> {state.mode === "edit" || state.hosts.length > 1 ? ( ) : null} {state.disclosure.showProjectField ? ( ) : null} {state.disclosure.showModelField ? ( ) : null} {state.disclosure.showThinkingField ? ( 6} title="Select thinking" size={controlSize} triggerTestID="schedule-thinking-trigger" renderOption={renderThinkingOption} /> ) : null} {state.disclosure.showModeField ? ( 6} title="Select mode" size={controlSize} triggerTestID="schedule-mode-trigger" /> ) : null} {state.disclosure.showIsolationField ? ( ) : null} {state.disclosure.showArchiveOnFinishField ? ( ) : null} ); } function ScheduleIsolationField({ model, state, size, }: { model: ScheduleFormModel; state: ScheduleFormState; size: FieldControlSize; }): ReactElement { const options = useMemo[]>( () => [ { id: "local", value: "local", label: "Local", testID: "schedule-isolation-local", }, { id: "worktree", value: "worktree", label: "Worktree", testID: "schedule-isolation-worktree", }, ], [], ); const selectedDisplay = useMemo( () => ({ label: state.effectiveIsolation === "worktree" ? "Worktree" : "Local" }), [state.effectiveIsolation], ); const triggerLeading = useMemo( () => ( {state.effectiveIsolation === "worktree" ? ( ) : ( )} ), [state.effectiveIsolation], ); const handleSelectIsolation = useCallback( (value: "local" | "worktree") => { model.setIsolation(value); }, [model], ); const renderIsolationOption = useCallback( (input: SelectFieldRenderOptionInput<"local" | "worktree">) => ( ), [], ); return ( ); } function ScheduleAgentTargetField({ label, size, }: { label: string | null; size: FieldControlSize; }): ReactElement { const fieldStyle = useMemo( () => [styles.readonlyField, size === "sm" ? styles.readonlyFieldSm : styles.readonlyFieldMd], [size], ); const textStyle = useMemo( () => [styles.readonlyText, size === "sm" ? styles.readonlyTextSm : styles.readonlyTextMd], [size], ); return ( {label} ); } function IsolationOptionItem({ option, selected, active, onPress, }: SelectFieldRenderOptionInput<"local" | "worktree">): ReactElement { const leadingSlot = useMemo( () => ( {option.value === "worktree" ? ( ) : ( )} ), [option.value], ); return ( ); } function HostOptionItem({ option, selected, active, onPress, }: SelectFieldRenderOptionInput): ReactElement { const leadingSlot = useMemo(() => , [option.value]); return ( ); } function ProjectOptionItem({ option, selected, active, onPress, }: SelectFieldRenderOptionInput): ReactElement { const leadingSlot = useMemo( () => ( ), [], ); return ( ); } function ThinkingOptionItem({ option, selected, active, onPress, }: SelectFieldRenderOptionInput): ReactElement { const leadingSlot = useMemo( () => ( ), [], ); return ( ); } function ProviderGlyph({ provider }: { provider: string | null }): ReactElement | null { if (!provider) { return null; } const Icon = getProviderIcon(provider); return ; } const styles = StyleSheet.create((theme) => { const geometry = createControlGeometry(theme); return { multilineInput: { minHeight: 96, }, readonlyField: { flexDirection: "row", alignItems: "center", backgroundColor: theme.colors.surface2, borderWidth: 1, borderColor: theme.colors.border, }, readonlyFieldSm: { ...geometry.formTextInputSm, }, readonlyFieldMd: { ...geometry.formTextInputMd, }, readonlyText: { flex: 1, minWidth: 0, color: theme.colors.foreground, }, readonlyTextSm: { fontSize: theme.fontSize.sm, }, readonlyTextMd: { fontSize: theme.fontSize.base, }, optionIconBox: { width: 18, height: 18, alignItems: "center", justifyContent: "center", }, footer: { flex: 1, flexDirection: "row", gap: theme.spacing[3], }, footerButton: { flex: 1, }, submitError: { color: theme.colors.palette.red[300], fontSize: theme.fontSize.xs, }, providerIcon: { color: theme.colors.foregroundMuted, }, }; });