diff --git a/app/App.tsx b/app/App.tsx index 879a267..c373e89 100644 --- a/app/App.tsx +++ b/app/App.tsx @@ -34,6 +34,7 @@ import { FleetScreen } from "./src/screens/FleetScreen"; import { AgentDetailScreen } from "./src/screens/AgentDetailScreen"; import { AnswerScreen } from "./src/screens/AnswerScreen"; import { SpawnScreen } from "./src/screens/SpawnScreen"; +import { SchedulesScreen } from "./src/screens/SchedulesScreen"; import { LogScreen } from "./src/screens/LogScreen"; import { SettingsScreen } from "./src/screens/SettingsScreen"; @@ -46,6 +47,7 @@ function Router() { detail: AgentDetailScreen, answer: AnswerScreen, spawn: SpawnScreen, + schedules: SchedulesScreen, log: LogScreen, settings: SettingsScreen, }[screen]; diff --git a/app/src/components/Icon.tsx b/app/src/components/Icon.tsx index 1120ce3..1202e32 100644 --- a/app/src/components/Icon.tsx +++ b/app/src/components/Icon.tsx @@ -12,6 +12,8 @@ export type IconName = | "chevronDown" | "home" | "file" + | "clock" + | "brain" | "settings" | "x"; @@ -30,6 +32,24 @@ const paths: Record = { ), + clock: ( + <> + + + + ), + /* Lucide waypoints — the memory note graph. */ + brain: ( + <> + + + + + + + + + ), settings: ( <> diff --git a/app/src/components/TabBar.tsx b/app/src/components/TabBar.tsx index 490973e..5daaf4a 100644 --- a/app/src/components/TabBar.tsx +++ b/app/src/components/TabBar.tsx @@ -7,13 +7,14 @@ import { Icon, type IconName } from "./Icon"; import { useAppState, type Screen } from "../state/AppState"; /** - * Bottom tab bar shown on the three primary screens (Fleet / Log / Settings). - * The design's fixed 24px bottom inset is replaced by the real home-indicator - * safe-area inset. + * Bottom tab bar shown on the primary screens (Fleet / Schedules / Log / + * Settings). The design's fixed 24px bottom inset is replaced by the real + * home-indicator safe-area inset. */ const TABS: { key: Screen; label: string; icon: IconName }[] = [ { key: "fleet", label: "Fleet", icon: "home" }, + { key: "schedules", label: "Schedules", icon: "clock" }, { key: "log", label: "Log", icon: "file" }, { key: "settings", label: "Settings", icon: "settings" }, ]; diff --git a/app/src/screens/SchedulesScreen.tsx b/app/src/screens/SchedulesScreen.tsx new file mode 100644 index 0000000..9a540b5 --- /dev/null +++ b/app/src/screens/SchedulesScreen.tsx @@ -0,0 +1,336 @@ +import React, { useEffect, useState } from "react"; +import { Alert, ScrollView, StyleSheet, Text, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { radius, text } from "../theme/tokens"; +import { useTheme } from "../theme/useTheme"; +import { Badge } from "../components/Badge"; +import { Button } from "../components/Button"; +import { Select } from "../components/Select"; +import { Switch } from "../components/Switch"; +import { TabBar } from "../components/TabBar"; +import { TextField } from "../components/TextField"; +import { Card, Divider, Mono, SectionLabel } from "../components/primitives"; +import { useAppState } from "../state/AppState"; +import { timeAgo } from "../api/format"; +import type { Schedule } from "../api/client"; + +/** + * Schedules — recurring agent spawns, the mobile counterpart of the web + * dashboard's Schedules page. Every interval the worker starts a fresh, + * stateless agent named - with the stored prompt. + */ + +const ROLE_OPTIONS = [ + { value: "", label: "Role — none" }, + { value: "junior", label: "junior" }, + { value: "senior", label: "senior" }, + { value: "deploy", label: "deploy" }, +]; + +const INTERVAL_OPTIONS = [ + { value: "900", label: "every 15 minutes" }, + { value: "1800", label: "every 30 minutes" }, + { value: "3600", label: "every hour" }, + { value: "21600", label: "every 6 hours" }, + { value: "86400", label: "every day" }, + { value: "604800", label: "every week" }, +]; + +function intervalLabel(seconds: number): string { + const opt = INTERVAL_OPTIONS.find((o) => Number(o.value) === seconds); + if (opt) return opt.label; + if (seconds % 3600 === 0) return `every ${seconds / 3600}h`; + if (seconds % 60 === 0) return `every ${seconds / 60}m`; + return `every ${seconds}s`; +} + +export function SchedulesScreen() { + const { colors } = useTheme(); + const insets = useSafeAreaInsets(); + const { + projects, + models, + schedules, + createSchedule, + toggleSchedule, + deleteSchedule, + } = useAppState(); + + const projectIds = projects.map((p) => p.id); + const [showForm, setShowForm] = useState(false); + const [project, setProject] = useState(""); + const [prefix, setPrefix] = useState(""); + const [task, setTask] = useState(""); + const [interval, setIntervalStr] = useState("3600"); + const [role, setRole] = useState(""); + const [model, setModel] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (projectIds.length > 0 && !projectIds.includes(project)) { + setProject(projectIds[0]); + } + }, [projectIds, project]); + + const modelOptions = [ + { value: "", label: "Claude (subscription)" }, + ...models + .filter((m) => m.enabled) + .map((m) => ({ value: String(m.id), label: `${m.name} (${m.model})` })), + ]; + const modelName = (id: number | null | undefined) => + id == null ? null : models.find((m) => m.id === id)?.name ?? `#${id}`; + + async function create() { + if (!project || !prefix.trim() || !task.trim()) { + setError("Project, name prefix, and prompt are all required."); + return; + } + setError(null); + setBusy(true); + try { + await createSchedule(project, { + name_prefix: prefix.trim(), + task: task.trim(), + interval_seconds: Number(interval), + role: role || null, + model_id: model ? Number(model) : null, + }); + setPrefix(""); + setTask(""); + setShowForm(false); + } catch (e) { + setError(e instanceof Error ? e.message : "Couldn’t create the schedule."); + } finally { + setBusy(false); + } + } + + function confirmDelete(sc: Schedule) { + Alert.alert( + "Delete schedule?", + `Stop spawning ${sc.name_prefix}-* runs in ${sc.project_id}. This can’t be undone.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: () => { + deleteSchedule(sc.id).catch((e) => + setError(e instanceof Error ? e.message : "Delete failed."), + ); + }, + }, + ], + ); + } + + return ( + + + + + + Schedules + + + + Spawn a fresh agent on an interval. Each run is stateless — keep + continuity in a file the prompt reads and overwrites. + + + {showForm ? ( + + {projectIds.length > 0 ? ( + + + ) : null} + + + Prompt + + + + + + ) : null} + + {error ? ( + + {error} + + ) : null} + + + {`${schedules.length} schedule${schedules.length === 1 ? "" : "s"}`} + + + {schedules.length === 0 ? ( + + No schedules yet. + + ) : ( + + {schedules.map((sc, i) => ( + + {i > 0 && } + + + + + {sc.name_prefix} + + {sc.role ? {sc.role} : null} + {sc.model_id != null ? ( + {modelName(sc.model_id) ?? ""} + ) : null} + + + {sc.project_id} · {intervalLabel(sc.interval_seconds)} + + + {sc.task} + + + {sc.enabled + ? new Date(sc.next_run_at).getTime() <= Date.now() + ? "next run due now" + : `next run in ${nextIn(sc.next_run_at)}` + : "paused"} + {sc.last_run_at ? ` · last ${timeAgo(sc.last_run_at)} ago` : ""} + + + + { + toggleSchedule(sc.id, v).catch((e) => + setError(e instanceof Error ? e.message : "Update failed."), + ); + }} + /> + + + + + ))} + + )} + + + + + ); +} + +/* Compact time-until, reusing timeAgo's units for a future timestamp. */ +function nextIn(iso: string): string { + const secs = Math.max(0, Math.floor((new Date(iso).getTime() - Date.now()) / 1000)); + if (secs < 60) return `${secs}s`; + const mins = Math.floor(secs / 60); + if (mins < 60) return `${mins}m`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h`; + return `${Math.floor(hours / 24)}d`; +} + +const styles = StyleSheet.create({ + page: { flex: 1 }, + content: { paddingTop: 20, paddingHorizontal: 20, paddingBottom: 24 }, + headRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 6, + }, + notice: { + borderWidth: 1, + borderRadius: radius.md, + padding: 12, + marginBottom: 16, + }, + row: { + flexDirection: "row", + gap: 12, + paddingVertical: 12, + paddingHorizontal: 16, + }, + titleRow: { + flexDirection: "row", + alignItems: "center", + gap: 8, + flexWrap: "wrap", + }, + rowActions: { + alignItems: "flex-end", + justifyContent: "space-between", + gap: 10, + }, +}); diff --git a/app/src/state/AppState.tsx b/app/src/state/AppState.tsx index f5297b2..ea146f6 100644 --- a/app/src/state/AppState.tsx +++ b/app/src/state/AppState.tsx @@ -18,6 +18,7 @@ import { type ClaudeModel, type LogEntry, type Project, + type Schedule, } from "../api/client"; import { statusLabel, statusTone, timeAgo } from "../api/format"; import { useServerConfig } from "./ServerConfig"; @@ -39,6 +40,7 @@ export type Screen = | "detail" | "answer" | "spawn" + | "schedules" | "log" | "settings"; @@ -99,6 +101,8 @@ interface AppStateValue { projects: Project[]; /* Registered model backends (the spawn/schedule dropdown next to the subscription). */ models: ClaudeModel[]; + /* Recurring agent spawns, across all projects. */ + schedules: Schedule[]; waiting: WaitingItem[]; recent: RecentItem[]; counts: { running: number; waiting: number; done: number }; @@ -117,6 +121,18 @@ interface AppStateValue { sendAnswer: (text: string) => Promise<{ resumed: boolean; note?: string }>; spawn: (project: string, task: string, modelId?: number | null) => Promise; kill: (project: string, name: string) => Promise; + createSchedule: ( + project: string, + input: { + name_prefix: string; + task: string; + interval_seconds: number; + role?: string | null; + model_id?: number | null; + }, + ) => Promise; + toggleSchedule: (id: number, enabled: boolean) => Promise; + deleteSchedule: (id: number) => Promise; } const AppStateContext = createContext(null); @@ -160,6 +176,7 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) { // Fleet data. const [projects, setProjects] = useState([]); const [models, setModels] = useState([]); + const [schedules, setSchedules] = useState([]); const [agentsByProject, setAgentsByProject] = useState>({}); const [checkmarks, setCheckmarks] = useState>({}); const [logsByAgent, setLogsByAgent] = useState>({}); @@ -175,6 +192,7 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) { const resetData = useCallback(() => { setProjects([]); setModels([]); + setSchedules([]); setAgentsByProject({}); setCheckmarks({}); setLogsByAgent({}); @@ -208,6 +226,13 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) { return [] as ClaudeModel[]; }); + const scheduleList = await client + .api("/schedules") + .catch((e) => { + if (e instanceof AuthError) throw e; + return [] as Schedule[]; + }); + // Per-agent/per-project sub-requests are isolated: one flaky agent (a 500 on // its log, say) must not blank the whole fleet. A rejected AuthError still // propagates via onUnauthorized inside the client; here we just record the @@ -268,6 +293,7 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) { setProjects(projs); setModels(modelList); + setSchedules(scheduleList); setAgentsByProject(abp); setCheckmarks(cmMap); setLogsByAgent(logMap); @@ -506,6 +532,53 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) { [client, refresh], ); + const createSchedule = useCallback( + async ( + project: string, + input: { + name_prefix: string; + task: string; + interval_seconds: number; + role?: string | null; + model_id?: number | null; + }, + ) => { + if (!client) throw new Error("not connected"); + await client.api(`/projects/${enc(project)}/schedules`, { + body: { + name_prefix: input.name_prefix, + task: input.task, + interval_seconds: input.interval_seconds, + ...(input.role ? { role: input.role } : {}), + ...(input.model_id != null ? { model_id: input.model_id } : {}), + }, + }); + await refresh(); + }, + [client, refresh], + ); + + const toggleSchedule = useCallback( + async (id: number, enabled: boolean) => { + if (!client) throw new Error("not connected"); + await client.api(`/schedules/${id}`, { + method: "PATCH", + body: { enabled }, + }); + await refresh(); + }, + [client, refresh], + ); + + const deleteSchedule = useCallback( + async (id: number) => { + if (!client) throw new Error("not connected"); + await client.api(`/schedules/${id}`, { method: "DELETE" }); + await refresh(); + }, + [client, refresh], + ); + const value = useMemo( () => ({ screen, @@ -521,6 +594,7 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) { error, projects, models, + schedules, waiting, recent, counts, @@ -535,6 +609,9 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) { sendAnswer, spawn, kill, + createSchedule, + toggleSchedule, + deleteSchedule, }), [ screen, @@ -546,6 +623,7 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) { error, projects, models, + schedules, waiting, recent, counts, @@ -558,6 +636,9 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) { sendAnswer, spawn, kill, + createSchedule, + toggleSchedule, + deleteSchedule, ], );