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`;
+}