From a2ecbf946240f510606a6b635c8eedec8cacd534 Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Fri, 23 Oct 2026 18:03:15 +0200 Subject: [PATCH 01/10] Phone scope and client: automations, follow-ups, goals, runs, spend, away, heartbeat, dreams, mark read --- apps/mobile/src/lib/api.ts | 55 +++++++++++++++++++++++++++++++ apps/mobile/src/lib/query.ts | 10 ++++++ packages/core/src/mobile/scope.ts | 46 ++++++++++++++++++++++++-- packages/core/test/mobile.test.ts | 22 +++++++++++-- 4 files changed, 129 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/lib/api.ts b/apps/mobile/src/lib/api.ts index dab69a3a..32ed60be 100644 --- a/apps/mobile/src/lib/api.ts +++ b/apps/mobile/src/lib/api.ts @@ -49,6 +49,19 @@ import type { TaskType, Vm, Workspace, + AgentHeartbeatState, + AutomationEvent, + AwaySummary, + Dream, + DreamDetail, + DreamOverview, + Followup, + Goal, + GoalInput, + HeartbeatBeat, + RoutineInput, + SpendPeriod, + SpendReport, } from "@godmode/shared"; import { CloudErrorCode, LICENSE_REQUIRED, isPhoneUrlAllowed } from "@godmode/shared"; import type { PendingFile, UploadFile } from "./attachments"; @@ -475,6 +488,38 @@ export const api = { saveMemory: (id: string, content: string, digest?: string) => put(`/api/agents/${id}/memory`, { content, ...(digest ? { digest } : {}) }), /** Roles to start a new agent from, by department. Their automations are set up on the computer. */ templates: () => get("/api/agent-templates"), + /** The human has seen the agent's last failed run. */ + dismissFailure: (id: string) => del(`/api/agents/${id}/failed-run`), + heartbeat: (id: string) => get(`/api/agents/${id}/heartbeat`), + /** "Wake now": one beat right away (400 when the agent is switched off). */ + beat: (id: string) => post(`/api/agents/${id}/heartbeat/beat`, {}), + }, + + dreams: { + overview: (agentId: string) => get(`/api/agents/${agentId}/dreams`), + /** Dream now; a dream is undone on the computer. */ + start: (agentId: string) => post(`/api/agents/${agentId}/dreams`, {}), + get: (id: string) => get(`/api/dreams/${id}`), + }, + + /** What the team did between `since` and `until` (Home's "while you were away"). */ + away: (since: string, until?: string) => get("/api/away", until ? { since, until } : { since }), + + spend: { + report: (period: SpendPeriod, agentId?: string | null) => get("/api/spend", agentId ? { period, agentId } : { period }), + }, + + goals: { + /** A workspace's goals with the global ones, or every goal (null). */ + list: (workspaceId: string | null) => get("/api/goals", { workspaceId: workspaceId ?? "all" }), + create: (input: GoalInput) => post("/api/goals", input), + update: (id: string, input: Partial) => patch(`/api/goals/${id}`, input), + delete: (id: string) => del<{ ok: true }>(`/api/goals/${id}`), + }, + + followups: { + /** Every chat that continues by itself later, soonest first. */ + list: (q: { agentId?: string } = {}) => get("/api/followups", q), }, models: (refresh = false) => get("/api/models", refresh ? { refresh: 1 } : {}), @@ -510,6 +555,8 @@ export const api = { sendNow: (id: string) => post<{ ok: true }>(`/api/conversations/${id}/queue/send`, {}, 30_000), }, delete: (id: string) => del<{ ok: true }>(`/api/conversations/${id}`), + /** "Mark all read": every chat, or these. */ + read: (ids: string[] | "all") => post<{ read: number }>("/api/conversations/read", { ids }), /** Continue the chat's paused run where it stopped. */ continue: (id: string) => post(`/api/conversations/${id}/continue`), /** Make the chat's run stand still; it continues where it stopped. */ @@ -565,6 +612,9 @@ export const api = { runs: { list: (q: { status?: string; limit?: number; agentId?: string } = {}) => get("/api/runs", q), + get: (id: string) => get(`/api/runs/${id}`), + /** The run's raw stream-json log. */ + log: (id: string) => get(`/api/runs/${id}/log`), cancel: (id: string) => post<{ ok: true }>(`/api/runs/${id}/cancel`), }, @@ -572,6 +622,11 @@ export const api = { list: (q: { agentId?: string } = {}) => get("/api/routines", q), run: (id: string) => post(`/api/routines/${id}/run`), setEnabled: (id: string, enabled: boolean) => patch(`/api/routines/${id}`, { enabled }), + /** Schedules and conditions only: app and webhook triggers are set up on the computer. */ + create: (input: RoutineInput) => post("/api/routines", input), + update: (id: string, input: Partial) => patch(`/api/routines/${id}`, input), + delete: (id: string) => del<{ ok: true }>(`/api/routines/${id}`), + events: (id: string, limit = 50) => get(`/api/routines/${id}/events`, { limit }), }, browser: { diff --git a/apps/mobile/src/lib/query.ts b/apps/mobile/src/lib/query.ts index e1e5da47..9a21398f 100644 --- a/apps/mobile/src/lib/query.ts +++ b/apps/mobile/src/lib/query.ts @@ -47,4 +47,14 @@ export const qk = { humanTasks: ["human-tasks"], humanTaskList: (status: "active" | "all") => ["human-tasks", "list", status], chatHumanTasks: (conversationId: string) => ["human-tasks", "chat", conversationId], + run: (id: string) => ["runs", "detail", id], + runLog: (id: string) => ["runs", "log", id], + spend: (period: string, agentId: string | null = null) => ["spend", period, agentId ?? "all"], + routineEvents: (id: string) => ["routines", "events", id], + followups: ["followups"], + goals: (workspaceId: string | null) => ["goals", workspaceId ?? "all"], + away: (since: string) => ["away", since], + heartbeat: (agentId: string) => ["heartbeat", agentId], + dreams: (agentId: string) => ["dreams", agentId], + dream: (id: string) => ["dreams", "detail", id], } as const; diff --git a/packages/core/src/mobile/scope.ts b/packages/core/src/mobile/scope.ts index dc5e4e57..88fb5f47 100644 --- a/packages/core/src/mobile/scope.ts +++ b/packages/core/src/mobile/scope.ts @@ -32,6 +32,9 @@ const ROUTES: [method: string, path: RegExp][] = [ ["PUT", /^\/api\/settings$/], ["GET", /^\/api\/notifications$/], ["POST", /^\/api\/notifications\/read$/], + // What waits for the human and what the team did while they were away. + ["GET", /^\/api\/attention$/], + ["GET", /^\/api\/away$/], // Logins agents miss: seen, dismissed or opened again. Logins themselves are added on the computer. ["GET", /^\/api\/missing-logins$/], ["PATCH", new RegExp(`^/api/missing-logins/${ID}$`)], @@ -54,12 +57,20 @@ const ROUTES: [method: string, path: RegExp][] = [ ["GET", new RegExp(`^/api/agents/${ID}$`)], ["PATCH", new RegExp(`^/api/agents/${ID}$`)], ["GET", new RegExp(`^/api/agents/${ID}/commands$`)], + // A failed run the human has seen, the heartbeat (seen, woken now) and dreams (seen, started, read); a dream is undone on the computer. + ["DELETE", new RegExp(`^/api/agents/${ID}/failed-run$`)], + ["GET", new RegExp(`^/api/agents/${ID}/heartbeat$`)], + ["POST", new RegExp(`^/api/agents/${ID}/heartbeat/beat$`)], + ["GET", new RegExp(`^/api/agents/${ID}/dreams$`)], + ["POST", new RegExp(`^/api/agents/${ID}/dreams$`)], + ["GET", new RegExp(`^/api/dreams/${ID}$`)], // Only MEMORY.md, through the memory editor: the rest of the agent's repository stays on the computer. ["GET", new RegExp(`^/api/agents/${ID}/memory$`)], ["PUT", new RegExp(`^/api/agents/${ID}/memory$`)], ["GET", /^\/api\/models$/], ["GET", /^\/api\/conversations$/], + ["POST", /^\/api\/conversations\/read$/], ["GET", new RegExp(`^/api/conversations/${ID}$`)], ["GET", new RegExp(`^/api/conversations/${ID}/messages$`)], ["GET", new RegExp(`^/api/conversations/${ID}/messages/${ID}/images/${ID}$`)], @@ -75,6 +86,7 @@ const ROUTES: [method: string, path: RegExp][] = [ ["DELETE", new RegExp(`^/api/conversations/${ID}/followup$`)], ["POST", new RegExp(`^/api/conversations/${ID}/followup/run$`)], ["POST", /^\/api\/chat$/], + ["GET", /^\/api\/followups$/], ["GET", /^\/api\/tasks$/], ["POST", /^\/api\/tasks\/attachments$/], @@ -85,13 +97,24 @@ const ROUTES: [method: string, path: RegExp][] = [ ["PATCH", new RegExp(`^/api/tasks/${ID}$`)], ["POST", new RegExp(`^/api/tasks/${ID}/messages$`)], ["POST", new RegExp(`^/api/tasks/${ID}/approve$`)], + ["GET", /^\/api\/goals$/], + ["POST", /^\/api\/goals$/], + ["PATCH", new RegExp(`^/api/goals/${ID}$`)], + ["DELETE", new RegExp(`^/api/goals/${ID}$`)], ["GET", /^\/api\/runs$/], + ["GET", new RegExp(`^/api/runs/${ID}$`)], + ["GET", new RegExp(`^/api/runs/${ID}/log$`)], ["POST", new RegExp(`^/api/runs/${ID}/cancel$`)], + ["GET", /^\/api\/spend$/], + // Scheduled and condition automations are set up from the phone too; app events and webhooks only on the computer. ["GET", /^\/api\/routines$/], + ["POST", /^\/api\/routines$/], ["PATCH", new RegExp(`^/api/routines/${ID}$`)], + ["DELETE", new RegExp(`^/api/routines/${ID}$`)], ["POST", new RegExp(`^/api/routines/${ID}/run$`)], + ["GET", new RegExp(`^/api/routines/${ID}/events$`)], ["GET", /^\/api\/browser\/profiles$/], ["POST", new RegExp(`^/api/browser/profiles/${ID}/launch$`)], @@ -112,7 +135,7 @@ const ROUTES: [method: string, path: RegExp][] = [ * phone listener and the cloud link stay on the computer. */ const SETTINGS: Record = { - general: ["userName", "desktopNotifications", "theme"], + general: ["userName", "desktopNotifications", "theme", "mode"], runner: [ "model", "fallbackModel", @@ -142,6 +165,9 @@ const NULLABLE_SETTINGS = new Set(["defaultMaxBudgetUsd", "monthlyBudgetUsd"]); const WORKSPACE_KEYS = ["name", "description", "color", "icon", "instructions", "autoMerge", "browserProfileId", "sources"]; const PROJECT_KEYS = ["name", "description", "color", "icon", "instructions", "browserProfileId", "sources"]; /** What an agent is, looks like and how it thinks; its permissions, tools, folders and machines are set on the computer. */ +/** Never `fillOnly`: only the human on the computer lets an automation's runs read raw secrets. */ +const ROUTINE_KEYS = ["agentId", "name", "trigger", "cron", "timezone", "prompt", "filter", "enabled", "reuseConversation", "notify"]; +const GOAL_KEYS = ["title", "why", "workspaceId", "targetDate", "status"]; const AGENT_KEYS = ["name", "role", "description", "instructions", "personality", "avatar", "color", "character", "model", "effort", "ultracode", "enabled", "projectId", "heartbeat"]; /** Requests whose JSON body may only carry these fields when a phone sends them. */ @@ -165,7 +191,13 @@ const BODIES: [method: string, path: RegExp, keys: string[]][] = [ ["POST", new RegExp(`^/api/tasks/${ID}/approve$`), []], ["PATCH", new RegExp(`^/api/conversations/${ID}/followup$`), ["dueAt"]], ["POST", new RegExp(`^/api/conversations/${ID}/followup/run$`), []], - ["PATCH", new RegExp(`^/api/routines/${ID}$`), ["enabled"]], + ["POST", /^\/api\/routines$/, ROUTINE_KEYS], + ["PATCH", new RegExp(`^/api/routines/${ID}$`), ROUTINE_KEYS], + ["POST", /^\/api\/goals$/, GOAL_KEYS], + ["PATCH", new RegExp(`^/api/goals/${ID}$`), GOAL_KEYS], + ["POST", /^\/api\/conversations\/read$/, ["ids"]], + ["POST", new RegExp(`^/api/agents/${ID}/heartbeat/beat$`), []], + ["POST", new RegExp(`^/api/agents/${ID}/dreams$`), []], ["POST", new RegExp(`^/api/questions/${ID}/answer$`), ["optionId", "decision", "note", "text"]], ["POST", /^\/api\/human-tasks$/, ["title", "body", "url", "priority", "workspaceId", "status"]], ["PATCH", new RegExp(`^/api/human-tasks/${ID}$`), ["status", "beforeId", "priority", "title", "body"]], @@ -202,6 +234,7 @@ export function deviceQueryRefusal(method: string, path: string, query: URLSearc */ export function deviceBodyRefusal(method: string, path: string, body: Record): string | null { if (path === "/api/settings") return settingsRefusal(body); + if (path.startsWith("/api/routines")) return routineRefusal(body); if (!("sources" in body)) return null; const sources = body.sources; if (!Array.isArray(sources)) return null; @@ -211,6 +244,15 @@ export function deviceBodyRefusal(method: string, path: string, body: Record typeof f.path === "string" && known.has(f.path)) ? null : "Add folders of your computer in Godmode there. From the phone you can add git repositories."; } +/** A phone sets up schedules and conditions; app events and webhooks need connections and secret URLs that live on the computer. */ +function routineRefusal(body: Record): string | null { + const trigger = body.trigger as { type?: unknown } | undefined; + if (trigger === undefined || trigger === null) return null; + return typeof trigger === "object" && (trigger.type === "schedule" || trigger.type === "condition") + ? null + : "Set up app and webhook automations in Godmode on your computer."; +} + function existingFolders(method: string, path: string): string[] { if (method !== "PATCH") return []; const [, , kind, id] = path.split("/"); diff --git a/packages/core/test/mobile.test.ts b/packages/core/test/mobile.test.ts index 1644c724..dd4fd69e 100644 --- a/packages/core/test/mobile.test.ts +++ b/packages/core/test/mobile.test.ts @@ -162,7 +162,23 @@ describe("device scope", () => { expect(deviceMayCall("PATCH", "/api/conversations/conv_1/followup")).toBe(true); expect(deviceMayCall("DELETE", "/api/conversations/conv_1/followup")).toBe(true); expect(deviceMayCall("POST", "/api/conversations/conv_1/followup/run")).toBe(true); - expect(deviceMayCall("GET", "/api/followups")).toBe(false); + expect(deviceMayCall("GET", "/api/followups")).toBe(true); + expect(deviceMayCall("POST", "/api/conversations/read")).toBe(true); + expect(deviceMayCall("GET", "/api/goals")).toBe(true); + expect(deviceMayCall("DELETE", "/api/goals/gol_1")).toBe(true); + expect(deviceMayCall("GET", "/api/runs/run_1")).toBe(true); + expect(deviceMayCall("GET", "/api/runs/run_1/log")).toBe(true); + expect(deviceMayCall("GET", "/api/spend")).toBe(true); + expect(deviceMayCall("GET", "/api/away")).toBe(true); + expect(deviceMayCall("POST", "/api/routines")).toBe(true); + expect(deviceMayCall("DELETE", "/api/routines/rtn_1")).toBe(true); + expect(deviceMayCall("GET", "/api/routines/rtn_1/events")).toBe(true); + expect(deviceMayCall("POST", "/api/routines/rtn_1/test-event")).toBe(false); + expect(deviceMayCall("POST", "/api/routines/rtn_1/webhook/rotate")).toBe(false); + expect(deviceMayCall("GET", "/api/agents/a1/heartbeat")).toBe(true); + expect(deviceMayCall("POST", "/api/agents/a1/dreams")).toBe(true); + expect(deviceMayCall("GET", "/api/dreams/drm_1")).toBe(true); + expect(deviceMayCall("POST", "/api/dreams/drm_1/revert")).toBe(false); expect(deviceMayCall("GET", "/api/conversations/conv_1/messages")).toBe(true); expect(deviceMayCall("GET", "/api/conversations/conv_1/messages/msg_1/images/toolu_1")).toBe(true); expect(deviceMayCall("DELETE", "/api/conversations/conv_1/messages/msg_1")).toBe(false); @@ -296,8 +312,10 @@ describe("phone access", () => { expect((await phone("/api/conversations/cnv_missing/messages", json("POST", { content: "hi", workingDirectory: "/" }))).status).toBe(403); expect((await phone("/api/conversations/cnv_missing/queue/qmsg_x", json("PATCH", { content: "reworded", attachments: [] }))).status).toBe(403); expect((await phone("/api/chat", json("POST", { content: "hi", computerTarget: { kind: "desktop" } }))).status).toBe(403); - expect((await phone("/api/routines/rtn_missing", json("PATCH", { prompt: "rm -rf" }))).status).toBe(403); + expect((await phone("/api/routines/rtn_missing", json("PATCH", { prompt: "Summarize my inbox" }))).status).toBe(404); expect((await phone("/api/routines/rtn_missing", json("PATCH", { enabled: true, fillOnly: false }))).status).toBe(403); + expect((await phone("/api/routines/rtn_missing", json("PATCH", { trigger: { type: "webhook" } }))).status).toBe(403); + expect((await phone("/api/routines", json("POST", { agentId: "a1", name: "Hook", prompt: "x", trigger: { type: "app", connectionId: "c1", triggerSlug: "GMAIL_NEW" } }))).status).toBe(403); expect((await phone("/api/tasks", json("POST", { title: "Fork it", repoUrl: "https://evil.example.com/x.git" }))).status).toBe(403); expect((await phone("/api/tasks/tsk_missing", json("PATCH", { repoPath: "/" }))).status).toBe(403); expect((await phone("/api/tasks/tsk_missing", json("PATCH", { status: "todo" }))).status).toBe(404); From a6468678788e1336f7561bf7ae1beeeaef3f45a1 Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Fri, 23 Oct 2026 18:03:25 +0200 Subject: [PATCH 02/10] Phone: register automation, run, dream and goal screens --- apps/mobile/src/app/_layout.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 0e3ba5e1..4bcc0d65 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -104,6 +104,11 @@ export default function RootLayout() { + + + + + From eb3fd2f82ae76f03152fbf35b6ad7b03d21e8be5 Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Fri, 23 Oct 2026 18:10:03 +0200 Subject: [PATCH 03/10] Phone Chats: filter by who started them, needs-you counts, mark all read, chat status lines; interface mode setting --- apps/mobile/src/app/(tabs)/chats/index.tsx | 202 +++++++++++++++--- apps/mobile/src/app/settings/index.tsx | 21 +- apps/mobile/src/components/rows.tsx | 59 ++++- apps/mobile/src/components/segmented-tabs.tsx | 127 +++++++++++ apps/mobile/src/lib/api.ts | 2 +- apps/mobile/src/lib/chat-filters.ts | 84 ++++++++ packages/core/test/mobile.test.ts | 2 +- 7 files changed, 457 insertions(+), 40 deletions(-) create mode 100644 apps/mobile/src/components/segmented-tabs.tsx create mode 100644 apps/mobile/src/lib/chat-filters.ts diff --git a/apps/mobile/src/app/(tabs)/chats/index.tsx b/apps/mobile/src/app/(tabs)/chats/index.tsx index 9892817e..af09c081 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: [...qk.conversations, "has-archived", 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: [...qk.conversations, "archived-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 ( + ({ 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 ( @@ -168,7 +291,21 @@ function changeChat(id: string, patch: { pinned?: boolean; archived?: boolean }) .finally(() => void queryClient.invalidateQueries({ queryKey: qk.conversations })); } -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 +321,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 +330,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/settings/index.tsx b/apps/mobile/src/app/settings/index.tsx index 0a15f8d0..f46aa3b9 100644 --- a/apps/mobile/src/app/settings/index.tsx +++ b/apps/mobile/src/app/settings/index.tsx @@ -3,7 +3,7 @@ import Constants from "expo-constants"; import * as LocalAuthentication from "expo-local-authentication"; import { router, Stack } from "expo-router"; import { Alert, ScrollView, StyleSheet, View } from "react-native"; -import { EFFORT_LABELS, findModel, type Theme } from "@godmode/shared"; +import { EFFORT_LABELS, findModel, type InterfaceMode, type Theme } from "@godmode/shared"; import { CloseButton } from "@/components/close-button"; import { FieldRow, Group, LinkRow, SegmentRow, SwitchRow } from "@/components/form"; import { Icon, type IconName } from "@/components/icon"; @@ -30,6 +30,16 @@ const THEME_OPTIONS = [ { value: "system", label: "System" }, ] as const satisfies readonly { value: Theme; label: string }[]; +const MODE_OPTIONS = [ + { value: "easy", label: "Easy" }, + { value: "pro", label: "Professional" }, +] as const satisfies readonly { value: InterfaceMode; label: string }[]; + +const MODE_BLURB: Record = { + easy: "Plain words and only what you need day to day. Everything else is one click away.", + pro: "Every tool, setting and detail in view: models, runs, machines, logs.", +}; + export default function Settings() { const c = useColors(); const connection = useSession((s) => s.connection); @@ -128,6 +138,15 @@ export default function Settings() { }} /> ) : null} + {s ? ( + void patchSettings({ general: { mode } })} + /> + ) : null} > = { routine: "Automation", delegation: "Handed over", + heartbeat: "Heartbeat", slack: "Slack", telegram: "Telegram", teams: "Teams", api: "Chat", }; +type ChatStatus = { text: string; icon: IconName; tone: "warning" | "brand" | "muted" }; + +/** What a chat still has ahead, the way the desktop's chat lists say it. Null: nothing. */ +export function chatStatus(c: Pick, live: { running?: boolean; queued?: boolean }): ChatStatus | null { + if (live.running && !c.paused) return null; + if (live.queued && !c.paused) return { text: "Queued — waiting for a free slot", icon: "clock", tone: "muted" }; + const p = c.paused; + if (p?.reason === "question") { + const what = p.question?.kind === "approval" ? "Needs your OK" : "Needs your answer"; + return { text: p.question?.title ? `${what} · ${p.question.title}` : what, icon: "question", tone: "warning" }; + } + if (p) { + const text = + p.reason === "user" ? "Paused" : p.reason === "budget" ? "Held — budget used up" : p.auto && p.resumeAt ? `Continues ${whenText(p.resumeAt)}` : "Waiting for the limit"; + return { text, icon: p.reason === "user" ? "pause" : "clock", tone: p.reason === "user" ? "muted" : "warning" }; + } + if (c.followup) return { text: `Continues ${whenText(c.followup.dueAt)}`, icon: "clock", tone: "brand" }; + return null; +} + export function openChat(id: string) { tap(); router.push({ pathname: "/chat/[id]", params: { id } }); @@ -29,33 +50,56 @@ export function openChat(id: string) { export function ConversationRow({ conversation, agent, + from, running, + queued, project, }: { conversation: Conversation; agent?: Agent; + /** The agent that handed the chat over. */ + from?: Agent; running?: boolean; + queued?: boolean; /** The project it works on, when the list isn't scoped to it already. */ project?: Pick; }) { const c = useColors(); - const origin = ORIGIN_LABEL[conversation.origin]; + const origin = conversation.origin === "delegation" && from ? `From ${from.name}` : ORIGIN_LABEL[conversation.origin]; + const status = chatStatus(conversation, { running, queued }); + const unread = conversation.unread; + const statusColor = status?.tone === "warning" ? c.warning : status?.tone === "brand" ? c.brandStrong : c.textMuted; return ( openChat(conversation.id)} style={({ pressed }) => [styles.convo, pressed && { backgroundColor: c.sunken }]}> {conversation.pinned && } - + {conversation.title || "New chat"} + {unread ? ( + + ) : null} {project ? : null} {running ? "Working" : shortTime(conversation.lastMessageAt ?? conversation.createdAt)} - + {status ? ( + + + + {status.text} + + + ) : null} + {origin ? {`${origin} · `} : null} {agent ? `${agent.name}: ` : ""} {conversation.preview || "No messages yet"} @@ -141,6 +185,11 @@ export function AgentRow({ agent, running, queued, onPress }: { agent: Agent; ru } const styles = StyleSheet.create({ + unread: { + width: 7, + height: 7, + borderRadius: 3.5, + }, convo: { flexDirection: "row", gap: space.md, diff --git a/apps/mobile/src/components/segmented-tabs.tsx b/apps/mobile/src/components/segmented-tabs.tsx new file mode 100644 index 00000000..a6aa2c96 --- /dev/null +++ b/apps/mobile/src/components/segmented-tabs.tsx @@ -0,0 +1,127 @@ +import { useState } from "react"; +import { type LayoutRectangle, Pressable, StyleSheet, View } from "react-native"; +import Animated, { useAnimatedStyle, withSpring } from "react-native-reanimated"; +import { LiveDot, T, tap } from "./ui"; +import { alpha, radius, useColors } from "@/lib/theme"; + +export interface SegmentedTab { + value: V; + label: string; + /** A count pill after the label. */ + count?: number; + /** brand: something works (pulsing dot), warning: someone waits for you. */ + tone?: "brand" | "warning" | "neutral"; + /** A small dot instead of a count: new replies (brand) or waiting for you (warning). */ + dot?: "brand" | "warning" | null; + accessibilityHint?: string; +} + +const SPRING = { stiffness: 460, damping: 36, mass: 1 }; + +/** The desktop's segmented tab bar: one row of equal tabs with a sliding pill under the picked one. */ +export function SegmentedTabs({ tabs, value, onChange, label }: { tabs: readonly SegmentedTab[]; value: V; onChange: (next: V) => void; label: string }) { + const c = useColors(); + const [frames, setFrames] = useState>>({}); + const frame = frames[value]; + const pill = useAnimatedStyle(() => + frame ? { opacity: 1, transform: [{ translateX: withSpring(frame.x, SPRING) }], width: withSpring(frame.width, SPRING) } : { opacity: 0 }, + ); + + return ( + + + {tabs.map((t) => { + const active = t.value === value; + const pillTone = t.tone === "warning" ? c.warning : t.tone === "brand" ? c.brandStrong : active ? c.text : c.textMuted; + return ( + { + const next = e.nativeEvent.layout; + setFrames((prev) => (prev[t.value]?.x === next.x && prev[t.value]?.width === next.width ? prev : { ...prev, [t.value]: next })); + }} + onPress={() => { + if (active) return; + tap(); + onChange(t.value); + }} + style={styles.tab} + > + + {t.label} + + {t.count ? ( + + {t.tone === "brand" && } + + {t.count} + + + ) : t.dot && !active ? ( + + ) : null} + + ); + })} + + ); +} + +const styles = StyleSheet.create({ + list: { + flexDirection: "row", + padding: 2, + gap: 1, + borderRadius: radius.lg, + borderCurve: "continuous", + borderWidth: StyleSheet.hairlineWidth, + }, + pill: { + position: "absolute", + top: 2, + bottom: 2, + left: 0, + borderRadius: 10, + borderCurve: "continuous", + borderWidth: StyleSheet.hairlineWidth, + shadowColor: "#000", + shadowOpacity: 0.06, + shadowRadius: 3, + shadowOffset: { width: 0, height: 1 }, + }, + tab: { + flex: 1, + minWidth: 0, + height: 34, + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 5, + paddingHorizontal: 6, + }, + count: { + flexDirection: "row", + alignItems: "center", + gap: 3, + minWidth: 18, + height: 18, + paddingHorizontal: 5, + borderRadius: radius.pill, + justifyContent: "center", + }, + countText: { + fontWeight: "600", + fontVariant: ["tabular-nums"], + fontSize: 11, + lineHeight: 13, + }, + dot: { + width: 6, + height: 6, + borderRadius: 3, + }, +}); diff --git a/apps/mobile/src/lib/api.ts b/apps/mobile/src/lib/api.ts index 32ed60be..e3b303bc 100644 --- a/apps/mobile/src/lib/api.ts +++ b/apps/mobile/src/lib/api.ts @@ -401,7 +401,7 @@ export async function pairWith(payload: MobilePairingPayload): Promise>; + general?: Partial>; runner?: Partial>; browser?: Partial>; computer?: Partial>; diff --git a/apps/mobile/src/lib/chat-filters.ts b/apps/mobile/src/lib/chat-filters.ts new file mode 100644 index 00000000..9ec03569 --- /dev/null +++ b/apps/mobile/src/lib/chat-filters.ts @@ -0,0 +1,84 @@ +import * as SecureStore from "expo-secure-store"; +import { create } from "zustand"; +import type { Conversation } from "@godmode/shared"; +import type { ChatBucket } from "./desktop-parity"; + +export type ChatSource = "mine" | "automations" | "agents"; +export type RecentSource = ChatSource | "all"; +export type RecentTab = "all" | ChatBucket; + +const SOURCE_KEY = "godmode.recentSource"; +const TAB_KEY = "godmode.recentTab"; + +export const SOURCES: { value: RecentSource; label: string; hint: string }[] = [ + { value: "mine", label: "Mine", hint: "Chats you started" }, + { value: "automations", label: "Automations", hint: "Started by an automation or the API" }, + { value: "agents", label: "Agents", hint: "Started by an agent: handovers, board tasks, heartbeats" }, + { value: "all", label: "All", hint: "Every chat" }, +]; + +export const TABS: { value: RecentTab; label: string; hint: string }[] = [ + { value: "all", label: "All", hint: "Every recent chat" }, + { value: "running", label: "Running", hint: "Working, queued or waiting for you" }, + { value: "scheduled", label: "Scheduled", hint: "Continue on their own later" }, + { value: "done", label: "Done", hint: "Finished chats" }, +]; + +/** Mine: chats the human started, here or from Slack, Telegram or Teams. Automations: started by a trigger. Agents: an agent started it itself. */ +export function chatSource(c: Conversation): ChatSource { + switch (c.origin) { + case "routine": + case "api": + return "automations"; + case "delegation": + case "task": + case "heartbeat": + case "dream": + return "agents"; + default: + return "mine"; + } +} + +export type SourceStat = { unread: number; needsYou: number }; + +export function sourceStats(chats: Conversation[]): Record { + const stats = Object.fromEntries(SOURCES.map((s) => [s.value, { unread: 0, needsYou: 0 }])) as Record; + for (const c of chats) { + for (const key of [chatSource(c), "all"] as const) { + if (c.unread) stats[key].unread++; + if (c.paused?.reason === "question") stats[key].needsYou++; + } + } + return stats; +} + +interface ChatFilters { + source: RecentSource; + tab: RecentTab; + setSource: (source: RecentSource) => void; + setTab: (tab: RecentTab) => void; +} + +const save = (key: string, value: string) => void SecureStore.setItemAsync(key, value).catch(() => undefined); + +/** The Chats tab's filters, kept on this phone like the desktop keeps its sidebar's: yours first. */ +export const useChatFilters = create((set) => ({ + source: "mine", + tab: "all", + setSource: (source) => { + set({ source }); + save(SOURCE_KEY, source); + }, + setTab: (tab) => { + set({ tab }); + save(TAB_KEY, tab); + }, +})); + +void Promise.all([SecureStore.getItemAsync(SOURCE_KEY).catch(() => null), SecureStore.getItemAsync(TAB_KEY).catch(() => null)]).then(([source, tab]) => { + const patch: Partial = {}; + if (SOURCES.some((s) => s.value === source)) patch.source = source as RecentSource; + if (TABS.some((t) => t.value === tab)) patch.tab = tab as RecentTab; + useChatFilters.setState(patch); +}); diff --git a/packages/core/test/mobile.test.ts b/packages/core/test/mobile.test.ts index dd4fd69e..52a0454b 100644 --- a/packages/core/test/mobile.test.ts +++ b/packages/core/test/mobile.test.ts @@ -333,7 +333,7 @@ describe("phone access", () => { const settings = await phone("/api/settings", { bearer: device }); expect(settings.status).toBe(200); - const saved = await phone("/api/settings", json("PUT", { runner: { appendSystemPrompt: "Commit as me only.", effort: "high", monthlyBudgetUsd: 50 }, memory: { dreaming: { enabled: true } } })); + const saved = await phone("/api/settings", json("PUT", { general: { mode: "easy" }, runner: { appendSystemPrompt: "Commit as me only.", effort: "high", monthlyBudgetUsd: 50 }, memory: { dreaming: { enabled: true } } })); expect(saved.status).toBe(200); expect(((await saved.json()) as { runner: { appendSystemPrompt: string; monthlyBudgetUsd: number } }).runner).toMatchObject({ appendSystemPrompt: "Commit as me only.", monthlyBudgetUsd: 50 }); expect((await phone("/api/settings", json("PUT", { runner: { monthlyBudgetUsd: null } }))).status).toBe(200); From 82e6e9a2023590c4d8ccd48297083567620e0f22 Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Fri, 23 Oct 2026 18:16:54 +0200 Subject: [PATCH 04/10] Phone automations: create, edit and delete schedules and conditions, detail with recent events, follow-ups --- apps/mobile/src/app/automation-edit.tsx | 542 ++++++++++++++++++++ apps/mobile/src/app/automation/[id].tsx | 412 +++++++++++++++ apps/mobile/src/app/automations.tsx | 49 +- apps/mobile/src/components/cron-builder.tsx | 365 +++++++++++++ apps/mobile/src/components/followup-row.tsx | 193 +++++++ apps/mobile/src/components/routine-row.tsx | 26 +- 6 files changed, 1566 insertions(+), 21 deletions(-) create mode 100644 apps/mobile/src/app/automation-edit.tsx create mode 100644 apps/mobile/src/app/automation/[id].tsx create mode 100644 apps/mobile/src/components/cron-builder.tsx create mode 100644 apps/mobile/src/components/followup-row.tsx diff --git a/apps/mobile/src/app/automation-edit.tsx b/apps/mobile/src/app/automation-edit.tsx new file mode 100644 index 00000000..534b5685 --- /dev/null +++ b/apps/mobile/src/app/automation-edit.tsx @@ -0,0 +1,542 @@ +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, 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..914292c2 --- /dev/null +++ b/apps/mobile/src/app/automation/[id].tsx @@ -0,0 +1,412 @@ +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} + +