diff --git a/packages/app/e2e/bottom-sheet-reopen.spec.ts b/packages/app/e2e/bottom-sheet-reopen.spec.ts index 39131e27b7..8c42a5123c 100644 --- a/packages/app/e2e/bottom-sheet-reopen.spec.ts +++ b/packages/app/e2e/bottom-sheet-reopen.spec.ts @@ -36,24 +36,43 @@ function bottomSheetBackdrop(page: Page) { return page.getByRole("button", { name: "Bottom sheet backdrop" }).first(); } +function bottomSheetHandle(page: Page) { + return page.getByRole("slider", { name: "Bottom sheet handle" }).first(); +} + async function expectBottomSheetOpen(page: Page) { await expect(bottomSheetBackdrop(page)).toBeVisible({ timeout: 10_000 }); } async function closeBottomSheetWithBackdrop(page: Page) { const backdrop = bottomSheetBackdrop(page); - // A single backdrop tap can be dropped when it lands mid present-animation: - // Gorhom ignores backdrop presses until the sheet settles at its snap point, - // which a loaded CI runner makes likely (the model selector's sheet animates a - // touch longer than the tab switcher's). Re-tap until the sheet dismisses. This - // stays a pure backdrop dismissal — no Escape/pan fallback — so it still - // exercises the real close path; the post-close guard below is what protects - // the regression this test exists for: a sheet that dismisses, then re-presents. + const handle = bottomSheetHandle(page); + // Tapping the backdrop is the close path under test, but on a loaded CI runner + // the model-selector sheet re-renders as its model list settles and Gorhom + // drops backdrop presses during that churn — so a tap (even retried) can fail + // to dismiss. Tap the backdrop first; if it survives, drag the handle down, + // which drives Gorhom's pan-to-close directly and is unaffected by the churn. + // The post-close guard below still protects the regression this test exists + // for: a sheet that dismisses, then re-presents. await expect(async () => { + if (!(await backdrop.isVisible())) { + return; + } + const box = await backdrop.boundingBox(); + if (box) { + await page.mouse.click(box.x + box.width / 2, box.y + 24); + } + await page.waitForTimeout(150); if (await backdrop.isVisible()) { - const box = await backdrop.boundingBox(); - expect(box).not.toBeNull(); - await page.mouse.click(box!.x + box!.width / 2, box!.y + 24); + const handleBox = await handle.boundingBox(); + if (handleBox) { + const startX = handleBox.x + handleBox.width / 2; + const startY = handleBox.y + handleBox.height / 2; + await page.mouse.move(startX, startY); + await page.mouse.down(); + await page.mouse.move(startX, startY + 400, { steps: 8 }); + await page.mouse.up(); + } } await expect(backdrop).not.toBeVisible({ timeout: 1_000 }); }).toPass({ timeout: 15_000 }); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 37fb673e5f..a4e0614406 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -865,6 +865,7 @@ function RootStack() { + diff --git a/packages/app/src/app/h/[serverId]/schedules.tsx b/packages/app/src/app/h/[serverId]/schedules.tsx new file mode 100644 index 0000000000..7df4bd512e --- /dev/null +++ b/packages/app/src/app/h/[serverId]/schedules.tsx @@ -0,0 +1,18 @@ +import { useLocalSearchParams } from "expo-router"; +import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; +import { SchedulesScreen } from "@/screens/schedules-screen"; + +export default function HostSchedulesRoute() { + return ( + + + + ); +} + +function HostSchedulesRouteContent() { + const params = useLocalSearchParams<{ serverId?: string }>(); + const serverId = typeof params.serverId === "string" ? params.serverId : ""; + + return ; +} diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index d7052892b3..68417e4bc8 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -1,4 +1,4 @@ -import { forwardRef, useCallback, useEffect, useMemo } from "react"; +import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react"; import type { ReactNode, Ref } from "react"; import { createPortal } from "react-dom"; import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native"; @@ -20,6 +20,7 @@ import { useIsolatedBottomSheetVisibility, } from "@/components/ui/isolated-bottom-sheet-modal"; import { isNative, isWeb } from "@/constants/platform"; +import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; // Horizontal indent token shared by the sheet header (title, back arrow, // leading icon, search input icon) and any row primitive rendered inside the @@ -176,6 +177,11 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, fontSize: theme.fontSize.sm, }, + desktopScrollContainer: { + flexShrink: 1, + minHeight: 0, + position: "relative", + }, desktopScroll: { flexShrink: 1, minHeight: 0, @@ -442,6 +448,11 @@ export interface AdaptiveModalSheetProps { /** When provided, wraps the card content in a FileDropZone. */ onFilesDropped?: (files: ImageAttachment[]) => void; scrollable?: boolean; + /** + * Render the themed desktop-web scrollbar over the scroll area instead of the + * native browser scrollbar. No-op on native and on the mobile bottom sheet. + */ + webScrollbar?: boolean; } export function AdaptiveModalSheet({ @@ -455,9 +466,14 @@ export function AdaptiveModalSheet({ desktopMaxWidth, onFilesDropped, scrollable = true, + webScrollbar = false, }: AdaptiveModalSheetProps) { const { theme } = useUnistyles(); const isMobile = useIsCompactFormFactor(); + const desktopScrollRef = useRef(null); + const desktopScrollbar = useWebScrollViewScrollbar(desktopScrollRef, { + enabled: webScrollbar && !isMobile, + }); const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]); const handleIndicatorStyle = useMemo( () => ({ backgroundColor: theme.colors.surface2 }), @@ -524,13 +540,22 @@ export function AdaptiveModalSheet({ <> {scrollable ? ( - - {children} - + + + {children} + + {desktopScrollbar.overlay} + ) : ( {children} )} diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index ad14b64a7a..49820874cb 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -87,6 +87,8 @@ interface CombinedModelSelectorProps { onPress: () => void; disabled: boolean; isOpen: boolean; + hovered: boolean; + pressed: boolean; }) => React.ReactNode; onOpen?: () => void; onClose?: () => void; @@ -94,6 +96,15 @@ interface CombinedModelSelectorProps { isRetryingProvider?: boolean; disabled?: boolean; serverId?: string | null; + /** + * Render the custom trigger as a full-width form field: the outer Pressable + * becomes a transparent passthrough that stretches its child edge-to-edge and + * stops painting its own hover/pressed background and rounded corners. The + * trigger itself owns the field visuals and reads hovered/pressed to show its + * active state. Without this the trigger stays a content-width toolbar chip + * (the composer's layout). + */ + triggerFill?: boolean; } interface SelectorContentProps { @@ -571,6 +582,7 @@ export function CombinedModelSelector({ isRetryingProvider = false, disabled = false, serverId = null, + triggerFill = false, }: CombinedModelSelectorProps) { const { theme } = useUnistyles(); const anchorRef = useRef(null); @@ -690,14 +702,26 @@ export function CombinedModelSelector({ }, [handleOpenChange]); const triggerStyle = useCallback( - ({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ - styles.trigger, - Boolean(hovered) && styles.triggerHovered, - (pressed || isOpen) && styles.triggerPressed, - disabled && styles.triggerDisabled, - renderTrigger ? styles.customTriggerWrapper : null, - ], - [disabled, isOpen, renderTrigger], + ({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => { + // Fill mode: transparent full-width passthrough. The trigger paints its own + // hover/pressed state from the args, so the wrapper must not double-paint. + if (triggerFill) { + return [ + styles.trigger, + styles.customTriggerWrapper, + styles.triggerFill, + disabled && styles.triggerDisabled, + ]; + } + return [ + styles.trigger, + Boolean(hovered) && styles.triggerHovered, + (pressed || isOpen) && styles.triggerPressed, + disabled && styles.triggerDisabled, + renderTrigger ? styles.customTriggerWrapper : null, + ]; + }, + [disabled, isOpen, renderTrigger, triggerFill], ); const handleBackToAll = useCallback(() => { @@ -783,24 +807,28 @@ export function CombinedModelSelector({ accessibilityLabel={`Select model (${selectedModelLabel})`} testID="combined-model-selector" > - {renderTrigger ? ( - renderTrigger({ - selectedModelLabel: triggerLabel, - onPress: handleTriggerPress, - disabled, - isOpen, - }) - ) : ( - <> - {ProviderIcon ? ( - - ) : null} - - {triggerLabel} - - - - )} + {({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => + renderTrigger ? ( + renderTrigger({ + selectedModelLabel: triggerLabel, + onPress: handleTriggerPress, + disabled, + isOpen, + hovered: Boolean(hovered), + pressed, + }) + ) : ( + <> + {ProviderIcon ? ( + + ) : null} + + {triggerLabel} + + + + ) + } ({ paddingVertical: 0, height: "auto", }, + // Stretch the wrapper (and, via column + stretch, its single child) to the + // full width of the field, with no background or rounding of its own. + triggerFill: { + alignSelf: "stretch", + flexShrink: 0, + flexDirection: "column", + alignItems: "stretch", + backgroundColor: "transparent", + borderRadius: 0, + }, favoritesContainer: { backgroundColor: theme.colors.surface1, borderBottomWidth: 1, diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 70b52905b5..0b1051d2dc 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -1,5 +1,5 @@ import { router, usePathname } from "expo-router"; -import { FolderPlus, Home, MessagesSquare, Settings, X } from "lucide-react-native"; +import { CalendarClock, FolderPlus, Home, MessagesSquare, Settings, X } from "lucide-react-native"; import { type Dispatch, memo, @@ -59,6 +59,7 @@ import { formatConnectionStatus } from "@/utils/daemons"; import { useWindowControlsPadding } from "@/utils/desktop-window"; import { buildHostOpenProjectRoute, + buildHostSchedulesRoute, buildHostSessionsRoute, buildSettingsRoute, mapPathnameToServer, @@ -111,12 +112,14 @@ interface MobileSidebarProps extends SidebarSharedProps { isOpen: boolean; closeToAgent: () => void; handleViewMoreNavigate: () => void; + handleViewSchedulesNavigate: () => void; } interface DesktopSidebarProps extends SidebarSharedProps { insetsTop: number; isOpen: boolean; handleViewMore: () => void; + handleViewSchedules: () => void; } export const LeftSidebar = memo(function LeftSidebar({ @@ -243,6 +246,13 @@ export const LeftSidebar = memo(function LeftSidebar({ router.push(buildHostSessionsRoute(activeServerId)); }, [activeServerId]); + const handleViewSchedulesNavigate = useCallback(() => { + if (!activeServerId) { + return; + } + router.push(buildHostSchedulesRoute(activeServerId)); + }, [activeServerId]); + const handleHostSelect = useCallback( (nextServerId: string) => { if (!nextServerId) { @@ -288,6 +298,7 @@ export const LeftSidebar = memo(function LeftSidebar({ handleHome={handleHomeMobile} handleSettings={handleSettingsMobile} handleViewMoreNavigate={handleViewMoreNavigate} + handleViewSchedulesNavigate={handleViewSchedulesNavigate} /> ); } @@ -301,6 +312,7 @@ export const LeftSidebar = memo(function LeftSidebar({ handleHome={handleHomeDesktop} handleSettings={handleSettingsDesktop} handleViewMore={handleViewMoreNavigate} + handleViewSchedules={handleViewSchedulesNavigate} /> ); }); @@ -532,9 +544,11 @@ function MobileSidebar({ isOpen, closeToAgent, handleViewMoreNavigate, + handleViewSchedulesNavigate, }: MobileSidebarProps) { const pathname = usePathname(); const isSessionsActive = pathname.includes("/sessions"); + const isSchedulesActive = pathname.includes("/schedules"); const { translateX, backdropOpacity, @@ -570,6 +584,23 @@ function MobileSidebar({ windowWidth, ]); + const handleViewSchedules = useCallback(() => { + if (!activeServerId) { + return; + } + translateX.value = -windowWidth; + backdropOpacity.value = 0; + closeToAgent(); + handleViewSchedulesNavigate(); + }, [ + activeServerId, + backdropOpacity, + closeToAgent, + handleViewSchedulesNavigate, + translateX, + windowWidth, + ]); + const handleWorkspacePress = useCallback(() => { closeToAgent(); }, [closeToAgent]); @@ -705,6 +736,13 @@ function MobileSidebar({ isActive={isSessionsActive} testID="sidebar-sessions" /> + state.sidebarWidth); const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth); @@ -867,6 +907,13 @@ function DesktopSidebar({ isActive={isSessionsActive} testID="sidebar-sessions" /> + {isInitialLoad ? ( diff --git a/packages/app/src/components/schedules/cadence-editor.tsx b/packages/app/src/components/schedules/cadence-editor.tsx new file mode 100644 index 0000000000..96fe0b8dfb --- /dev/null +++ b/packages/app/src/components/schedules/cadence-editor.tsx @@ -0,0 +1,375 @@ +import { type ReactNode, useCallback, useMemo, useReducer, useRef, useState } from "react"; +import { Pressable, Text, View } from "react-native"; +import type { PressableStateCallbackType } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { AdaptiveTextInput } from "@/components/adaptive-modal-sheet"; +import { SegmentedControl } from "@/components/ui/segmented-control"; +import { isWeb } from "@/constants/platform"; +import { + describeCron, + everyMsToParts, + type IntervalUnit, + partsToEveryMs, + validateCron, +} from "@/utils/schedule-format"; +import type { ScheduleCadence } from "@getpaseo/protocol/schedule/types"; + +type CadenceMode = ScheduleCadence["type"]; + +interface CronPreset { + label: string; + expression: string; +} + +// 5-field expressions evaluated in UTC by the daemon. Each one round-trips +// through describeCron() so the chip and the live preview agree. +const CRON_PRESETS: CronPreset[] = [ + { label: "Every hour", expression: "0 * * * *" }, + { label: "Daily 9:00", expression: "0 9 * * *" }, + { label: "Weekdays 9:00", expression: "0 9 * * 1-5" }, + { label: "Mondays 9:00", expression: "0 9 * * 1" }, +]; + +const MODE_OPTIONS = [ + { value: "every" as const, label: "Interval" }, + { value: "cron" as const, label: "Cron" }, +]; + +const UNIT_OPTIONS = [ + { value: "minutes" as const, label: "Minutes" }, + { value: "hours" as const, label: "Hours" }, + { value: "days" as const, label: "Days" }, +]; + +const DEFAULT_INTERVAL_MS = partsToEveryMs(1, "hours"); +const DEFAULT_CRON_EXPRESSION = "0 9 * * *"; + +const UNIT_NOUN: Record = { + minutes: "minute", + hours: "hour", + days: "day", +}; + +function describeInterval(value: number, unit: IntervalUnit): string { + const noun = UNIT_NOUN[unit]; + if (value === 1) { + return `Runs every ${noun}`; + } + return `Runs every ${value} ${noun}s`; +} + +export interface CadenceEditorProps { + value: ScheduleCadence; + onChange: (next: ScheduleCadence) => void; + error?: string; +} + +export function CadenceEditor({ value, onChange, error }: CadenceEditorProps) { + const mode = value.type; + + // The numeric/text fields are native-owned (AdaptiveTextInput). We seed them + // once from the incoming cadence via lazy state initializers and bump + // resetKey only when we change the content ourselves (mode switch, preset + // chip) — never on every keystroke. + const [intervalValueText, setIntervalValueText] = useState(() => + String(everyMsToParts(value.type === "every" ? value.everyMs : DEFAULT_INTERVAL_MS).value), + ); + const [intervalUnit, setIntervalUnit] = useState( + () => everyMsToParts(value.type === "every" ? value.everyMs : DEFAULT_INTERVAL_MS).unit, + ); + const [cronText, setCronText] = useState(() => + value.type === "cron" ? value.expression : DEFAULT_CRON_EXPRESSION, + ); + const [fieldResetKey, bumpFieldResetKey] = useReducer((key: number) => key + 1, 0); + + // Remember the cron expression the user had so toggling Interval -> Cron and + // back does not discard a blanked-out field. Interval mode rebuilds straight + // from the live numeric value + unit, so it needs no equivalent ref. + const lastCronExpression = useRef( + value.type === "cron" ? value.expression : DEFAULT_CRON_EXPRESSION, + ); + + const parsedIntervalValue = useMemo(() => { + const parsed = Number.parseInt(intervalValueText, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 1; + }, [intervalValueText]); + + const emitInterval = useCallback( + (rawValue: number, unit: IntervalUnit) => { + onChange({ type: "every", everyMs: partsToEveryMs(rawValue, unit) }); + }, + [onChange], + ); + + const emitCron = useCallback( + (expression: string) => { + lastCronExpression.current = expression; + onChange({ type: "cron", expression }); + }, + [onChange], + ); + + const handleModeChange = useCallback( + (nextMode: CadenceMode) => { + if (nextMode === mode) { + return; + } + if (nextMode === "every") { + emitInterval(parsedIntervalValue, intervalUnit); + } else { + emitCron(cronText.trim() || lastCronExpression.current); + } + }, + [mode, parsedIntervalValue, intervalUnit, cronText, emitInterval, emitCron], + ); + + const handleIntervalValueChange = useCallback( + (text: string) => { + // Keep only digits so the cadence stays a positive integer count. + const digits = text.replace(/[^0-9]/g, ""); + setIntervalValueText(digits); + const parsed = Number.parseInt(digits, 10); + emitInterval(Number.isFinite(parsed) && parsed > 0 ? parsed : 1, intervalUnit); + }, + [emitInterval, intervalUnit], + ); + + const handleUnitChange = useCallback( + (unit: IntervalUnit) => { + setIntervalUnit(unit); + emitInterval(parsedIntervalValue, unit); + }, + [emitInterval, parsedIntervalValue], + ); + + const handleCronChange = useCallback( + (text: string) => { + setCronText(text); + emitCron(text.trim()); + }, + [emitCron], + ); + + const handlePresetPress = useCallback( + (expression: string) => { + setCronText(expression); + bumpFieldResetKey(); + emitCron(expression); + }, + [emitCron], + ); + + const intervalPreview = describeInterval(parsedIntervalValue, intervalUnit); + const trimmedCron = cronText.trim(); + const cronError = trimmedCron ? validateCron(trimmedCron) : null; + const cronPreview = cronError ? null : (describeCron(trimmedCron) ?? trimmedCron); + + let cronFeedback: ReactNode = null; + if (cronError) { + cronFeedback = {cronError}; + } else if (cronPreview) { + cronFeedback = {cronPreview}; + } + + return ( + + + + {mode === "every" ? ( + + + + + + {intervalPreview} + + ) : ( + + + {CRON_PRESETS.map((preset) => ( + + ))} + + + {cronFeedback} + Times are in UTC + + )} + + {error ? {error} : null} + + ); +} + +function CronPresetChip({ + label, + expression, + isSelected, + onSelect, +}: { + label: string; + expression: string; + isSelected: boolean; + onSelect: (expression: string) => void; +}) { + const handlePress = useCallback(() => { + onSelect(expression); + }, [onSelect, expression]); + const chipStyle = useCallback( + ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.chip, + isSelected && styles.chipSelected, + !isSelected && (Boolean(hovered) || pressed) && styles.chipHover, + ], + [isSelected], + ); + const labelStyle = useMemo( + () => [styles.chipLabel, isSelected && styles.chipLabelSelected], + [isSelected], + ); + const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]); + return ( + + + {label} + + + ); +} + +const MONOSPACE_FONT = isWeb ? "ui-monospace, SFMono-Regular, Menlo, monospace" : "Menlo"; + +const styles = StyleSheet.create((theme) => ({ + container: { + gap: theme.spacing[3], + }, + section: { + gap: theme.spacing[3], + }, + intervalRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + }, + intervalInput: { + width: 88, + minHeight: 44, + backgroundColor: theme.colors.surface2, + borderRadius: theme.borderRadius.lg, + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[3], + borderWidth: 1, + borderColor: theme.colors.border, + fontSize: theme.fontSize.base, + }, + // Both cadence segmented controls hug their options and stand at the form's + // field height, so the interval row reads as input + toggle rather than a + // full-width track with the controls floating inside it. + modeControl: { + alignSelf: "flex-start", + height: 44, + }, + unitControl: { + height: 44, + }, + presetRow: { + flexDirection: "row", + flexWrap: "wrap", + gap: theme.spacing[2], + }, + chip: { + minHeight: 32, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + borderRadius: theme.borderRadius.lg, + borderWidth: 1, + borderColor: theme.colors.border, + backgroundColor: theme.colors.surface2, + }, + chipHover: { + backgroundColor: theme.colors.surface3, + }, + chipSelected: { + backgroundColor: theme.colors.accent, + borderColor: theme.colors.accent, + }, + chipLabel: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.medium, + color: theme.colors.foregroundMuted, + }, + chipLabelSelected: { + color: theme.colors.accentForeground, + }, + cronInput: { + minHeight: 44, + backgroundColor: theme.colors.surface2, + borderRadius: theme.borderRadius.lg, + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[3], + borderWidth: 1, + borderColor: theme.colors.border, + fontSize: theme.fontSize.sm, + fontFamily: MONOSPACE_FONT, + }, + preview: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, + hint: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, + error: { + fontSize: theme.fontSize.xs, + color: theme.colors.destructive, + }, +})); diff --git a/packages/app/src/components/schedules/schedule-form-sheet.tsx b/packages/app/src/components/schedules/schedule-form-sheet.tsx new file mode 100644 index 0000000000..8a15d9a9ec --- /dev/null +++ b/packages/app/src/components/schedules/schedule-form-sheet.tsx @@ -0,0 +1,709 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactElement, + type ReactNode, +} from "react"; +import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; +import { ChevronDown } from "lucide-react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { useQuery } from "@tanstack/react-query"; +import type { AgentProvider } from "@getpaseo/protocol/agent-types"; +import type { ScheduleCadence, ScheduleSummary } from "@getpaseo/protocol/schedule/types"; +import { + AdaptiveModalSheet, + AdaptiveTextInput, + type SheetHeader, +} from "@/components/adaptive-modal-sheet"; +import { Combobox, type ComboboxOption } from "@/components/ui/combobox"; +import { Button } from "@/components/ui/button"; +import { CombinedModelSelector } from "@/components/combined-model-selector"; +import { getProviderIcon } from "@/components/provider-icons"; +import { CadenceEditor } from "@/components/schedules/cadence-editor"; +import { useScheduleMutations } from "@/hooks/use-schedule-mutations"; +import { useAgentFormState, type FormInitialValues } from "@/hooks/use-agent-form-state"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useRecommendedProjectPaths } from "@/stores/session-store-hooks"; +import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions"; +import { validateCron } from "@/utils/schedule-format"; +import { toErrorMessage } from "@/utils/error-messages"; +import { shortenPath } from "@/utils/shorten-path"; + +const DEFAULT_CADENCE: ScheduleCadence = { type: "every", everyMs: 60 * 60 * 1000 }; + +export interface ScheduleFormSheetProps { + serverId: string; + visible: boolean; + onClose: () => void; + mode: "create" | "edit"; + schedule?: ScheduleSummary; +} + +// The model/cwd config only exists on new-agent schedules; this screen filters +// to that target, but guard anyway so prefill stays type-safe. +function newAgentConfig(schedule: ScheduleSummary | undefined) { + if (schedule && schedule.target.type === "new-agent") { + return schedule.target.config; + } + return null; +} + +function buildInitialValues(schedule: ScheduleSummary | undefined): FormInitialValues | undefined { + const config = newAgentConfig(schedule); + if (!config) { + return undefined; + } + return { + serverId: null, + provider: config.provider as AgentProvider, + model: config.model ?? null, + modeId: config.modeId ?? null, + workingDir: config.cwd, + }; +} + +export function ScheduleFormSheet({ + serverId, + visible, + onClose, + mode, + schedule, +}: ScheduleFormSheetProps): ReactElement { + const isEdit = mode === "edit"; + const editConfig = newAgentConfig(schedule); + + const onlineServerIds = useMemo(() => [serverId], [serverId]); + const initialValues = useMemo( + () => (isEdit ? buildInitialValues(schedule) : undefined), + [isEdit, schedule], + ); + + // isCreateFlow drives useAgentFormState's RESOLVE pass that applies + // initialValues. We want that for edit too (to prefill the picker fields from + // the schedule's config), so this stays true in both modes — the form is + // always a "fill these fields" flow, seeded either from preferences (create) + // or from the schedule (edit). + const form = useAgentFormState({ + initialServerId: serverId, + initialValues, + isVisible: visible, + isCreateFlow: true, + onlineServerIds, + }); + + const { + selectedProvider, + selectedModel, + selectedMode, + selectedThinkingOptionId, + workingDir, + setProviderAndModelFromUser, + setModeFromUser, + setWorkingDirFromUser, + modeOptions, + modelSelectorProviders, + isAllModelsLoading, + persistFormPreferences, + } = form; + + // One nested control selects provider → model (the draft screen's selector). + // Render it as a full-width field that leads with the provider glyph and mutes + // its placeholder, matching the working-directory field. + const renderModelTrigger = useCallback( + ({ + selectedModelLabel, + disabled, + isOpen, + hovered, + pressed, + }: { + selectedModelLabel: string; + onPress: () => void; + disabled: boolean; + isOpen: boolean; + hovered: boolean; + pressed: boolean; + }): ReactNode => ( + + ), + [selectedProvider], + ); + + const { createSchedule, updateSchedule, isCreating, isUpdating } = useScheduleMutations({ + serverId, + }); + const isSubmitting = isCreating || isUpdating; + + // Name / prompt / cadence / maxRuns are local to this form — not part of + // useAgentFormState. Seed once per open from the schedule being edited. + const [name, setName] = useState(() => schedule?.name ?? ""); + const [prompt, setPrompt] = useState(() => schedule?.prompt ?? ""); + const [maxRuns, setMaxRuns] = useState(() => + schedule?.maxRuns != null ? String(schedule.maxRuns) : "", + ); + const [cadence, setCadence] = useState( + () => schedule?.cadence ?? DEFAULT_CADENCE, + ); + const [submitError, setSubmitError] = useState(null); + const [fieldResetKey, setFieldResetKey] = useState(0); + + // The sheet stays mounted across opens, so the lazy initializers above only + // run once. Re-seed the locally-owned fields (name/prompt/cadence/maxRuns) + // each time the sheet transitions closed -> open; the picker fields are + // re-seeded by useAgentFormState from initialValues on the same flip. + const wasVisibleRef = useRef(false); + useEffect(() => { + if (visible && !wasVisibleRef.current) { + setName(schedule?.name ?? ""); + setPrompt(schedule?.prompt ?? ""); + setMaxRuns(schedule?.maxRuns != null ? String(schedule.maxRuns) : ""); + setCadence(schedule?.cadence ?? DEFAULT_CADENCE); + setSubmitError(null); + setFieldResetKey((key) => key + 1); + } + wasVisibleRef.current = visible; + }, [visible, schedule]); + + const promptTrimmed = prompt.trim(); + const cadenceError = cadence.type === "cron" ? validateCron(cadence.expression) : null; + const canSubmit = + promptTrimmed.length > 0 && + Boolean(selectedProvider) && + workingDir.trim().length > 0 && + cadenceError === null && + !isSubmitting; + + const handleSubmit = useCallback(async () => { + if (!selectedProvider || !workingDir.trim() || !promptTrimmed) { + return; + } + setSubmitError(null); + try { + await persistFormPreferences(); + const parsedMaxRuns = Number.parseInt(maxRuns, 10); + const maxRunsValue = + Number.isFinite(parsedMaxRuns) && parsedMaxRuns > 0 ? parsedMaxRuns : null; + + if (isEdit && schedule) { + await updateSchedule({ + id: schedule.id, + name: name.trim() || null, + prompt: promptTrimmed, + cadence, + newAgentConfig: { + provider: selectedProvider, + model: selectedModel || null, + modeId: selectedMode || null, + cwd: workingDir.trim(), + }, + maxRuns: maxRunsValue, + }); + } else { + await createSchedule({ + prompt: promptTrimmed, + name: name.trim() || undefined, + cadence, + target: { + type: "new-agent", + config: { + provider: selectedProvider, + cwd: workingDir.trim(), + model: selectedModel || undefined, + modeId: selectedMode || undefined, + thinkingOptionId: selectedThinkingOptionId || undefined, + title: name.trim() || undefined, + }, + }, + ...(maxRunsValue != null ? { maxRuns: maxRunsValue } : {}), + }); + } + onClose(); + } catch (error) { + setSubmitError(toErrorMessage(error)); + } + }, [ + cadence, + createSchedule, + isEdit, + maxRuns, + name, + onClose, + persistFormPreferences, + promptTrimmed, + schedule, + selectedMode, + selectedModel, + selectedProvider, + selectedThinkingOptionId, + updateSchedule, + workingDir, + ]); + + const handleSubmitPress = useCallback(() => { + void handleSubmit(); + }, [handleSubmit]); + + const header = useMemo( + () => ({ title: isEdit ? "Edit schedule" : "New schedule" }), + [isEdit], + ); + + const footer = useMemo( + () => ( + + + + + ), + [canSubmit, handleSubmitPress, isEdit, isSubmitting, onClose], + ); + + return ( + + + Name + + + + + Prompt + + + + + Model + + + + {modeOptions.length > 0 ? ( + + ) : null} + + + + + Cadence + + + + + Max runs + + Leave blank to run indefinitely + + + {editConfig === null && isEdit ? ( + This schedule does not target a new agent. + ) : null} + + {submitError ? {submitError} : null} + + ); +} + +// --------------------------------------------------------------------------- +// Mode field — Combobox over the selected provider's modes. +// --------------------------------------------------------------------------- + +function ModeField({ + options, + selectedMode, + onSelect, +}: { + options: { id: string; label: string }[]; + selectedMode: string; + onSelect: (modeId: string) => void; +}): ReactElement { + const anchorRef = useRef(null); + const [open, setOpen] = useState(false); + + const comboboxOptions = useMemo( + () => options.map((option) => ({ id: option.id, label: option.label })), + [options], + ); + + const selectedLabel = + options.find((option) => option.id === selectedMode)?.label ?? "Default mode"; + + const handleSelect = useCallback( + (id: string) => { + onSelect(id); + setOpen(false); + }, + [onSelect], + ); + + const handlePress = useCallback(() => { + setOpen((current) => !current); + }, []); + + const triggerStyle = useCallback( + ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.selectTrigger, + (Boolean(hovered) || pressed || open) && styles.selectTriggerActive, + ], + [open], + ); + + return ( + + Mode + + + + {selectedLabel} + + + + + 6} + title="Select mode" + open={open} + onOpenChange={setOpen} + anchorRef={anchorRef} + desktopPlacement="bottom-start" + /> + + ); +} + +// --------------------------------------------------------------------------- +// Working directory — searchable Combobox backed by directory suggestions, +// allowing a custom path. Mirrors the project picker's query shape. +// --------------------------------------------------------------------------- + +function WorkingDirectoryField({ + serverId, + value, + onSelect, + visible, +}: { + serverId: string; + value: string; + onSelect: (value: string) => void; + visible: boolean; +}): ReactElement { + const anchorRef = useRef(null); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + + const client = useHostRuntimeClient(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); + const recommendedPaths = useRecommendedProjectPaths(serverId); + + const directorySuggestionsQuery = useQuery({ + queryKey: ["schedule-form-directory-suggestions", serverId, query], + queryFn: async () => { + if (!client) { + return []; + } + const result = await client.getDirectorySuggestions({ + query, + includeDirectories: true, + includeFiles: false, + limit: 30, + }); + return result.entries?.flatMap((entry) => (entry.kind === "directory" ? [entry.path] : [])); + }, + enabled: Boolean(client) && isConnected && visible && open, + staleTime: 15_000, + retry: false, + }); + + const options = useMemo(() => { + const paths = buildWorkingDirectorySuggestions({ + recommendedPaths, + serverPaths: directorySuggestionsQuery.data ?? [], + query, + }); + return paths.map((path) => ({ id: path, label: shortenPath(path), kind: "directory" })); + }, [directorySuggestionsQuery.data, query, recommendedPaths]); + + const handleSelect = useCallback( + (id: string) => { + onSelect(id); + setOpen(false); + }, + [onSelect], + ); + + const handlePress = useCallback(() => { + setOpen((current) => !current); + }, []); + + const triggerStyle = useCallback( + ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.selectTrigger, + (Boolean(hovered) || pressed || open) && styles.selectTriggerActive, + ], + [open], + ); + + const displayValue = value.trim() ? shortenPath(value.trim()) : "Select a directory"; + + return ( + + Working directory + + + + {displayValue} + + + + + + + ); +} + +// --------------------------------------------------------------------------- +// Shared bits +// --------------------------------------------------------------------------- + +/** Dynamic provider glyph — reads its color off a StyleSheet object so the + * runtime-resolved component stays compliant without useUnistyles. */ +function ProviderGlyph({ provider }: { provider: string | null }): ReactElement | null { + if (!provider) { + return null; + } + const Icon = getProviderIcon(provider); + return ; +} + +// Non-interactive field rendered inside CombinedModelSelector's trigger (with +// triggerFill). The selector's outer Pressable owns press/hover; this leaf just +// paints the field and reads `active` for the focus border. +function ModelTrigger({ + label, + provider, + disabled, + active, + isPlaceholder, +}: { + label: string; + provider: string | null; + disabled: boolean; + active: boolean; + isPlaceholder: boolean; +}): ReactElement { + const containerStyle = useMemo( + () => [ + styles.selectTrigger, + active && styles.selectTriggerActive, + disabled && styles.selectTriggerDisabled, + ], + [active, disabled], + ); + return ( + + + + {label} + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + field: { + gap: theme.spacing[2], + }, + label: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + }, + input: { + backgroundColor: theme.colors.surface2, + borderRadius: theme.borderRadius.lg, + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[3], + color: theme.colors.foreground, + borderWidth: 1, + borderColor: theme.colors.border, + fontSize: theme.fontSize.base, + }, + multilineInput: { + backgroundColor: theme.colors.surface2, + borderRadius: theme.borderRadius.lg, + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[3], + color: theme.colors.foreground, + borderWidth: 1, + borderColor: theme.colors.border, + fontSize: theme.fontSize.base, + minHeight: 96, + }, + hint: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + }, + error: { + color: theme.colors.destructive, + fontSize: theme.fontSize.xs, + }, + selectTrigger: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + backgroundColor: theme.colors.surface2, + borderRadius: theme.borderRadius.lg, + borderWidth: 1, + borderColor: theme.colors.border, + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[3], + minHeight: 44, + }, + selectTriggerActive: { + borderColor: theme.colors.borderAccent, + }, + selectTriggerDisabled: { + opacity: theme.opacity[50], + }, + selectTriggerText: { + flex: 1, + minWidth: 0, + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + }, + selectTriggerPlaceholder: { + flex: 1, + minWidth: 0, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.base, + }, + footer: { + flex: 1, + flexDirection: "row", + gap: theme.spacing[3], + }, + footerButton: { + flex: 1, + }, + // Static color holders read by the dynamic provider icon + chevron (compliant + // idiom — no useUnistyles in render). + providerIcon: { + color: theme.colors.foregroundMuted, + }, + chevron: { + color: theme.colors.foregroundMuted, + }, +})); diff --git a/packages/app/src/components/schedules/schedule-row.tsx b/packages/app/src/components/schedules/schedule-row.tsx new file mode 100644 index 0000000000..9fc35e332d --- /dev/null +++ b/packages/app/src/components/schedules/schedule-row.tsx @@ -0,0 +1,699 @@ +import { MoreVertical, Pause, Pencil, Play, RotateCw, Trash2 } from "lucide-react-native"; +import { useCallback, useMemo, useState, type ReactElement, type ReactNode } from "react"; +import { + Pressable, + Text, + View, + type GestureResponderEvent, + 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 type { Theme } from "@/styles/theme"; +import { formatCadence, formatNextRun } from "@/utils/schedule-format"; +import { shortenPath } from "@/utils/shorten-path"; +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 ACTION_ICON_SIZE = 15; +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; + pending?: ScheduleRowPending; +} + +/** Row primary label: name → title → first prompt line → fallback. */ +function resolveTitle(schedule: ScheduleSummary): string { + const name = schedule.name?.trim(); + if (name) { + return name; + } + if (schedule.target.type === "new-agent") { + const configTitle = schedule.target.config.title?.trim(); + if (configTitle) { + return configTitle; + } + } + const firstPromptLine = schedule.prompt + .split("\n") + .map((line) => line.trim()) + .find((line) => line.length > 0); + return firstPromptLine || "Untitled schedule"; +} + +function resolveProvider(schedule: ScheduleSummary): string | null { + return schedule.target.type === "new-agent" ? schedule.target.config.provider : null; +} + +function resolveModelLabel(schedule: ScheduleSummary): string { + if (schedule.target.type === "new-agent" && schedule.target.config.model) { + return schedule.target.config.model; + } + return "Default model"; +} + +function resolveCwd(schedule: ScheduleSummary): string | null { + if (schedule.target.type !== "new-agent") { + return null; + } + const cwd = schedule.target.config.cwd?.trim(); + return cwd ? shortenPath(cwd) : null; +} + +function statusVariant(status: ScheduleSummary["status"]): "success" | "muted" { + return status === "active" ? "success" : "muted"; +} + +function statusLabel(status: ScheduleSummary["status"]): string { + if (status === "active") { + return "Active"; + } + if (status === "paused") { + return "Paused"; + } + return "Completed"; +} + +function nextRunLabel(schedule: ScheduleSummary): string { + if (schedule.status === "paused") { + return "Paused"; + } + if (schedule.status === "completed") { + return "Completed"; + } + return formatNextRun(schedule.nextRunAt) || "—"; +} + +/** Small provider glyph. Reads the icon color off a StyleSheet object so the + * dynamic component (getProviderIcon) stays compliant without useUnistyles. */ +function ProviderGlyph({ + provider, + size = PROVIDER_ICON_SIZE, +}: { + provider: string | null; + size?: number; +}): ReactElement | null { + if (!provider) { + return null; + } + const Icon = getProviderIcon(provider); + return ; +} + +export function ScheduleRow(props: ScheduleRowProps): ReactElement { + const isCompact = useIsCompactFormFactor(); + if (isCompact || isNative) { + return ; + } + return ; +} + +// --------------------------------------------------------------------------- +// Desktop — borderless SaaS table row with hover-revealed action cluster +// --------------------------------------------------------------------------- + +function DesktopScheduleRow({ + schedule, + pending, + onEdit, + onPause, + onResume, + onRunNow, + onDelete, +}: ScheduleRowProps): ReactElement { + const [isHovered, setIsHovered] = useState(false); + const handlePointerEnter = useCallback(() => setIsHovered(true), []); + const handlePointerLeave = useCallback(() => setIsHovered(false), []); + + const provider = resolveProvider(schedule); + const title = resolveTitle(schedule); + const cwd = resolveCwd(schedule); + const model = resolveModelLabel(schedule); + + const rowStyle = useCallback( + ({ pressed }: PressableStateCallbackType) => [ + styles.desktopRow, + isHovered && styles.desktopRowHovered, + pressed && styles.desktopRowPressed, + ], + [isHovered], + ); + + // Actions are always mounted; revealed via opacity + pointerEvents so the row + // never reflows on hover (docs/hover.md). Always shown on native/compact — + // but this branch is desktop-only, so isHovered drives it. + const actionsStyle = useMemo( + () => [styles.actionCluster, !isHovered && styles.actionClusterHidden], + [isHovered], + ); + + return ( + + + + + + + {title} + + {cwd ? ( + + {cwd} + + ) : null} + + + + + + + {model} + + + + + + {formatCadence(schedule.cadence)} + + + + + + {nextRunLabel(schedule)} + + + + + + + + + + + {schedule.status === "paused" ? ( + + ) : ( + + )} + + + + + + + ); +} + +type ActionVariant = "edit" | "pause" | "resume" | "run" | "delete"; + +// Module-scope icon elements keep JSX out of the render-time prop path +// (eslint-plugin-react-perf) and avoid re-creating elements per row. +const ACTION_ICONS: Record = { + edit: , + pause: , + resume: , + run: , + delete: , +}; + +function ActionIconButton({ + variant, + label, + onPress, + busy, + disabled, + destructive, + testID, +}: { + variant: ActionVariant; + label: string; + onPress: () => void; + busy?: boolean; + disabled?: boolean; + destructive?: boolean; + testID?: string; +}): ReactElement { + const isDisabled = Boolean(disabled || busy); + const buttonStyle = useCallback( + ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.iconButton, + hovered && (destructive ? styles.iconButtonHoveredDestructive : styles.iconButtonHovered), + pressed && styles.iconButtonPressed, + isDisabled && styles.iconButtonDisabled, + ], + [destructive, isDisabled], + ); + // The button sits inside the row Pressable (which opens edit on press); stop + // the event so acting on a row never also opens the editor (web bubbling). + const handlePressIn = useCallback((event: GestureResponderEvent) => { + event.stopPropagation(); + }, []); + const handlePress = useCallback( + (event: GestureResponderEvent) => { + event.stopPropagation(); + onPress(); + }, + [onPress], + ); + return ( + + {ACTION_ICONS[variant]} + + ); +} + +// --------------------------------------------------------------------------- +// Compact — stacked row with an always-visible kebab menu +// --------------------------------------------------------------------------- + +function CompactScheduleRow({ + schedule, + pending, + onEdit, + onPause, + onResume, + onRunNow, + onDelete, +}: ScheduleRowProps): ReactElement { + const provider = resolveProvider(schedule); + const title = resolveTitle(schedule); + const model = resolveModelLabel(schedule); + + const metaParts = [model, formatCadence(schedule.cadence), nextRunLabel(schedule)].filter( + Boolean, + ); + + return ( + + + + + + {title} + + + + + + + {metaParts.join(" · ")} + + + + + ); +} + +const editLeading = ; +const pauseLeading = ; +const resumeLeading = ; +const runLeading = ; +const deleteLeading = ; + +function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }): ReactElement { + return ( + + ); +} + +function ScheduleKebabMenu({ + schedule, + pending, + onEdit, + onPause, + onResume, + onRunNow, + onDelete, +}: ScheduleRowProps): ReactElement { + return ( + + + {renderKebabTriggerIcon} + + + + Edit schedule + + {schedule.status === "paused" ? ( + + Resume schedule + + ) : ( + + Pause schedule + + )} + + Run now + + + + Delete schedule + + + + ); +} + +function kebabTriggerStyle({ + hovered = false, +}: PressableStateCallbackType & { hovered?: boolean }) { + return [styles.kebabTrigger, hovered && styles.kebabTriggerHovered]; +} + +// --------------------------------------------------------------------------- +// Desktop column header +// --------------------------------------------------------------------------- + +export function SchedulesColumnHeader(): ReactElement { + return ( + + Name + Model + Cadence + Next run + Status + + + ); +} + +const ROW_MIN_HEIGHT = 56; +const ACTIONS_COLUMN_WIDTH = 148; + +const styles = StyleSheet.create((theme) => ({ + // Static color holder for the dynamic provider icon (compliant idiom). + providerIcon: { + color: theme.colors.foregroundMuted, + }, + + // --- shared columns (header + desktop row use the same flex weights) --- + colName: { + flex: 2.4, + minWidth: 0, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + colModel: { + flex: 1.6, + minWidth: 0, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1.5], + }, + colCadence: { + flex: 1.4, + minWidth: 0, + }, + colNextRun: { + flex: 1, + minWidth: 0, + }, + colStatus: { + flex: 1, + minWidth: 0, + flexDirection: "row", + alignItems: "center", + }, + colActions: { + width: ACTIONS_COLUMN_WIDTH, + flexDirection: "row", + justifyContent: "flex-end", + alignItems: "center", + }, + + nameTextGroup: { + flex: 1, + minWidth: 0, + gap: 1, + }, + + // --- header --- + headerRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + paddingHorizontal: theme.spacing[3], + paddingBottom: theme.spacing[2], + }, + headerLabel: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.medium, + color: theme.colors.foregroundMuted, + }, + + // --- desktop row --- + desktopRowContainer: { + position: "relative", + }, + desktopRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + minHeight: ROW_MIN_HEIGHT, + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + }, + desktopRowHovered: { + backgroundColor: theme.colors.surface1, + }, + desktopRowPressed: { + backgroundColor: theme.colors.surface2, + }, + + titleText: { + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.normal, + color: theme.colors.foreground, + }, + cellText: { + flex: 1, + minWidth: 0, + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.normal, + color: theme.colors.foreground, + }, + metaText: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + color: theme.colors.foregroundMuted, + }, + + // --- desktop action cluster --- + actionCluster: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, + actionClusterHidden: { + opacity: 0, + }, + iconButton: { + width: 28, + height: 28, + alignItems: "center", + justifyContent: "center", + borderRadius: theme.borderRadius.md, + }, + iconButtonHovered: { + backgroundColor: theme.colors.surface2, + }, + iconButtonHoveredDestructive: { + backgroundColor: theme.colors.palette.red[900], + }, + iconButtonPressed: { + backgroundColor: theme.colors.surface3, + }, + iconButtonDisabled: { + opacity: theme.opacity[50], + }, + + // --- compact row --- + compactRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + minHeight: ROW_MIN_HEIGHT, + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[3], + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + }, + compactBody: { + flex: 1, + minWidth: 0, + gap: theme.spacing[1], + }, + compactTitleRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + compactStatus: { + marginLeft: "auto", + }, + + // --- kebab --- + kebabTrigger: { + padding: theme.spacing[1], + borderRadius: theme.borderRadius.base, + }, + kebabTriggerHovered: { + backgroundColor: theme.colors.surface2, + }, +})); + +// Precomputed column + label style arrays for the header row — hoisted so the +// JSX does not create a new array prop per render (eslint-plugin-react-perf). +const headerNameStyle = [styles.colName, styles.headerLabel]; +const headerModelStyle = [styles.colModel, styles.headerLabel]; +const headerCadenceStyle = [styles.colCadence, styles.headerLabel]; +const headerNextRunStyle = [styles.colNextRun, styles.headerLabel]; +const headerStatusStyle = [styles.colStatus, styles.headerLabel]; diff --git a/packages/app/src/components/schedules/schedules-table.tsx b/packages/app/src/components/schedules/schedules-table.tsx new file mode 100644 index 0000000000..a9b92dc677 --- /dev/null +++ b/packages/app/src/components/schedules/schedules-table.tsx @@ -0,0 +1,207 @@ +import { useCallback, useMemo, useState, type ReactElement } from "react"; +import { FlatList, type ListRenderItem } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { + ScheduleRow, + SchedulesColumnHeader, + type ScheduleRowPending, +} from "@/components/schedules/schedule-row"; +import { useScheduleMutations } from "@/hooks/use-schedule-mutations"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { confirmDialog } from "@/utils/confirm-dialog"; +import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types"; + +interface SchedulesTableProps { + serverId: string; + schedules: ScheduleSummary[]; + /** + * The form sheet is owned by the screen (it serves both create and edit and + * shares the header's "New schedule" button), so the table delegates edit + * upward rather than mounting a second sheet here. + */ + onEditSchedule: (schedule: ScheduleSummary) => void; +} + +/** + * Borderless SaaS table of new-agent schedules. Owns row-level actions + * (pause/resume/run/delete via the mutations hook + a destructive confirm for + * delete) and delegates editing to the parent. Row chrome — the hairline + * between rows, hover highlight, and the compact-vs-desktop layout — lives in + * ScheduleRow; this component only arranges the rows and the column header. + */ +export function SchedulesTable({ + serverId, + schedules, + onEditSchedule, +}: SchedulesTableProps): ReactElement { + const isCompact = useIsCompactFormFactor(); + const mutations = useScheduleMutations({ serverId }); + + const renderItem: ListRenderItem = useCallback( + ({ item }) => ( + + ), + [mutations, onEditSchedule], + ); + + // The desktop column header aligns with the rows because both live inside the + // same horizontally padded, width-constrained content container and share the + // row's flex column weights. Compact rows are self-labeled, so no header. + const listHeader = useMemo(() => (isCompact ? null : ), [isCompact]); + + const contentContainerStyle = useMemo( + () => [styles.listContent, isCompact ? styles.listContentCompact : styles.listContentDesktop], + [isCompact], + ); + + return ( + + ); +} + +function keyExtractor(schedule: ScheduleSummary): string { + return schedule.id; +} + +/** + * Human label for the schedule, used in the delete confirmation. Mirrors the + * row's title precedence (name → config title → first prompt line → fallback). + */ +function scheduleLabel(schedule: ScheduleSummary): string { + const name = schedule.name?.trim(); + if (name) { + return name; + } + if (schedule.target.type === "new-agent") { + const configTitle = schedule.target.config.title?.trim(); + if (configTitle) { + return configTitle; + } + } + const firstPromptLine = schedule.prompt + .split("\n") + .map((line) => line.trim()) + .find((line) => line.length > 0); + return firstPromptLine || "Untitled schedule"; +} + +// --------------------------------------------------------------------------- +// Per-row wrapper — owns local in-flight state and binds the table's mutation +// callbacks to this schedule. Local state keeps pending precise to the acting +// row even when several rows are acted on at once (the mutations hook exposes +// only a single global pending flag per action). +// --------------------------------------------------------------------------- + +type ScheduleMutations = ReturnType; + +const NO_PENDING: ScheduleRowPending = {}; + +function SchedulesTableRow({ + schedule, + mutations, + onEditSchedule, +}: { + schedule: ScheduleSummary; + mutations: ScheduleMutations; + onEditSchedule: (schedule: ScheduleSummary) => void; +}): ReactElement { + const { id } = schedule; + const [pending, setPending] = useState(NO_PENDING); + + const runAction = useCallback( + async (key: keyof ScheduleRowPending, action: () => Promise): Promise => { + setPending((current) => ({ ...current, [key]: true })); + try { + await action(); + } catch { + // Mutations roll back their own optimistic cache writes on error and + // re-fetch on settle; surfacing per-row toasts here is out of scope. + } finally { + setPending((current) => { + const next = { ...current }; + delete next[key]; + return next; + }); + } + }, + [], + ); + + const handleEdit = useCallback(() => { + onEditSchedule(schedule); + }, [onEditSchedule, schedule]); + + const handlePause = useCallback(() => { + void runAction("pause", () => mutations.pauseSchedule(id)); + }, [runAction, mutations, id]); + + const handleResume = useCallback(() => { + void runAction("resume", () => mutations.resumeSchedule(id)); + }, [runAction, mutations, id]); + + const handleRunNow = useCallback(() => { + void runAction("runNow", () => mutations.runScheduleNow(id)); + }, [runAction, mutations, id]); + + const handleDelete = useCallback(() => { + void (async () => { + const confirmed = await confirmDialog({ + title: "Delete schedule", + message: `Delete "${scheduleLabel(schedule)}"? This cannot be undone.`, + confirmLabel: "Delete", + destructive: true, + }); + if (!confirmed) { + return; + } + await runAction("delete", () => mutations.deleteSchedule(id)); + })(); + }, [runAction, mutations, id, schedule]); + + return ( + + ); +} + +const DESKTOP_MAX_WIDTH = 1040; + +const styles = StyleSheet.create((theme) => ({ + list: { + flex: 1, + minHeight: 0, + }, + listContent: { + paddingBottom: theme.spacing[6], + }, + listContentCompact: { + paddingHorizontal: theme.spacing[3], + paddingTop: theme.spacing[2], + }, + // Center the table in a width-constrained column on desktop, with horizontal + // page padding that matches the row's own padding so the columns line up. + listContentDesktop: { + width: "100%", + maxWidth: DESKTOP_MAX_WIDTH, + alignSelf: "center", + paddingHorizontal: theme.spacing[6], + paddingTop: theme.spacing[4], + }, +})); diff --git a/packages/app/src/hooks/use-schedule-mutations.ts b/packages/app/src/hooks/use-schedule-mutations.ts new file mode 100644 index 0000000000..147cb892f2 --- /dev/null +++ b/packages/app/src/hooks/use-schedule-mutations.ts @@ -0,0 +1,251 @@ +import { useCallback } from "react"; +import { useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query"; +import type { + CreateScheduleOptions, + DaemonClient, + UpdateScheduleOptions, +} from "@getpaseo/client/internal/daemon-client"; +import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types"; +import { useSessionStore } from "@/stores/session-store"; +import { schedulesQueryKey } from "@/hooks/use-schedules"; + +export type CreateScheduleInput = Omit; +export type UpdateScheduleInput = Omit; + +export interface UseScheduleMutationsResult { + createSchedule: (input: CreateScheduleInput) => Promise; + updateSchedule: (input: UpdateScheduleInput) => Promise; + pauseSchedule: (id: string) => Promise; + resumeSchedule: (id: string) => Promise; + deleteSchedule: (id: string) => Promise; + runScheduleNow: (id: string) => Promise; + isCreating: boolean; + isUpdating: boolean; + isPausing: boolean; + isResuming: boolean; + isDeleting: boolean; + isRunningNow: boolean; +} + +function requireClient(serverId: string): DaemonClient { + const client = useSessionStore.getState().sessions[serverId]?.client ?? null; + if (!client) { + throw new Error("Daemon client not available"); + } + return client; +} + +interface ScheduleListSnapshot { + previous: ScheduleSummary[] | undefined; +} + +function snapshotSchedules(queryClient: QueryClient, serverId: string): ScheduleListSnapshot { + return { + previous: queryClient.getQueryData(schedulesQueryKey(serverId)), + }; +} + +function restoreSchedules( + queryClient: QueryClient, + serverId: string, + snapshot: ScheduleListSnapshot, +): void { + if (snapshot.previous === undefined) { + return; + } + queryClient.setQueryData(schedulesQueryKey(serverId), snapshot.previous); +} + +function optimisticallySetStatus( + queryClient: QueryClient, + serverId: string, + id: string, + status: ScheduleSummary["status"], +): void { + queryClient.setQueryData(schedulesQueryKey(serverId), (current) => { + if (!current) { + return current; + } + const pausedAt = status === "paused" ? new Date().toISOString() : null; + return current.map((schedule) => + schedule.id === id ? { ...schedule, status, pausedAt } : schedule, + ); + }); +} + +function optimisticallyRemove(queryClient: QueryClient, serverId: string, id: string): void { + queryClient.setQueryData(schedulesQueryKey(serverId), (current) => { + if (!current) { + return current; + } + return current.filter((schedule) => schedule.id !== id); + }); +} + +export function useScheduleMutations({ + serverId, +}: { + serverId: string; +}): UseScheduleMutationsResult { + const queryClient = useQueryClient(); + + const invalidate = useCallback(() => { + void queryClient.invalidateQueries({ queryKey: schedulesQueryKey(serverId) }); + }, [queryClient, serverId]); + + const createMutation = useMutation({ + mutationFn: async (input: CreateScheduleInput): Promise => { + const client = requireClient(serverId); + const payload = await client.scheduleCreate(input); + if (payload.error) { + throw new Error(payload.error); + } + }, + onSettled: invalidate, + }); + + const updateMutation = useMutation({ + mutationFn: async (input: UpdateScheduleInput): Promise => { + const client = requireClient(serverId); + const payload = await client.scheduleUpdate(input); + if (payload.error) { + throw new Error(payload.error); + } + }, + onSettled: invalidate, + }); + + const pauseMutation = useMutation({ + mutationFn: async (id: string): Promise => { + const client = requireClient(serverId); + const payload = await client.schedulePause({ id }); + if (payload.error) { + throw new Error(payload.error); + } + }, + onMutate: async (id): Promise => { + await queryClient.cancelQueries({ queryKey: schedulesQueryKey(serverId) }); + const snapshot = snapshotSchedules(queryClient, serverId); + optimisticallySetStatus(queryClient, serverId, id, "paused"); + return snapshot; + }, + onError: (_error, _id, context) => { + if (context) { + restoreSchedules(queryClient, serverId, context); + } + }, + onSettled: invalidate, + }); + + const resumeMutation = useMutation({ + mutationFn: async (id: string): Promise => { + const client = requireClient(serverId); + const payload = await client.scheduleResume({ id }); + if (payload.error) { + throw new Error(payload.error); + } + }, + onMutate: async (id): Promise => { + await queryClient.cancelQueries({ queryKey: schedulesQueryKey(serverId) }); + const snapshot = snapshotSchedules(queryClient, serverId); + optimisticallySetStatus(queryClient, serverId, id, "active"); + return snapshot; + }, + onError: (_error, _id, context) => { + if (context) { + restoreSchedules(queryClient, serverId, context); + } + }, + onSettled: invalidate, + }); + + const deleteMutation = useMutation({ + mutationFn: async (id: string): Promise => { + const client = requireClient(serverId); + const payload = await client.scheduleDelete({ id }); + if (payload.error) { + throw new Error(payload.error); + } + }, + onMutate: async (id): Promise => { + await queryClient.cancelQueries({ queryKey: schedulesQueryKey(serverId) }); + const snapshot = snapshotSchedules(queryClient, serverId); + optimisticallyRemove(queryClient, serverId, id); + return snapshot; + }, + onError: (_error, _id, context) => { + if (context) { + restoreSchedules(queryClient, serverId, context); + } + }, + onSettled: invalidate, + }); + + const runNowMutation = useMutation({ + mutationFn: async (id: string): Promise => { + const client = requireClient(serverId); + const payload = await client.scheduleRunOnce({ id }); + if (payload.error) { + throw new Error(payload.error); + } + }, + onSettled: invalidate, + }); + + const createSchedule = useCallback( + async (input: CreateScheduleInput): Promise => { + await createMutation.mutateAsync(input); + }, + [createMutation], + ); + + const updateSchedule = useCallback( + async (input: UpdateScheduleInput): Promise => { + await updateMutation.mutateAsync(input); + }, + [updateMutation], + ); + + const pauseSchedule = useCallback( + async (id: string): Promise => { + await pauseMutation.mutateAsync(id); + }, + [pauseMutation], + ); + + const resumeSchedule = useCallback( + async (id: string): Promise => { + await resumeMutation.mutateAsync(id); + }, + [resumeMutation], + ); + + const deleteSchedule = useCallback( + async (id: string): Promise => { + await deleteMutation.mutateAsync(id); + }, + [deleteMutation], + ); + + const runScheduleNow = useCallback( + async (id: string): Promise => { + await runNowMutation.mutateAsync(id); + }, + [runNowMutation], + ); + + return { + createSchedule, + updateSchedule, + pauseSchedule, + resumeSchedule, + deleteSchedule, + runScheduleNow, + isCreating: createMutation.isPending, + isUpdating: updateMutation.isPending, + isPausing: pauseMutation.isPending, + isResuming: resumeMutation.isPending, + isDeleting: deleteMutation.isPending, + isRunningNow: runNowMutation.isPending, + }; +} diff --git a/packages/app/src/hooks/use-schedules.ts b/packages/app/src/hooks/use-schedules.ts new file mode 100644 index 0000000000..50fd172131 --- /dev/null +++ b/packages/app/src/hooks/use-schedules.ts @@ -0,0 +1,57 @@ +import { useQuery } from "@tanstack/react-query"; +import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types"; +import { useSessionStore } from "@/stores/session-store"; +import { isNewAgentSchedule } from "@/utils/schedule-format"; + +export function schedulesQueryKey(serverId: string) { + return ["schedules", serverId] as const; +} + +export interface UseSchedulesInput { + serverId: string; +} + +export interface UseSchedulesResult { + schedules: ScheduleSummary[]; + isLoading: boolean; + isError: boolean; + error: Error | null; + refetch: () => void; + isRefetching: boolean; +} + +async function fetchNewAgentSchedules(serverId: string): Promise { + const client = useSessionStore.getState().sessions[serverId]?.client ?? null; + if (!client) { + throw new Error("Daemon client not available"); + } + + const payload = await client.scheduleList(); + if (payload.error) { + throw new Error(payload.error); + } + + return payload.schedules.filter(isNewAgentSchedule); +} + +export function useSchedules({ serverId }: UseSchedulesInput): UseSchedulesResult { + const hasClient = useSessionStore((state) => (state.sessions[serverId]?.client ?? null) !== null); + + const query = useQuery({ + queryKey: schedulesQueryKey(serverId), + queryFn: () => fetchNewAgentSchedules(serverId), + enabled: hasClient, + staleTime: 5_000, + }); + + return { + schedules: query.data ?? [], + isLoading: query.isLoading, + isError: query.isError, + error: query.error, + refetch: () => { + void query.refetch(); + }, + isRefetching: query.isRefetching, + }; +} diff --git a/packages/app/src/screens/schedules-screen.tsx b/packages/app/src/screens/schedules-screen.tsx new file mode 100644 index 0000000000..177b957c69 --- /dev/null +++ b/packages/app/src/screens/schedules-screen.tsx @@ -0,0 +1,152 @@ +import { useCallback, useMemo, useState, type ReactElement } from "react"; +import { Text, View } from "react-native"; +import { useIsFocused } from "@react-navigation/native"; +import { Plus } from "lucide-react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { MenuHeader } from "@/components/headers/menu-header"; +import { ScheduleFormSheet } from "@/components/schedules/schedule-form-sheet"; +import { SchedulesTable } from "@/components/schedules/schedules-table"; +import { Button } from "@/components/ui/button"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { useSchedules } from "@/hooks/use-schedules"; +import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types"; + +type FormState = + | { mode: "closed" } + | { mode: "create" } + | { mode: "edit"; schedule: ScheduleSummary }; + +export function SchedulesScreen({ serverId }: { serverId: string }): ReactElement { + const isFocused = useIsFocused(); + + if (!isFocused) { + return ; + } + + return ; +} + +function SchedulesScreenContent({ serverId }: { serverId: string }): ReactElement { + const { schedules, isLoading, isError, error, refetch } = useSchedules({ serverId }); + const [form, setForm] = useState({ mode: "closed" }); + + const openCreate = useCallback(() => { + setForm({ mode: "create" }); + }, []); + + const openEdit = useCallback((schedule: ScheduleSummary) => { + setForm({ mode: "edit", schedule }); + }, []); + + const closeForm = useCallback(() => { + setForm({ mode: "closed" }); + }, []); + + const headerAction = useMemo( + () => ( + + ), + [openCreate], + ); + + return ( + + + + + + ); +} + +function SchedulesBody({ + serverId, + schedules, + isLoading, + isError, + error, + onRetry, + onCreate, + onEdit, +}: { + serverId: string; + schedules: ScheduleSummary[]; + isLoading: boolean; + isError: boolean; + error: Error | null; + onRetry: () => void; + onCreate: () => void; + onEdit: (schedule: ScheduleSummary) => void; +}): ReactElement { + if (isLoading) { + return ( + + + + ); + } + + if (isError) { + return ( + + {error?.message ?? "Could not load schedules"} + + + ); + } + + if (schedules.length === 0) { + return ( + + No schedules yet + + + ); + } + + return ; +} + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + backgroundColor: theme.colors.surface0, + }, + centered: { + flex: 1, + justifyContent: "center", + alignItems: "center", + gap: theme.spacing[6], + padding: theme.spacing[6], + }, + 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, + }, +})); diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 9630087ae5..e7c1031e12 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -347,6 +347,14 @@ export function buildHostSessionsRoute(serverId: string) { return `${base}/sessions` as const; } +export function buildHostSchedulesRoute(serverId: string) { + const base = buildHostRootRoute(serverId); + if (base === "/") { + return "/" as const; + } + return `${base}/schedules` as const; +} + export function buildHostOpenProjectRoute(serverId: string) { const base = buildHostRootRoute(serverId); if (base === "/") { @@ -432,6 +440,9 @@ export function mapPathnameToServer(pathname: string, nextServerId: string) { if (suffix.startsWith("sessions")) { return `${base}/sessions` as const; } + if (suffix.startsWith("schedules")) { + return `${base}/schedules` as const; + } if (suffix.startsWith("open-project")) { return `${base}/open-project` as const; } diff --git a/packages/app/src/utils/schedule-format.ts b/packages/app/src/utils/schedule-format.ts new file mode 100644 index 0000000000..bfe436e1c4 --- /dev/null +++ b/packages/app/src/utils/schedule-format.ts @@ -0,0 +1,249 @@ +import type { ScheduleCadence, ScheduleSummary } from "@getpaseo/protocol/schedule/types"; + +/** + * Pure, dependency-free helpers for presenting schedules. + * + * Cron is a 5-field expression (minute hour day-of-month month day-of-week), + * evaluated by the daemon in UTC. The validation here mirrors the daemon's + * structural parser in packages/server/src/server/schedule/cron.ts so the + * client preview rejects exactly what the server would reject. + */ + +export type IntervalUnit = "minutes" | "hours" | "days"; + +const MS_PER_MINUTE = 60_000; +const MS_PER_HOUR = MS_PER_MINUTE * 60; +const MS_PER_DAY = MS_PER_HOUR * 24; + +const UNIT_MS: Record = { + minutes: MS_PER_MINUTE, + hours: MS_PER_HOUR, + days: MS_PER_DAY, +}; + +const DAY_NAMES = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", +] as const; + +export function isNewAgentSchedule(schedule: ScheduleSummary): boolean { + return schedule.target.type === "new-agent"; +} + +function pluralize(value: number, noun: string): string { + return value === 1 ? `1 ${noun}` : `${value} ${noun}s`; +} + +export function everyMsToParts(ms: number): { value: number; unit: IntervalUnit } { + if (!Number.isFinite(ms) || ms <= 0) { + return { value: 1, unit: "hours" }; + } + if (ms % MS_PER_DAY === 0) { + return { value: ms / MS_PER_DAY, unit: "days" }; + } + if (ms % MS_PER_HOUR === 0) { + return { value: ms / MS_PER_HOUR, unit: "hours" }; + } + return { value: Math.max(1, Math.round(ms / MS_PER_MINUTE)), unit: "minutes" }; +} + +export function partsToEveryMs(value: number, unit: IntervalUnit): number { + const normalized = Number.isFinite(value) ? Math.max(1, Math.round(value)) : 1; + return normalized * UNIT_MS[unit]; +} + +const UNIT_NOUN: Record = { + minutes: "minute", + hours: "hour", + days: "day", +}; + +function formatEvery(everyMs: number): string { + const { value, unit } = everyMsToParts(everyMs); + return `Every ${pluralize(value, UNIT_NOUN[unit])}`; +} + +export function formatCadence(cadence: ScheduleCadence): string { + if (cadence.type === "every") { + return formatEvery(cadence.everyMs); + } + return describeCron(cadence.expression) ?? cadence.expression; +} + +/** + * Humanize a handful of common 5-field cron shapes. Returns null when the + * expression is valid but not one of the recognized patterns (callers fall + * back to showing the raw expression). + */ +export function describeCron(expr: string): string | null { + const trimmed = expr.trim(); + if (validateCron(trimmed) !== null) { + return null; + } + + const [minute, hour, dayOfMonth, month, dayOfWeek] = trimmed.split(/\s+/); + + // Only humanize the simple "fixed time" family: literal minute/hour with the + // date fields either wildcarded or a recognized day-of-week constraint. + const minuteNum = Number.parseInt(minute, 10); + const isLiteralMinute = /^\d+$/.test(minute); + const isWildcardMonth = month === "*"; + const isWildcardDom = dayOfMonth === "*"; + + if (!isLiteralMinute || !isWildcardMonth || !isWildcardDom) { + return null; + } + + // "Every hour" / "Every hour at :MM" + if (hour === "*") { + if (dayOfWeek !== "*") { + return null; + } + return minuteNum === 0 ? "Every hour" : `Every hour at :${pad2(minuteNum)}`; + } + + if (!/^\d+$/.test(hour)) { + return null; + } + const time = `${pad2(Number.parseInt(hour, 10))}:${pad2(minuteNum)}`; + + if (dayOfWeek === "*") { + return `Daily at ${time} UTC`; + } + if (dayOfWeek === "1-5") { + return `Weekdays at ${time} UTC`; + } + if (dayOfWeek === "0,6" || dayOfWeek === "6,0") { + return `Weekends at ${time} UTC`; + } + if (/^\d$/.test(dayOfWeek)) { + const day = DAY_NAMES[Number.parseInt(dayOfWeek, 10)]; + if (day) { + return `${day}s at ${time} UTC`; + } + } + return null; +} + +function pad2(value: number): string { + return value < 10 ? `0${value}` : String(value); +} + +interface CronFieldBounds { + min: number; + max: number; + name: string; +} + +const CRON_FIELD_BOUNDS: CronFieldBounds[] = [ + { min: 0, max: 59, name: "minute" }, + { min: 0, max: 23, name: "hour" }, + { min: 1, max: 31, name: "day-of-month" }, + { min: 1, max: 12, name: "month" }, + { min: 0, max: 6, name: "day-of-week" }, +]; + +function validateCronField(source: string, bounds: CronFieldBounds): string | null { + const trimmed = source.trim(); + if (!trimmed) { + return `Invalid ${bounds.name} field`; + } + + for (const rawPart of trimmed.split(",")) { + const part = rawPart.trim(); + if (!part) { + return `Invalid ${bounds.name} field`; + } + + const [base, stepSource] = part.split("/"); + if (stepSource !== undefined) { + const step = Number.parseInt(stepSource, 10); + if (!Number.isInteger(step) || step <= 0 || String(step) !== stepSource.trim()) { + return `Invalid ${bounds.name} step`; + } + } + + if (base === "*") { + continue; + } + + const rangeMatch = base.match(/^(\d+)-(\d+)$/); + if (rangeMatch) { + const start = Number.parseInt(rangeMatch[1], 10); + const end = Number.parseInt(rangeMatch[2], 10); + if (start > end || start < bounds.min || end > bounds.max) { + return `Invalid ${bounds.name} range`; + } + continue; + } + + if (!/^\d+$/.test(base)) { + return `Invalid ${bounds.name} value`; + } + const value = Number.parseInt(base, 10); + if (!Number.isInteger(value) || value < bounds.min || value > bounds.max) { + return `Invalid ${bounds.name} value`; + } + } + + return null; +} + +/** + * Returns null when the expression is a structurally valid 5-field cron the + * daemon would accept, otherwise a human-readable error message. + */ +export function validateCron(expr: string): string | null { + const trimmed = expr.trim(); + if (!trimmed) { + return "Enter a cron expression"; + } + + const fields = trimmed.split(/\s+/); + if (fields.length !== 5) { + return "Cron expressions must have 5 fields"; + } + + for (let index = 0; index < CRON_FIELD_BOUNDS.length; index += 1) { + const error = validateCronField(fields[index], CRON_FIELD_BOUNDS[index]); + if (error) { + return error; + } + } + + return null; +} + +/** + * Forward-relative description of the next run, e.g. "in 3h", "in 2d", "soon". + * Returns "" when there is no scheduled next run. + */ +export function formatNextRun(iso: string | null): string { + if (!iso) { + return ""; + } + const target = new Date(iso).getTime(); + if (Number.isNaN(target)) { + return ""; + } + + const diffMs = target - Date.now(); + if (diffMs <= 0) { + return "soon"; + } + if (diffMs < MS_PER_MINUTE) { + return "soon"; + } + if (diffMs < MS_PER_HOUR) { + return `in ${Math.round(diffMs / MS_PER_MINUTE)}m`; + } + if (diffMs < MS_PER_DAY) { + return `in ${Math.round(diffMs / MS_PER_HOUR)}h`; + } + return `in ${Math.round(diffMs / MS_PER_DAY)}d`; +}