diff --git a/apps/mobile/bun.lock b/apps/mobile/bun.lock index 1cff5f64..e9de6865 100644 --- a/apps/mobile/bun.lock +++ b/apps/mobile/bun.lock @@ -12,6 +12,7 @@ "@expo/vector-icons": "^15.0.2", "@shopify/flash-list": "2.0.2", "@tanstack/react-query": "^5.104.1", + "croner": "^10.0.1", "date-fns": "^4.4.0", "expo": "~57.0.26", "expo-build-properties": "~57.0.22", @@ -512,6 +513,8 @@ "core-js-compat": ["core-js-compat@3.50.0", "", { "dependencies": { "browserslist": "^4.28.7" } }, "sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q=="], + "croner": ["croner@10.0.1", "", {}, "sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], diff --git a/apps/mobile/package.json b/apps/mobile/package.json index ec486e5a..76f882ed 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -19,6 +19,7 @@ "@expo/vector-icons": "^15.0.2", "@shopify/flash-list": "2.0.2", "@tanstack/react-query": "^5.104.1", + "croner": "^10.0.1", "date-fns": "^4.4.0", "expo": "~57.0.26", "expo-build-properties": "~57.0.22", diff --git a/apps/mobile/src/app/(tabs)/(home)/index.tsx b/apps/mobile/src/app/(tabs)/(home)/index.tsx index 335b42cc..ed295d95 100644 --- a/apps/mobile/src/app/(tabs)/(home)/index.tsx +++ b/apps/mobile/src/app/(tabs)/(home)/index.tsx @@ -1,10 +1,11 @@ import { useQuery } from "@tanstack/react-query"; import { format } from "date-fns"; import { router, Stack } from "expo-router"; -import { memo, useMemo, useState } from "react"; +import { memo, useEffect, useMemo, useState } from "react"; import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, View } from "react-native"; import type { AgentQuestion } from "@godmode/shared"; import Animated, { FadeIn, LinearTransition } from "react-native-reanimated"; +import { AwaySummaryCard } from "@/components/away-summary"; import { Glass } from "@/components/glass"; import { HeaderActions } from "@/components/header-actions"; import { openHumanTask } from "@/components/human-task-row"; @@ -16,6 +17,7 @@ import { openTask } from "@/components/task-row"; import { Card, LiveDot, Row, SkeletonRows, T, tap } from "@/components/ui"; import { WorkspaceChip } from "@/components/workspace-chip"; import { api, errorText } from "@/lib/api"; +import { trackAway } from "@/lib/away"; import { greeting, needsYou, pickAgent, SUGGESTIONS, type Need } from "@/lib/desktop-parity"; import { chatListQuery, useAgents, useOpenHumanTasks } from "@/lib/hooks"; import { useLinkComputer, useLive, useRunningChats } from "@/lib/live"; @@ -44,6 +46,7 @@ export default function Home() { reconnectNow(); await queryClient.invalidateQueries({ predicate: (q) => q.queryKey[1] !== "detail" }); }); + useEffect(() => trackAway(), []); const enabled = (allAgents ?? []).filter((a) => a.enabled); const agent = pickAgent(enabled); @@ -144,6 +147,7 @@ export default function Home() { )} + diff --git a/apps/mobile/src/app/(tabs)/chats/index.tsx b/apps/mobile/src/app/(tabs)/chats/index.tsx index 9892817e..dd9a3992 100644 --- a/apps/mobile/src/app/(tabs)/chats/index.tsx +++ b/apps/mobile/src/app/(tabs)/chats/index.tsx @@ -2,24 +2,25 @@ import { FlashList, type ListRenderItem } from "@shopify/flash-list"; import { useQuery } from "@tanstack/react-query"; import { Link, router, Stack } from "expo-router"; import { memo, useCallback, useEffect, useMemo, useState } from "react"; -import { Alert, RefreshControl, View } from "react-native"; +import { Alert, Pressable, RefreshControl, View } from "react-native"; import type { Agent, Conversation, Project } from "@godmode/shared"; import { HeaderActions } from "@/components/header-actions"; import { ConversationRow } from "@/components/rows"; -import { FilterChips } from "@/components/filter-chips"; -import type { IconName } from "@/components/icon"; -import { EmptyState, ErrorState, Hairline, SectionTitle, SkeletonRows } from "@/components/ui"; +import { Icon, type IconName } from "@/components/icon"; +import { SegmentedTabs } from "@/components/segmented-tabs"; +import { EmptyState, ErrorState, Hairline, Row, SectionTitle, SkeletonRows, T, tap } from "@/components/ui"; import { WorkspaceChip } from "@/components/workspace-chip"; import { api, errorText } from "@/lib/api"; -import { chatBucket, chatDayLabel, continuesAt, type ChatBucket } from "@/lib/desktop-parity"; +import { chatSource, SOURCES, sourceStats, TABS, useChatFilters, type ChatSource, type RecentTab } from "@/lib/chat-filters"; +import { chatBucket, chatDayLabel, continuesAt } from "@/lib/desktop-parity"; import { CHAT_LIST_LIMIT, useAgents } from "@/lib/hooks"; -import { useRunningChats } from "@/lib/live"; +import { useLive } from "@/lib/live"; import { qk, queryClient } from "@/lib/query"; import { usePullRefresh } from "@/lib/use-pull-refresh"; import { inProject, useProjectIndex, useWorkspace, chatProject } from "@/lib/workspace"; -import { space } from "@/lib/theme"; +import { radius, space, useColors } from "@/lib/theme"; -type Tab = "all" | ChatBucket; +type Tab = RecentTab; type Item = { kind: "header"; key: string; title: string } | { kind: "chat"; key: string; conversation: Conversation; divided: boolean }; const EMPTY: Record = { @@ -29,6 +30,26 @@ const EMPTY: Record = { done: { icon: "check", title: "Nothing finished yet", body: "Finished chats land here." }, }; +const EMPTY_SOURCE: Record = { + mine: { icon: "chats", title: "No chats from you yet", body: "Chats you start show up here. Automations and agents have their own tabs." }, + automations: { icon: "clock", title: "No automation chats", body: "When an automation runs, its chat lands here." }, + agents: { icon: "user", title: "No agent chats", body: "Handovers, board tasks and heartbeats agents start themselves land here." }, +}; + +/** The chats a run works on right now, and the ones waiting for a free slot. */ +function useChatRuns(): { running: Set; queued: Set } { + const key = useLive((s) => { + const runs = Object.values(s.runs).map(({ run }) => run); + const running = new Set(runs.filter((r) => r.status === "running").map((r) => r.conversationId)); + const queued = new Set(runs.filter((r) => r.status === "queued" && !running.has(r.conversationId)).map((r) => r.conversationId)); + return `${[...running].sort().join(",")}|${[...queued].sort().join(",")}`; + }); + return useMemo(() => { + const [running = "", queued = ""] = key.split("|"); + return { running: new Set(running ? running.split(",") : []), queued: new Set(queued ? queued.split(",") : []) }; + }, [key]); +} + export default function Chats() { const [search, setSearch] = useState(""); // The search reads every message on the computer: ask once the typing pauses. @@ -39,7 +60,10 @@ export default function Chats() { const timer = setTimeout(() => setQ(next), 300); return () => clearTimeout(timer); }, [search]); - const [tab, setTab] = useState("all"); + const tab = useChatFilters((s) => s.tab); + const setTab = useChatFilters((s) => s.setTab); + const source = useChatFilters((s) => s.source); + const setSource = useChatFilters((s) => s.setSource); const { id: workspaceId, workspace, projectId, project } = useWorkspace(); const projects = useProjectIndex(); const list = useQuery({ @@ -47,22 +71,45 @@ export default function Chats() { queryFn: () => api.conversations.list({ search: q || undefined, limit: CHAT_LIST_LIMIT, workspaceId }), placeholderData: (previous) => previous, }); + const hasArchived = useQuery({ + queryKey: ["archived-count", "any", workspaceId ?? "all"], + queryFn: async () => (await api.conversations.list({ archived: true, limit: 1, workspaceId })).length > 0, + }).data; + // Archived chats that still work, wait or come back on their own. + const archivedOngoing = + useQuery({ + queryKey: ["archived-count", "ongoing", workspaceId ?? "all"], + queryFn: async () => (await api.conversations.list({ archived: true, ongoing: true, limit: 100, workspaceId })).length, + enabled: !!hasArchived, + }).data ?? 0; const { byId } = useAgents(); const pull = usePullRefresh(list.refetch); - const running = useRunningChats(); - const visible = useMemo(() => inProject(list.data ?? [], projectId, byId).filter((conv) => conv.origin !== "dream" && !conv.archived), [list.data, projectId, byId]); + const runs = useChatRuns(); + const unarchived = useMemo(() => inProject(list.data ?? [], projectId, byId).filter((conv) => !conv.archived), [list.data, projectId, byId]); + const sources = useMemo(() => sourceStats(unarchived), [unarchived]); + const visible = useMemo(() => (q ? unarchived : unarchived.filter((conv) => source === "all" || chatSource(conv) === source)), [unarchived, source, q]); + const live = useCallback((conv: Conversation) => runs.running.has(conv.id) || runs.queued.has(conv.id), [runs]); const counts = useMemo(() => { - const n = { running: 0, scheduled: 0 }; + const n = { running: 0, scheduled: 0, needsYou: 0 }; for (const conv of visible) { - const bucket = chatBucket(conv, running.has(conv.id)); + const bucket = chatBucket(conv, live(conv)); if (bucket !== "done") n[bucket]++; + if (conv.paused?.reason === "question") n.needsYou++; } return n; - }, [visible, running]); + }, [visible, live]); + const unread = useMemo(() => visible.flatMap((conv) => (conv.unread ? [conv.id] : [])), [visible]); + const markRead = () => { + tap(); + api.conversations + .read(unread.slice(0, 500)) + .catch((err) => Alert.alert("Couldn't mark the chats read", errorText(err))) + .finally(() => void queryClient.invalidateQueries({ queryKey: qk.conversations })); + }; const items = useMemo(() => { const matching = visible - .filter((conv) => tab === "all" || chatBucket(conv, running.has(conv.id)) === tab) + .filter((conv) => tab === "all" || chatBucket(conv, live(conv)) === tab) .sort((a, b) => q ? 0 @@ -82,7 +129,7 @@ export default function Chats() { day = label; } return out; - }, [visible, tab, running, q]); + }, [visible, tab, live, q]); const renderItem: ListRenderItem = useCallback( ({ item }) => { @@ -92,14 +139,26 @@ export default function Chats() { return ( <> {item.divided && } - + ); }, - [byId, running, projectId, projects], + [byId, runs, projectId, projects], ); const scopeName = project?.name ?? workspace?.name ?? null; - const empty = tab === "all" && scopeName ? { ...EMPTY.all, title: `No chats in ${scopeName} yet`, body: "New chats you start here land in this list." } : EMPTY[tab]; + const empty = + tab === "all" && source !== "all" + ? EMPTY_SOURCE[source] + : tab === "all" && scopeName + ? { ...EMPTY.all, title: `No chats in ${scopeName} yet`, body: "New chats you start here land in this list." } + : EMPTY[tab]; return ( <> @@ -116,27 +175,48 @@ export default function Chats() { keyExtractor={(item) => item.key} getItemType={(item) => item.kind} renderItem={renderItem} - extraData={running} + extraData={runs} contentInsetAdjustmentBehavior="automatic" keyboardDismissMode="on-drag" contentContainerStyle={{ paddingHorizontal: space.sm, paddingBottom: 140 }} refreshControl={} ListHeaderComponent={ - - - + + + {unread.length > 0 && } + + {!q && ( + { + const { unread: n, needsYou } = sources[s.value]; + return { + value: s.value, + label: s.label, + accessibilityHint: s.hint, + dot: s.value === "all" ? null : needsYou > 0 ? "warning" : n > 0 ? "brand" : null, + }; + })} + /> + )} + ({ + value: t.value, + label: t.label, + accessibilityHint: t.hint, + count: t.value === "running" || t.value === "scheduled" ? counts[t.value] : 0, + tone: t.value === "running" ? (counts.needsYou > 0 ? "warning" : "brand") : "neutral", + }))} /> } + ListFooterComponent={hasArchived && !q ? : null} ListEmptyComponent={ list.isLoading ? ( @@ -153,6 +233,49 @@ export default function Chats() { ); } +function MarkRead({ onPress }: { onPress: () => void }) { + const c = useColors(); + return ( + ({ marginLeft: "auto", opacity: pressed ? 0.5 : 1 })}> + + Mark all read + + + ); +} + +/** The archive, with how many of its chats still have work ahead. */ +function ArchivedLink({ ongoing }: { ongoing: number }) { + const c = useColors(); + return ( + { + tap(); + router.push("/archived"); + }} + style={({ pressed }) => [{ flexDirection: "row", alignItems: "center", gap: space.sm, marginTop: space.lg, paddingHorizontal: space.md, height: 44, borderRadius: radius.lg, borderCurve: "continuous" }, pressed && { backgroundColor: c.sunken }]} + > + + + Archived chats + + {ongoing > 0 && ( + + + + {ongoing} + + + )} + + + ); +} + function DayTitle({ title }: { title: string }) { return ( @@ -165,10 +288,27 @@ function changeChat(id: string, patch: { pinned?: boolean; archived?: boolean }) api.conversations .update(id, patch) .catch((err) => Alert.alert("Couldn't change the chat", errorText(err))) - .finally(() => void queryClient.invalidateQueries({ queryKey: qk.conversations })); + .finally(() => { + void queryClient.invalidateQueries({ queryKey: qk.conversations }); + if (patch.archived !== undefined) void queryClient.invalidateQueries({ queryKey: ["archived-count"] }); + }); } -const ChatItem = memo(function ChatItem({ conversation, agent, running, project }: { conversation: Conversation; agent?: Agent; running: boolean; project?: Project }) { +const ChatItem = memo(function ChatItem({ + conversation, + agent, + from, + running, + queued, + project, +}: { + conversation: Conversation; + agent?: Agent; + from?: Agent; + running: boolean; + queued: boolean; + project?: Project; +}) { const update = { mutate: (patch: { pinned?: boolean; archived?: boolean }) => changeChat(conversation.id, patch) }; const remove = () => Alert.alert("Delete this chat?", "It's removed on your computer too.", [ @@ -184,7 +324,7 @@ const ChatItem = memo(function ChatItem({ conversation, agent, running, project }, ]); - const row = ; + const row = ; if (process.env.EXPO_OS !== "ios") return row; return ( @@ -193,6 +333,7 @@ const ChatItem = memo(function ChatItem({ conversation, agent, running, project + {conversation.unread ? void api.conversations.read([conversation.id]).finally(() => void queryClient.invalidateQueries({ queryKey: qk.conversations }))} /> : null} update.mutate({ pinned: !conversation.pinned })} /> update.mutate({ archived: true })} /> diff --git a/apps/mobile/src/app/(tabs)/tasks/index.tsx b/apps/mobile/src/app/(tabs)/tasks/index.tsx index 17e5b758..6518dc95 100644 --- a/apps/mobile/src/app/(tabs)/tasks/index.tsx +++ b/apps/mobile/src/app/(tabs)/tasks/index.tsx @@ -7,6 +7,7 @@ import { TASK_STATUSES, type Agent, type Task, type TaskStatus } from "@godmode/ import { HeaderActions } from "@/components/header-actions"; import { HumanTaskRow } from "@/components/human-task-row"; import { FilterChips } from "@/components/filter-chips"; +import { GoalsStrip } from "@/components/goals-strip"; import { STATUS_META, TaskRow } from "@/components/task-row"; import { Button, Card, EmptyState, ErrorState, Hairline, SectionTitle, SkeletonRows } from "@/components/ui"; import { WorkspaceChip } from "@/components/workspace-chip"; @@ -30,7 +31,7 @@ export default function Tasks() { const tasks = useQuery({ queryKey: qk.taskList(workspaceId), queryFn: () => api.tasks.list({ workspaceId }) }); const { byId: agents } = useAgents(); const names = useMemo(() => new Map(workspaces.map((w) => [w.id, w.name])), [workspaces]); - const pull = usePullRefresh(() => Promise.all([tasks.refetch(), mine.refetch()])); + const pull = usePullRefresh(() => Promise.all([tasks.refetch(), mine.refetch(), queryClient.invalidateQueries({ queryKey: qk.goals(workspaceId) })])); const [view, setView] = useState("active"); const newTask = () => router.push("/new-task"); @@ -105,6 +106,7 @@ export default function Tasks() { )} + {board.length > 0 && ( + + + + + diff --git a/apps/mobile/src/app/activity.tsx b/apps/mobile/src/app/activity.tsx index 97c7a284..c7ca7a3d 100644 --- a/apps/mobile/src/app/activity.tsx +++ b/apps/mobile/src/app/activity.tsx @@ -9,12 +9,13 @@ import type { Run, RunStatus } from "@godmode/shared"; import { FilterChips } from "@/components/filter-chips"; import { Icon } from "@/components/icon"; import { isLiveRun, RunRow } from "@/components/run-row"; +import { SpendStrip } from "@/components/spend-strip"; import { Button, Card, EmptyState, ErrorState, Hairline, LiveDot, Row, SectionTitle, SkeletonRows, T } from "@/components/ui"; import { api, errorText } from "@/lib/api"; import { runDayLabel } from "@/lib/desktop-parity"; import { useAgents } from "@/lib/hooks"; import { useLive } from "@/lib/live"; -import { qk } from "@/lib/query"; +import { qk, queryClient } from "@/lib/query"; import { WorkspaceChip } from "@/components/workspace-chip"; import { usePullRefresh } from "@/lib/use-pull-refresh"; import { useWorkspace } from "@/lib/workspace"; @@ -56,7 +57,7 @@ export default function Activity() { const { byId } = agents; // The runs themselves, not what they are doing: an activity label of one run doesn't re-render the list. const liveRuns = useLive(useShallow((s) => Object.values(s.runs).map((r) => r.run))); - const pull = usePullRefresh(list.refetch); + const pull = usePullRefresh(() => Promise.all([list.refetch(), queryClient.invalidateQueries({ queryKey: ["spend"] })])); // A workspace's runs are known once its agents are. const loading = list.isLoading || (!!workspaceId && agents.isLoading); @@ -154,11 +155,12 @@ function DayTitle({ title }: { title: string }) { ); } -/** Three numbers of the fetched runs, then what to show of them. */ +/** What the team cost (with spend tracking on), three numbers of the fetched runs, then what to show of them. */ const ActivityHeader = memo(function ActivityHeader({ stats, filter, onFilter }: { stats: Stats; filter: Filter; onFilter: (filter: Filter) => void }) { const c = useColors(); return ( + 0} />} /> diff --git a/apps/mobile/src/app/agent/[id].tsx b/apps/mobile/src/app/agent/[id].tsx index a6d14db6..059a8c58 100644 --- a/apps/mobile/src/app/agent/[id].tsx +++ b/apps/mobile/src/app/agent/[id].tsx @@ -4,6 +4,9 @@ import { router, Stack, useLocalSearchParams } from "expo-router"; import { useMemo, type ReactNode } from "react"; import { Alert, Pressable, ScrollView, StyleSheet, Switch, View } from "react-native"; import { findModel, leadOf, presenceLabel, reportsOf, type Agent, type Routine, type TaskStatus } from "@godmode/shared"; +import { AgentHistory, FailedRunBanner } from "@/components/agent-history"; +import { DreamsCard } from "@/components/dreams-card"; +import { HeartbeatCard } from "@/components/heartbeat-card"; import { Icon } from "@/components/icon"; import { CharacterAvatar, moodOf, useAgentPresence } from "@/components/character"; import { HeaderActions } from "@/components/header-actions"; @@ -47,7 +50,10 @@ export default function AgentScreen() { return ; } -/** The desktop's agent header (role, presence, lead, scope, model), what is on its plate, its automations and chats. */ +/** + * The desktop's agent header (role, presence, lead, scope, model), a failed last run, what is on its plate, its recent + * runs, memory and dreams, automations, heartbeat and chats. + */ function AgentView({ agent, team }: { agent: Agent; team: Agent[] }) { const id = agent.id; const { workspaces } = useWorkspace(); @@ -109,13 +115,23 @@ function AgentView({ agent, team }: { agent: Agent; team: Agent[] }) { + + toggle.mutate(true)} switching={toggle.isPending} /> + + + + - - + router.push({ pathname: "/automation-edit", params: { agentId: agent.id } })} + /> + {routines.data?.length ? ( routines.data.map((r, i) => ( @@ -127,12 +143,14 @@ function AgentView({ agent, team }: { agent: Agent; team: Agent[] }) { ) : ( - No automations yet. Set them up in Godmode on your computer. + {`No automations yet. Let ${agent.name} get to work on its own — even while you're away.`} )} + + @@ -359,8 +377,16 @@ function RoutineRow({ routine }: { routine: Routine }) { onError: (err) => Alert.alert("Couldn't run it", errorText(err)), }); const enabled = toggle.isPending ? !!toggle.variables : routine.enabled; + const canRun = routine.enabled && !run.isPending; return ( - + { + tap(); + router.push({ pathname: "/automation/[id]", params: { id: routine.id } }); + }} + style={({ pressed }) => [styles.routine, pressed && { backgroundColor: c.sunken }]} + > {routine.name} @@ -371,9 +397,13 @@ function RoutineRow({ routine }: { routine: Routine }) { { + if (!canRun) return; tap(); run.mutate(); }} @@ -382,7 +412,7 @@ function RoutineRow({ routine }: { routine: Routine }) { toggle.mutate(v)} trackColor={{ true: c.brand, false: c.sunken }} /> - + ); } @@ -417,6 +447,12 @@ const styles = StyleSheet.create({ paddingHorizontal: space.sm, paddingTop: 2, }, + routine: { + flexDirection: "row", + alignItems: "center", + gap: space.md, + padding: space.lg, + }, runNow: { width: 34, height: 34, diff --git a/apps/mobile/src/app/archived.tsx b/apps/mobile/src/app/archived.tsx index ab4c5c58..7744e4ac 100644 --- a/apps/mobile/src/app/archived.tsx +++ b/apps/mobile/src/app/archived.tsx @@ -95,7 +95,10 @@ function GroupTitle({ title, hint }: { title: string; hint?: string }) { ); } -const refreshChats = () => void queryClient.invalidateQueries({ queryKey: qk.conversations }); +const refreshChats = () => { + void queryClient.invalidateQueries({ queryKey: qk.conversations }); + void queryClient.invalidateQueries({ queryKey: ["archived-count"] }); +}; /** Out of every cached archive right away; the refetch brings it back if the computer refused. */ function dropFromArchive(id: string) { diff --git a/apps/mobile/src/app/automation-edit.tsx b/apps/mobile/src/app/automation-edit.tsx new file mode 100644 index 00000000..6b20fca9 --- /dev/null +++ b/apps/mobile/src/app/automation-edit.tsx @@ -0,0 +1,543 @@ +import { useQuery } from "@tanstack/react-query"; +import { router, useLocalSearchParams } from "expo-router"; +import { useState } from "react"; +import { ActivityIndicator, Alert, Pressable, ScrollView, StyleSheet, TextInput, View } from "react-native"; +import { + cronToHuman, + DEFAULT_CRON, + validateCron, + type Agent, + type Routine, + type RoutineInput, + type RoutineNotify, + type RoutineTrigger, + type RoutineTriggerType, +} from "@godmode/shared"; +import { CharacterAvatar } from "@/components/character"; +import { CronBuilder, StartWindowField, startWindowProblem } from "@/components/cron-builder"; +import { Group, PickerRow, SwitchRow, Tile, type Option } from "@/components/form"; +import { Icon, type IconName } from "@/components/icon"; +import { phoneTimezone, triggerText, TRIGGER_TITLES } from "@/components/routine-row"; +import { ErrorState, LoadingState, T, tap } from "@/components/ui"; +import { api, errorText } from "@/lib/api"; +import { useAgents } from "@/lib/hooks"; +import { qk, queryClient } from "@/lib/query"; +import { radius, space, useColors } from "@/lib/theme"; + +type PhoneTrigger = "schedule" | "condition"; + +/** Conditions are checked hourly unless the human picks otherwise. */ +const DEFAULT_CHECK_CRON = "0 * * * *"; +const CHECK_EVERY = ["*/5 * * * *", "*/15 * * * *", "*/30 * * * *", "0 * * * *", "0 9 * * *"]; + +const NAME_PLACEHOLDERS: Record = { + schedule: "Weekly numbers report", + app: "Invoice emails to Drive", + condition: "Competitor pricing watch", + webhook: "Abandoned checkout recovery", +}; + +const PROMPT_PLACEHOLDERS: Record = { + schedule: "Collect last week's numbers from Stripe and Google Analytics, write a short report and send it to me.", + app: "Save the invoice PDF to Drive and log sender, amount and due date in my Invoices sheet. The event details are passed along.", + condition: "Compare their new plans with ours and update the pricing battlecard in Notion. What the check found is passed along.", + webhook: "Check whether the order qualifies for a recovery offer and draft a friendly email. The request body is passed along.", +}; + +const FILTER_PLACEHOLDERS: Partial> = { + app: "the sender is a customer, or the subject mentions an invoice", + webhook: "the order total is over $100", +}; + +const TRIGGER_OPTIONS: { type: PhoneTrigger; icon: IconName; hint: string }[] = [ + { type: "schedule", icon: "clock", hint: "Every morning, each Monday, on the 1st…" }, + { type: "condition", icon: "eye", hint: "Checked regularly, in plain language" }, +]; + +const NOTIFY_OPTIONS: Option[] = [ + { value: "failures", label: "When a run fails" }, + { value: "always", label: "After every run" }, + { value: "never", label: "Never" }, +]; + +const NOTIFY_HINTS: Record = { + never: "No notifications. A failing automation still shows under Needs you.", + always: "A notification after every run, with what it did.", + failures: "A failure is told once, until a run works again.", +}; + +interface Draft { + agentId: string; + name: string; + triggerType: RoutineTriggerType; + /** Schedule triggers: when to run. */ + cron: string; + /** Schedule triggers: random start window in minutes, 0 = on time. */ + startWindow: number; + /** Schedule triggers: runs spread over the start window, 1 = once. */ + runs: number; + /** Condition triggers: how often to check. */ + checkCron: string; + condition: string; + /** App/webhook triggers: only act on matching events. */ + filter: string; + prompt: string; + enabled: boolean; + reuseConversation: boolean; + notify: RoutineNotify; +} + +function initialDraft(routine: Routine | null, agentId: string): Draft { + const t = routine?.trigger; + return { + agentId: routine?.agentId ?? agentId, + name: routine?.name ?? "", + triggerType: t?.type ?? "schedule", + cron: t?.type === "schedule" ? routine!.cron : DEFAULT_CRON, + startWindow: t?.type === "schedule" ? (t.startWindowMinutes ?? 0) : 0, + runs: t?.type === "schedule" ? (t.runsPerWindow ?? 1) : 1, + checkCron: t?.type === "condition" ? routine!.cron : DEFAULT_CHECK_CRON, + condition: t?.type === "condition" ? t.condition : "", + filter: routine?.filter ?? "", + prompt: routine?.prompt ?? "", + enabled: routine?.enabled ?? true, + reuseConversation: routine?.reuseConversation ?? true, + notify: routine?.notify ?? "failures", + }; +} + +/** Create (`agentId` preselects the agent) or edit (`id`) an automation that runs on a schedule or when a condition is met. */ +export default function AutomationEdit() { + const { id, agentId } = useLocalSearchParams<{ id?: string; agentId?: string }>(); + const list = useQuery({ queryKey: qk.allRoutines, queryFn: () => api.routines.list(), enabled: !!id }); + const agents = useAgents(); + const routine = id ? list.data?.find((r) => r.id === id) : undefined; + + if ((id && !routine) || !agents.data) { + const loading = (!!id && list.isLoading) || agents.isLoading; + const error = id && list.isError ? list.error : agents.isError ? agents.error : null; + return ( + +
+ + {loading ? ( + + ) : ( + void (id && list.isError ? list.refetch() : agents.refetch()) : undefined} + /> + )} + + + ); + } + const preset = agents.data.find((a) => a.id === agentId && a.enabled) ?? agents.data.find((a) => a.isDefault && a.enabled) ?? agents.data.find((a) => a.enabled); + return ; +} + +function AutomationForm({ routine, agents, defaultAgentId }: { routine: Routine | null; agents: Agent[]; defaultAgentId: string }) { + const c = useColors(); + const editing = !!routine; + const [draft, setDraft] = useState(() => initialDraft(routine, defaultAgentId)); + const [touched, setTouched] = useState(false); + const [saving, setSaving] = useState(false); + const set = (k: K, v: Draft[K]) => setDraft((d) => ({ ...d, [k]: v })); + + const type = draft.triggerType; + // App and webhook triggers need connections and secret URLs that live on the computer. + const phoneTrigger = type === "schedule" || type === "condition"; + const eventTrigger = !phoneTrigger; + const timezone = routine?.timezone || phoneTimezone() || "UTC"; + const zone = timezone !== phoneTimezone() ? timezone.replace(/_/g, " ") : null; + const windowProblem = type === "schedule" ? startWindowProblem(draft.cron, timezone, draft.startWindow) : null; + const cronError = type === "schedule" ? (validateCron(draft.cron) ?? windowProblem) : type === "condition" ? validateCron(draft.checkCron) : null; + const problems = { + agentId: !draft.agentId ? "Pick an agent" : null, + name: !draft.name.trim() ? "Name your automation" : null, + condition: type === "condition" && !draft.condition.trim() ? "Describe what to watch for" : null, + prompt: !draft.prompt.trim() ? (eventTrigger ? "Tell the agent what to do with each event" : "Tell the agent what to do each time") : null, + }; + const firstProblem = Object.values(problems).find(Boolean) ?? cronError; + const choosable = agents.filter((a) => a.enabled || a.id === draft.agentId); + const agent = agents.find((a) => a.id === draft.agentId); + const checkOptions: Option[] = [...new Set([...CHECK_EVERY, draft.checkCron])].map((cron) => ({ value: cron, label: cronToHuman(cron) })); + + const buildTrigger = (): RoutineTrigger => { + if (type === "condition") { + const before = routine?.trigger.type === "condition" ? routine.trigger.checkModel : null; + return { type: "condition", condition: draft.condition.trim(), checkModel: before }; + } + if (!draft.startWindow) return { type: "schedule" }; + return draft.runs > 1 + ? { type: "schedule", startWindowMinutes: draft.startWindow, runsPerWindow: draft.runs } + : { type: "schedule", startWindowMinutes: draft.startWindow }; + }; + + const save = async () => { + setTouched(true); + if (saving) return; + if (firstProblem) { + Alert.alert(editing ? "Couldn't update automation" : "Couldn't create automation", firstProblem); + return; + } + const common = { + name: draft.name.trim(), + prompt: draft.prompt.trim(), + enabled: draft.enabled, + reuseConversation: draft.reuseConversation, + notify: draft.notify, + }; + // An app or webhook trigger is never sent from the phone: only what it does and how it reports changes. + const input: Partial = phoneTrigger + ? { ...common, trigger: buildTrigger(), cron: (type === "schedule" ? draft.cron : draft.checkCron).trim(), timezone, filter: "" } + : { ...common, filter: draft.filter.trim() }; + setSaving(true); + try { + if (routine) await api.routines.update(routine.id, input); + else await api.routines.create({ ...input, agentId: draft.agentId } as RoutineInput); + void queryClient.invalidateQueries({ queryKey: qk.routines }); + tap(); + router.back(); + } catch (err) { + Alert.alert(editing ? "Couldn't update automation" : "Couldn't create automation", errorText(err)); + } finally { + setSaving(false); + } + }; + + return ( + +
void save()} /> + + + When it happens, the agent gets to work — even while you're away. + + + + + set("name", v)} + placeholder={NAME_PLACEHOLDERS[type]} + placeholderTextColor={c.textFaint} + maxLength={120} + autoFocus={!editing} + returnKeyType="done" + submitBehavior="blurAndSubmit" + style={[styles.name, { color: c.text }]} + /> + + + + + + + Agent + + {editing ? ( + + + + ) : ( + + {choosable.map((a) => ( + set("agentId", a.id)} /> + ))} + + )} + + + + + + When should it run? + + {phoneTrigger ? ( + + {TRIGGER_OPTIONS.map((o) => ( + set("triggerType", o.type)} /> + ))} + + ) : ( + + + + + + {TRIGGER_TITLES[type]} + + {routine ? ( + + {triggerText(routine)} + + ) : null} + + + + + )} + + + {type === "schedule" ? ( + + set("cron", cron)} + startWindowMinutes={draft.startWindow} + runsPerWindow={draft.runs} + zone={zone} + problem={windowProblem} + /> + setDraft((d) => ({ ...d, startWindow: m, runs: 1 }))} /> + + ) : null} + + {type === "condition" ? ( + + + + When… + + set("condition", v)} + placeholder="a competitor changes the price of their Pro plan (acme.com/pricing)" + placeholderTextColor={c.textFaint} + multiline + style={[styles.area, { minHeight: 76, color: c.text, backgroundColor: c.surface, borderColor: touched && problems.condition ? c.danger : c.border }]} + /> + + + In plain language. The agent checks it on the schedule below and gets to work once it's true. + + + + set("checkCron", cron)} /> + + + ) : null} + + {eventTrigger ? ( + + + Only when… (optional) + + set("filter", v)} + placeholder={FILTER_PLACEHOLDERS[type]} + placeholderTextColor={c.textFaint} + multiline + style={[styles.area, { minHeight: 52, color: c.text, backgroundColor: c.surface, borderColor: c.border }]} + /> + + In plain language. Events that don't match are skipped; leave empty to act on every event. + + + ) : null} + + + + What should it do? + + set("prompt", v)} + placeholder={PROMPT_PLACEHOLDERS[type]} + placeholderTextColor={c.textFaint} + multiline + style={[styles.area, { minHeight: 130, color: c.text, backgroundColor: c.surface, borderColor: touched && problems.prompt ? c.danger : c.border }]} + /> + + + + + set("reuseConversation", v)} + /> + set("enabled", v)} + /> + set("notify", v)} /> + + + + ); +} + +/** The sheet's bar: Cancel, the title and Save. */ +function Header({ title, action, saving, onSave }: { title: string; action?: string; saving?: boolean; onSave?: () => void }) { + const c = useColors(); + return ( + + router.back()} style={styles.headerSide}> + + Cancel + + + + {title} + + + {action && onSave ? ( + saving ? ( + + ) : ( + + + {action} + + + ) + ) : null} + + + ); +} + +function TriggerOption({ icon, title, hint, active, onPress }: { icon: IconName; title: string; hint: string; active: boolean; onPress: () => void }) { + const c = useColors(); + return ( + { + if (active) return; + tap(); + onPress(); + }} + style={({ pressed }) => [styles.row, pressed && { backgroundColor: c.sunken }]} + > + + + + {title} + + + {hint} + + + {active ? : null} + + ); +} + +function AgentChip({ agent, active, onPress }: { agent: Agent | undefined; active: boolean; onPress?: () => void }) { + const c = useColors(); + return ( + { + tap(); + onPress?.(); + }} + style={[styles.chip, { backgroundColor: active ? c.primary : c.sunken }]} + > + + + {agent?.name ?? "Agent"} + + + ); +} + +function Problem({ text }: { text: string | null }) { + const c = useColors(); + if (!text) return null; + return ( + + {text} + + ); +} + +const styles = StyleSheet.create({ + header: { + flexDirection: "row", + alignItems: "center", + gap: space.sm, + paddingHorizontal: space.lg, + paddingTop: space.xl, + paddingBottom: space.md, + borderBottomWidth: StyleSheet.hairlineWidth, + }, + headerSide: { + minWidth: 64, + }, + content: { + paddingTop: space.lg, + paddingHorizontal: space.xl, + paddingBottom: 60, + gap: space.xl, + }, + fields: { + borderRadius: radius.lg, + borderCurve: "continuous", + borderWidth: StyleSheet.hairlineWidth, + }, + name: { + fontSize: 17, + fontWeight: "600", + paddingHorizontal: space.lg, + paddingVertical: 14, + }, + area: { + fontSize: 15, + lineHeight: 20, + maxHeight: 260, + paddingHorizontal: space.lg, + paddingTop: 12, + paddingBottom: 12, + textAlignVertical: "top", + borderRadius: radius.lg, + borderCurve: "continuous", + borderWidth: StyleSheet.hairlineWidth, + }, + row: { + flexDirection: "row", + alignItems: "center", + gap: space.md, + minHeight: 52, + paddingHorizontal: space.lg, + paddingVertical: 11, + }, + chips: { + flexDirection: "row", + flexWrap: "wrap", + gap: space.sm, + }, + chip: { + flexDirection: "row", + alignItems: "center", + gap: 6, + height: 36, + paddingLeft: 8, + paddingRight: 14, + borderRadius: radius.pill, + maxWidth: "100%", + }, +}); diff --git a/apps/mobile/src/app/automation/[id].tsx b/apps/mobile/src/app/automation/[id].tsx new file mode 100644 index 00000000..96e73a6e --- /dev/null +++ b/apps/mobile/src/app/automation/[id].tsx @@ -0,0 +1,413 @@ +import { useQuery } from "@tanstack/react-query"; +import { formatDistanceToNowStrict } from "date-fns"; +import { router, Stack, useFocusEffect, useLocalSearchParams } from "expo-router"; +import { useCallback, useRef, useState } from "react"; +import { Alert, Pressable, RefreshControl, ScrollView, StyleSheet, View } from "react-native"; +import type { AutomationEvent, AutomationEventSource, AutomationEventStatus, Routine, RoutineNotify } from "@godmode/shared"; +import { CharacterAvatar } from "@/components/character"; +import { Group, SwitchRow } from "@/components/form"; +import { HeaderActions, type HeaderMenuItem } from "@/components/header-actions"; +import { Icon, type IconName } from "@/components/icon"; +import { appName, runLabel, runRoutine, setRoutineEnabled, triggerText, TRIGGER_TITLES } from "@/components/routine-row"; +import { openChat } from "@/components/rows"; +import { runStatusLabel } from "@/components/run-row"; +import { Badge, Button, Card, EmptyState, ErrorState, Hairline, LoadingState, Row, SectionTitle, SkeletonRows, T, tap, type Tone } from "@/components/ui"; +import { api, errorText } from "@/lib/api"; +import { isFailingRoutine } from "@/lib/desktop-parity"; +import { whenText } from "@/lib/format"; +import { useAgents, useNow } from "@/lib/hooks"; +import { useLive } from "@/lib/live"; +import { qk, queryClient } from "@/lib/query"; +import { radius, space, useColors } from "@/lib/theme"; + +const EVENT_LIMIT = 50; + +const EVENT_SOURCES: Record = { + schedule: { label: "Schedule", icon: "clock" }, + app: { label: "App event", icon: "layers" }, + webhook: { label: "Webhook", icon: "link" }, + condition: { label: "Condition met", icon: "eye" }, + manual: { label: "Manual", icon: "hand" }, +}; + +const EVENT_STATUS: Record = { + pending: { label: "Waiting", tone: "warning" }, + running: { label: "Running", tone: "brand" }, + done: { label: "Done", tone: "success" }, + failed: { label: "Failed", tone: "danger" }, + skipped: { label: "Skipped", tone: "neutral" }, +}; + +const NOTIFY_LABELS: Record = { + failures: "When a run fails", + always: "After every run", + never: "Never", +}; + +const ago = (iso: string) => formatDistanceToNowStrict(new Date(iso), { addSuffix: true }); + +/** One automation, like the desktop's row and events sheet together: what starts it, when, what it does, and what happened lately. */ +export default function AutomationScreen() { + const { id } = useLocalSearchParams<{ id: string }>(); + const list = useQuery({ queryKey: qk.allRoutines, queryFn: () => api.routines.list() }); + const routine = list.data?.find((r) => r.id === id); + + if (!routine) { + return ( + + Automation + {list.isLoading ? ( + + ) : ( + void list.refetch() : undefined} + /> + )} + + ); + } + return ; +} + +function AutomationView({ routine, refetchList }: { routine: Routine; refetchList: () => Promise }) { + const c = useColors(); + const id = routine.id; + const { byId } = useAgents(); + const agent = byId.get(routine.agentId); + const working = useLive((s) => Object.values(s.runs).some(({ run }) => run.routineId === id)); + const [starting, setStarting] = useState(false); + const [refreshing, setRefreshing] = useState(false); + useNow(30_000); + const events = useQuery({ + queryKey: qk.routineEvents(id), + queryFn: () => api.routines.events(id, EVENT_LIMIT), + // Live-ish while something is still waiting or running. + refetchInterval: (q) => (q.state.data?.some((e) => e.status === "pending" || e.status === "running") ? 5000 : false), + }); + + const refetchEvents = events.refetch; + const shown = useRef(false); + useFocusEffect( + useCallback(() => { + if (shown.current) { + void refetchList(); + void refetchEvents(); + } + shown.current = true; + }, [refetchList, refetchEvents]), + ); + + const t = routine.trigger; + const status = routine.triggerStatus; + const keepsChat = routine.reuseConversation && !!routine.conversationId; + const editable = t.type === "schedule" || t.type === "condition"; + const edit = () => router.push({ pathname: "/automation-edit", params: { id } }); + + const refresh = async () => { + setRefreshing(true); + try { + await Promise.all([refetchList(), refetchEvents()]); + } finally { + setRefreshing(false); + } + }; + + const run = () => { + setStarting(true); + void runRoutine(routine).finally(() => setStarting(false)); + }; + + const remove = () => + Alert.alert( + `Delete “${routine.name}”?`, + `${t.type === "app" ? `Godmode stops listening for “${t.triggerName}” in ${appName(t.toolkit)}.` : t.type === "webhook" ? "Its webhook URL stops working." : "It won't run again."} Past runs and conversations stay in your history.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete automation", + style: "destructive", + onPress: () => + void api.routines + .delete(id) + .then(() => { + router.back(); + void queryClient.invalidateQueries({ queryKey: qk.routines }); + }) + .catch((err) => Alert.alert("Couldn't delete automation", errorText(err))), + }, + ], + ); + + const openEvent = (e: AutomationEvent) => { + if (!e.runId) return; + if (keepsChat) return openChat(routine.conversationId!); + const runId = e.runId; + queryClient + .fetchQuery({ queryKey: qk.run(runId), queryFn: () => api.runs.get(runId) }) + .then((r) => openChat(r.conversationId)) + .catch((err) => Alert.alert("Couldn't open the chat", errorText(err))); + }; + + const menu: HeaderMenuItem[] = [ + ...(routine.conversationId ? [{ icon: "chats" as IconName, title: "Open chat", onPress: () => openChat(routine.conversationId!) }] : []), + { icon: "trash", title: "Delete", destructive: true, onPress: remove }, + ]; + + const next = routine.enabled && routine.nextRunAt ? `${whenText(routine.nextRunAt)} · ${ago(routine.nextRunAt)}` : routine.enabled ? "—" : "Paused"; + const last = routine.lastRunAt ? `${routine.lastStatus ? `${runStatusLabel(routine.lastStatus)} · ` : ""}${ago(routine.lastRunAt)}` : "Never"; + const lastCheck = t.type === "condition" && (status.observation || status.lastCheckAt); + const shownEvents = events.data ?? []; + + return ( + void refresh()} />} + > + {routine.name} + + + + { + tap(); + router.push({ pathname: "/agent/[id]", params: { id: routine.agentId } }); + }} + > + + + + {routine.name} + + + {working ? ( + + ) : routine.enabled ? ( + + ) : ( + + )} + {routine.fillOnly ? : null} + + {agent ? ( + + {agent.name} + + ) : null} + +