import { useCallback, useEffect, useMemo, useState, useSyncExternalStore, type ReactElement, } from "react"; import { ScrollView, Text, View } from "react-native"; import { useIsFocused } from "@react-navigation/native"; import { CalendarClock, Plus } from "lucide-react-native"; import { StyleSheet } from "react-native-unistyles"; import { MenuHeader } from "@/components/headers/menu-header"; import { ExternalLink } from "@/components/ui/external-link"; import { HostFilter } from "@/components/hosts/host-filter"; import { ALL_HOSTS_OPTION_ID } from "@/components/hosts/host-picker"; import { ScheduleFormSheet } from "@/components/schedules/schedule-form-sheet"; import { SchedulesTable, type ScheduleRowView } from "@/components/schedules/schedules-table"; import { Button } from "@/components/ui/button"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { SegmentedControl } from "@/components/ui/segmented-control"; import { useAggregatedAgents } from "@/hooks/use-aggregated-agents"; import { useProjects } from "@/hooks/use-projects"; import { useSchedules, type AggregateLoadState, type AggregatedSchedule, type ScheduleHostError, } from "@/hooks/use-schedules"; import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime"; import { resolveSchedule, type ScheduleBucket, type ScheduleTargetAgent, } from "@/schedules/schedule-derivation"; import { resolveSchedulesScreenBodyState } from "./schedules-screen-state"; import { buildProjectNameByCwd, buildScheduleProjectTargets, } from "@/schedules/schedule-project-targets"; import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types"; type FormState = | { mode: "closed" } | { mode: "create" } | { mode: "edit"; serverId: string; schedule: ScheduleSummary }; const STATUS_FILTER_OPTIONS: { value: ScheduleBucket; label: string; testID: string }[] = [ { value: "runnable", label: "Active", testID: "schedules-filter-active" }, { value: "ended", label: "Ended", testID: "schedules-filter-ended" }, ]; const EMPTY_SCHEDULES: AggregatedSchedule[] = []; export function SchedulesScreen(): ReactElement { const isFocused = useIsFocused(); if (!isFocused) { return ; } return ; } function SchedulesScreenContent(): ReactElement { const { loadState, hostErrors, isError, refetch } = useSchedules(); const schedules = loadState.status === "loaded" ? loadState.data : EMPTY_SCHEDULES; const { agents } = useAggregatedAgents({ includeArchived: true }); const { projects } = useProjects(); const hosts = useHosts(); const runtime = getHostRuntimeStore(); const runtimeVersion = useSyncExternalStore( (onStoreChange) => runtime.subscribeAll(onStoreChange), () => runtime.getVersion(), () => runtime.getVersion(), ); // Per-host agent-directory readiness from the runtime, not the aggregate agent // flag: the aggregate `isInitialLoad` flips false as soon as *any* host has // agents, so a still-loading host would falsely mark its agent-target // schedules "gone". `hasEverLoadedAgentDirectory` is true only once that // host's directory has loaded at least once. const agentDirReadyHosts = useMemo(() => { void runtimeVersion; const ready = new Set(); for (const host of hosts) { if (runtime.getSnapshot(host.serverId)?.hasEverLoadedAgentDirectory) { ready.add(host.serverId); } } return ready; }, [hosts, runtime, runtimeVersion]); const [form, setForm] = useState({ mode: "closed" }); const [selectedHost, setSelectedHost] = useState(ALL_HOSTS_OPTION_ID); const [statusFilter, setStatusFilter] = useState("runnable"); useEffect(() => { if ( selectedHost !== ALL_HOSTS_OPTION_ID && !hosts.some((host) => host.serverId === selectedHost) ) { setSelectedHost(ALL_HOSTS_OPTION_ID); } }, [hosts, selectedHost]); const openCreate = useCallback(() => setForm({ mode: "create" }), []); const openEdit = useCallback((schedule: AggregatedSchedule) => { setForm({ mode: "edit", serverId: schedule.serverId, schedule }); }, []); const closeForm = useCallback(() => setForm({ mode: "closed" }), []); const agentsByKey = useMemo(() => { const map = new Map(); for (const agent of agents) { map.set(`${agent.serverId}:${agent.id}`, { title: agent.title, provider: agent.provider }); } return map; }, [agents]); const projectNameByCwd = useMemo( () => buildProjectNameByCwd(buildScheduleProjectTargets(projects)), [projects], ); // Resolve every schedule's derived state and target line once, then partition // by the host and status filters. Sorted newest-first for a stable order // across hosts. const resolvedRows = useMemo(() => { const now = Date.now(); return schedules.map((schedule) => ({ schedule, resolved: resolveSchedule({ schedule, serverId: schedule.serverId, now, agentsByKey, projectNameByCwd, agentDataLoaded: agentDirReadyHosts.has(schedule.serverId), }), })); }, [schedules, agentsByKey, projectNameByCwd, agentDirReadyHosts]); const visibleRows = useMemo(() => { const singleHost = hosts.length <= 1; return resolvedRows .filter( ({ schedule, resolved }) => (selectedHost === ALL_HOSTS_OPTION_ID || schedule.serverId === selectedHost) && resolved.bucket === statusFilter, ) .sort((a, b) => Date.parse(b.schedule.createdAt) - Date.parse(a.schedule.createdAt)) .map(({ schedule, resolved }) => ({ schedule, targetLabel: resolved.target.label, provider: resolved.target.provider, state: resolved.state, serverName: schedule.serverName, singleHost, })); }, [resolvedRows, selectedHost, statusFilter, hosts.length]); const showLoadError = isError && loadState.status !== "loaded"; const showHostFilter = hosts.length > 1; return ( ); } function SchedulesScreenBody({ rows, loadState, hostErrors, showLoadError, statusFilter, onStatusFilterChange, showHostFilter, hosts, selectedHost, onSelectHost, onRetry, onCreate, onEdit, }: { rows: ScheduleRowView[]; loadState: AggregateLoadState; hostErrors: ScheduleHostError[]; showLoadError: boolean; statusFilter: ScheduleBucket; onStatusFilterChange: (value: ScheduleBucket) => void; showHostFilter: boolean; hosts: ReturnType; selectedHost: string; onSelectHost: (serverId: string) => void; onRetry: () => void; onCreate: () => void; onEdit: (schedule: AggregatedSchedule) => void; }): ReactElement { const bodyState = resolveSchedulesScreenBodyState({ loadState, showLoadError }); if (bodyState.kind === "loading") { return ( ); } if (bodyState.kind === "load-error") { return ( Unable to load schedules ); } if (bodyState.kind === "empty") { return ( {hostErrors.length > 0 ? : null} ); } let schedulesContent: ReactElement; if (rows.length > 0) { schedulesContent = ; } else if (statusFilter === "ended") { schedulesContent = ; } else { schedulesContent = ( ); } return ( {showHostFilter ? ( ) : null} {hostErrors.length > 0 ? : null} {schedulesContent} ); } function SchedulesEmptyState({ onCreate, testID, }: { onCreate: () => void; testID?: string; }): ReactElement { return ( No active schedules Schedules run agents on a cadence. ); } function SchedulesEndedEmptyState(): ReactElement { return ( No ended schedules ); } function ScheduleHostErrorsBanner({ errors }: { errors: ScheduleHostError[] }): ReactElement { return ( {errors.map((error) => ( {`${error.serverName}: Could not load schedules`} ))} ); } const styles = StyleSheet.create((theme) => ({ container: { flex: 1, backgroundColor: theme.colors.surface0, }, body: { flex: 1, minHeight: 0, }, centered: { flex: 1, justifyContent: "center", alignItems: "center", gap: theme.spacing[6], padding: theme.spacing[6], }, filterRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: theme.spacing[3], paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[6] }, paddingTop: theme.spacing[4], }, filterRowControls: { flexDirection: "row", alignItems: "center", gap: theme.spacing[3], flexShrink: 1, flexWrap: "wrap", }, scroll: { flex: 1, minHeight: 0, }, scrollContent: { flexGrow: 1, gap: theme.spacing[3], paddingTop: theme.spacing[4], paddingBottom: theme.spacing[6], }, errorsBannerWrap: { paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[6] }, }, errorsBanner: { borderWidth: 1, borderColor: theme.colors.border, borderRadius: theme.borderRadius.lg, padding: theme.spacing[3], gap: theme.spacing[1], }, errorsBannerText: { color: theme.colors.palette.red[300], fontSize: theme.fontSize.xs, }, filterEmpty: { flexGrow: 1, paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[6] }, paddingVertical: theme.spacing[6], alignItems: "center", justifyContent: "center", }, emptyState: { alignItems: "center", gap: theme.spacing[4], maxWidth: 420, width: "100%", }, endedEmptyState: { alignItems: "center", gap: theme.spacing[3], }, emptyTextStack: { alignItems: "center", gap: theme.spacing[2], }, emptyTitle: { color: theme.colors.foreground, fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, textAlign: "center", }, emptyDescription: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, textAlign: "center", }, message: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.lg, textAlign: "center", }, // Static color holder read by the spinner; keeps the muted token without // useUnistyles (banned in new code). spinner: { color: theme.colors.foregroundMuted, }, emptyIcon: { color: theme.colors.foregroundMuted, width: theme.iconSize.lg, }, }));