import { MoreVertical, Pause, Pencil, Play, RotateCw, Trash2 } from "lucide-react-native"; import { useCallback, useState, type ReactElement } from "react"; import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { StatusBadge } from "@/components/ui/status-badge"; import { getProviderIcon } from "@/components/provider-icons"; import { isNative } from "@/constants/platform"; import { useIsCompactFormFactor } from "@/constants/layout"; import { settingsStyles } from "@/styles/settings"; import type { Theme } from "@/styles/theme"; import type { ScheduleDerivedState } from "@/schedules/schedule-derivation"; import { formatCadence, formatNextRun, resolveScheduleTitle, scheduleProductName, } from "@/utils/schedule-format"; import { formatTimeAgo } from "@/utils/time"; import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types"; // Themed lucide wrappers — module-scope so only the icon re-renders on theme // change (never call useUnistyles in render). See docs/unistyles.md. const ThemedPencil = withUnistyles(Pencil); const ThemedPause = withUnistyles(Pause); const ThemedPlay = withUnistyles(Play); const ThemedRotateCw = withUnistyles(RotateCw); const ThemedTrash2 = withUnistyles(Trash2); const ThemedKebab = withUnistyles(MoreVertical); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive }); const MENU_ICON_SIZE = 14; const PROVIDER_ICON_SIZE = 16; // Pending flags for each action so the parent table can wire a mutation hook // and the row reflects in-flight state without owning the mutation itself. export interface ScheduleRowPending { pause?: boolean; resume?: boolean; runNow?: boolean; delete?: boolean; } export interface ScheduleRowActions { onEdit: () => void; onPause: () => void; onResume: () => void; onRunNow: () => void; onDelete: () => void; } interface ScheduleRowProps extends ScheduleRowActions { schedule: ScheduleSummary; /** Client-derived target line (agent title / project / shortened path). */ targetLabel: string; /** Provider glyph, resolved from the schedule config or the target agent. */ provider: string | null; /** Client-derived state — the single source for the badge and next-run copy. */ state: ScheduleDerivedState; /** Host name, rendered when the list spans more than one host. */ serverName?: string; /** True when only one host exists and the host name would be redundant. */ singleHost?: boolean; pending?: ScheduleRowPending; isFirst: boolean; } function stateBadge(state: ScheduleDerivedState): { label: string; variant: "success" | "error" | "muted"; } { switch (state) { case "active": return { label: "Active", variant: "success" }; case "paused": return { label: "Paused", variant: "muted" }; case "expired": return { label: "Expired", variant: "muted" }; case "finished": return { label: "Finished", variant: "muted" }; case "targetGone": return { label: "Target gone", variant: "error" }; } } // Meta reads left-to-right as identity → history → future: how often, when it // was created, when it last ran, and (only while it can still run) when it runs // next. Status lives on the badge, never repeated here. function buildMeta( schedule: ScheduleSummary, state: ScheduleDerivedState, serverName: string | undefined, singleHost: boolean, ): string { const parts = [ formatCadence(schedule.cadence), `Created ${formatTimeAgo(new Date(schedule.createdAt))}`, schedule.lastRunAt ? `Last run ${formatTimeAgo(new Date(schedule.lastRunAt))}` : "Never run", ]; if (state === "active") { const next = formatNextRun(schedule.nextRunAt); if (next) { parts.push(`Next run ${next}`); } } if (serverName && !singleHost) { parts.unshift(serverName); } return parts.join(" · "); } /** Small provider glyph. Reads the icon color off a StyleSheet object so the * dynamic component (getProviderIcon) stays compliant without useUnistyles. */ function ProviderGlyph({ provider }: { provider: string | null }): ReactElement | null { if (!provider) { return null; } const Icon = getProviderIcon(provider); return ; } /** * One schedule, rendered as a settings-style card row: provider glyph + title, * a muted secondary line (model · cadence · next run), a StatusBadge, and the * kebab menu that owns every row action. Tapping the row opens the editor. * * Hover lives on the outer plain View (docs/hover.md): the inner Pressable owns * press, the nested kebab Pressable never fights it, and the row background * highlights without reflow. */ export function ScheduleRow({ schedule, targetLabel, provider, state, serverName, singleHost, pending, isFirst, onEdit, onPause, onResume, onRunNow, onDelete, }: ScheduleRowProps): ReactElement { const isCompact = useIsCompactFormFactor(); const [isHovered, setIsHovered] = useState(false); const handlePointerEnter = useCallback(() => setIsHovered(true), []); const handlePointerLeave = useCallback(() => setIsHovered(false), []); const title = resolveScheduleTitle(schedule); const productName = scheduleProductName(schedule); const badge = stateBadge(state); const meta = buildMeta(schedule, state, serverName, singleHost ?? false); const canRun = schedule.target.type === "new-agent" && (state === "active" || state === "paused"); const rowStyle = useCallback( ({ pressed }: PressableStateCallbackType) => [ settingsStyles.row, styles.row, !isFirst && settingsStyles.rowBorder, isHovered && !isCompact && styles.rowHovered, pressed && styles.rowPressed, ], [isFirst, isHovered, isCompact], ); return ( {title} {targetLabel} {meta} ); } const editLeading = ; const pauseLeading = ; const resumeLeading = ; const runLeading = ; const deleteLeading = ; function ScheduleExecutionMenuItems({ schedule, canRun, pending, onPause, onResume, onRunNow, }: Pick & { canRun: boolean; }): ReactElement | null { if (schedule.target.type === "agent") { return null; } let cadenceAction: ReactElement; if (schedule.status === "paused") { cadenceAction = ( Resume schedule ); } else { cadenceAction = ( Pause schedule ); } return ( <> {cadenceAction} Run now ); } function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }): ReactElement { return ( ); } function ScheduleKebabMenu({ schedule, canRun, pending, onEdit, onPause, onResume, onRunNow, onDelete, }: Pick< ScheduleRowProps, "schedule" | "pending" | "onEdit" | "onPause" | "onResume" | "onRunNow" | "onDelete" > & { canRun: boolean; }): ReactElement { const productName = scheduleProductName(schedule); const productNameLower = productName.toLowerCase(); return ( {renderKebabTriggerIcon} Edit {productNameLower} Delete {productNameLower} ); } function kebabTriggerStyle({ hovered = false, }: PressableStateCallbackType & { hovered?: boolean }) { return [styles.kebabTrigger, hovered && styles.kebabTriggerHovered]; } const styles = StyleSheet.create((theme) => ({ // Static color holder for the dynamic provider icon (compliant idiom). providerIcon: { color: theme.colors.foregroundMuted, }, rowContainer: { position: "relative", }, row: { gap: theme.spacing[3], }, rowHovered: { backgroundColor: theme.colors.surface2, }, rowPressed: { backgroundColor: theme.colors.surface3, }, main: { flex: 1, minWidth: 0, flexDirection: "row", alignItems: "center", gap: theme.spacing[3], }, leading: { width: PROVIDER_ICON_SIZE, height: PROVIDER_ICON_SIZE, alignItems: "center", justifyContent: "center", }, textGroup: { flex: 1, minWidth: 0, }, target: { marginTop: theme.spacing[1], color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, trailing: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, kebabTrigger: { padding: theme.spacing[1], borderRadius: theme.borderRadius.base, }, kebabTriggerHovered: { backgroundColor: theme.colors.surface2, }, }));