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}
+
+
+ {keepsChat ? (
+
+
+
+ {routine.enabled && (status.state === "error" || status.state === "pending") ? (
+
+
+
+ {status.message ?? (status.state === "error" ? "The trigger isn't working." : "Setting up the trigger…")}
+
+
+ ) : null}
+
+
+
+ {t.type === "schedule" || t.type === "condition" ? : null}
+
+ {routine.filter ? : null}
+
+
+ {t.type === "condition" ? (
+
+
+
+ When {t.condition}
+ {lastCheck ? (
+
+
+ Last check{status.lastCheckAt ? ` · ${ago(status.lastCheckAt)}` : ""}
+
+
+ {status.observation ?? "Nothing noted yet."}
+
+
+ ) : null}
+
+
+ ) : null}
+
+
+
+
+ {routine.prompt}
+
+
+
+
+ setRoutineEnabled(routine, enabled)}
+ />
+
+
+
+
+
+
+
+ {events.isLoading ? (
+
+
+
+ ) : events.isError ? (
+ void refetchEvents()} />
+ ) : shownEvents.length === 0 ? (
+
+ ) : (
+
+ {shownEvents.map((e, i) => (
+
+ {i > 0 && }
+ openEvent(e) : undefined} />
+
+ ))}
+
+ )}
+ {shownEvents.length >= EVENT_LIMIT ? (
+
+ Showing the latest {EVENT_LIMIT} events.
+
+ ) : null}
+
+
+
+
+ );
+}
+
+function Info({ label, value, tone }: { label: string; value: string; tone?: "danger" }) {
+ const c = useColors();
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ );
+}
+
+function EventRow({ event, onPress }: { event: AutomationEvent; onPress?: () => void }) {
+ const c = useColors();
+ const source = EVENT_SOURCES[event.source] ?? EVENT_SOURCES.manual;
+ const status = EVENT_STATUS[event.status] ?? EVENT_STATUS.pending;
+ return (
+ [styles.event, pressed && { backgroundColor: c.sunken }]}
+ >
+
+
+
+
+
+ {event.title || source.label}
+
+
+
+
+ {source.label} · {ago(event.createdAt)}
+
+
+ {event.note ? (
+
+ {event.note}
+
+ ) : null}
+
+ {onPress ? : null}
+
+ );
+}
+
+function EmptyEvents({ routine }: { routine: Routine }) {
+ const t = routine.trigger;
+ const next = routine.enabled && routine.nextRunAt ? ago(routine.nextRunAt) : null;
+ const copy =
+ t.type === "schedule"
+ ? { title: "No runs yet", body: next ? `The first run is ${next}.` : "Runs show up here when the schedule fires." }
+ : t.type === "app"
+ ? { title: "Waiting for the first event", body: `Godmode is listening for “${t.triggerName}” in ${appName(t.toolkit)}.` }
+ : t.type === "condition"
+ ? { title: "Not met yet", body: next ? `The next check is ${next}.` : "Once a check finds the condition true, it shows up here." }
+ : { title: "No calls yet", body: "Call the webhook URL — or send a test event — and it shows up here." };
+ return ;
+}
+
+const styles = StyleSheet.create({
+ content: {
+ paddingHorizontal: space.lg,
+ paddingBottom: 60,
+ gap: space.xl,
+ },
+ hero: {
+ alignItems: "center",
+ gap: space.sm,
+ paddingTop: space.md,
+ },
+ notice: {
+ flexDirection: "row",
+ alignItems: "flex-start",
+ gap: space.sm,
+ padding: space.md,
+ borderRadius: radius.md,
+ borderCurve: "continuous",
+ borderWidth: StyleSheet.hairlineWidth,
+ },
+ info: {
+ gap: 2,
+ paddingHorizontal: space.lg,
+ paddingVertical: 11,
+ },
+ text: {
+ padding: space.lg,
+ },
+ event: {
+ flexDirection: "row",
+ alignItems: "flex-start",
+ gap: space.md,
+ paddingHorizontal: space.lg,
+ paddingVertical: space.md,
+ },
+ eventIcon: {
+ width: 28,
+ height: 28,
+ borderRadius: radius.sm,
+ borderCurve: "continuous",
+ alignItems: "center",
+ justifyContent: "center",
+ },
+});
diff --git a/apps/mobile/src/app/automations.tsx b/apps/mobile/src/app/automations.tsx
index 579d74d4..7ca78e3d 100644
--- a/apps/mobile/src/app/automations.tsx
+++ b/apps/mobile/src/app/automations.tsx
@@ -1,11 +1,13 @@
import { FlashList, type ListRenderItem } from "@shopify/flash-list";
import { useQuery } from "@tanstack/react-query";
-import { Stack } from "expo-router";
-import { useCallback, useMemo, useState } from "react";
+import { router, Stack, useFocusEffect } from "expo-router";
+import { useCallback, useMemo, useRef, useState } from "react";
import { RefreshControl, View } from "react-native";
import type { Routine } from "@godmode/shared";
-import { RoutineRow } from "@/components/routine-row";
import { FilterChips } from "@/components/filter-chips";
+import { FollowupsSection } from "@/components/followup-row";
+import { HeaderActions } from "@/components/header-actions";
+import { RoutineRow } from "@/components/routine-row";
import { Button, EmptyState, ErrorState, Hairline, SkeletonRows } from "@/components/ui";
import { WorkspaceChip } from "@/components/workspace-chip";
import { api, errorText } from "@/lib/api";
@@ -30,28 +32,42 @@ function useWorkingRoutines(): Set {
return useMemo(() => new Set(key ? key.split("\n") : []), [key]);
}
-/** Every automation of the picked workspace's agents (or project's), the ones that are on first. */
+const openNew = () => router.push("/automation-edit");
+
+/** Every automation of the picked workspace's agents (or project's), the ones that are on first, and the chats they'll pick up again. */
export default function Automations() {
const { id: workspaceId, projectId } = useWorkspace();
const list = useQuery({ queryKey: qk.allRoutines, queryFn: () => api.routines.list() });
+ const followups = useQuery({ queryKey: qk.followups, queryFn: () => api.followups.list() });
const agents = useAgents();
const { byId } = agents;
const working = useWorkingRoutines();
- const pull = usePullRefresh(list.refetch);
+ const pull = usePullRefresh(() => Promise.all([list.refetch(), followups.refetch()]));
const [filter, setFilter] = useState("all");
// A workspace's automations are known once its agents are.
const loading = list.isLoading || (!!workspaceId && agents.isLoading);
- const scoped = useMemo(
- () =>
- (list.data ?? []).filter((r) => {
- if (!workspaceId) return true;
- const agent = byId.get(r.agentId);
- if (!agent) return false;
- return projectId ? agent.projectId === projectId : !agent.workspaceId || agent.workspaceId === workspaceId;
- }),
- [list.data, workspaceId, projectId, byId],
+ // Follow-ups move with the chats they belong to: fresh each time the screen comes back to the front.
+ const shown = useRef(false);
+ const refetchFollowups = followups.refetch;
+ useFocusEffect(
+ useCallback(() => {
+ if (shown.current) void refetchFollowups();
+ shown.current = true;
+ }, [refetchFollowups]),
+ );
+
+ const inScope = useCallback(
+ (agentId: string) => {
+ if (!workspaceId) return true;
+ const agent = byId.get(agentId);
+ if (!agent) return false;
+ return projectId ? agent.projectId === projectId : !agent.workspaceId || agent.workspaceId === workspaceId;
+ },
+ [workspaceId, projectId, byId],
);
+ const scoped = useMemo(() => (list.data ?? []).filter((r) => inScope(r.agentId)), [list.data, inScope]);
+ const scopedFollowups = useMemo(() => (followups.data ?? []).filter((f) => inScope(f.agentId)), [followups.data, inScope]);
const visible = useMemo(
() =>
scoped
@@ -68,6 +84,7 @@ export default function Automations() {
return (
<>
Automations
+
r.id}
@@ -77,6 +94,7 @@ export default function Automations() {
ListHeaderComponent={
+
{scoped.length > 0 && (
}
/>
)
}
diff --git a/apps/mobile/src/app/dream/[id].tsx b/apps/mobile/src/app/dream/[id].tsx
new file mode 100644
index 00000000..02e6a7de
--- /dev/null
+++ b/apps/mobile/src/app/dream/[id].tsx
@@ -0,0 +1,383 @@
+import { useQuery } from "@tanstack/react-query";
+import { Stack, useLocalSearchParams } from "expo-router";
+import { useMemo, useState, type ReactNode } from "react";
+import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
+import { formatBytes, type DreamDetail, type DreamFileChange, type DreamWorkspaceChange } from "@godmode/shared";
+import { ACTIVE_DREAM, ChangeKindChip, DreamReasonBadge, DreamStatusBadge, ROLLED_BACK, dreamWhen, reviewedLabel } from "@/components/dream-parts";
+import { Icon, type IconName } from "@/components/icon";
+import { Badge, Card, ErrorState, Hairline, LoadingState, Row, SectionTitle, T, tap } from "@/components/ui";
+import { api, ApiError, errorText, UPDATE_DESKTOP_MESSAGE } from "@/lib/api";
+import { useAgents } from "@/lib/hooks";
+import { diffFile, foldContext, type DiffLine, type FileDiff } from "@/lib/line-diff";
+import { qk } from "@/lib/query";
+import { alpha, fonts, radius, space, useColors, type Colors } from "@/lib/theme";
+
+/** Rows rendered per file before "Show everything" (a new or deleted note can be long). */
+const MAX_ROWS = 400;
+
+/** One dream, read-only like the desktop's review: what the agent says it did, and a line diff of every file it changed. */
+export default function DreamScreen() {
+ const { id } = useLocalSearchParams<{ id: string }>();
+ const q = useQuery({
+ queryKey: qk.dream(id),
+ queryFn: () => api.dreams.get(id),
+ refetchInterval: (query) => (query.state.data && ACTIVE_DREAM.includes(query.state.data.status) ? 5_000 : false),
+ });
+
+ if (!q.data) {
+ const tooOld = q.error instanceof ApiError && (q.error.code === "device_forbidden" || q.error.status === 403);
+ return (
+
+ Dream
+ {q.isError ? (
+ void q.refetch()} />
+ ) : (
+
+ )}
+
+ );
+ }
+ return ;
+}
+
+function DreamView({ dream: d }: { dream: DreamDetail }) {
+ const c = useColors();
+ const agentName = useAgents().byId.get(d.agentId)?.name ?? "The agent";
+ const files = useMemo(() => d.fileChanges.map((f) => ({ file: f, diff: diffFile(f.before, f.after) })), [d.fileChanges]);
+ const totals = files.reduce((t, f) => ({ added: t.added + f.diff.added, removed: t.removed + f.diff.removed }), { added: 0, removed: 0 });
+ const rolledBack = ROLLED_BACK.includes(d.status);
+ const note =
+ d.status === "reverted"
+ ? "This dream was undone — the files are back the way they were before it."
+ : d.canRevert
+ ? "Undo a dream in Godmode on your computer."
+ : d.files.length && !ACTIVE_DREAM.includes(d.status)
+ ? "The memory changed after this dream, so it can't be undone automatically."
+ : null;
+
+ return (
+
+ Dream
+
+
+
+
+ Dream · {dreamWhen(d.createdAt)}
+
+
+ What {agentName} changed
+
+ {d.summary || reviewedLabel(d)}
+
+
+
+
+ {files.length ? (
+
+ {`+${totals.added}`}{" "}
+ {`−${totals.removed}`}
+
+ ) : null}
+
+ {d.summary ? (
+
+ {reviewedLabel(d)}
+
+ ) : null}
+ {note ? (
+
+
+
+ {note}
+
+
+ ) : null}
+
+
+ {d.status !== "succeeded" && d.error ? (
+
+
+
+ {d.error}
+
+
+ ) : null}
+
+
+
+
+ {rolledBack && d.changes.length > 0 ? (
+
+
+
+ {d.files.length ? "Rolled back where possible" : "Rolled back — nothing was kept"}
+
+
+ ) : null}
+ {d.changes.length ? (
+ d.changes.map((ch, i) => (
+
+
+
+ {ch.text}
+
+
+ ))
+ ) : (
+
+ {`${agentName} didn't describe its changes — the diff shows exactly what it wrote.`}
+
+ )}
+
+
+
+
+
+ {files.length ? (
+ files.map((f, i) => )
+ ) : (
+
+
+ {rolledBack ? "Nothing from this dream is left in the memory files." : "This dream didn't change any memory files."}
+
+
+ )}
+
+
+ {d.files.some((path) => path !== "MEMORY.md") ? (
+
+ The phone shows MEMORY.md. The other memory files this dream changed are in Godmode on your computer.
+
+ ) : null}
+ {d.workspace.length > 0 ? : null}
+
+ );
+}
+
+function FileChangeCard({ file, diff }: { file: DreamFileChange; diff: FileDiff }) {
+ const c = useColors();
+ const state = file.before === null ? "created" : file.after === null ? "deleted" : "edited";
+ const empty =
+ state === "created"
+ ? "Created an empty file."
+ : state === "deleted"
+ ? "Deleted an empty file."
+ : diff.finalNewline
+ ? `Only the newline at the end of the file was ${diff.finalNewline}.`
+ : "No line changes.";
+ return (
+
+
+
+
+ {file.path}
+
+ {state !== "edited" ? : null}
+
+ {`+${diff.added}`}{" "}
+ {`−${diff.removed}`}
+
+
+
+ {diff.lines.length ? (
+
+ ) : (
+
+ {empty}
+
+ )}
+
+ );
+}
+
+function DiffView({ lines, finalNewline }: { lines: DiffLine[]; finalNewline: FileDiff["finalNewline"] }) {
+ const c = useColors();
+ const blocks = useMemo(() => foldContext(lines), [lines]);
+ const [unfolded, setUnfolded] = useState>(() => new Set());
+ const [everything, setEverything] = useState(false);
+
+ // Render until the row budget runs out, then stop — later blocks (folds included) collapse into one note.
+ const rows: ReactNode[] = [];
+ let budget = everything ? Infinity : MAX_ROWS;
+ let notShown = 0;
+ blocks.forEach((b, i) => {
+ if (budget <= 0) {
+ notShown += b.lines.length;
+ return;
+ }
+ if (b.type === "fold" && !unfolded.has(i)) {
+ budget -= 1;
+ rows.push(
+ {
+ tap();
+ setUnfolded((s) => new Set(s).add(i));
+ }}
+ style={({ pressed }) => [styles.note, { backgroundColor: pressed ? c.accent : c.surfaceAlt, borderColor: c.border }]}
+ >
+
+
+ Show {b.lines.length} unchanged lines
+
+ ,
+ );
+ return;
+ }
+ const shown = b.lines.slice(0, budget);
+ notShown += b.lines.length - shown.length;
+ budget -= shown.length;
+ shown.forEach((l, k) => rows.push());
+ });
+
+ return (
+
+ {rows}
+ {notShown > 0 ? (
+
+
+ {notShown.toLocaleString()} more lines not shown
+
+ setEverything(true)}>
+
+ Show everything
+
+
+
+ ) : finalNewline ? (
+
+
+ {finalNewline === "added" ? "Newline added at end of file" : "No newline at end of file anymore"}
+
+
+ ) : null}
+
+ );
+}
+
+function DiffRow({ line }: { line: DiffLine }) {
+ const c = useColors();
+ const add = line.kind === "add";
+ const del = line.kind === "del";
+ const mark = add ? alpha(c.success, 0.2) : alpha(c.danger, 0.15);
+ return (
+
+
+ {add ? "+" : del ? "−" : " "}
+
+ {line.parts
+ ? line.parts.map((p, i) =>
+ p.changed ? (
+
+ {p.text}
+
+ ) : (
+ {p.text}
+ ),
+ )
+ : line.text || " "}
+
+
+ );
+}
+
+const MOVE_META: Record string }> = {
+ move: { label: "sorted", icon: "move", color: (c) => c.sky },
+ rewrite: { label: "rewritten", icon: "pencil", color: (c) => c.violet },
+ discard: { label: "discarded", icon: "trash", color: (c) => c.rose },
+};
+
+/** How the dream tidied the workspace; after a rollback, what couldn't be moved back. */
+function WorkspaceMoves({ moves, stuck }: { moves: DreamWorkspaceChange[]; stuck: boolean }) {
+ const c = useColors();
+ const counts = (["move", "rewrite", "discard"] as const)
+ .map((action) => ({ action, n: moves.filter((m) => m.action === action).length }))
+ .filter((x) => x.n > 0)
+ .map((x) => `${x.n} ${MOVE_META[x.action].label}`);
+ return (
+
+
+
+
+ {counts.join(" · ")}
+
+ {moves.map((m, i) => {
+ const meta = MOVE_META[m.action];
+ return (
+
+
+
+
+
+
+ {m.from}
+
+
+ {m.action === "move" ? `→ ${m.to}` : m.action === "rewrite" ? `${formatBytes(m.bytes)} now` : "removed after a week"}
+
+
+
+
+ );
+ })}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ content: {
+ paddingHorizontal: space.lg,
+ paddingBottom: 60,
+ gap: space.xl,
+ },
+ callout: {
+ flexDirection: "row",
+ gap: space.sm,
+ padding: space.md,
+ borderRadius: radius.md,
+ borderCurve: "continuous",
+ borderWidth: StyleSheet.hairlineWidth,
+ },
+ fileHead: {
+ gap: space.sm,
+ paddingHorizontal: space.md,
+ paddingVertical: 10,
+ },
+ diffRow: {
+ flexDirection: "row",
+ alignItems: "flex-start",
+ paddingRight: space.md,
+ },
+ bar: {
+ width: 2,
+ alignSelf: "stretch",
+ },
+ sign: {
+ width: 18,
+ textAlign: "center",
+ },
+ code: {
+ fontFamily: fonts.mono,
+ fontSize: 12,
+ lineHeight: 18,
+ },
+ note: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 6,
+ paddingHorizontal: space.md,
+ paddingVertical: 6,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ },
+ move: {
+ gap: space.md,
+ paddingHorizontal: space.lg,
+ paddingVertical: space.sm + 2,
+ },
+});
diff --git a/apps/mobile/src/app/goal.tsx b/apps/mobile/src/app/goal.tsx
new file mode 100644
index 00000000..edcfc2b1
--- /dev/null
+++ b/apps/mobile/src/app/goal.tsx
@@ -0,0 +1,356 @@
+import { DateTimePicker } from "@expo/ui/community/datetime-picker";
+import { DatePicker, Host } from "@expo/ui/swift-ui";
+import { datePickerStyle, tint } from "@expo/ui/swift-ui/modifiers";
+import { useMutation, useQuery } from "@tanstack/react-query";
+import { addDays, endOfMonth, endOfQuarter, format, parseISO } from "date-fns";
+import { router, useLocalSearchParams } from "expo-router";
+import { useMemo, useState } from "react";
+import { ActivityIndicator, Alert, Pressable, ScrollView, StyleSheet, TextInput, View } from "react-native";
+import { formatUsd, MAX_GOAL_TITLE_LENGTH, MAX_GOAL_WHY_LENGTH, type Goal, type GoalStatus } from "@godmode/shared";
+import { Group, SegmentRow, type Option } from "@/components/form";
+import { Icon } from "@/components/icon";
+import { Button, ErrorState, LoadingState, Row, T, tap } from "@/components/ui";
+import { api, ApiError, errorText, UPDATE_DESKTOP_MESSAGE } from "@/lib/api";
+import { qk, queryClient } from "@/lib/query";
+import { useSpendTracking } from "@/lib/setup";
+import { radius, space, useColors } from "@/lib/theme";
+
+interface Draft {
+ title: string;
+ why: string;
+ targetDate: string | null;
+ status: GoalStatus;
+}
+
+const STATUSES: readonly Option[] = [
+ { value: "active", label: "Active" },
+ { value: "achieved", label: "Achieved" },
+ { value: "dropped", label: "Dropped" },
+];
+
+const day = (date: Date) => format(date, "yyyy-MM-dd");
+
+const draftOf = (g?: Goal): Draft => ({ title: g?.title ?? "", why: g?.why ?? "", targetDate: g?.targetDate ?? null, status: g?.status ?? "active" });
+
+const failure = (err: unknown) => (err instanceof ApiError && err.code === "device_forbidden" ? UPDATE_DESKTOP_MESSAGE : errorText(err));
+
+/** The goal as any goals list on the phone has it, so the sheet opens without waiting. */
+function cachedGoal(id: string): Goal | undefined {
+ for (const [, list] of queryClient.getQueriesData({ queryKey: ["goals"] })) {
+ const hit = list?.find((g) => g.id === id);
+ if (hit) return hit;
+ }
+}
+
+function refresh() {
+ void queryClient.invalidateQueries({ queryKey: ["goals"] });
+ void queryClient.invalidateQueries({ queryKey: qk.tasks });
+}
+
+/** Add or edit a goal: what it is, why it matters (the agents get that), when; once there, whether it's reached. */
+export default function GoalSheet() {
+ const c = useColors();
+ const params = useLocalSearchParams<{ id?: string; workspaceId?: string }>();
+ const id = params.id || null;
+ const seed = useMemo(() => (id ? cachedGoal(id) : undefined), [id]);
+ const list = useQuery({ queryKey: qk.goals(null), queryFn: () => api.goals.list(null), enabled: !!id, placeholderData: seed ? [seed] : undefined });
+ const goal = id ? list.data?.find((g) => g.id === id) : undefined;
+ const spendTracking = useSpendTracking();
+ const [draft, setDraft] = useState(() => (id ? (seed ? draftOf(seed) : null) : draftOf()));
+ // Opened before the goal was on the phone: fill the form once it is.
+ if (!draft && goal) setDraft(draftOf(goal));
+
+ const save = useMutation({
+ mutationFn: (d: Draft) => {
+ const input = { title: d.title.trim(), why: d.why.trim(), targetDate: d.targetDate };
+ return id ? api.goals.update(id, { ...input, status: d.status }) : api.goals.create({ ...input, workspaceId: params.workspaceId || null });
+ },
+ onSuccess: () => {
+ refresh();
+ router.back();
+ },
+ onError: (err) => Alert.alert("Couldn't save the goal", failure(err)),
+ });
+ const remove = useMutation({
+ mutationFn: (goalId: string) => api.goals.delete(goalId),
+ onSuccess: () => {
+ refresh();
+ router.back();
+ },
+ onError: (err) => Alert.alert("Couldn't delete the goal", failure(err)),
+ });
+
+ if (!draft) {
+ return (
+
+ {list.isLoading ? (
+
+ ) : list.isError ? (
+ void list.refetch()} />
+ ) : (
+
+ )}
+
+ );
+ }
+
+ const busy = save.isPending || remove.isPending;
+ const ready = !!draft.title.trim() && !busy;
+ const set = (patch: Partial) => setDraft({ ...draft, ...patch });
+ const submit = () => {
+ if (!ready) return;
+ tap();
+ save.mutate(draft);
+ };
+ const confirmDelete = (g: Goal) =>
+ Alert.alert(
+ `Delete “${g.title}”?`,
+ `${g.tickets.total ? `Its ${g.tickets.total === 1 ? "ticket stays" : `${g.tickets.total} tickets stay`}, serving no goal.` : "No ticket serves it yet."} To keep it on record, mark it achieved or drop it instead.`,
+ [
+ { text: "Keep it", style: "cancel" },
+ { text: "Delete", style: "destructive", onPress: () => remove.mutate(g.id) },
+ ],
+ );
+
+ return (
+
+
+
+ router.back()} style={styles.headSide}>
+
+ Cancel
+
+
+
+ {id ? "Edit goal" : "New goal"}
+
+
+ {save.isPending ? (
+
+ ) : (
+
+ {id ? "Save" : "Add goal"}
+
+ )}
+
+
+
+ What the work is for. Tickets that serve it tell their agent why.
+
+
+
+
+
+ set({ title })}
+ placeholder="Launch the new pricing"
+ placeholderTextColor={c.textFaint}
+ maxLength={MAX_GOAL_TITLE_LENGTH}
+ autoFocus={!id}
+ returnKeyType="done"
+ submitBehavior="blurAndSubmit"
+ style={[styles.title, { color: c.text }]}
+ />
+
+ set({ why })}
+ placeholder="Why it matters, or how to tell it's reached"
+ placeholderTextColor={c.textFaint}
+ maxLength={MAX_GOAL_WHY_LENGTH}
+ multiline
+ style={[styles.why, { color: c.text }]}
+ />
+
+
+ The agents get this with every ticket that serves it.
+
+
+
+ 0 ? `${formatUsd(goal.costUsd)} spent` : null]
+ .filter(Boolean)
+ .join(" · ")
+ : undefined
+ }
+ >
+ set({ targetDate })} />
+ {id ? set({ status })} /> : null}
+
+
+ {goal ? confirmDelete(goal)} /> : null}
+
+ );
+}
+
+/** The day it should be reached by: the system's date picker, a few quick picks, or none. */
+function TargetDate({ value, onChange }: { value: string | null; onChange: (day: string | null) => void }) {
+ const c = useColors();
+ const [asking, setAsking] = useState(false);
+ const date = value ? parseISO(value) : null;
+ const today = new Date();
+ const quick = [
+ { label: "In a week", day: day(addDays(today, 7)) },
+ { label: "End of month", day: day(endOfMonth(today)) },
+ { label: "End of quarter", day: day(endOfQuarter(today)) },
+ ];
+ const ios = process.env.EXPO_OS === "ios";
+ return (
+
+
+
+ Target date
+
+ {date && ios ? (
+
+ onChange(day(d))} modifiers={[datePickerStyle("compact"), tint(c.brand)]} />
+
+ ) : !ios ? (
+ {
+ tap();
+ setAsking(true);
+ }}
+ style={[styles.dateButton, { backgroundColor: c.sunken }]}
+ >
+
+ {date ? format(date, "d MMM yyyy") : "Pick a date"}
+
+
+ ) : (
+
+ None
+
+ )}
+ {date ? (
+ {
+ tap();
+ onChange(null);
+ }}
+ >
+
+
+ ) : null}
+
+
+ {quick.map((q) => {
+ const on = q.day === value;
+ return (
+ {
+ tap();
+ onChange(q.day);
+ }}
+ style={({ pressed }) => [styles.quick, { backgroundColor: on ? c.primary : c.sunken, opacity: pressed ? 0.7 : 1 }]}
+ >
+
+ {q.label}
+
+
+ );
+ })}
+
+ {asking ? (
+ {
+ setAsking(false);
+ onChange(day(d));
+ }}
+ onDismiss={() => setAsking(false)}
+ />
+ ) : null}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ fill: {
+ flex: 1,
+ justifyContent: "center",
+ },
+ content: {
+ paddingTop: 20,
+ paddingHorizontal: space.xl,
+ paddingBottom: 40,
+ gap: space.xl,
+ },
+ headSide: {
+ minWidth: 76,
+ height: 32,
+ justifyContent: "center",
+ },
+ fields: {
+ borderRadius: radius.lg,
+ borderCurve: "continuous",
+ borderWidth: StyleSheet.hairlineWidth,
+ },
+ title: {
+ fontSize: 17,
+ fontWeight: "600",
+ paddingHorizontal: space.lg,
+ paddingVertical: 14,
+ },
+ why: {
+ fontSize: 15,
+ lineHeight: 20,
+ minHeight: 88,
+ maxHeight: 200,
+ paddingHorizontal: space.lg,
+ paddingTop: 12,
+ paddingBottom: 12,
+ textAlignVertical: "top",
+ },
+ note: {
+ marginTop: space.sm,
+ paddingHorizontal: 4,
+ },
+ dateRow: {
+ gap: space.sm,
+ paddingHorizontal: space.lg,
+ paddingVertical: 11,
+ },
+ dateButton: {
+ height: 34,
+ justifyContent: "center",
+ paddingHorizontal: space.md,
+ borderRadius: radius.md,
+ borderCurve: "continuous",
+ },
+ quick: {
+ height: 30,
+ justifyContent: "center",
+ paddingHorizontal: 12,
+ borderRadius: radius.pill,
+ },
+});
diff --git a/apps/mobile/src/app/run/[id].tsx b/apps/mobile/src/app/run/[id].tsx
new file mode 100644
index 00000000..5e074410
--- /dev/null
+++ b/apps/mobile/src/app/run/[id].tsx
@@ -0,0 +1,537 @@
+import { useMutation, useQuery } from "@tanstack/react-query";
+import { format, formatDistanceToNowStrict, isSameDay } from "date-fns";
+import { router, Stack, useLocalSearchParams } from "expo-router";
+import { useMemo, useState, type ReactElement, type ReactNode } from "react";
+import { Alert, Pressable, ScrollView, StyleSheet, View, type StyleProp, type TextStyle } from "react-native";
+import { findModel, type Agent, type Run, type RunStatus, type RunTrigger } from "@godmode/shared";
+import { CharacterAvatar } from "@/components/character";
+import { Icon, type IconName } from "@/components/icon";
+import { Markdown } from "@/components/markdown";
+import { openChat } from "@/components/rows";
+import { CopyButton, RunLog } from "@/components/run-log";
+import { formatDuration, formatElapsed, formatTokens, openRun, RunStatusBadge, TRIGGER_LABEL } from "@/components/run-row";
+import { Badge, Button, Card, EmptyState, ErrorState, Hairline, LiveDot, LoadingState, Row, Skeleton, T, tap } from "@/components/ui";
+import { ApiError, api, errorText } from "@/lib/api";
+import { useModelCatalog } from "@/lib/composer";
+import { formatCost } from "@/lib/desktop-parity";
+import { activityText } from "@/lib/format";
+import { useAgents, useNow } from "@/lib/hooks";
+import { useLive } from "@/lib/live";
+import { qk, queryClient } from "@/lib/query";
+import { useSpendTracking } from "@/lib/setup";
+import { fonts, radius, space, useColors } from "@/lib/theme";
+
+const TRIGGER_ICON: Record = {
+ chat: "chats",
+ routine: "workflow",
+ check: "gauge",
+ dream: "moon",
+ delegation: "subagent",
+ manual: "hand",
+ api: "link",
+ task: "tasks",
+ followup: "clock",
+ heartbeat: "pulse",
+ call: "call",
+};
+
+const PROMPT_FOLD = 600;
+
+/** A run the lists already have, so the screen opens at once while the fresh copy loads. */
+function listedRun(id: string): Run | undefined {
+ for (const [, data] of queryClient.getQueriesData({ queryKey: qk.runs })) {
+ const hit = Array.isArray(data) ? data.find((r) => r.id === id) : undefined;
+ if (hit) return hit;
+ }
+ return undefined;
+}
+
+const unfinished = (status: RunStatus | undefined) => status === "running" || status === "queued";
+
+/** Everything about one run, like the desktop's run sheet: who, what started it, what it took, its result and its log. */
+export default function RunScreen() {
+ const { id } = useLocalSearchParams<{ id: string }>();
+ const seed = useMemo(() => listedRun(id) ?? useLive.getState().runs[id]?.run, [id]);
+ const liveStatus = useLive((s) => s.runs[id]?.run.status);
+ const run = useQuery({
+ queryKey: qk.run(id),
+ queryFn: () => api.runs.get(id),
+ placeholderData: seed,
+ refetchInterval: (q) => (unfinished(liveStatus ?? q.state.data?.status) ? 5000 : false),
+ });
+
+ if (!run.data) {
+ return (
+
+ Run
+ {run.error instanceof ApiError && run.error.code === "device_forbidden" ? (
+
+ ) : run.isError ? (
+ void run.refetch()} />
+ ) : (
+
+ )}
+
+ );
+ }
+ return ;
+}
+
+function RunDetail({ run }: { run: Run }) {
+ const c = useColors();
+ const { byId } = useAgents();
+ const agent = byId.get(run.agentId);
+ const live = useLive((s) => s.runs[run.id]);
+ const status = live?.run.status ?? run.status;
+ const running = status === "running";
+ const cancellable = running || status === "queued" || status === "paused";
+ const spendTracking = useSpendTracking();
+ const catalog = useModelCatalog();
+ const modelLabel = run.model ? (findModel(catalog.data?.models ?? [], run.model)?.label ?? run.model) : "Default";
+ // A paused run may wait for the human's answer: that, not Continue, is the way on.
+ const questions = useQuery({ queryKey: qk.questions, queryFn: api.questions.open, enabled: status === "paused" });
+ const asked = status === "paused" ? questions.data?.find((q) => q.runId === run.id) : undefined;
+ const [logOpen, setLogOpen] = useState(false);
+ const [promptOpen, setPromptOpen] = useState(false);
+ const when = run.startedAt ?? run.createdAt;
+
+ const refresh = () => void queryClient.invalidateQueries({ queryKey: qk.runs });
+ const cancel = useMutation({
+ mutationFn: () => api.runs.cancel(run.id),
+ onSuccess: refresh,
+ onError: (err) => Alert.alert("Couldn't cancel the run", errorText(err)),
+ });
+ const resume = useMutation({
+ mutationFn: () => api.conversations.continue(run.conversationId),
+ onSuccess: refresh,
+ onError: (err) => Alert.alert("Couldn't continue", errorText(err)),
+ });
+
+ const confirmCancel = () =>
+ Alert.alert("Cancel this run?", `${agent?.name ?? "The agent"} stops where it is.`, [
+ { text: "Keep running", style: "cancel" },
+ { text: "Cancel run", style: "destructive", onPress: () => cancel.mutate() },
+ ]);
+ const answer = () => {
+ if (!asked) return;
+ if (asked.taskId) router.push({ pathname: "/task/[id]", params: { id: asked.taskId } });
+ else openChat(run.conversationId);
+ };
+
+ const tokensIn = run.usage ? run.usage.inputTokens + run.usage.cacheReadTokens + run.usage.cacheWriteTokens : null;
+ const finished = run.finishedAt && !cancellable ? new Date(run.finishedAt) : null;
+
+ return (
+
+ Run
+
+ router.push({ pathname: "/agent/[id]", params: { id: agent.id } }) : undefined}>
+
+
+
+ {agent?.name ?? "Run"}
+
+
+
+ } />
+
+ {formatDistanceToNowStrict(new Date(when), { addSuffix: true })}
+
+
+
+ {agent ? : null}
+
+
+ {agent?.failedRunId === run.id ? : null}
+
+ {running ? : null}
+
+
+ {status === "paused" ? (
+ asked ? (
+
+ ) : (
+ resume.mutate()} />
+ )
+ ) : null}
+ openChat(run.conversationId)} />
+ {cancellable ? : null}
+
+
+
+ : formatDuration(run.durationMs)} />
+ {spendTracking ? : null}
+
+
+
+
+ {finished ? : null}
+
+
+ {run.parentRunId || run.routineId ? (
+
+ {run.parentRunId ? : null}
+ {run.routineId ? (
+ {
+ tap();
+ router.push("/automations");
+ }}
+ >
+
+ Started by an automation
+
+
+ ) : null}
+
+ ) : null}
+
+ : undefined}>
+
+ {run.prompt ? (
+ <>
+
+ {run.prompt}
+
+ {run.prompt.length > PROMPT_FOLD ? (
+ setPromptOpen((o) => !o)} style={{ alignSelf: "flex-start" }}>
+
+ {promptOpen ? "Show less" : "Show all"}
+
+
+ ) : null}
+ >
+ ) : (
+
+ (empty)
+
+ )}
+
+
+
+ {run.error ? (
+
+
+
+
+ Error
+
+
+
+ {run.error}
+
+
+ ) : null}
+
+ {run.result ? (
+ }>
+
+
+
+
+ ) : !running && !run.error ? (
+
+
+ {"This run didn't produce a final answer."}
+
+
+ ) : null}
+
+
+ {
+ tap();
+ setLogOpen((o) => !o);
+ }}
+ style={({ pressed }) => [styles.logHead, pressed && { backgroundColor: c.sunken }]}
+ >
+
+
+ Raw log
+
+
+ stream-json events
+
+
+
+ {logOpen ? (
+
+
+
+ ) : null}
+
+
+
+
+ {run.id}
+
+
+
+
+ );
+}
+
+/** What the agent does right now and for how long. */
+function Working({ since, activity }: { since: string | null; activity: string | null }) {
+ const c = useColors();
+ return (
+
+
+
+
+ {activityText(activity)}
+
+ {since ? (
+
+ ) : (
+
+ Starting…
+
+ )}
+
+
+ );
+}
+
+/** The live timer, ticking on its own so the rest of the screen doesn't re-render every second. */
+function Elapsed({
+ since,
+ suffix = "",
+ variant = "subhead",
+ muted,
+ style,
+}: {
+ since: string;
+ suffix?: string;
+ variant?: "subhead" | "caption";
+ muted?: boolean;
+ style?: StyleProp;
+}) {
+ const now = useNow(1000);
+ return (
+
+ {`${formatElapsed(now - new Date(since).getTime())}${suffix}`}
+
+ );
+}
+
+function Fact({ label, value, detail, last }: { label: string; value: string | ReactElement; detail?: string; last?: boolean }) {
+ return (
+ <>
+
+
+ {label}
+
+
+ {typeof value === "string" ? (
+
+ {value}
+
+ ) : (
+ value
+ )}
+ {detail ? (
+
+ {detail}
+
+ ) : null}
+
+
+ {last ? null : }
+ >
+ );
+}
+
+function Section({ title, action, done, children }: { title: string; action?: ReactNode; done?: boolean; children: ReactNode }) {
+ const c = useColors();
+ return (
+
+
+ {done ? : null}
+
+ {title}
+
+ {action}
+
+ {children}
+
+ );
+}
+
+function Chip({ icon, onPress, children }: { icon: IconName; onPress?: () => void; children: ReactNode }) {
+ const c = useColors();
+ return (
+ [styles.chip, { backgroundColor: c.surface, borderColor: c.border, opacity: pressed ? 0.7 : 1 }]}
+ >
+
+ {children}
+ {onPress ? : null}
+
+ );
+}
+
+/** "Handed over by [face] Lena": opens the run that handed this one over. */
+function HandedOverBy({ parentRunId }: { parentRunId: string }) {
+ const c = useColors();
+ const parent = useQuery({ queryKey: qk.run(parentRunId), queryFn: () => api.runs.get(parentRunId), retry: false });
+ const { byId } = useAgents();
+ const from = parent.data ? byId.get(parent.data.agentId) : undefined;
+ if (parent.isLoading) return ;
+ if (!parent.data) {
+ return (
+
+
+ Handed over by an agent that no longer exists
+
+
+ );
+ }
+ return (
+ openRun(parentRunId)}>
+
+ Handed over by
+
+ {from ? (
+ <>
+
+
+ {from.name}
+
+ >
+ ) : (
+
+ another agent
+
+ )}
+
+ );
+}
+
+/** This run is why the agent says "Last run failed": the human can let that go. */
+function FailureNotice({ agent }: { agent: Agent }) {
+ const c = useColors();
+ const dismiss = useMutation({
+ mutationFn: () => api.agents.dismissFailure(agent.id),
+ onSuccess: (next) => {
+ queryClient.setQueryData(qk.agent(next.id), next);
+ queryClient.setQueryData(qk.agents, (list) => list?.map((a) => (a.id === next.id ? next : a)));
+ void queryClient.invalidateQueries({ queryKey: qk.agents });
+ },
+ onError: (err) => Alert.alert("Couldn't dismiss it", errorText(err)),
+ });
+ return (
+
+
+
+ {`This is why ${agent.name} shows “Last run failed”.`}
+
+ {
+ tap();
+ dismiss.mutate();
+ }}
+ style={({ pressed }) => ({ opacity: dismiss.isPending ? 0.5 : pressed ? 0.6 : 1 })}
+ >
+
+ Dismiss
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ content: {
+ padding: space.lg,
+ paddingBottom: 80,
+ gap: space.lg,
+ },
+ hero: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: space.md,
+ padding: space.md,
+ },
+ working: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: space.md,
+ padding: space.md,
+ borderWidth: 1,
+ },
+ facts: {
+ paddingVertical: 2,
+ },
+ fact: {
+ gap: space.md,
+ minHeight: 44,
+ paddingVertical: 10,
+ paddingHorizontal: space.lg,
+ },
+ block: {
+ padding: space.lg,
+ gap: space.sm,
+ },
+ error: {
+ gap: space.sm,
+ padding: space.lg,
+ borderRadius: radius.lg,
+ borderCurve: "continuous",
+ },
+ mono: {
+ fontFamily: fonts.mono,
+ fontSize: 12,
+ lineHeight: 17,
+ },
+ dashed: {
+ padding: space.lg,
+ borderRadius: radius.lg,
+ borderCurve: "continuous",
+ borderWidth: 1,
+ borderStyle: "dashed",
+ },
+ logHead: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: space.sm,
+ minHeight: 48,
+ paddingHorizontal: space.lg,
+ },
+ logBody: {
+ padding: space.md,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ },
+ chip: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 6,
+ minHeight: 30,
+ paddingHorizontal: 10,
+ borderRadius: radius.md,
+ borderCurve: "continuous",
+ borderWidth: StyleSheet.hairlineWidth,
+ },
+ notice: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: space.sm,
+ paddingVertical: space.md,
+ paddingHorizontal: space.lg,
+ borderRadius: radius.lg,
+ borderCurve: "continuous",
+ },
+});
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}
{
+ tap();
+ router.push({ pathname: "/run/[id]", params: { id } });
+};
+
+function firstLine(text: string | null | undefined): string {
+ return (text ?? "").split("\n").find((l) => l.trim())?.replace(/\s+/g, " ").trim() ?? "";
+}
+
+/** The agent's latest run failed and the human hasn't let it go yet: what went wrong, the run, and Dismiss. */
+export function FailedRunBanner({ agent }: { agent: Agent }) {
+ const c = useColors();
+ const runId = agent.failedRunId;
+ const run = useQuery({ queryKey: qk.run(runId ?? ""), queryFn: () => api.runs.get(runId!), enabled: !!runId });
+ const dismiss = useMutation({
+ mutationFn: () => api.agents.dismissFailure(agent.id),
+ onSuccess: (updated) => {
+ queryClient.setQueryData(qk.agents, (old) => old?.map((a) => (a.id === updated.id ? updated : a)));
+ void queryClient.invalidateQueries({ queryKey: qk.agents });
+ },
+ onError: (err) => Alert.alert("Couldn't dismiss it", errorText(err)),
+ });
+ if (!runId) return null;
+ const error = firstLine(run.data?.error);
+
+ return (
+
+
+
+
+
+ Last run failed
+
+ {error ? (
+
+ {error}
+
+ ) : run.isLoading ? null : (
+
+ This is why {agent.name} shows “Last run failed”.
+
+ )}
+
+
+
+ {
+ tap();
+ dismiss.mutate();
+ }}
+ style={{ opacity: dismiss.isPending ? 0.5 : 1, flexDirection: "row", alignItems: "center", gap: 6 }}
+ >
+ {dismiss.isPending ? : null}
+
+ Dismiss
+
+
+ openRun(runId)}>
+
+ Open run
+
+
+
+
+ );
+}
+
+/** The desktop's "Recent runs": the agent's latest runs, how each went, what started it, when, how long and what it cost. */
+export function AgentHistory({ agent }: { agent: Agent }) {
+ const runs = useQuery({ queryKey: qk.agentRuns(agent.id), queryFn: () => api.runs.list({ agentId: agent.id, limit: SHOWN_RUNS }) });
+ return (
+
+
+
+ {runs.data?.length ? (
+ runs.data.map((r, i) => (
+
+ {i > 0 && }
+
+
+ ))
+ ) : runs.isLoading ? (
+
+ ) : (
+
+ {runs.isError ? `Couldn't load runs: ${errorText(runs.error)}` : "No runs yet. Start a chat or give it a task — every run shows up here."}
+
+ )}
+
+
+ );
+}
+
+const GLYPH: Record, { icon: IconName; color: (c: Colors) => string }> = {
+ queued: { icon: "clock", color: (c) => c.textMuted },
+ paused: { icon: "pause", color: (c) => c.warning },
+ succeeded: { icon: "checkCircle", color: (c) => c.success },
+ failed: { icon: "closeCircle", color: (c) => c.danger },
+ cancelled: { icon: "minus", color: (c) => c.textMuted },
+};
+
+function RunGlyph({ status }: { status: RunStatus }) {
+ const c = useColors();
+ if (status === "running") return ;
+ const g = GLYPH[status] ?? GLYPH.queued;
+ return ;
+}
+
+const HistoryRow = memo(function HistoryRow({ run }: { run: Run }) {
+ const c = useColors();
+ const status = useLive((s) => s.runs[run.id]?.run.status) ?? run.status;
+ const activity = useRunActivity(status === "running" ? run.id : undefined);
+ const spendTracking = useSpendTracking();
+ const finished = status === "succeeded" || status === "failed" || status === "cancelled";
+ const when = run.startedAt ?? run.createdAt;
+ const meta = [
+ TRIGGER_LABEL[run.trigger] ?? "Manual",
+ formatDistanceToNowStrict(new Date(when), { addSuffix: true }),
+ finished && run.durationMs != null ? formatWork(run.durationMs) : null,
+ spendTracking && run.costUsd ? formatCost(run.costUsd) : null,
+ ].filter(Boolean);
+ const snippet = (run.prompt || "").replace(/\s+/g, " ").trim() || "(no prompt)";
+ const error = status === "failed" ? firstLine(run.error) : "";
+
+ return (
+ openRun(run.id)}
+ style={({ pressed }) => [styles.row, pressed && { backgroundColor: c.sunken }]}
+ >
+
+
+
+
+
+ {snippet}
+
+
+ {meta.join(" · ")}
+
+ {error ? (
+
+ {error}
+
+ ) : status === "running" ? (
+
+ {activityText(activity)}
+
+ ) : status === "queued" ? (
+
+ Waiting to start
+
+ ) : null}
+
+
+
+ );
+});
+
+const styles = StyleSheet.create({
+ banner: {
+ gap: space.sm,
+ paddingHorizontal: space.md,
+ paddingVertical: space.md,
+ borderRadius: radius.lg,
+ borderCurve: "continuous",
+ borderWidth: StyleSheet.hairlineWidth,
+ },
+ bannerActions: {
+ flexDirection: "row",
+ justifyContent: "flex-end",
+ alignItems: "center",
+ gap: space.xl,
+ paddingTop: 2,
+ },
+ row: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: space.md,
+ paddingHorizontal: space.lg,
+ paddingVertical: space.md,
+ },
+ glyph: {
+ width: 16,
+ alignItems: "center",
+ },
+});
diff --git a/apps/mobile/src/components/away-summary.tsx b/apps/mobile/src/components/away-summary.tsx
new file mode 100644
index 00000000..bfa68722
--- /dev/null
+++ b/apps/mobile/src/components/away-summary.tsx
@@ -0,0 +1,168 @@
+import { useQuery } from "@tanstack/react-query";
+import { formatDistanceStrict } from "date-fns";
+import { router } from "expo-router";
+import { Pressable, StyleSheet, View } from "react-native";
+import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
+import { formatUsd, type AwayHighlight } from "@godmode/shared";
+import { CharacterAvatar } from "./character";
+import { Icon, type IconName } from "./icon";
+import { openChat } from "./rows";
+import { openTask } from "./task-row";
+import { Card, Hairline, Row, T, tap } from "./ui";
+import { api } from "@/lib/api";
+import { useAwayStore } from "@/lib/away";
+import { useAgents } from "@/lib/hooks";
+import { qk } from "@/lib/query";
+import { useSpendTracking } from "@/lib/setup";
+import { space, useColors, type Colors } from "@/lib/theme";
+
+const ICON: Record = { delivered: "tasks", failed: "warning", replied: "chats", automation: "workflow" };
+
+const tone = (c: Colors, kind: AwayHighlight["kind"]) => (kind === "failed" || kind === "automation" ? c.danger : kind === "delivered" ? c.brandStrong : c.textMuted);
+
+const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
+
+/** A highlight's desktop route on the phone: a chat, a ticket, or the board. */
+function open(link: string) {
+ const chat = /^\/chat\/([^/?#]+)/.exec(link);
+ if (chat) return openChat(chat[1]!);
+ const task = /[?&]task=([^]+)/.exec(link);
+ if (task) return openTask(task[1]!);
+ tap();
+ router.push(link.startsWith("/tasks") ? "/tasks" : "/activity");
+}
+
+/**
+ * First thing on Home after the human was away for a while: what the team did meanwhile — who worked, what it cost,
+ * and the few things worth a look. Closed with ×; it doesn't come back until the next time they were away.
+ */
+export function AwaySummaryCard() {
+ const c = useColors();
+ const away = useAwayStore((s) => s.away);
+ const dismiss = useAwayStore((s) => s.dismiss);
+ const { byId: agents } = useAgents();
+ const spendTracking = useSpendTracking();
+ const { data } = useQuery({
+ queryKey: qk.away(away?.since ?? ""),
+ queryFn: () => api.away(away!.since, away!.until),
+ enabled: !!away,
+ staleTime: 60_000,
+ });
+ if (!away || !data || (data.finished === 0 && data.delivered === 0)) return null;
+ const facts = [
+ plural(data.finished, "run") + " finished",
+ data.failed ? plural(data.failed, "problem") : null,
+ data.delivered ? `${plural(data.delivered, "ticket")} delivered` : null,
+ spendTracking && data.costUsd > 0 ? `${formatUsd(data.costUsd)} spent` : null,
+ ].filter(Boolean);
+ const now = new Date();
+
+ return (
+
+
+
+ While you were away · {formatDistanceStrict(new Date(away.since), new Date(away.until))}
+
+ {
+ tap();
+ dismiss();
+ }}
+ style={({ pressed }) => [styles.close, { opacity: pressed ? 0.5 : 1 }]}
+ >
+
+
+
+
+ {
+ tap();
+ router.push("/activity");
+ }}
+ style={({ pressed }) => [styles.facts, pressed && { backgroundColor: c.sunken }]}
+ >
+
+
+ {facts.join(" · ")}
+
+
+
+ {data.agents.length > 0 && (
+
+ {data.agents.slice(0, 5).map((a) => {
+ const agent = agents.get(a.agentId);
+ return (
+
+ {agent ? : null}
+ {a.name}
+
+ {a.runs}
+
+
+ );
+ })}
+
+ )}
+
+ {data.highlights.map((h) => (
+
+
+ open(h.link)} style={({ pressed }) => [styles.highlight, pressed && { backgroundColor: c.sunken }]}>
+
+
+ {h.text}
+
+
+ {formatDistanceStrict(new Date(h.at), now, { addSuffix: true })}
+
+
+
+ ))}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ section: {
+ marginTop: space.sm,
+ },
+ head: {
+ gap: space.sm,
+ paddingHorizontal: 4,
+ marginBottom: space.sm,
+ },
+ close: {
+ marginLeft: "auto",
+ width: 22,
+ height: 22,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ facts: {
+ gap: space.sm,
+ paddingHorizontal: space.lg,
+ paddingVertical: space.md,
+ },
+ agents: {
+ flexDirection: "row",
+ flexWrap: "wrap",
+ columnGap: space.md,
+ rowGap: 4,
+ },
+ highlight: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: space.md,
+ paddingHorizontal: space.lg,
+ paddingVertical: 11,
+ },
+ num: {
+ fontVariant: ["tabular-nums"],
+ },
+});
diff --git a/apps/mobile/src/components/cron-builder.tsx b/apps/mobile/src/components/cron-builder.tsx
new file mode 100644
index 00000000..5f884698
--- /dev/null
+++ b/apps/mobile/src/components/cron-builder.tsx
@@ -0,0 +1,373 @@
+import { DatePicker, Host } from "@expo/ui/swift-ui";
+import { datePickerStyle, labelsHidden } from "@expo/ui/swift-ui/modifiers";
+import { Cron } from "croner";
+import { useState } from "react";
+import { Pressable, StyleSheet, TextInput, View } from "react-native";
+import {
+ formatMinutes,
+ MAX_START_WINDOW_MINUTES,
+ ordinal,
+ parseCron,
+ scheduleToHuman,
+ startWindowLimit,
+ startWindowTooLong,
+ validateCron,
+ WEEKDAYS,
+ type CronDraft,
+} from "@godmode/shared";
+import { Group, PickerRow, StepperRow, SwitchRow } from "./form";
+import { Icon } from "./icon";
+import { T, tap } from "./ui";
+import { fonts, radius, space, useColors, useIsDark } from "@/lib/theme";
+
+type Kind = "hourly" | "daily" | "weekdays" | "weekly" | "monthly" | "custom";
+
+const KINDS: { value: Kind; label: string }[] = [
+ { value: "hourly", label: "Every hour" },
+ { value: "daily", label: "Every day" },
+ { value: "weekdays", label: "Every weekday" },
+ { value: "weekly", label: "Every week" },
+ { value: "monthly", label: "Every month" },
+ { value: "custom", label: "Custom (cron)" },
+];
+
+// Monday-first order reads more naturally in a picker
+const WEEK_ORDER = [1, 2, 3, 4, 5, 6, 0];
+const MINUTES = Array.from({ length: 12 }, (_, i) => i * 5);
+const DAYS_OF_MONTH = Array.from({ length: 31 }, (_, i) => i + 1);
+
+const CUSTOM_EXAMPLES = [
+ { cron: "*/15 * * * *", label: "every 15 min" },
+ { cron: "0 */2 * * *", label: "every 2 hours" },
+ { cron: "0 9,17 * * 1-5", label: "9:00 & 17:00 weekdays" },
+ { cron: "0 8 1,15 * *", label: "1st & 15th" },
+];
+
+export const START_WINDOW_PRESETS = [15, 30, 60, 90, 120, 180];
+
+const pad = (n: number) => String(n).padStart(2, "0");
+const clamp = (n: number, min: number, max: number) => (Number.isFinite(n) ? Math.min(max, Math.max(min, Math.round(n))) : min);
+const tidy = (cron: string) => cron.trim().replace(/\s+/g, " ");
+
+/** The preset an expression reads as; a schedule the phone has no preset for ("several times a day") is edited as cron. */
+function draftOf(cron: string): CronDraft {
+ const d = parseCron(cron);
+ return d.kind === "several" ? { ...d, kind: "custom", custom: tidy(cron) } : d;
+}
+
+/** The expression of a preset (desktop: buildCron). */
+function buildCron(d: CronDraft): string {
+ const m = clamp(d.minute, 0, 59);
+ const h = clamp(d.hour, 0, 23);
+ switch (d.kind) {
+ case "hourly":
+ return `${m} * * * *`;
+ case "daily":
+ return `${m} ${h} * * *`;
+ case "weekdays":
+ return `${m} ${h} * * 1-5`;
+ case "weekly":
+ return `${m} ${h} * * ${clamp(d.weekday, 0, 6)}`;
+ case "monthly":
+ return `${m} ${h} ${clamp(d.monthDay, 1, 31)} * *`;
+ default:
+ return tidy(d.custom);
+ }
+}
+
+/** Longest random start window the schedule leaves room for (same rule as the core and the desktop). */
+function maxStartWindow(cron: string, timezone: string): number {
+ if (validateCron(cron)) return MAX_START_WINDOW_MINUTES;
+ try {
+ const job = new Cron(cron.trim(), { paused: true, timezone, mode: "5-or-6-parts" });
+ return startWindowLimit((after) => job.nextRun(after), timezone);
+ } catch {
+ return MAX_START_WINDOW_MINUTES;
+ }
+}
+
+/** A start window longer than the gap between runs, in the core's words; null when it fits. */
+export function startWindowProblem(cron: string, timezone: string, minutes: number): string | null {
+ const max = maxStartWindow(cron, timezone);
+ return minutes > max ? startWindowTooLong(max) : null;
+}
+
+/**
+ * Friendly schedule editor (desktop: cron-builder.tsx): presets with a time, or a cron expression, and the schedule in
+ * words below. Emits the expression through `onChange`; the caller checks it with validateCron before saving.
+ */
+export function CronBuilder({
+ value,
+ onChange,
+ startWindowMinutes = 0,
+ runsPerWindow = 1,
+ zone,
+ problem,
+}: {
+ value: string;
+ onChange: (cron: string) => void;
+ startWindowMinutes?: number;
+ runsPerWindow?: number;
+ /** Shown after the schedule when it runs in another timezone than the phone's. */
+ zone?: string | null;
+ /** A problem the expression can't show (a start window that doesn't fit). */
+ problem?: string | null;
+}) {
+ const c = useColors();
+ const [draft, setDraft] = useState(() => draftOf(value));
+ const cron = buildCron(draft);
+ const error = validateCron(cron) ?? problem ?? null;
+ const timed = draft.kind === "daily" || draft.kind === "weekdays" || draft.kind === "weekly" || draft.kind === "monthly";
+ const ios = process.env.EXPO_OS === "ios";
+
+ const update = (patch: Partial) => {
+ const next = { ...draft, ...patch };
+ // Carry the current expression into the custom editor when switching to it
+ if (patch.kind === "custom" && draft.kind !== "custom") next.custom = buildCron(draft);
+ setDraft(next);
+ onChange(buildCron(next));
+ };
+
+ return (
+
+
+ update({ kind })} />
+ {draft.kind === "hourly" ? (
+ `:${pad(m)}`} onChange={(minute) => update({ minute })} />
+ ) : null}
+ {draft.kind === "weekly" ? update({ weekday })} /> : null}
+ {draft.kind === "monthly" ? (
+ update({ monthDay })} />
+ ) : null}
+ {timed && ios ? update({ hour, minute })} /> : null}
+ {timed && !ios ? (
+ update({ hour })} />
+ ) : null}
+ {timed && !ios ? update({ minute })} /> : null}
+ {draft.kind === "custom" ? update({ custom })} /> : null}
+
+
+
+
+ {error ?? `${scheduleToHuman(cron, startWindowMinutes, runsPerWindow)}${zone ? ` (${zone})` : ""}`}
+
+ {!error ? (
+
+ {cron}
+
+ ) : null}
+
+
+ );
+}
+
+function WeekdayRow({ value, onChange }: { value: number; onChange: (weekday: number) => void }) {
+ const c = useColors();
+ return (
+
+
+ On
+
+
+ {WEEK_ORDER.map((d) => {
+ const active = d === value;
+ return (
+ {
+ if (active) return;
+ tap();
+ onChange(d);
+ }}
+ style={[styles.day, { backgroundColor: active ? c.primary : c.sunken }]}
+ >
+
+ {WEEKDAYS[d].slice(0, 3)}
+
+
+ );
+ })}
+
+
+ );
+}
+
+/** "At" with the system's time picker. */
+function TimeRow({ hour, minute, onChange }: { hour: number; minute: number; onChange: (hour: number, minute: number) => void }) {
+ const dark = useIsDark();
+ return (
+
+
+ At
+
+
+ onChange(d.getHours(), d.getMinutes())}
+ modifiers={[labelsHidden(), datePickerStyle("compact")]}
+ />
+
+
+ );
+}
+
+function CustomCronRow({ value, invalid, onChange }: { value: string; invalid: boolean; onChange: (cron: string) => void }) {
+ const c = useColors();
+ return (
+
+
+ Cron expression (minute hour day month weekday)
+
+
+
+ {CUSTOM_EXAMPLES.map((ex) => (
+ {
+ tap();
+ onChange(ex.cron);
+ }}
+ style={({ pressed }) => [styles.example, { borderColor: c.border, backgroundColor: pressed ? c.sunken : c.surface }]}
+ >
+
+
+ {ex.cron}
+
+ {` · ${ex.label}`}
+
+
+ ))}
+
+
+ );
+}
+
+/** "Random start time": the run begins somewhere in a window after the scheduled time, 0 = on time (desktop: start-window-field.tsx). */
+export function StartWindowField({ cron, timezone, value, onChange }: { cron: string; timezone: string; value: number; onChange: (minutes: number) => void }) {
+ const c = useColors();
+ const [initial] = useState(value);
+ const [remembered, setRemembered] = useState(value || 60);
+ const on = value > 0;
+ const max = maxStartWindow(cron, timezone);
+ const options = [...new Set([...START_WINDOW_PRESETS.filter((m) => m <= max), ...(initial ? [initial] : []), ...(on ? [value] : [])])].sort((a, b) => a - b);
+
+ return (
+
+ onChange(v ? Math.min(remembered, max) : 0)}
+ />
+ {on ? (
+
+
+ Start within
+
+
+ {options.map((m) => {
+ const active = m === value;
+ return (
+ {
+ if (active) return;
+ tap();
+ setRemembered(m);
+ onChange(m);
+ }}
+ style={[styles.chip, { backgroundColor: active ? c.primary : c.sunken }]}
+ >
+
+ {formatMinutes(m)}
+
+
+ );
+ })}
+
+
+ ) : null}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ row: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: space.md,
+ minHeight: 52,
+ paddingHorizontal: space.lg,
+ paddingVertical: 8,
+ },
+ stackedRow: {
+ gap: space.sm,
+ paddingHorizontal: space.lg,
+ paddingVertical: 11,
+ },
+ day: {
+ flex: 1,
+ height: 34,
+ alignItems: "center",
+ justifyContent: "center",
+ borderRadius: radius.sm,
+ borderCurve: "continuous",
+ },
+ cron: {
+ fontFamily: fonts.mono,
+ fontSize: 15,
+ paddingHorizontal: space.md,
+ paddingVertical: 10,
+ borderRadius: radius.md,
+ borderCurve: "continuous",
+ borderWidth: 1,
+ },
+ examples: {
+ flexDirection: "row",
+ flexWrap: "wrap",
+ gap: 6,
+ },
+ example: {
+ paddingHorizontal: 8,
+ paddingVertical: 4,
+ borderRadius: radius.badge,
+ borderCurve: "continuous",
+ borderWidth: StyleSheet.hairlineWidth,
+ },
+ chip: {
+ height: 32,
+ justifyContent: "center",
+ paddingHorizontal: 12,
+ borderRadius: radius.pill,
+ },
+ summary: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: space.sm,
+ paddingHorizontal: space.md,
+ paddingVertical: 10,
+ borderRadius: radius.md,
+ borderCurve: "continuous",
+ borderWidth: StyleSheet.hairlineWidth,
+ },
+});
diff --git a/apps/mobile/src/components/dream-parts.tsx b/apps/mobile/src/components/dream-parts.tsx
new file mode 100644
index 00000000..cb397f15
--- /dev/null
+++ b/apps/mobile/src/components/dream-parts.tsx
@@ -0,0 +1,121 @@
+import { addDays, format, isThisYear, isToday, isTomorrow, isYesterday, startOfDay } from "date-fns";
+import type { Dream, DreamChangeKind, DreamReason, DreamStatus } from "@godmode/shared";
+import { Icon, type IconName } from "./icon";
+import { Badge, toneColors, type Tone } from "./ui";
+import { useColors } from "@/lib/theme";
+
+export const ACTIVE_DREAM: DreamStatus[] = ["queued", "running"];
+/** Dreams that ended early: the core rolled their memory changes back, so their changes describe an attempt. */
+export const ROLLED_BACK: DreamStatus[] = ["failed", "cancelled", "paused"];
+
+export function plural(n: number, word: string): string {
+ return `${n.toLocaleString()} ${word}${n === 1 ? "" : "s"}`;
+}
+
+/** "940" · "7.8k" · "21k" */
+export function shortChars(n: number): string {
+ if (n < 1000) return String(n);
+ const k = n / 1000;
+ return `${k < 10 ? k.toFixed(1).replace(/\.0$/, "") : Math.round(k)}k`;
+}
+
+/** "MEMORY.md", "MEMORY.md and memory/people.md", "3 memory files" */
+export function fileList(files: string[]): string {
+ if (files.length <= 2) return files.join(" and ");
+ return `${files.length} memory files`;
+}
+
+/** "1 workspace entry", "12 workspace entries" */
+export function entryCount(n: number): string {
+ return `${n.toLocaleString()} workspace ${n === 1 ? "entry" : "entries"}`;
+}
+
+/** What the dream read: "Reviewed 14 exchanges in 5 chats". */
+export function reviewedLabel(dream: Pick): string {
+ const active = ACTIVE_DREAM.includes(dream.status);
+ const ended = ROLLED_BACK.includes(dream.status);
+ if (!dream.exchanges) {
+ if (active) return "Nothing new to read — tidying up and refreshing dates";
+ return ended ? "Set out to tidy up and refresh dates" : "No new conversations — tidied up and refreshed dates";
+ }
+ const what = `${plural(dream.exchanges, "exchange")} in ${plural(dream.conversations, "chat")}`;
+ return active ? `Reviewing ${what}` : ended ? `Set out to review ${what}` : `Reviewed ${what}`;
+}
+
+/** "Today, 03:00" · "Yesterday, 03:00" · "Mon, Sep 22, 03:00" */
+export function dreamWhen(iso: string): string {
+ const d = new Date(iso);
+ if (isToday(d)) return `Today, ${format(d, "HH:mm")}`;
+ if (isYesterday(d)) return `Yesterday, ${format(d, "HH:mm")}`;
+ return format(d, isThisYear(d) ? "EEE, MMM d, HH:mm" : "MMM d, yyyy, HH:mm");
+}
+
+/** "due now" · "tonight at 03:00" · "today at 15:00" · "tomorrow at 15:00" · "Saturday at 04:00" · "Mon, Oct 12 at 03:00" */
+export function upcomingWhen(iso: string, now = Date.now()): string {
+ const d = new Date(iso);
+ if (d.getTime() <= now) return "due now";
+ const time = format(d, "HH:mm");
+ const hour = d.getHours();
+ // "Tonight" is this evening or the small hours after it — never tomorrow evening.
+ if ((isToday(d) && hour >= 20) || (isTomorrow(d) && hour < 6)) return `tonight at ${time}`;
+ if (isToday(d)) return `today at ${time}`;
+ if (isTomorrow(d)) return `tomorrow at ${time}`;
+ if (d < addDays(startOfDay(now), 7)) return `${format(d, "EEEE")} at ${time}`;
+ return `${format(d, isThisYear(d) ? "EEE, MMM d" : "MMM d, yyyy")} at ${time}`;
+}
+
+const STATUS_META: Record = {
+ queued: { label: "Waiting", tone: "neutral" },
+ running: { label: "Running", tone: "dream" },
+ succeeded: { label: "Consolidated", tone: "success" },
+ failed: { label: "Failed", tone: "danger" },
+ cancelled: { label: "Cancelled", tone: "neutral" },
+ paused: { label: "Paused", tone: "warning" },
+ reverted: { label: "Undone", tone: "neutral" },
+};
+
+export function DreamStatusBadge({ status }: { status: DreamStatus }) {
+ const meta = STATUS_META[status] ?? STATUS_META.queued;
+ return ;
+}
+
+const REASON_META: Record = {
+ schedule: { label: "Scheduled", icon: "clock" },
+ manual: { label: "Manual", icon: "hand" },
+ size: { label: "Memory full", icon: "gauge" },
+};
+
+export function DreamReasonBadge({ reason }: { reason: DreamReason }) {
+ const c = useColors();
+ const meta = REASON_META[reason] ?? REASON_META.manual;
+ return } />;
+}
+
+export const CHANGE_KINDS: Record = {
+ added: { label: "Added", icon: "plus", tone: "emerald" },
+ updated: { label: "Updated", icon: "pencil", tone: "sky" },
+ merged: { label: "Merged", icon: "merge", tone: "violet" },
+ corrected: { label: "Corrected", icon: "checkCircle", tone: "amber" },
+ removed: { label: "Removed", icon: "minus", tone: "rose" },
+ dated: { label: "Dated", icon: "clock", tone: "dream" },
+};
+
+export const CHANGE_KIND_ORDER: DreamChangeKind[] = ["added", "updated", "merged", "corrected", "dated", "removed"];
+
+/** Small coloured chip for a kind of memory change; with `count` it reads "3 added". */
+export function ChangeKindChip({ kind, count }: { kind: DreamChangeKind; count?: number }) {
+ const c = useColors();
+ const meta = CHANGE_KINDS[kind] ?? CHANGE_KINDS.updated;
+ return (
+ }
+ />
+ );
+}
+
+/** How many entries of each kind a dream changed, in the desktop's order. */
+export function changeCounts(dream: Pick): { kind: DreamChangeKind; n: number }[] {
+ return CHANGE_KIND_ORDER.map((kind) => ({ kind, n: dream.changes.filter((ch) => ch.kind === kind).length })).filter((ch) => ch.n > 0);
+}
diff --git a/apps/mobile/src/components/dreams-card.tsx b/apps/mobile/src/components/dreams-card.tsx
new file mode 100644
index 00000000..58b3d205
--- /dev/null
+++ b/apps/mobile/src/components/dreams-card.tsx
@@ -0,0 +1,344 @@
+import { useMutation, useQuery } from "@tanstack/react-query";
+import { formatDistanceToNowStrict } from "date-fns";
+import * as Haptics from "expo-haptics";
+import { router } from "expo-router";
+import { useState } from "react";
+import { Alert, Pressable, StyleSheet, View } from "react-native";
+import type { Agent, Dream, DreamOverview, DreamStatus } from "@godmode/shared";
+import { Group, LinkRow } from "./form";
+import { Icon, type IconName } from "./icon";
+import {
+ ACTIVE_DREAM,
+ CHANGE_KINDS,
+ DreamStatusBadge,
+ ROLLED_BACK,
+ changeCounts,
+ dreamWhen,
+ entryCount,
+ fileList,
+ plural,
+ reviewedLabel,
+ shortChars,
+ upcomingWhen,
+} from "./dream-parts";
+import { Button, Card, Hairline, SectionTitle, Skeleton, T, tap } from "./ui";
+import { api, ApiError, errorText } from "@/lib/api";
+import { elapsed } from "@/lib/format";
+import { useNow } from "@/lib/hooks";
+import { qk, queryClient } from "@/lib/query";
+import { useSettings } from "@/lib/setup";
+import { alpha, radius, space, useColors, type Colors } from "@/lib/theme";
+
+const JOURNAL_PREVIEW = 3;
+
+/** An older Godmode on the computer doesn't let the phone read it. */
+const refused = (err: unknown) => err instanceof ApiError && (err.status === 403 || err.status === 404);
+
+const LAST_DREAM: Partial> = {
+ failed: "Last dream failed",
+ cancelled: "Last dream was cancelled",
+ paused: "Last dream paused",
+};
+
+const NODE: Record string }> = {
+ queued: { icon: "clock", color: (c) => c.textMuted },
+ running: { icon: "moon", color: (c) => c.dream },
+ succeeded: { icon: "moon", color: (c) => c.dream },
+ failed: { icon: "warning", color: (c) => c.danger },
+ cancelled: { icon: "closeCircle", color: (c) => c.textMuted },
+ paused: { icon: "pause", color: (c) => c.warning },
+ reverted: { icon: "unarchive", color: (c) => c.textMuted },
+};
+
+/** The desktop's dreams panel: whether the agent dreams and when next, "Dream now", and its dream journal. */
+export function DreamsCard({ agent }: { agent: Agent }) {
+ const c = useColors();
+ const [showAll, setShowAll] = useState(false);
+ const q = useQuery({
+ queryKey: qk.dreams(agent.id),
+ queryFn: () => api.dreams.overview(agent.id),
+ refetchInterval: (query) => (query.state.data?.active ? 5_000 : 60_000),
+ retry: (count, err) => !refused(err) && count < 2,
+ });
+ const start = useMutation({
+ mutationFn: () => api.dreams.start(agent.id),
+ onSuccess: (dream) => {
+ void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ queryClient.setQueryData(qk.dreams(agent.id), (old) =>
+ old ? { ...old, active: dream, dreams: [dream, ...old.dreams.filter((d) => d.id !== dream.id)] } : old,
+ );
+ void queryClient.invalidateQueries({ queryKey: qk.dreams(agent.id) });
+ void queryClient.invalidateQueries({ queryKey: qk.agentMemory(agent.id) });
+ },
+ onError: (err) => {
+ Alert.alert("Couldn't start a dream", errorText(err));
+ void queryClient.invalidateQueries({ queryKey: qk.dreams(agent.id) });
+ },
+ });
+
+ if (q.isError && refused(q.error)) return null;
+ const o = q.data;
+ if (o && !o.enabled && !o.active && !o.dreams.length) {
+ return (
+
+ router.push("/settings/memory")} />
+
+ );
+ }
+ const active = o?.active ?? null;
+ const dreams = o?.dreams ?? [];
+ const shown = showAll ? dreams : dreams.slice(0, JOURNAL_PREVIEW);
+ const older = dreams.length - JOURNAL_PREVIEW;
+
+ return (
+
+
+
+
+
+
+
+
+
+ {q.isLoading ? (
+ <>
+
+
+ >
+ ) : q.isError ? (
+
+ {`Couldn't load dreams: ${errorText(q.error)}`}
+
+ ) : o ? (
+
+ ) : null}
+
+
+ start.mutate()}
+ />
+
+
+ {o ? (
+ dreams.length ? (
+
+
+
+ Dream journal
+
+ {shown.map((d, i) => (
+
+ {i > 0 && }
+
+
+ ))}
+ {older > 0 ? (
+ {
+ tap();
+ setShowAll((v) => !v);
+ }}
+ style={({ pressed }) => [styles.more, { borderTopColor: c.border }, pressed && { backgroundColor: c.sunken }]}
+ >
+
+
+ {showAll ? "Show fewer" : `Show ${plural(older, "older dream")}`}
+
+
+ ) : null}
+
+ ) : (
+
+
+
+ Every dream lands here: what {agent.name} reviewed, what it changed in its memory and why — with a line-by-line diff.
+
+
+ )
+ ) : null}
+
+
+ );
+}
+
+function StatusLines({ overview: o, agent }: { overview: DreamOverview; agent: Agent }) {
+ const c = useColors();
+ const minExchanges = useSettings().data?.memory?.dreaming?.minNewExchanges;
+ const active = o.active;
+
+ if (active) {
+ const queued = active.status === "queued";
+ return (
+ <>
+
+ {queued ? "Waiting to dream…" : "Dreaming…"}
+ {!queued ? : null}
+
+
+ {reviewedLabel(active)}
+
+ {queued ? (
+
+ {`Starts as soon as ${agent.name} has finished what it's doing.`}
+
+ ) : null}
+ >
+ );
+ }
+
+ const last = o.dreams.find((d) => !ACTIVE_DREAM.includes(d.status));
+ const lastAt = last ? new Date(last.finishedAt ?? last.createdAt) : null;
+ const threshold = Math.max(1, minExchanges ?? 1);
+ const { exchanges, conversations } = o.pending;
+ const lastText =
+ last && lastAt
+ ? `${LAST_DREAM[last.status] ?? "Last dream"} ${formatDistanceToNowStrict(lastAt, { addSuffix: true })}${
+ last.status === "paused" ? (last.reason === "size" ? " — the next scheduled dream takes over" : ` — it continues when ${agent.name} is idle`) : ""
+ }${last.status === "reverted" ? " (undone)" : ""}`
+ : "Hasn't dreamt yet";
+ const nextText = !o.enabled
+ ? "Dreaming is off — turn it on in Settings → Memory"
+ : o.nextDreamAt
+ ? `Next dream ${upcomingWhen(o.nextDreamAt)}`
+ : "No upcoming dream — check the schedule in Settings → Memory";
+
+ return (
+ <>
+
+ {lastText}
+
+
+ {nextText}
+
+ {!agent.enabled ? (
+
+ {agent.name} is switched off — switch it on to let it dream.
+
+ ) : exchanges > 0 ? (
+
+
+ {plural(exchanges, "new exchange")}
+ {" "}
+ in {plural(conversations, "chat")} waiting{o.enabled && exchanges < threshold ? ` — the next scheduled dream needs ${threshold}` : ""}
+
+ ) : (
+
+ {last ? "Nothing new since the last dream." : "Nothing to dream about yet — chat with the agent first."}
+
+ )}
+ >
+ );
+}
+
+/** " · 1m 04s", ticking while the dream runs. */
+function DreamElapsed({ since }: { since: string }) {
+ const now = useNow(1000);
+ return {` · ${elapsed(since, now)}`};
+}
+
+/** What a dream changed, in one line: "3 added · 2 updated · MEMORY.md 7.8k → 6.1k". */
+function changeLine(d: Dream): { text: string; danger?: boolean } | null {
+ if (d.status === "failed" && d.error) return { text: d.error, danger: true };
+ if (ROLLED_BACK.includes(d.status)) {
+ if (d.changes.length) return { text: d.files.length ? "Attempted — rolled back where possible" : "Attempted — rolled back, nothing was kept" };
+ return d.error ? { text: d.error } : null;
+ }
+ if (ACTIVE_DREAM.includes(d.status)) return null;
+ const parts = changeCounts(d).map(({ kind, n }) => `${n} ${CHANGE_KINDS[kind].label.toLowerCase()}`);
+ if (!parts.length && d.files.length) parts.push(fileList(d.files));
+ if (d.memoryChars && shortChars(d.memoryChars.before) !== shortChars(d.memoryChars.after)) {
+ parts.push(`MEMORY.md ${shortChars(d.memoryChars.before)} → ${shortChars(d.memoryChars.after)}`);
+ }
+ if (d.workspace.length) parts.push(entryCount(d.workspace.length));
+ if (parts.length) return { text: parts.join(" · ") };
+ return d.status === "succeeded" ? { text: "Memory was already in good shape — nothing to rewrite." } : null;
+}
+
+function JournalRow({ dream: d }: { dream: Dream }) {
+ const c = useColors();
+ const node = NODE[d.status] ?? NODE.queued;
+ const rolledBack = ROLLED_BACK.includes(d.status);
+ const line = changeLine(d);
+ return (
+ {
+ tap();
+ router.push({ pathname: "/dream/[id]", params: { id: d.id } });
+ }}
+ style={({ pressed }) => [styles.row, pressed && { backgroundColor: c.sunken }]}
+ >
+
+
+
+
+
+
+ {dreamWhen(d.createdAt)}
+
+
+
+
+ {d.summary || (ACTIVE_DREAM.includes(d.status) ? "Consolidating memory…" : reviewedLabel(d))}
+
+ {line ? (
+
+ {line.text}
+
+ ) : null}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ head: {
+ padding: space.lg,
+ gap: space.md,
+ },
+ tile: {
+ width: 36,
+ height: 36,
+ borderRadius: radius.md,
+ borderCurve: "continuous",
+ borderWidth: StyleSheet.hairlineWidth,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ eyebrow: {
+ paddingHorizontal: space.lg,
+ paddingTop: space.md,
+ paddingBottom: 2,
+ },
+ row: {
+ flexDirection: "row",
+ alignItems: "flex-start",
+ gap: space.md,
+ paddingHorizontal: space.lg,
+ paddingVertical: space.md,
+ },
+ node: {
+ width: 26,
+ height: 26,
+ borderRadius: 13,
+ borderWidth: StyleSheet.hairlineWidth,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ more: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 6,
+ paddingHorizontal: space.lg,
+ paddingVertical: space.md,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ },
+});
diff --git a/apps/mobile/src/components/followup-row.tsx b/apps/mobile/src/components/followup-row.tsx
new file mode 100644
index 00000000..e9a257b7
--- /dev/null
+++ b/apps/mobile/src/components/followup-row.tsx
@@ -0,0 +1,193 @@
+import { addDays, addHours, formatDistanceStrict, nextMonday, setHours, startOfHour } from "date-fns";
+import { useState } from "react";
+import { ActivityIndicator, Alert, Pressable, StyleSheet, View } from "react-native";
+import type { Agent, Followup } from "@godmode/shared";
+import { CharacterAvatar } from "./character";
+import { MenuSheet, type HeaderMenuItem } from "./header-actions";
+import { Icon, type IconName } from "./icon";
+import { openChat } from "./rows";
+import { Card, Hairline, T, tap } from "./ui";
+import { ApiError, api, errorText } from "@/lib/api";
+import { whenText } from "@/lib/format";
+import { useNow } from "@/lib/hooks";
+import { useLive, useRunningChats } from "@/lib/live";
+import { qk, queryClient } from "@/lib/query";
+import { radius, space, useColors } from "@/lib/theme";
+
+/** "in 3 hours", "in 12 minutes", "any moment" */
+function followupIn(iso: string, now: number): string {
+ if (new Date(iso).getTime() - now < 60_000) return "any moment";
+ return `in ${formatDistanceStrict(new Date(iso), now, { roundingMethod: "floor" })}`;
+}
+
+function refresh(conversationId: string) {
+ void queryClient.invalidateQueries({ queryKey: qk.followups });
+ void queryClient.invalidateQueries({ queryKey: qk.conversation(conversationId) });
+ void queryClient.invalidateQueries({ queryKey: qk.conversations, predicate: (q) => q.queryKey[1] !== "detail" });
+}
+
+const failed = (title: string) => (err: unknown) =>
+ Alert.alert(title, err instanceof ApiError && err.code === "device_forbidden" ? "Update Godmode on your computer to change follow-ups from the phone." : errorText(err));
+
+/** Like the desktop's time picker: the next hours, this evening, tomorrow morning, next Monday, a week. */
+function moves(now: Date): { title: string; at: Date }[] {
+ const morning = (d: Date) => setHours(startOfHour(d), 9);
+ return [
+ { title: "In 1 hour", at: addHours(now, 1) },
+ { title: "In 3 hours", at: addHours(now, 3) },
+ now.getHours() < 17 ? { title: "This evening", at: setHours(startOfHour(now), 18) } : { title: "In 12 hours", at: addHours(now, 12) },
+ { title: "Tomorrow morning", at: morning(addDays(now, 1)) },
+ { title: "Next Monday", at: morning(nextMonday(now)) },
+ { title: "In a week", at: morning(addDays(now, 7)) },
+ ];
+}
+
+/** Chats agents will pick up again on their own (followup_schedule), soonest first. Nothing when there are none. */
+export function FollowupsSection({ followups, agents }: { followups: Followup[]; agents: Map }) {
+ const c = useColors();
+ const now = useNow(30_000);
+ const running = useRunningChats();
+ if (!followups.length) return null;
+ const sorted = [...followups].sort((a, b) => a.dueAt.localeCompare(b.dueAt));
+ return (
+
+
+
+
+ Follow-ups
+
+
+
+ Chats your agents will pick up again on their own
+
+
+ {sorted.map((f, i) => (
+
+ {i > 0 && }
+
+
+ ))}
+
+
+ );
+}
+
+function FollowupRow({ followup: f, agent, now, working }: { followup: Followup; agent?: Agent; now: number; working: boolean }) {
+ const c = useColors();
+ const [menu, setMenu] = useState(false);
+ const [busy, setBusy] = useState(false);
+ const id = f.conversationId;
+ const title = f.title || "A chat";
+
+ const runNow = () => {
+ setBusy(true);
+ api.conversations.followup
+ .runNow(id)
+ .then((run) => {
+ useLive.getState().runStarted(run);
+ refresh(id);
+ })
+ .catch(failed("Couldn't continue now"))
+ .finally(() => setBusy(false));
+ };
+ const move = (at: Date) => void api.conversations.followup.move(id, at.toISOString()).then(() => refresh(id), failed("Couldn't move the follow-up"));
+ const cancel = () => {
+ const before = queryClient.getQueryData(qk.followups);
+ queryClient.setQueryData(qk.followups, (list) => list?.filter((x) => x.conversationId !== id));
+ api.conversations.followup
+ .cancel(id)
+ .then(() => refresh(id))
+ .catch((err) => {
+ queryClient.setQueryData(qk.followups, before);
+ failed("Couldn't cancel the follow-up")(err);
+ });
+ };
+ const items: HeaderMenuItem[] = [
+ ...(working ? [] : [{ icon: "play" as IconName, title: "Continue now", onPress: runNow }]),
+ ...moves(new Date()).map((m) => ({ icon: "clock" as IconName, title: m.title, onPress: () => move(m.at) })),
+ { icon: "close", title: "Cancel the follow-up", destructive: true, onPress: cancel },
+ ];
+
+ return (
+ <>
+ openChat(id)}
+ style={({ pressed }) => [styles.row, pressed && { backgroundColor: c.sunken }]}
+ >
+
+
+
+ {title}
+
+
+ {agent ? `${agent.name} · ` : ""}
+ {working ? "Working in this chat right now" : `${whenText(f.dueAt)} · ${followupIn(f.dueAt, now)}`}
+
+ {f.note ? (
+
+ {f.note}
+
+ ) : null}
+
+ {
+ if (working || busy) return;
+ tap();
+ runNow();
+ }}
+ style={({ pressed }) => [styles.action, { backgroundColor: c.sunken, opacity: busy ? 1 : working ? 0.4 : pressed ? 0.6 : 1 }]}
+ >
+ {busy ? : }
+
+ {
+ tap();
+ setMenu(true);
+ }}
+ style={({ pressed }) => [styles.action, { backgroundColor: c.sunken, opacity: pressed ? 0.6 : 1 }]}
+ >
+
+
+
+ setMenu(false)} />
+ >
+ );
+}
+
+const styles = StyleSheet.create({
+ heading: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 6,
+ paddingHorizontal: 4,
+ },
+ hint: {
+ paddingHorizontal: 4,
+ marginTop: 2,
+ marginBottom: space.sm,
+ },
+ row: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: space.md,
+ paddingVertical: space.md,
+ paddingHorizontal: space.lg,
+ },
+ action: {
+ width: 32,
+ height: 32,
+ borderRadius: radius.pill,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+});
diff --git a/apps/mobile/src/components/goals-strip.tsx b/apps/mobile/src/components/goals-strip.tsx
new file mode 100644
index 00000000..52342041
--- /dev/null
+++ b/apps/mobile/src/components/goals-strip.tsx
@@ -0,0 +1,167 @@
+import { useQuery } from "@tanstack/react-query";
+import { router } from "expo-router";
+import { useState } from "react";
+import { Pressable, ScrollView, StyleSheet, View } from "react-native";
+import { formatUsd, type Goal } from "@godmode/shared";
+import { Icon } from "./icon";
+import { Row, T, tap } from "./ui";
+import { api } from "@/lib/api";
+import { daysUntil, dueLabel } from "@/lib/desktop-parity";
+import { qk } from "@/lib/query";
+import { useSpendTracking } from "@/lib/setup";
+import { radius, space, useColors } from "@/lib/theme";
+
+/**
+ * The goals the work serves, above the board: how far each is (tickets done of all, what it cost); a tap opens one.
+ * Achieved and dropped goals fold away behind a count. `bleed`: how far the row reaches past its parent's padding.
+ */
+export function GoalsStrip({ workspaceId, bleed = 0 }: { workspaceId: string | null; bleed?: number }) {
+ const c = useColors();
+ const goals = useQuery({ queryKey: qk.goals(workspaceId), queryFn: () => api.goals.list(workspaceId) });
+ const spendTracking = useSpendTracking();
+ const [showDone, setShowDone] = useState(false);
+ if (!goals.data) return null;
+ const active = goals.data.filter((g) => g.status === "active");
+ const done = goals.data.length - active.length;
+ const shown = showDone ? goals.data : active;
+ const add = () => {
+ tap();
+ router.push(workspaceId ? { pathname: "/goal", params: { workspaceId } } : "/goal");
+ };
+
+ return (
+
+ {shown.map((g) => (
+
+ ))}
+
+ [styles.add, { borderColor: c.borderStrong, opacity: pressed ? 0.6 : 1 }]}
+ >
+
+
+ {goals.data.length ? "Goal" : "Add a goal"}
+
+
+ {done > 0 && (
+ {
+ tap();
+ setShowDone((v) => !v);
+ }}
+ >
+
+ {showDone ? "Hide finished" : `${done} finished`}
+
+
+ )}
+
+
+ );
+}
+
+function GoalCard({ goal: g, spendTracking }: { goal: Goal; spendTracking: boolean }) {
+ const c = useColors();
+ const share = g.tickets.total ? Math.round((g.tickets.done / g.tickets.total) * 100) : 0;
+ const overdue = g.status === "active" && !!g.targetDate && daysUntil(g.targetDate) < 0;
+ const progress = g.tickets.total ? `${g.tickets.done} of ${g.tickets.total} done` : "No tickets yet";
+ const cost = spendTracking && g.costUsd > 0 ? formatUsd(g.costUsd) : null;
+ return (
+ {
+ tap();
+ router.push({ pathname: "/goal", params: { id: g.id } });
+ }}
+ style={({ pressed }) => [
+ styles.card,
+ { backgroundColor: c.surface, borderColor: c.border, boxShadow: c.shadowCard },
+ { opacity: (g.status === "active" ? 1 : 0.7) * (pressed ? 0.72 : 1) },
+ ]}
+ >
+
+ {g.status === "achieved" ? (
+
+ ) : g.status === "dropped" ? (
+
+ ) : (
+
+ )}
+
+ {g.title}
+
+
+
+
+
+
+
+ {cost ? `${progress} · ${cost}` : progress}
+
+ {g.targetDate ? (
+
+ {dueLabel(g.targetDate)}
+
+ ) : null}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ row: {
+ gap: space.sm,
+ alignItems: "stretch",
+ },
+ card: {
+ width: 216,
+ gap: space.sm,
+ paddingHorizontal: space.md,
+ paddingVertical: 10,
+ borderRadius: radius.lg,
+ borderCurve: "continuous",
+ borderWidth: StyleSheet.hairlineWidth,
+ },
+ track: {
+ height: 5,
+ borderRadius: 3,
+ overflow: "hidden",
+ },
+ fill: {
+ height: "100%",
+ borderRadius: 3,
+ },
+ side: {
+ justifyContent: "center",
+ alignItems: "flex-start",
+ gap: 6,
+ },
+ add: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 5,
+ height: 30,
+ paddingHorizontal: 12,
+ borderRadius: radius.pill,
+ borderWidth: 1,
+ borderStyle: "dashed",
+ },
+ num: {
+ fontVariant: ["tabular-nums"],
+ },
+});
diff --git a/apps/mobile/src/components/heartbeat-card.tsx b/apps/mobile/src/components/heartbeat-card.tsx
new file mode 100644
index 00000000..f0bc549f
--- /dev/null
+++ b/apps/mobile/src/components/heartbeat-card.tsx
@@ -0,0 +1,376 @@
+import { useMutation, useQuery } from "@tanstack/react-query";
+import { format, isThisYear, isToday, isYesterday } from "date-fns";
+import * as Haptics from "expo-haptics";
+import { router } from "expo-router";
+import { useEffect, useState } from "react";
+import { Alert, Pressable, StyleSheet, View } from "react-native";
+import {
+ formatUsd,
+ heartbeatIntervalText,
+ normalizeHeartbeat,
+ type Agent,
+ type AgentHeartbeat,
+ type AgentHeartbeatState,
+ type HeartbeatBeat,
+ type HeartbeatOutcome,
+} from "@godmode/shared";
+import { plural, upcomingWhen } from "./dream-parts";
+import { Group, LinkRow } from "./form";
+import { Icon } from "./icon";
+import { openChat } from "./rows";
+import { Button, Card, Hairline, SectionTitle, SkeletonRows, T, tap } from "./ui";
+import { api, ApiError, errorText } from "@/lib/api";
+import { useNow } from "@/lib/hooks";
+import { qk, queryClient } from "@/lib/query";
+import { useSpendTracking } from "@/lib/setup";
+import { alpha, radius, space, useColors, type Colors } from "@/lib/theme";
+
+const BEATS_PREVIEW = 5;
+
+/** An older Godmode on the computer doesn't let the phone read it. */
+const refused = (err: unknown) => err instanceof ApiError && (err.status === 403 || err.status === 404);
+
+const openSettings = (agent: Agent) => router.push({ pathname: "/settings/agent/[id]", params: { id: agent.id } });
+
+/**
+ * The desktop's heartbeat tab, read-only apart from "Wake now": where it stands, when it beats next and its last beats.
+ * Off, it is one quiet row into the agent's settings, where the heartbeat is set up.
+ */
+export function HeartbeatCard({ agent }: { agent: Agent }) {
+ const hb = normalizeHeartbeat(agent.heartbeat);
+ if (!hb.enabled) {
+ return (
+
+ openSettings(agent)}
+ />
+
+ );
+ }
+ return ;
+}
+
+function HeartbeatOn({ agent, hb }: { agent: Agent; hb: AgentHeartbeat }) {
+ const c = useColors();
+ const now = useNow(30_000);
+ const [showAll, setShowAll] = useState(false);
+ const q = useQuery({
+ queryKey: qk.heartbeat(agent.id),
+ queryFn: () => api.agents.heartbeat(agent.id),
+ refetchInterval: 60_000,
+ retry: (count, err) => !refused(err) && count < 2,
+ });
+ const next = q.data?.nextAt ?? null;
+ // Refetch right after the next beat.
+ useEffect(() => {
+ if (!next) return;
+ const wait = Date.parse(next) - Date.now() + 5_000;
+ if (wait <= 0 || wait > 2_147_483_647) return;
+ const timer = setTimeout(() => void queryClient.invalidateQueries({ queryKey: qk.heartbeat(agent.id) }), wait);
+ return () => clearTimeout(timer);
+ }, [next, agent.id]);
+
+ const beat = useMutation({
+ mutationFn: () => api.agents.beat(agent.id),
+ onSuccess: (b) => {
+ queryClient.setQueryData(qk.heartbeat(agent.id), (old) => (old ? { ...old, beats: [b, ...old.beats.filter((x) => x.id !== b.id)] } : old));
+ void queryClient.invalidateQueries({ queryKey: qk.heartbeat(agent.id) });
+ void queryClient.invalidateQueries({ queryKey: qk.runs });
+ if (b.outcome === "failed") Alert.alert("The beat couldn't start", b.summary);
+ else void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ },
+ onError: (err) => Alert.alert(`Couldn't wake ${agent.name}`, errorText(err)),
+ });
+
+ if (q.isError && refused(q.error)) {
+ return (
+
+ openSettings(agent)} />
+
+ );
+ }
+
+ const live = agent.enabled;
+ const items = groupBeats(q.data?.beats ?? []);
+ const shown = showAll ? items : items.slice(0, BEATS_PREVIEW);
+ const older = items.length - BEATS_PREVIEW;
+
+ return (
+
+
+
+
+
+
+
+
+
+ {q.isError ? (
+
+ {`Couldn't load the heartbeat: ${errorText(q.error)}`}
+
+ ) : (
+
+
+
+ {statusLine(agent, hb, q.data, now)}
+
+
+ )}
+
+ Wakes {agent.name} on its own rhythm to move its tickets forward and run its checklist — and leaves a trail.
+
+
+
+ {q.isError ? void q.refetch()} /> : null}
+ beat.mutate()} />
+ {!agent.enabled ? (
+
+ Switch {agent.name} on first
+
+ ) : null}
+
+
+
+
+ Recent beats
+
+ {q.isLoading ? (
+
+ ) : !items.length ? (
+
+ {q.data?.nextAt && agent.enabled ? `No beats yet — the first one comes ${upcomingWhen(q.data.nextAt)}.` : "No beats yet."}
+
+ ) : (
+ 0 ? 0 : space.xs }}>
+ {shown.map((item, i) => (
+
+ {i > 0 && }
+ {item.kind === "quiet" ? : }
+
+ ))}
+ {older > 0 ? (
+ {
+ tap();
+ setShowAll((v) => !v);
+ }}
+ style={({ pressed }) => [styles.more, { borderTopColor: c.border }, pressed && { backgroundColor: c.sunken }]}
+ >
+
+
+ {showAll ? "Show fewer" : `Show ${older} older`}
+
+
+ ) : null}
+
+ )}
+
+
+ );
+}
+
+/** "Beats every hour · 08:00–18:00 · weekdays · next at 10:30". */
+function statusLine(agent: Agent, hb: AgentHeartbeat, state: AgentHeartbeatState | undefined, now: number): string {
+ const parts = [`Beats ${heartbeatIntervalText(hb.intervalMinutes)}`];
+ if (hb.hours) parts.push(`${hourLabel(hb.hours.from)}–${hourLabel(hb.hours.to)}`);
+ if (hb.weekdays) parts.push("weekdays");
+ if (!agent.enabled) parts.push(`waits while ${agent.name} is switched off`);
+ else if (state?.nextAt) parts.push(nextLabel(state.nextAt, now));
+ else if (state) parts.push("no hour to wake in");
+ return parts.join(" · ");
+}
+
+function nextLabel(iso: string, now: number): string {
+ const d = new Date(iso);
+ if (d.getTime() <= now) return "due now";
+ return isToday(d) ? `next at ${format(d, "HH:mm")}` : `next ${upcomingWhen(iso, now)}`;
+}
+
+function hourLabel(hour: number): string {
+ return `${String(hour % 24).padStart(2, "0")}:00`;
+}
+
+/** "10:30" today, "Yesterday, 10:30", "Mon, Oct 5, 10:30". */
+function beatTime(iso: string): string {
+ const d = new Date(iso);
+ if (isToday(d)) return format(d, "HH:mm");
+ if (isYesterday(d)) return `Yesterday, ${format(d, "HH:mm")}`;
+ return format(d, isThisYear(d) ? "EEE, MMM d, HH:mm" : "MMM d, yyyy, HH:mm");
+}
+
+const OUTCOME: Record string }> = {
+ woke: { label: "Woke", dot: (c) => c.brand },
+ quiet: { label: "Quiet", dot: (c) => alpha(c.textMuted, 0.4) },
+ skipped: { label: "Skipped", dot: (c) => c.warning },
+ failed: { label: "Failed", dot: (c) => c.danger },
+};
+
+const RUN_STATE: Record = {
+ queued: "Waiting to start",
+ running: "Working through it…",
+ paused: "Paused",
+ failed: "Failed",
+ cancelled: "Stopped",
+};
+
+type BeatItem = { kind: "beat"; beat: HeartbeatBeat } | { kind: "quiet"; beats: HeartbeatBeat[] };
+
+/** Runs of consecutive quiet beats fold into one row. */
+function groupBeats(beats: HeartbeatBeat[]): BeatItem[] {
+ const out: BeatItem[] = [];
+ for (const b of beats) {
+ const last = out[out.length - 1];
+ if (b.outcome === "quiet" && last?.kind === "quiet") last.beats.push(b);
+ else out.push(b.outcome === "quiet" ? { kind: "quiet", beats: [b] } : { kind: "beat", beat: b });
+ }
+ return out.map((item) => (item.kind === "quiet" && item.beats.length === 1 ? { kind: "beat", beat: item.beats[0]! } : item));
+}
+
+/** Markdown flattened to one readable line for the preview. */
+function plainText(md: string): string {
+ return md
+ .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
+ .replace(/[`*_~#>|]+/g, "")
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
+function BeatRow({ beat: b }: { beat: HeartbeatBeat }) {
+ const c = useColors();
+ const spendTracking = useSpendTracking();
+ const meta = OUTCOME[b.outcome] ?? OUTCOME.quiet;
+ const runState = b.runStatus && b.runStatus !== "succeeded" ? RUN_STATE[b.runStatus] : undefined;
+ const checklist = b.runId
+ ? ["Checklist", runState, spendTracking && b.costUsd != null && b.costUsd > 0 ? formatUsd(b.costUsd) : null].filter(Boolean).join(" · ")
+ : null;
+ const chat = b.conversationId;
+ return (
+ chat && openChat(chat)}
+ style={({ pressed }) => [styles.beat, pressed && { backgroundColor: c.sunken }]}
+ >
+
+
+
+
+ {beatTime(b.createdAt)}
+
+
+ {meta.label}
+ {b.reason === "now" ? " · Wake now" : ""}
+
+
+ {b.summary ? (
+
+ {b.summary}
+
+ ) : null}
+ {b.changes > 0 ? (
+
+ {plural(b.changes, "change")} since the beat before
+
+ ) : null}
+ {checklist ? (
+
+
+
+
+ {checklist}
+
+
+ {b.result ? (
+
+ {plainText(b.result)}
+
+ ) : null}
+
+ ) : null}
+
+ {chat ? : null}
+
+ );
+}
+
+function QuietRow({ beats }: { beats: HeartbeatBeat[] }) {
+ const c = useColors();
+ const newest = new Date(beats[0]!.createdAt);
+ const oldest = beats[beats.length - 1]!.createdAt;
+ const sameDay = newest.toDateString() === new Date(oldest).toDateString();
+ return (
+
+
+
+
+ Quiet
+
+ {` · ${beats.length} beats · ${beatTime(oldest)}–${sameDay ? format(newest, "HH:mm") : beatTime(beats[0]!.createdAt)}`}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ head: {
+ padding: space.lg,
+ gap: space.md,
+ },
+ headRow: {
+ flexDirection: "row",
+ gap: space.md,
+ },
+ tile: {
+ width: 36,
+ height: 36,
+ borderRadius: radius.md,
+ borderCurve: "continuous",
+ borderWidth: StyleSheet.hairlineWidth,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ dot: {
+ width: 6,
+ height: 6,
+ borderRadius: 3,
+ },
+ eyebrow: {
+ paddingHorizontal: space.lg,
+ paddingTop: space.md,
+ paddingBottom: 2,
+ },
+ beat: {
+ flexDirection: "row",
+ alignItems: "flex-start",
+ gap: space.md,
+ paddingHorizontal: space.lg,
+ paddingVertical: space.md,
+ },
+ beatDot: {
+ width: 10,
+ height: 10,
+ borderRadius: 5,
+ marginTop: 5,
+ },
+ checklist: {
+ marginTop: 4,
+ gap: 2,
+ paddingHorizontal: space.sm + 2,
+ paddingVertical: space.sm,
+ borderRadius: radius.md,
+ borderCurve: "continuous",
+ },
+ more: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 6,
+ paddingHorizontal: space.lg,
+ paddingVertical: space.md,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ },
+});
diff --git a/apps/mobile/src/components/icon.tsx b/apps/mobile/src/components/icon.tsx
index e3aba6d7..9c0b309a 100644
--- a/apps/mobile/src/components/icon.tsx
+++ b/apps/mobile/src/components/icon.tsx
@@ -100,6 +100,7 @@ const ICONS = {
callOut: ["phone.arrow.up.right.fill", "call_made"],
inbox: ["tray.fill", "inbox"],
checkAll: ["checkmark.circle", "done_all"],
+ share: ["square.and.arrow.up", "share"],
} as const satisfies Record;
export type IconName = keyof typeof ICONS;
diff --git a/apps/mobile/src/components/routine-row.tsx b/apps/mobile/src/components/routine-row.tsx
index 96011af4..791bb73b 100644
--- a/apps/mobile/src/components/routine-row.tsx
+++ b/apps/mobile/src/components/routine-row.tsx
@@ -2,7 +2,7 @@ import { formatDistanceToNowStrict } from "date-fns";
import { router } from "expo-router";
import { memo, useState } from "react";
import { ActivityIndicator, Alert, Pressable, StyleSheet, Switch, View } from "react-native";
-import { cronToHuman, scheduleToHuman, type Agent, type Routine } from "@godmode/shared";
+import { cronToHuman, scheduleToHuman, type Agent, type Routine, type RoutineTriggerType } from "@godmode/shared";
import { CharacterAvatar } from "./character";
import { Icon } from "./icon";
import { T, tap } from "./ui";
@@ -13,11 +13,12 @@ import { qk, queryClient } from "@/lib/query";
import { radius, space, useColors } from "@/lib/theme";
/** "gmail" → "Gmail", "google_calendar" → "Google Calendar". */
-function appName(slug: string): string {
+export function appName(slug: string): string {
return slug.replace(/[_-]+/g, " ").replace(/\b\w/g, (ch) => ch.toUpperCase());
}
-function phoneTimezone(): string | null {
+/** This phone's timezone, e.g. "Europe/Berlin"; null when the system doesn't say. */
+export function phoneTimezone(): string | null {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || null;
} catch {
@@ -25,9 +26,22 @@ function phoneTimezone(): string | null {
}
}
-/** What starts an automation, in one line: its schedule and next run, the app event, the condition or the webhook. */
-export function triggerText(r: Routine): string {
- const next = r.enabled && r.nextRunAt ? whenText(r.nextRunAt) : null;
+/** What starts an automation, in the desktop's words (trigger-meta.ts). */
+export const TRIGGER_TITLES: Record = {
+ schedule: "On a schedule",
+ app: "When something happens in an app",
+ condition: "When a condition is met",
+ webhook: "When a webhook is called",
+};
+
+/** Schedule: run now · condition: check now · app/webhook: run with a test event. */
+export function runLabel(type: RoutineTriggerType): string {
+ return type === "condition" ? "Check now" : type === "schedule" ? "Run now" : "Send test event";
+}
+
+/** What starts an automation, in one line: its schedule and next run (`withNext`), the app event, the condition or the webhook. */
+export function triggerText(r: Routine, withNext = true): string {
+ const next = withNext && r.enabled && r.nextRunAt ? whenText(r.nextRunAt) : null;
const t = r.trigger;
switch (t.type) {
case "schedule": {
@@ -75,7 +89,7 @@ export function runRoutine(routine: Routine): Promise {
.finally(refreshRoutines);
}
-/** One automation in a list: whose it is, what starts it, a switch and Run now. Tapping it opens its agent. */
+/** One automation in a list: whose it is, what starts it, a switch and Run now. Tapping it opens the automation. */
export const RoutineRow = memo(function RoutineRow({ routine, agent, working }: { routine: Routine; agent?: Agent; working?: boolean }) {
const c = useColors();
const [starting, setStarting] = useState(false);
@@ -88,7 +102,7 @@ export const RoutineRow = memo(function RoutineRow({ routine, agent, working }:
{
tap();
- router.push({ pathname: "/agent/[id]", params: { id: routine.agentId } });
+ router.push({ pathname: "/automation/[id]", params: { id: routine.id } });
}}
style={({ pressed }) => [styles.row, pressed && { backgroundColor: c.sunken }]}
>
@@ -126,7 +140,7 @@ export const RoutineRow = memo(function RoutineRow({ routine, agent, working }:
accessibilityLabel={`Run ${routine.name} now`}
accessibilityState={{ disabled: !canRun, busy: starting }}
hitSlop={6}
- // Not `disabled`: a press on it must not fall through to the row and open the agent.
+ // Not `disabled`: a press on it must not fall through to the row and open the automation.
onPress={() => {
if (!canRun) return;
setStarting(true);
diff --git a/apps/mobile/src/components/rows.tsx b/apps/mobile/src/components/rows.tsx
index 8daa0d32..a8e671ac 100644
--- a/apps/mobile/src/components/rows.tsx
+++ b/apps/mobile/src/components/rows.tsx
@@ -4,23 +4,44 @@ import type { Agent, Conversation, Project } from "@godmode/shared";
import { presenceLabel } from "@godmode/shared";
import { CharacterAvatar, moodOf, useAgentPresence } from "./character";
import { ProjectTag } from "./project-picker";
-import { Icon } from "./icon";
+import { Icon, type IconName } from "./icon";
import { Badge, Card, LiveDot, Row, T, tap } from "./ui";
import { api } from "@/lib/api";
-import { activityText, elapsed, shortTime } from "@/lib/format";
-import { useNow } from "@/lib/hooks";
+import { activityText, elapsed, shortTime, whenText } from "@/lib/format";
+import { useAgents, useNow } from "@/lib/hooks";
import type { LiveRun } from "@/lib/live";
import { alpha, radius, space, useColors } from "@/lib/theme";
const ORIGIN_LABEL: Partial> = {
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,58 @@ 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 { byId } = useAgents();
+ const handedBy = from ?? (conversation.delegatedFrom ? byId.get(conversation.delegatedFrom.agentId) : undefined);
+ const origin = conversation.origin === "delegation" && handedBy ? `From ${handedBy.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 +187,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/run-log.tsx b/apps/mobile/src/components/run-log.tsx
new file mode 100644
index 00000000..ed41748b
--- /dev/null
+++ b/apps/mobile/src/components/run-log.tsx
@@ -0,0 +1,518 @@
+import { useQuery } from "@tanstack/react-query";
+import * as Clipboard from "expo-clipboard";
+import { type ReactNode, memo, useEffect, useMemo, useRef, useState } from "react";
+import { Pressable, Share, StyleSheet, View } from "react-native";
+import { Segmented } from "./form";
+import { Icon, type IconName } from "./icon";
+import { Markdown } from "./markdown";
+import { formatDuration, formatTokens } from "./run-row";
+import { Badge, Button, LiveDot, Row, Skeleton, T, tap } from "./ui";
+import { ApiError, api, errorText } from "@/lib/api";
+import { formatCost } from "@/lib/desktop-parity";
+import { toolLabel } from "@/lib/format";
+import { qk } from "@/lib/query";
+import { useSpendTracking } from "@/lib/setup";
+import { fonts, radius, space, useColors } from "@/lib/theme";
+
+type Json = Record;
+
+export type LogEntry =
+ | { kind: "init"; raw: string; model: string; tools: number; mcp: { name: string; status: string }[]; cwd: string }
+ | { kind: "text"; raw: string; text: string; sub: boolean }
+ | { kind: "thinking"; raw: string; text: string }
+ | { kind: "tool_use"; raw: string; id: string; name: string; input: unknown; sub: boolean }
+ | { kind: "tool_result"; raw: string; toolUseId: string; content: string; isError: boolean; images: number }
+ | {
+ kind: "result";
+ raw: string;
+ subtype: string;
+ isError: boolean;
+ durationMs: number | null;
+ costUsd: number | null;
+ numTurns: number | null;
+ tokensIn: number | null;
+ tokensOut: number | null;
+ }
+ | { kind: "rate_limit"; raw: string; status: string; detail: string }
+ | { kind: "system"; raw: string; subtype: string }
+ | { kind: "stream"; raw: string; eventType: string }
+ | { kind: "other"; raw: string; type: string };
+
+const str = (v: unknown) => (typeof v === "string" ? v : "");
+const num = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : null);
+
+function resultContent(content: unknown): { text: string; images: number } {
+ if (typeof content === "string") return { text: content, images: 0 };
+ if (Array.isArray(content)) {
+ let images = 0;
+ const parts: string[] = [];
+ for (const c of content) {
+ if (c && typeof c === "object") {
+ const o = c as Json;
+ if (o.type === "text") parts.push(str(o.text));
+ else if (o.type === "image") images++;
+ else parts.push(JSON.stringify(o));
+ }
+ }
+ return { text: parts.join("\n"), images };
+ }
+ return { text: content == null ? "" : JSON.stringify(content, null, 2), images: 0 };
+}
+
+/** Turn Claude CLI stream-json lines into displayable entries (the desktop's run-log-viewer). */
+export function parseRunLog(text: string): LogEntry[] {
+ const out: LogEntry[] = [];
+ for (const line of text.split("\n")) {
+ const raw = line.trim();
+ if (!raw) continue;
+ let ev: Json;
+ try {
+ ev = JSON.parse(raw);
+ } catch {
+ out.push({ kind: "other", raw, type: "text" });
+ continue;
+ }
+ const type = str(ev.type);
+ const sub = !!ev.parent_tool_use_id;
+ if (type === "system" && ev.subtype === "init") {
+ const mcp = Array.isArray(ev.mcp_servers) ? (ev.mcp_servers as Json[]).map((m) => ({ name: str(m.name), status: str(m.status) })) : [];
+ out.push({ kind: "init", raw, model: str(ev.model), tools: Array.isArray(ev.tools) ? ev.tools.length : 0, mcp, cwd: str(ev.cwd) });
+ } else if (type === "system") {
+ out.push({ kind: "system", raw, subtype: str(ev.subtype) || "system" });
+ } else if (type === "assistant") {
+ const content = ((ev.message as Json | undefined)?.content ?? []) as Json[];
+ for (const block of Array.isArray(content) ? content : []) {
+ if (block.type === "text" && str(block.text).trim()) out.push({ kind: "text", raw, text: str(block.text), sub });
+ else if (block.type === "thinking") out.push({ kind: "thinking", raw, text: str(block.thinking) });
+ else if (block.type === "redacted_thinking") out.push({ kind: "thinking", raw, text: "" });
+ else if (block.type === "tool_use") out.push({ kind: "tool_use", raw, id: str(block.id), name: str(block.name), input: block.input, sub });
+ }
+ } else if (type === "user") {
+ const content = ((ev.message as Json | undefined)?.content ?? []) as unknown;
+ if (Array.isArray(content)) {
+ for (const block of content as Json[]) {
+ if (block?.type === "tool_result") {
+ const { text: t, images } = resultContent(block.content);
+ out.push({ kind: "tool_result", raw, toolUseId: str(block.tool_use_id), content: t, isError: !!block.is_error, images });
+ } else if (block?.type === "text" && str(block.text).trim()) {
+ out.push({ kind: "other", raw, type: "user" });
+ }
+ }
+ } else if (typeof content === "string") {
+ out.push({ kind: "other", raw, type: "user" });
+ }
+ } else if (type === "result" || (ev.total_cost_usd !== undefined && ev.duration_ms !== undefined)) {
+ const usage = (ev.usage ?? {}) as Json;
+ out.push({
+ kind: "result",
+ raw,
+ subtype: str(ev.subtype) || str(ev.stop_reason) || "done",
+ isError: !!ev.is_error,
+ durationMs: num(ev.duration_ms),
+ costUsd: num(ev.total_cost_usd),
+ numTurns: num(ev.num_turns),
+ tokensIn: num(usage.input_tokens),
+ tokensOut: num(usage.output_tokens),
+ });
+ } else if (type === "rate_limit_event") {
+ const info = (ev.rate_limit_info ?? {}) as Json;
+ const windows = (info.unifiedWindows ?? {}) as Record;
+ const detail = Object.entries(windows)
+ .map(([k, w]) => `${k.replace("_", " ")} ${Math.round((num(w.utilization) ?? 0) * 100)}%`)
+ .join(" · ");
+ out.push({ kind: "rate_limit", raw, status: str(info.status) || "unknown", detail });
+ } else if (type === "stream_event") {
+ out.push({ kind: "stream", raw, eventType: str((ev.event as Json | undefined)?.type) || "event" });
+ } else {
+ out.push({ kind: "other", raw, type: type || "event" });
+ }
+ }
+ return out;
+}
+
+/** The raw log; none yet (a queued run) reads as empty. */
+async function fetchLog(id: string): Promise {
+ try {
+ const res = (await api.runs.log(id)) as unknown;
+ return typeof res === "string" ? res : JSON.stringify(res);
+ } catch (err) {
+ if (err instanceof ApiError && err.status === 404) return "";
+ throw err;
+ }
+}
+
+const PAGE = 100;
+const MAX_TEXT = 20_000;
+const oneLine = (s: string) => s.replace(/\s+/g, " ").slice(0, 90);
+type Tools = Map;
+
+/** A run's stream-json log as readable steps (or raw), newest at the bottom; refreshes every few seconds while `live`. */
+export function RunLog({ runId, live = false }: { runId: string; live?: boolean }) {
+ const c = useColors();
+ const spendTracking = useSpendTracking();
+ const [mode, setMode] = useState<"steps" | "raw">("steps");
+ const [showStream, setShowStream] = useState(false);
+ const [limit, setLimit] = useState(PAGE);
+ // A finished run's log doesn't change; a live one is fetched again every few seconds, never on other runs' events.
+ const log = useQuery({ queryKey: qk.runLog(runId), queryFn: () => fetchLog(runId), refetchInterval: live ? 10_000 : false, staleTime: live ? 0 : Infinity });
+ const { refetch } = log;
+ const wasLive = useRef(live);
+ useEffect(() => {
+ // The run just ended: its last lines came after the last fetch.
+ if (wasLive.current && !live) void refetch();
+ wasLive.current = live;
+ }, [live, refetch]);
+ const text = log.data ?? "";
+ const entries = useMemo(() => parseRunLog(text), [text]);
+ const tools = useMemo(() => {
+ const m: Tools = new Map();
+ for (const e of entries) if (e.kind === "tool_use") m.set(e.id, { name: e.name, input: e.input });
+ return m;
+ }, [entries]);
+ const streamCount = useMemo(() => entries.filter((e) => e.kind === "stream").length, [entries]);
+ const visible = useMemo(() => entries.flatMap((entry, at) => (showStream || entry.kind !== "stream" ? [{ entry, at }] : [])), [entries, showStream]);
+
+ if (log.isLoading) {
+ return (
+
+ {Array.from({ length: 5 }, (_, i) => (
+
+ ))}
+
+ );
+ }
+ if (log.isError) return ;
+ if (!entries.length) return ;
+
+ return (
+
+
+
+ {`${entries.length} events`}
+
+ {live ? (
+
+
+
+ Live
+
+
+ ) : null}
+ {streamCount > 0 && mode === "steps" ? (
+ {
+ tap();
+ setShowStream((v) => !v);
+ }}
+ >
+
+
+ ) : null}
+
+
+ void Share.share({ message: text, title: `run-${runId}.jsonl` }).catch(() => undefined)}
+ />
+
+
+ {mode === "raw" ? (
+
+ ) : (
+
+ {visible.slice(0, limit).map(({ entry, at }) => (
+
+ ))}
+
+ )}
+ {mode === "steps" && visible.length > limit ? (
+ setLimit((l) => l + PAGE)}
+ />
+ ) : null}
+
+ );
+}
+
+const LogRow = memo(function LogRow({ entry, tools, spendTracking }: { entry: LogEntry; tools: Tools; spendTracking: boolean }) {
+ const c = useColors();
+ switch (entry.kind) {
+ case "init":
+ return (
+
+
+ {`${entry.model || "model"} · ${entry.tools} tools`}
+ {entry.mcp.length > 0 ? ` · MCP: ${entry.mcp.map((m) => `${m.name}${m.status && m.status !== "connected" ? ` (${m.status})` : ""}`).join(", ")}` : ""}
+
+
+ );
+ case "text":
+ return (
+
+
+
+ );
+ case "thinking":
+ return (
+
+
+ {entry.text || "Thinking is redacted."}
+
+
+ );
+ case "tool_use": {
+ const meta = toolLabel({ type: "tool_use", id: entry.id, name: entry.name, input: entry.input });
+ return (
+
+
+
+ );
+ }
+ case "tool_result": {
+ const tool = tools.get(entry.toolUseId);
+ const title = tool ? toolLabel({ type: "tool_use", id: entry.toolUseId, name: tool.name, input: tool.input }).title : "Tool result";
+ return (
+ 1 ? "s" : ""}` : oneLine(entry.content)}
+ raw={entry.raw}
+ tone={entry.isError ? "error" : undefined}
+ >
+
+
+ );
+ }
+ case "result":
+ return (
+
+
+ {formatDuration(entry.durationMs)}
+ {spendTracking && entry.costUsd != null ? ` · ${formatCost(entry.costUsd)}` : ""}
+ {entry.numTurns != null ? ` · ${entry.numTurns} turns` : ""}
+ {entry.tokensIn != null ? ` · ${formatTokens(entry.tokensIn)} in / ${formatTokens(entry.tokensOut)} out` : ""}
+
+
+ );
+ case "rate_limit":
+ return ;
+ case "system":
+ return ;
+ case "stream":
+ return ;
+ case "other":
+ return ;
+ }
+});
+
+/** One collapsible line of the log; open, it shows its details and can show its raw JSON. */
+function Step({
+ icon,
+ iconColor,
+ title,
+ summary,
+ children,
+ raw,
+ defaultOpen = false,
+ muted,
+ tone,
+ badge,
+}: {
+ icon: IconName;
+ iconColor?: string;
+ title: string;
+ summary?: string;
+ children?: ReactNode;
+ raw: string;
+ defaultOpen?: boolean;
+ muted?: boolean;
+ tone?: "error" | "success";
+ badge?: string;
+}) {
+ const c = useColors();
+ const [open, setOpen] = useState(defaultOpen);
+ const [showRaw, setShowRaw] = useState(false);
+ return (
+
+ setOpen((o) => !o)}
+ style={({ pressed }) => [styles.stepHead, pressed && { opacity: 0.6 }]}
+ >
+
+
+
+
+ {title}
+
+ {summary && !open ? {` ${summary}`} : null}
+
+ {badge ? : null}
+
+ {open ? (
+
+ {children}
+ setShowRaw((r) => !r)} style={{ alignSelf: "flex-start" }}>
+
+ {showRaw ? "Hide raw JSON" : "Raw JSON"}
+
+
+ {showRaw ? : null}
+
+ ) : null}
+
+ );
+}
+
+function prettyJson(raw: string) {
+ try {
+ return JSON.stringify(JSON.parse(raw), null, 2);
+ } catch {
+ return raw;
+ }
+}
+
+function formatToolInput(input: unknown): string {
+ if (input == null) return "";
+ if (typeof input === "string") return input;
+ try {
+ return JSON.stringify(input, null, 2);
+ } catch {
+ return String(input);
+ }
+}
+
+function Pre({ text }: { text: string }) {
+ const c = useColors();
+ const clipped = text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT)}\n… (${text.length - MAX_TEXT} more characters)` : text;
+ return (
+
+
+ {clipped}
+
+
+ );
+}
+
+function Note({ text }: { text: string }) {
+ const c = useColors();
+ return (
+
+
+ {text}
+
+
+ );
+}
+
+function IconButton({ icon, label, onPress, color }: { icon: IconName; label: string; onPress: () => void; color?: string }) {
+ const c = useColors();
+ return (
+ {
+ tap();
+ onPress();
+ }}
+ style={({ pressed }) => [styles.iconButton, pressed && { backgroundColor: c.sunken }]}
+ >
+
+
+ );
+}
+
+/** Copies `text`; a check says it worked. */
+export function CopyButton({ text, label }: { text: string; label: string }) {
+ const c = useColors();
+ const [copied, setCopied] = useState(false);
+ useEffect(() => {
+ if (!copied) return;
+ const timer = setTimeout(() => setCopied(false), 1400);
+ return () => clearTimeout(timer);
+ }, [copied]);
+ return (
+ {
+ void Clipboard.setStringAsync(text);
+ setCopied(true);
+ }}
+ />
+ );
+}
+
+const styles = StyleSheet.create({
+ steps: {
+ borderLeftWidth: 1,
+ paddingLeft: space.xs,
+ gap: 2,
+ },
+ step: {
+ borderRadius: radius.md,
+ borderCurve: "continuous",
+ },
+ stepHead: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: space.sm,
+ minHeight: 34,
+ paddingHorizontal: space.sm,
+ },
+ stepBody: {
+ gap: space.sm,
+ paddingLeft: 40,
+ paddingRight: space.sm,
+ paddingBottom: space.sm,
+ },
+ pre: {
+ padding: 10,
+ borderRadius: radius.md,
+ borderCurve: "continuous",
+ borderWidth: StyleSheet.hairlineWidth,
+ },
+ mono: {
+ fontFamily: fonts.mono,
+ fontSize: 11.5,
+ lineHeight: 17,
+ },
+ note: {
+ padding: space.lg,
+ borderRadius: radius.md,
+ borderCurve: "continuous",
+ borderWidth: 1,
+ borderStyle: "dashed",
+ },
+ iconButton: {
+ width: 30,
+ height: 30,
+ borderRadius: radius.md,
+ borderCurve: "continuous",
+ alignItems: "center",
+ justifyContent: "center",
+ },
+});
diff --git a/apps/mobile/src/components/run-row.tsx b/apps/mobile/src/components/run-row.tsx
index 6316313e..a7bdc56f 100644
--- a/apps/mobile/src/components/run-row.tsx
+++ b/apps/mobile/src/components/run-row.tsx
@@ -1,10 +1,10 @@
+import { router } from "expo-router";
import { memo } from "react";
import { Pressable, StyleSheet, View } from "react-native";
import type { Agent, Run, RunStatus, RunTrigger } from "@godmode/shared";
import { formatUsd } from "@godmode/shared";
import { CharacterAvatar } from "./character";
-import { openChat } from "./rows";
-import { Badge, Row, T, type Tone } from "./ui";
+import { Badge, Row, T, tap, type Tone } from "./ui";
import { activityText, shortTime } from "@/lib/format";
import { useRunActivity } from "@/lib/live";
import { useSpendTracking } from "@/lib/setup";
@@ -50,7 +50,8 @@ function firstLine(text: string | null | undefined, max = 120): string {
}
/** "850ms", "12s", "3m 04s", "1h 12m" */
-function duration(ms: number): string {
+export function formatDuration(ms: number | null | undefined): string {
+ if (ms == null || !Number.isFinite(ms)) return "—";
if (ms < 1000) return `${Math.max(0, Math.round(ms))}ms`;
const s = Math.round(ms / 1000);
if (s < 60) return `${s}s`;
@@ -59,15 +60,41 @@ function duration(ms: number): string {
return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`;
}
-export function RunStatusBadge({ run }: { run: Run }) {
- // A paused run that waits for the human says so.
- const question = run.status === "paused" && run.pause?.reason === "question" ? (run.pause.question?.kind ?? "question") : null;
- if (question) return ;
- const meta = STATUS_META[run.status] ?? STATUS_META.queued;
- return ;
+/** Live timer format: 0:07, 1:23, 1:02:03 */
+export function formatElapsed(ms: number): string {
+ const total = Math.max(0, Math.floor(ms / 1000));
+ const h = Math.floor(total / 3600);
+ const m = Math.floor((total % 3600) / 60);
+ const s = total % 60;
+ return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` : `${m}:${String(s).padStart(2, "0")}`;
}
-/** One run in a list: what was asked, how it went, who did it, what started it and what it took. Tapping it opens its chat. */
+/** "850", "12.3k", "120k", "1.2M" */
+export function formatTokens(n: number | null | undefined): string {
+ if (n == null) return "—";
+ if (n < 1000) return String(n);
+ if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`;
+ return `${(n / 1_000_000).toFixed(1)}M`;
+}
+
+/** The run's own screen: status, what it took, its result and its log. */
+export function openRun(id: string) {
+ tap();
+ router.push({ pathname: "/run/[id]", params: { id } });
+}
+
+/**
+ * `status`: the live one when it is fresher than the run's. A paused run that waits for the human says so (`waitingFor`,
+ * else what a run list says about its pause).
+ */
+export function RunStatusBadge({ run, status = run.status, waitingFor }: { run: Run; status?: RunStatus; waitingFor?: "answer" | "approval" | null }) {
+ const asked = waitingFor ?? (run.pause?.reason === "question" ? (run.pause.question?.kind === "approval" ? "approval" : "answer") : null);
+ if (status === "paused" && asked) return ;
+ const meta = STATUS_META[status] ?? STATUS_META.queued;
+ return ;
+}
+
+/** One run in a list: what was asked, how it went, who did it, what started it and what it took. Tapping it opens the run. */
export const RunRow = memo(function RunRow({ run, agent }: { run: Run; agent?: Agent }) {
const c = useColors();
const live = isLiveRun(run);
@@ -79,13 +106,13 @@ export const RunRow = memo(function RunRow({ run, agent }: { run: Run; agent?: A
agent?.name ?? "Agent",
TRIGGER_LABEL[run.trigger] ?? "Manual",
shortTime(run.createdAt),
- finished && run.durationMs != null ? duration(run.durationMs) : null,
+ finished && run.durationMs != null ? formatDuration(run.durationMs) : null,
spendTracking && run.costUsd ? formatUsd(run.costUsd) : null,
].filter(Boolean);
const error = run.status === "failed" ? firstLine(run.error, 200) : "";
return (
- openChat(run.conversationId)} style={({ pressed }) => [styles.row, pressed && { backgroundColor: c.sunken }]}>
+ openRun(run.id)} style={({ pressed }) => [styles.row, pressed && { backgroundColor: c.sunken }]}>
diff --git a/apps/mobile/src/components/segmented-tabs.tsx b/apps/mobile/src/components/segmented-tabs.tsx
new file mode 100644
index 00000000..68f96164
--- /dev/null
+++ b/apps/mobile/src/components/segmented-tabs.tsx
@@ -0,0 +1,129 @@
+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: {
+ flexGrow: 1,
+ flexShrink: 1,
+ flexBasis: "auto",
+ minWidth: 0,
+ height: 34,
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 5,
+ paddingHorizontal: 8,
+ },
+ 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/components/spend-strip.tsx b/apps/mobile/src/components/spend-strip.tsx
new file mode 100644
index 00000000..17273329
--- /dev/null
+++ b/apps/mobile/src/components/spend-strip.tsx
@@ -0,0 +1,232 @@
+import { keepPreviousData, useQuery } from "@tanstack/react-query";
+import { addMonths, format, startOfMonth } from "date-fns";
+import { router } from "expo-router";
+import { useState } from "react";
+import { Pressable, StyleSheet, View } from "react-native";
+import { BUDGET_WARN_AT, type SpendPeriod, type SpendTotals } from "@godmode/shared";
+import { CharacterAvatar } from "./character";
+import { Segmented } from "./form";
+import { formatDuration } from "./run-row";
+import { Card, Hairline, Row, Skeleton, T, tap } from "./ui";
+import { ApiError, api, errorText } from "@/lib/api";
+import { formatCost } from "@/lib/desktop-parity";
+import { useAgents } from "@/lib/hooks";
+import { qk } from "@/lib/query";
+import { useSettings } from "@/lib/setup";
+import { radius, space, useColors } from "@/lib/theme";
+
+type Period = Exclude;
+
+const PERIODS: { value: Period; label: string; none: string }[] = [
+ { value: "today", label: "Today", none: "No runs yet today" },
+ { value: "week", label: "This week", none: "No runs this week yet" },
+ { value: "month", label: "This month", none: "No runs this month yet" },
+];
+
+const TOP = 5;
+
+const line = (t: SpendTotals) =>
+ [`${t.runs} run${t.runs === 1 ? "" : "s"}`, t.failed ? `${t.failed} failed` : null, t.durationMs ? `${formatDuration(t.durationMs)} working` : null].filter(Boolean).join(" · ");
+
+/**
+ * What the team cost today, this week or this month (the desktop's spend strip), the team's monthly budget when one is
+ * set, and who the money went to. Nothing while spend tracking is off.
+ */
+export function SpendStrip() {
+ const c = useColors();
+ const runner = useSettings().data?.runner;
+ const on = !!runner?.spendTracking;
+ const [period, setPeriod] = useState("today");
+ const report = useQuery({
+ queryKey: qk.spend(period),
+ queryFn: () => api.spend.report(period),
+ enabled: on,
+ placeholderData: keepPreviousData,
+ refetchInterval: 60_000,
+ });
+ const { byId } = useAgents();
+ if (!on || (report.error instanceof ApiError && report.error.code === "device_forbidden")) return null;
+
+ const data = report.data;
+ const meta = PERIODS.find((p) => p.value === period)!;
+ const totals = data?.periods[period];
+ const budget = runner?.monthlyBudgetUsd ?? null;
+ const top = data?.byAgent.slice(0, TOP) ?? [];
+ const most = Math.max(0.000001, ...top.map((a) => a.costUsd));
+
+ return (
+
+
+
+ {report.isLoading ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+
+ {totals ? formatCost(totals.costUsd) : "—"}
+
+
+ {totals && totals.runs ? line(totals) : meta.none}
+ {period === "today" && data?.active ? ` · ${data.active} not finished yet` : ""}
+
+ >
+ )}
+
+ {report.isError ? (
+
+
+ {`Couldn't load totals: ${errorText(report.error)}`}
+
+ {
+ tap();
+ void report.refetch();
+ }}
+ >
+
+ Try again
+
+
+
+ ) : null}
+
+ {budget !== null && budget > 0 && data ? (
+ <>
+
+
+ >
+ ) : null}
+
+ {top.length > 0 ? (
+ <>
+
+
+
+ By agent
+
+ {top.map((a) => {
+ const agent = byId.get(a.agentId);
+ return (
+ {
+ tap();
+ router.push({ pathname: "/agent/[id]", params: { id: a.agentId } });
+ }}
+ style={({ pressed }) => [styles.agent, pressed && { opacity: 0.6 }]}
+ >
+ {agent ? : }
+
+ {a.deleted ? `${a.name} (deleted)` : a.name}
+
+
+
+
+
+ {formatCost(a.costUsd)}
+ {` · ${a.runs} run${a.runs === 1 ? "" : "s"}`}
+
+
+ );
+ })}
+ {(data?.byAgent.length ?? 0) > TOP ? (
+
+ {`and ${(data?.byAgent.length ?? 0) - TOP} more`}
+
+ ) : null}
+
+ >
+ ) : data && !report.isLoading ? (
+
+ Nothing ran in this period.
+
+ ) : null}
+
+ );
+}
+
+/** "Spent $12.40 of $50.00 this month" with a bar (the desktop's budget meter); used up: what waits. */
+function BudgetMeter({ spentUsd, budgetUsd }: { spentUsd: number; budgetUsd: number }) {
+ const c = useColors();
+ const share = Math.min(1, spentUsd / budgetUsd);
+ const state = spentUsd >= budgetUsd ? "exhausted" : share >= BUDGET_WARN_AT ? "warning" : "ok";
+ const bar = state === "exhausted" ? c.danger : state === "warning" ? c.warning : c.textMuted;
+ const sentence = `Spent ${formatCost(spentUsd)} of ${formatCost(budgetUsd)} this month`;
+ return (
+
+
+
+ {"Spent "}
+
+ {formatCost(spentUsd)}
+
+ {` of ${formatCost(budgetUsd)} this month`}
+
+ {state === "warning" ? (
+
+
+ {`${Math.floor(share * 100)}% used`}
+
+
+ ) : null}
+
+
+ {`Resets ${format(startOfMonth(addMonths(new Date(), 1)), "MMM d")}`}
+
+
+
+
+
+ {state === "exhausted" ? (
+
+ {"Used up — the team's automations, follow-ups and board tickets wait. Chats you start still run."}
+
+ ) : null}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ card: {
+ padding: space.md,
+ gap: space.md,
+ },
+ agent: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: space.sm,
+ minHeight: 28,
+ },
+ gone: {
+ width: 22,
+ height: 22,
+ borderRadius: 6,
+ },
+ track: {
+ flex: 1,
+ height: 6,
+ borderRadius: 3,
+ overflow: "hidden",
+ },
+ meter: {
+ height: 6,
+ borderRadius: 3,
+ overflow: "hidden",
+ },
+ fill: {
+ height: "100%",
+ borderRadius: 3,
+ },
+ used: {
+ paddingHorizontal: 6,
+ borderRadius: radius.xs,
+ },
+});
diff --git a/apps/mobile/src/lib/api.ts b/apps/mobile/src/lib/api.ts
index dab69a3a..e3b303bc 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";
@@ -388,7 +401,7 @@ export async function pairWith(payload: MobilePairingPayload): Promise>;
+ general?: Partial>;
runner?: Partial>;
browser?: Partial>;
computer?: Partial>;
@@ -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/away.ts b/apps/mobile/src/lib/away.ts
new file mode 100644
index 00000000..e0267220
--- /dev/null
+++ b/apps/mobile/src/lib/away.ts
@@ -0,0 +1,57 @@
+import * as SecureStore from "expo-secure-store";
+import { AppState } from "react-native";
+import { create } from "zustand";
+
+/** Away this long, and Home sums up what the team did meanwhile (the desktop's rule). */
+const AWAY_MS = 2 * 60 * 60_000;
+const KEY = "godmode.lastSeen";
+const STORE_OPTIONS: SecureStore.SecureStoreOptions = { keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY };
+
+export interface AwayWindow {
+ since: string;
+ until: string;
+}
+
+interface AwayState {
+ /** The human came back after a while: from when to when they were away. Not kept. */
+ away: AwayWindow | null;
+ dismiss: () => void;
+}
+
+export const useAwayStore = create((set) => ({
+ away: null,
+ dismiss: () => set({ away: null }),
+}));
+
+/**
+ * Notices when the human comes back: the app in front again (or opened) at least two hours after it last left the
+ * screen. When it left is kept on the phone, so a cold start agrees.
+ */
+export function trackAway(): () => void {
+ let left: number | null = null;
+ let stopped = false;
+ const back = () => {
+ const now = Date.now();
+ if (left !== null && now - left >= AWAY_MS) useAwayStore.setState({ away: { since: new Date(left).toISOString(), until: new Date(now).toISOString() } });
+ left = null;
+ };
+ void SecureStore.getItemAsync(KEY, STORE_OPTIONS)
+ .catch(() => null)
+ .then((saved) => {
+ const at = Number(saved);
+ // Left again before the saved time was read: that newer time counts.
+ if (stopped || left !== null || !Number.isFinite(at) || at <= 0) return;
+ left = at;
+ if (AppState.currentState === "active") back();
+ });
+ const subscription = AppState.addEventListener("change", (state) => {
+ if (state === "active") return back();
+ if (left !== null) return;
+ left = Date.now();
+ void SecureStore.setItemAsync(KEY, String(left), STORE_OPTIONS).catch(() => undefined);
+ });
+ return () => {
+ stopped = true;
+ subscription.remove();
+ };
+}
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/apps/mobile/src/lib/line-diff.ts b/apps/mobile/src/lib/line-diff.ts
new file mode 100644
index 00000000..23a72f19
--- /dev/null
+++ b/apps/mobile/src/lib/line-diff.ts
@@ -0,0 +1,245 @@
+/**
+ * Line diff for reviewing prose edits (memory files): Myers' O((N+M)·D) diff over lines, word-level highlights
+ * inside edited lines, and unchanged runs folded into expandable blocks. A copy of apps/desktop/src/lib/line-diff.ts.
+ */
+
+export type DiffKind = "same" | "add" | "del";
+
+export interface WordPart {
+ text: string;
+ changed: boolean;
+}
+
+export interface DiffLine {
+ kind: DiffKind;
+ text: string;
+ /** 1-based line numbers in the old / new text (null on the side the line doesn't exist). */
+ oldNo: number | null;
+ newNo: number | null;
+ /** Set when the line is one half of an edited line: the words that changed are marked. */
+ parts?: WordPart[];
+}
+
+export type DiffBlock = { type: "lines"; lines: DiffLine[] } | { type: "fold"; lines: DiffLine[] };
+
+export interface FileDiff {
+ lines: DiffLine[];
+ added: number;
+ removed: number;
+ /** Only set when both versions exist, aren't empty and disagree on ending with a newline. */
+ finalNewline: "added" | "removed" | null;
+}
+
+/** Edit distance Myers explores before giving up (dream-sized edits are far below it). */
+const MAX_EDITS = 2_000;
+/** Beyond MAX_EDITS, an exact LCS table is still affordable up to this many cells; past it the middle is replaced. */
+const MAX_LINE_CELLS = 2_000_000;
+const MAX_WORD_CELLS = 40_000;
+/** Share of an edited line that must survive for word highlights to help rather than confetti the line. */
+const MIN_WORD_OVERLAP = 0.35;
+const TOKEN = /\s+|[\p{L}\p{N}_'’-]+|[^\s\p{L}\p{N}_]/gu;
+
+function normalize(text: string | null): string | null {
+ return text === null ? null : text.replace(/\r\n?/g, "\n");
+}
+
+function splitLines(text: string | null): string[] {
+ if (!text) return [];
+ const lines = text.split("\n");
+ if (lines[lines.length - 1] === "") lines.pop();
+ return lines;
+}
+
+/**
+ * Index pairs of a longest common subsequence via Myers' greedy algorithm; null when the edit distance exceeds
+ * `maxEdits`. Keeps one V slice per edit step for the backtrack, so memory grows with D², not N·M.
+ */
+function myers(a: readonly T[], b: readonly T[], maxEdits: number): [number, number][] | null {
+ const n = a.length;
+ const m = b.length;
+ const max = n + m;
+ if (!max) return [];
+ const offset = max + 1;
+ const v = new Int32Array(2 * max + 3);
+ const trace: Int32Array[] = [];
+ let found = -1;
+ for (let d = 0; d <= Math.min(max, maxEdits) && found < 0; d++) {
+ for (let k = -d; k <= d; k += 2) {
+ let x = k === -d || (k !== d && v[offset + k - 1] < v[offset + k + 1]) ? v[offset + k + 1] : v[offset + k - 1] + 1;
+ let y = x - k;
+ while (x < n && y < m && a[x] === b[y]) {
+ x++;
+ y++;
+ }
+ v[offset + k] = x;
+ if (x >= n && y >= m) found = d;
+ }
+ trace.push(v.slice(offset - d, offset + d + 1));
+ }
+ if (found < 0) return null;
+
+ const pairs: [number, number][] = [];
+ let x = n;
+ let y = m;
+ for (let d = found; d > 0; d--) {
+ const prev = trace[d - 1];
+ const at = (k: number) => prev[k + d - 1];
+ const k = x - y;
+ const prevK = k === -d || (k !== d && at(k - 1) < at(k + 1)) ? k + 1 : k - 1;
+ const prevX = at(prevK);
+ const prevY = prevX - prevK;
+ while (x > prevX && y > prevY) pairs.push([--x, --y]);
+ x = prevX;
+ y = prevY;
+ }
+ while (x > 0 && y > 0) pairs.push([--x, --y]);
+ return pairs.reverse();
+}
+
+/** Index pairs of a longest common subsequence via a DP table; null when the table would be too large. */
+function lcsTable(a: readonly T[], b: readonly T[], maxCells: number): [number, number][] | null {
+ const n = a.length;
+ const m = b.length;
+ if ((n + 1) * (m + 1) > maxCells) return null;
+ const w = m + 1;
+ const dp = new Uint32Array((n + 1) * w);
+ for (let i = n - 1; i >= 0; i--) {
+ for (let j = m - 1; j >= 0; j--) {
+ dp[i * w + j] = a[i] === b[j] ? dp[(i + 1) * w + j + 1] + 1 : Math.max(dp[(i + 1) * w + j], dp[i * w + j + 1]);
+ }
+ }
+ const pairs: [number, number][] = [];
+ let i = 0;
+ let j = 0;
+ while (i < n && j < m) {
+ if (a[i] === b[j]) {
+ pairs.push([i, j]);
+ i++;
+ j++;
+ } else if (dp[(i + 1) * w + j] >= dp[i * w + j + 1]) i++;
+ else j++;
+ }
+ return pairs;
+}
+
+function toParts(tokens: string[], kept: Set): WordPart[] {
+ const changed = tokens.map((_, i) => !kept.has(i));
+ // Whitespace between two changed words belongs to the change: one highlight instead of two.
+ for (let i = 1; i < tokens.length - 1; i++) {
+ if (!changed[i] && /^\s+$/.test(tokens[i]) && changed[i - 1] && changed[i + 1]) changed[i] = true;
+ }
+ const parts: WordPart[] = [];
+ tokens.forEach((text, i) => {
+ const last = parts[parts.length - 1];
+ if (last && last.changed === changed[i]) last.text += text;
+ else parts.push({ text, changed: changed[i] });
+ });
+ return parts;
+}
+
+/** Word-level parts of an old and a new version of a line, or null when the lines have too little in common. */
+export function diffWords(before: string, after: string): [WordPart[], WordPart[]] | null {
+ const a = before.match(TOKEN) ?? [];
+ const b = after.match(TOKEN) ?? [];
+ const pairs = lcsTable(a, b, MAX_WORD_CELLS);
+ if (!pairs) return null;
+ const common = pairs.reduce((sum, [i]) => sum + a[i].replace(/\s+/g, "").length, 0);
+ const longest = Math.max(before.replace(/\s+/g, "").length, after.replace(/\s+/g, "").length);
+ if (!longest || common / longest < MIN_WORD_OVERLAP) return null;
+ return [toParts(a, new Set(pairs.map((p) => p[0]))), toParts(b, new Set(pairs.map((p) => p[1])))];
+}
+
+/** Pair the removed and added lines of one change block in order and mark the words that changed. */
+function pairEdits(dels: DiffLine[], adds: DiffLine[]) {
+ for (let k = 0; k < Math.min(dels.length, adds.length); k++) {
+ const words = diffWords(dels[k].text, adds[k].text);
+ if (!words) continue;
+ dels[k].parts = words[0];
+ adds[k].parts = words[1];
+ }
+}
+
+/**
+ * Diff two versions of a text by line. `null` means the file didn't exist (before) or was deleted (after):
+ * every line shows as added or removed. Line endings are normalised to \n.
+ */
+export function diffText(before: string | null, after: string | null): DiffLine[] {
+ const a = splitLines(normalize(before));
+ const b = splitLines(normalize(after));
+ let head = 0;
+ while (head < a.length && head < b.length && a[head] === b[head]) head++;
+ let tail = 0;
+ while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail++;
+ const midA = a.slice(head, a.length - tail);
+ const midB = b.slice(head, b.length - tail);
+ const pairs = myers(midA, midB, MAX_EDITS) ?? lcsTable(midA, midB, MAX_LINE_CELLS) ?? [];
+
+ const out: DiffLine[] = [];
+ for (let k = 0; k < head; k++) out.push({ kind: "same", text: a[k], oldNo: k + 1, newNo: k + 1 });
+ let i = 0;
+ let j = 0;
+ const changesUntil = (ti: number, tj: number) => {
+ const dels: DiffLine[] = [];
+ const adds: DiffLine[] = [];
+ for (; i < ti; i++) dels.push({ kind: "del", text: midA[i], oldNo: head + i + 1, newNo: null });
+ for (; j < tj; j++) adds.push({ kind: "add", text: midB[j], oldNo: null, newNo: head + j + 1 });
+ pairEdits(dels, adds);
+ out.push(...dels, ...adds);
+ };
+ for (const [pi, pj] of pairs) {
+ changesUntil(pi, pj);
+ out.push({ kind: "same", text: midA[pi], oldNo: head + pi + 1, newNo: head + pj + 1 });
+ i++;
+ j++;
+ }
+ changesUntil(midA.length, midB.length);
+ for (let k = tail; k > 0; k--) out.push({ kind: "same", text: a[a.length - k], oldNo: a.length - k + 1, newNo: b.length - k + 1 });
+ return out;
+}
+
+export function diffStats(lines: DiffLine[]): { added: number; removed: number } {
+ let added = 0;
+ let removed = 0;
+ for (const l of lines) {
+ if (l.kind === "add") added++;
+ else if (l.kind === "del") removed++;
+ }
+ return { added, removed };
+}
+
+/** Everything the review of one file needs: the line diff, its stats and a change of the final newline. */
+export function diffFile(before: string | null, after: string | null): FileDiff {
+ const lines = diffText(before, after);
+ const a = normalize(before);
+ const b = normalize(after);
+ const finalNewline = a && b && a.endsWith("\n") !== b.endsWith("\n") ? (b.endsWith("\n") ? "added" : "removed") : null;
+ return { lines, ...diffStats(lines), finalNewline };
+}
+
+/** Keep `context` unchanged lines around each change; longer unchanged runs become fold blocks. */
+export function foldContext(lines: DiffLine[], context = 3): DiffBlock[] {
+ const near = new Array(lines.length).fill(false);
+ lines.forEach((l, i) => {
+ if (l.kind === "same") return;
+ for (let k = Math.max(0, i - context); k <= Math.min(lines.length - 1, i + context); k++) near[k] = true;
+ });
+ if (!near.includes(true)) return lines.length ? [{ type: "lines", lines }] : [];
+
+ const blocks: DiffBlock[] = [];
+ let i = 0;
+ while (i < lines.length) {
+ const visible = near[i];
+ let j = i;
+ while (j < lines.length && near[j] === visible) j++;
+ const run = lines.slice(i, j);
+ // Folding a line or two saves nothing.
+ if (!visible && run.length > 2) blocks.push({ type: "fold", lines: run });
+ else {
+ const last = blocks[blocks.length - 1];
+ if (last?.type === "lines") last.lines.push(...run);
+ else blocks.push({ type: "lines", lines: run });
+ }
+ i = j;
+ }
+ return blocks;
+}
diff --git a/apps/mobile/src/lib/query.ts b/apps/mobile/src/lib/query.ts
index e1e5da47..60291b0d 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) => ["run-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..96a36679 100644
--- a/packages/core/src/mobile/scope.ts
+++ b/packages/core/src/mobile/scope.ts
@@ -32,6 +32,8 @@ const ROUTES: [method: string, path: RegExp][] = [
["PUT", /^\/api\/settings$/],
["GET", /^\/api\/notifications$/],
["POST", /^\/api\/notifications\/read$/],
+ // What the team did while the human was away (Home's card).
+ ["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 +56,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 +85,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 +96,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 +134,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 +164,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 +190,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 +233,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 +243,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/src/server/routes/agents.ts b/packages/core/src/server/routes/agents.ts
index 756fd69d..6dc66be6 100644
--- a/packages/core/src/server/routes/agents.ts
+++ b/packages/core/src/server/routes/agents.ts
@@ -1,4 +1,4 @@
-import type { Hono } from "hono";
+import type { Context, Hono } from "hono";
import {
CHARACTER_BODIES,
CHARACTER_EYES,
@@ -28,7 +28,7 @@ import {
import { AGENT_TEMPLATES } from "../../agents/templates";
import { TEAM_TEMPLATES, installTeam } from "../../agents/teams";
import { listSlashCommands } from "../../runner/commands";
-import type { RoutineTrigger } from "@godmode/shared";
+import type { Routine, RoutineTrigger } from "@godmode/shared";
import { createRoutine, deleteRoutine, getRoutine, listRoutines, resolveAppTrigger, runRoutineNow, updateRoutine } from "../../services/routines";
import { listEvents, sendTestEvent } from "../../automations/events";
import { rotateWebhookToken } from "../../automations/webhooks";
@@ -376,18 +376,26 @@ export function registerAgentRoutes(app: Hono): void {
app.post("/api/agents/:id/dreams", async (c) => c.json(await startDream(c.req.param("id"), "manual")));
- app.get("/api/dreams/:id", (c) => c.json(getDream(c.req.param("id"))));
+ app.get("/api/dreams/:id", (c) => {
+ const dream = getDream(c.req.param("id"));
+ // A phone reads MEMORY.md, as in its memory editor; the agent's other files stay on the computer.
+ return c.json(requestDevice(c) ? { ...dream, fileChanges: dream.fileChanges.filter((f) => f.path === "MEMORY.md") } : dream);
+ });
app.post("/api/dreams/:id/revert", async (c) => c.json(await revertDream(c.req.param("id"))));
/* Routines ---------------------------------------------------------- */
- app.get("/api/routines", (c) => c.json(listRoutines({ agentId: c.req.query("agentId") || undefined })));
+ // A phone never gets a webhook's secret URL: webhooks are set up on the computer.
+ const forCaller = (c: Context, r: Routine): Routine => (requestDevice(c) ? { ...r, webhookPath: null } : r);
+
+ app.get("/api/routines", (c) => c.json(listRoutines({ agentId: c.req.query("agentId") || undefined }).map((r) => forCaller(c, r))));
app.post("/api/routines", async (c) => {
const input = await body(c, routineSchema);
const trigger = await resolveAppTrigger(input.trigger as RoutineTrigger | undefined, input.agentId);
- return c.json(createRoutine({ ...input, trigger }));
+ // Set up from a phone, its runs fill logins but read no raw secrets, like an agent made there.
+ return c.json(forCaller(c, createRoutine({ ...input, trigger, ...(requestDevice(c) ? { fillOnly: true } : {}) })));
});
app.patch("/api/routines/:id", async (c) => {
@@ -398,7 +406,9 @@ export function registerAgentRoutes(app: Hono): void {
const trigger = patch.trigger
? await resolveAppTrigger(patch.trigger as RoutineTrigger, patch.agentId ?? getRoutine(routineId).agentId)
: undefined;
- return c.json(updateRoutine(routineId, { ...patch, trigger }));
+ // What a phone writes runs without raw secrets from then on; switching it on or off changes nothing it does.
+ const fromPhone = requestDevice(c) && Object.keys(patch).some((k) => k !== "enabled");
+ return c.json(forCaller(c, updateRoutine(routineId, { ...patch, trigger, ...(fromPhone ? { fillOnly: true } : {}) })));
});
app.delete("/api/routines/:id", (c) => {
diff --git a/packages/core/src/server/routes/chat.ts b/packages/core/src/server/routes/chat.ts
index 4bc713cc..080fed7c 100644
--- a/packages/core/src/server/routes/chat.ts
+++ b/packages/core/src/server/routes/chat.ts
@@ -28,6 +28,7 @@ import { getAgent } from "../../agents/service";
import { listAttention } from "../../services/attention";
import { awaySummary } from "../../services/away";
import { retryRun } from "../../services/retries";
+import { requestDevice } from "../auth";
import { autoRetryOf } from "../../services/resume";
import { body, computerTargetSchema, z } from "../validate";
import { shareComputer } from "../../computer/share";
@@ -287,7 +288,8 @@ export function registerChatRoutes(app: Hono): void {
app.get("/api/runs/:id", (c) => {
const run = getRun(c.req.param("id"));
const retry = autoRetryOf(run.id);
- return c.json({ ...run, logPath: findRunLog(run), retryAt: retry?.at ?? null, retryCause: retry?.cause ?? null });
+ // A phone reads the log through /log; where it lies on this computer stays here.
+ return c.json({ ...run, logPath: requestDevice(c) ? null : findRunLog(run), retryAt: retry?.at ?? null, retryCause: retry?.cause ?? null });
});
app.post("/api/runs/:id/cancel", async (c) => {
diff --git a/packages/core/test/away.test.ts b/packages/core/test/away.test.ts
index 9ca9f5c5..7332fef5 100644
--- a/packages/core/test/away.test.ts
+++ b/packages/core/test/away.test.ts
@@ -82,7 +82,7 @@ describe("while you were away", () => {
expect(quiet).toMatchObject({ finished: 0, delivered: 0, highlights: [] });
}, 60_000);
- test("over HTTP, only with a time in the past; the phone can't ask", async () => {
+ test("over HTTP, only with a time in the past, also from the phone", async () => {
const get = (since: string) => fetch(`${env.baseUrl}/api/away?since=${encodeURIComponent(since)}`, { headers: { Authorization: `Bearer ${getAccessToken()}` } });
const ok = await get(new Date(Date.now() - 3_600_000).toISOString());
expect(ok.status).toBe(200);
@@ -91,6 +91,6 @@ describe("while you were away", () => {
expect((await get(new Date(Date.now() + 60_000).toISOString())).status).toBe(400);
const until = await fetch(`${env.baseUrl}/api/away?since=${encodeURIComponent(new Date(Date.now() - 3_600_000).toISOString())}&until=${encodeURIComponent(new Date(Date.now() - 7_200_000).toISOString())}`, { headers: { Authorization: `Bearer ${getAccessToken()}` } });
expect(until.status).toBe(400);
- expect(deviceMayCall("GET", "/api/away")).toBe(false);
+ expect(deviceMayCall("GET", "/api/away")).toBe(true);
});
});
diff --git a/packages/core/test/budgets.test.ts b/packages/core/test/budgets.test.ts
index 0ef577b1..c3c9db49 100644
--- a/packages/core/test/budgets.test.ts
+++ b/packages/core/test/budgets.test.ts
@@ -66,7 +66,7 @@ describe("what the team spent", () => {
const res = await api("GET", "/api/spend?period=week");
expect(res.status).toBe(200);
expect((await api("GET", "/api/spend?period=year")).status).toBe(400);
- expect(deviceMayCall("GET", "/api/spend")).toBe(false);
+ expect(deviceMayCall("GET", "/api/spend")).toBe(true);
expect(deviceMayCall("GET", "/api/budgets")).toBe(false);
}, 30_000);
diff --git a/packages/core/test/mobile.test.ts b/packages/core/test/mobile.test.ts
index 1644c724..596ebb9d 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);
@@ -315,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);
@@ -392,6 +410,23 @@ describe("phone access", () => {
expect((await phone(`/api/projects/${created.id}`, { bearer: device, method: "DELETE" })).status).toBe(200);
});
+ test("automations set up or changed on a phone run without raw secrets", async () => {
+ const { token: device } = await pair("Automations");
+ const json = (method: string, body: unknown) => ({ bearer: device, method, body: JSON.stringify(body) });
+ const agent = (await (await desktop("/api/agents", { method: "POST", body: JSON.stringify({ name: "Reporter" }) })).json()) as { id: string };
+ const made = await phone("/api/routines", json("POST", { agentId: agent.id, name: "Weekly report", prompt: "Sum up the week.", cron: "0 9 * * 1", timezone: "Europe/Berlin" }));
+ expect(made.status).toBe(200);
+ expect((await made.json()) as { fillOnly: boolean; webhookPath: string | null }).toMatchObject({ fillOnly: true, webhookPath: null });
+
+ const desk = (await (await desktop("/api/routines", { method: "POST", body: JSON.stringify({ agentId: agent.id, name: "Inbox", prompt: "Read the inbox.", cron: "0 8 * * *" }) })).json()) as { id: string; fillOnly: boolean };
+ expect(desk.fillOnly).toBe(false);
+ const paused = await phone(`/api/routines/${desk.id}`, json("PATCH", { enabled: false }));
+ expect(((await paused.json()) as { fillOnly: boolean }).fillOnly).toBe(false);
+ const reworded = await phone(`/api/routines/${desk.id}`, json("PATCH", { prompt: "Read the inbox and reply." }));
+ expect(((await reworded.json()) as { fillOnly: boolean }).fillOnly).toBe(true);
+ expect((await phone(`/api/routines/${desk.id}`, { bearer: device, method: "DELETE" })).status).toBe(200);
+ });
+
test("phones create and follow tasks in a workspace", async () => {
const { token: device } = await pair("Tasks");
const json = (method: string, body: unknown) => ({ bearer: device, method, body: JSON.stringify(body) });
diff --git a/packages/core/test/team.test.ts b/packages/core/test/team.test.ts
index 6a53dbaf..1ff74033 100644
--- a/packages/core/test/team.test.ts
+++ b/packages/core/test/team.test.ts
@@ -326,7 +326,7 @@ describe("last run failed", () => {
expect(dismissFailedRun(agent.id).failedRunId).toBeNull();
const res = await api("DELETE", `/api/agents/${agent.id}/failed-run`);
expect(res.status).toBe(200);
- expect(deviceMayCall("DELETE", `/api/agents/${agent.id}/failed-run`)).toBe(false);
+ expect(deviceMayCall("DELETE", `/api/agents/${agent.id}/failed-run`)).toBe(true);
expect(deviceMayCall("POST", `/api/agents/${agent.id}/duplicate`)).toBe(false);
// Deleting the chat of the failed run forgets the failure (there would be nothing to open).