diff --git a/apps/mobile/README.md b/apps/mobile/README.md
index 69645afd..d7eac7ab 100644
--- a/apps/mobile/README.md
+++ b/apps/mobile/README.md
@@ -1,10 +1,10 @@
# Godmode for iOS and Android
-The phone app for Godmode: hand over tasks with photos and files, follow answers as they stream, stop runs, run
-automations, and watch an agent's browser, shared screen or VM live (tap to take control). The chat input works like
-the desktop's: photos and files, Claude Code's slash commands, a queue for messages sent while the agent works, and the
-model, effort and Ultracode. It controls the Godmode running on your computer and
-nothing else.
+The phone app for Godmode: hand over tasks with photos and files, follow answers as they stream, answer an agent's
+question or approve a step with one tap, pause and stop runs, run automations, see what every agent did, and watch an
+agent's browser, shared screen or VM live (tap to take control). The chat input works like the desktop's: photos and
+files, Claude Code's slash commands, a queue for messages sent while the agent works, and the model, effort and
+Ultracode. It controls the Godmode running on your computer and nothing else.
Built with [Expo](https://expo.dev) SDK 57 and Expo Router: native tabs and headers (Liquid Glass on iOS 26),
`expo-glass-effect` for floating controls, `expo-camera` for pairing, `expo-secure-store` for the device key,
@@ -61,12 +61,15 @@ The app is **Godmode Bot** in both stores (Codext GmbH): App Store app id 681767
```
src/app/ routes (Expo Router)
(tabs)/ Home, Chats, Screens, Agents — native tabs, a stack per tab
- chat/[id].tsx a conversation with streaming replies
+ chat/[id].tsx a conversation with streaming replies; opens with its latest messages, older ones on scroll
+ task/[id].tsx a ticket: its result, its parts and its timeline
agent/[id].tsx an agent: tasks, automations, recent chats
+ archived.tsx archived-tasks.tsx automations.tsx activity.tsx lists behind the tabs' header buttons
live.tsx full-screen browser / shared screen / VM, take control
compose.tsx new chat (sheet)
settings.tsx connection, Face ID lock, disconnect (sheet)
welcome.tsx scan.tsx pair.tsx pairing
-src/lib/ api (address failover), realtime (WebSocket), session (Keychain), live state, attachments, theme
+src/lib/ api (address failover), realtime (WebSocket, patches the caches), chat-cache (message windows),
+ persist (lists kept between launches), session (Keychain), live state, attachments, theme
src/components/ glass, icons (SF Symbols / Material Symbols), rows, markdown, composer, attachments, live views
```
diff --git a/apps/mobile/src/app/(tabs)/(home)/index.tsx b/apps/mobile/src/app/(tabs)/(home)/index.tsx
index 5f46ddab..f4e16703 100644
--- a/apps/mobile/src/app/(tabs)/(home)/index.tsx
+++ b/apps/mobile/src/app/(tabs)/(home)/index.tsx
@@ -1,11 +1,14 @@
import { useQuery } from "@tanstack/react-query";
import { router, Stack } from "expo-router";
+import { memo, 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 { Glass } from "@/components/glass";
import { HeaderActions } from "@/components/header-actions";
import { openHumanTask } from "@/components/human-task-row";
import { Icon } from "@/components/icon";
+import { answerQuestion } from "@/components/question-card";
import { ConversationRow, RunCard } from "@/components/rows";
import { ScreenTile } from "@/components/screen-tile";
import { TaskRow } from "@/components/task-row";
@@ -13,8 +16,8 @@ import { Card, Hairline, LiveDot, Row, SectionTitle, SkeletonRows, T, tap } from
import { WorkspaceChip } from "@/components/workspace-chip";
import { api, errorText } from "@/lib/api";
import { greeting } from "@/lib/format";
-import { useAgents, useOpenHumanTasks } from "@/lib/hooks";
-import { useLive } from "@/lib/live";
+import { chatListQuery, useAgents, useOpenHumanTasks } from "@/lib/hooks";
+import { useLive, useRunningChats } from "@/lib/live";
import { qk, queryClient } from "@/lib/query";
import { reconnectNow } from "@/lib/realtime";
import { isLive, useLiveScreens } from "@/lib/screens";
@@ -35,23 +38,22 @@ export default function Home() {
const openForYou = useOpenHumanTasks();
// Scoped like the rest of Home: the workspace's tasks and the ones without a workspace.
const mine = { data: openForYou.data?.filter((t) => !workspaceId || !t.workspaceId || t.workspaceId === workspaceId) };
- const recent = useQuery({ queryKey: qk.conversationList("", workspaceId), queryFn: () => api.conversations.list({ limit: 100, workspaceId }) });
+ const recent = useQuery(chatListQuery(workspaceId));
const tasks = useQuery({ queryKey: qk.taskList(workspaceId), queryFn: () => api.tasks.list({ workspaceId }) });
const missing = useQuery({ queryKey: qk.missingLogins, queryFn: api.missingLogins.open });
const questions = useQuery({ queryKey: qk.questions, queryFn: api.questions.open });
const { screens } = useLiveScreens();
+ // The lists, not the open chats: those refresh on their own events.
const pull = usePullRefresh(async () => {
reconnectNow();
- await queryClient.invalidateQueries();
+ await queryClient.invalidateQueries({ predicate: (q) => q.queryKey[1] !== "detail" });
});
- const working = useWorkspaceRuns();
const liveScreens = screens.filter(isLive);
- const runningConversations = new Set(working.map((w) => w.run.conversationId));
+ const runningConversations = useRunningChats();
const chats = inProject(recent.data ?? [], projectId, agents)
.filter((conv) => conv.origin !== "dream")
.slice(0, 5);
- const titleOf = (id: string) => recent.data?.find((conv) => conv.id === id)?.title;
const openTasks = tasksInProject(tasks.data ?? [], projectId)
.filter((t) => t.status === "in_review" || t.status === "blocked" || t.status === "in_progress" || t.status === "todo")
.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1))
@@ -117,32 +119,7 @@ export default function Home() {
)}
-
-
- {working.length ? (
-
- {working.map((w) => (
-
-
-
- ))}
-
- ) : (
-
-
- {status === "connecting" ? : }
-
-
-
- {status === "connecting" ? "Checking in…" : "All quiet"}
-
-
- {status === "connecting" ? `Asking ${computer} what the team is doing.` : "No agent is working right now."}
-
-
-
- )}
-
+
{(vaultLocked || (missing.data?.length ?? 0) > 0 || (questions.data?.length ?? 0) > 0 || (mine.data?.length ?? 0) > 0) && (
@@ -164,13 +141,7 @@ export default function Home() {
router.push("/my-tasks")} />
)}
{questions.data?.slice(0, 4).map((q) => (
- router.push(`/chat/${q.conversationId}`)}
- />
+
))}
{missing.data?.slice(0, 4).map((m) => (
@@ -230,6 +201,90 @@ export default function Home() {
);
}
+/** Agents that work right now. On its own so their activity labels don't render the whole of Home again. */
+const WorkingNow = memo(function WorkingNow({ computer }: { computer: string }) {
+ const c = useColors();
+ const status = useLive((s) => s.status);
+ const { byId: agents } = useAgents();
+ const { id: workspaceId } = useWorkspace();
+ const recent = useQuery(chatListQuery(workspaceId));
+ const working = useWorkspaceRuns();
+ return (
+
+
+ {working.length ? (
+
+ {working.map((w) => (
+
+ conv.id === w.run.conversationId)?.title} />
+
+ ))}
+
+ ) : (
+
+
+ {status === "connecting" ? : }
+
+
+
+ {status === "connecting" ? "Checking in…" : "All quiet"}
+
+
+ {status === "connecting" ? `Asking ${computer} what the team is doing.` : "No agent is working right now."}
+
+
+
+ )}
+
+ );
+});
+
+/** A question an agent waits on: an approval is answered right here, anything else in its chat. */
+function QuestionRow({ question: q, agentName }: { question: AgentQuestion; agentName?: string }) {
+ const c = useColors();
+ const [sending, setSending] = useState<"approve" | "decline" | null>(null);
+ const approval = q.kind === "approval";
+ const decide = (decision: "approve" | "decline") => {
+ if (sending) return;
+ tap();
+ setSending(decision);
+ void answerQuestion(q.id, { decision }).finally(() => setSending(null));
+ };
+ return (
+
+ router.push(`/chat/${q.conversationId}`)}
+ />
+ {approval ? (
+
+ {(["approve", "decline"] as const).map((decision) => (
+ decide(decision)}
+ style={({ pressed }) => [
+ styles.decision,
+ { backgroundColor: decision === "approve" ? c.primary : c.surface, borderColor: decision === "approve" ? c.primary : c.borderStrong, opacity: pressed || (sending && sending !== decision) ? 0.6 : 1 },
+ ]}
+ >
+ {sending === decision ? : null}
+
+ {decision === "approve" ? "Approve" : "Decline"}
+
+
+ ))}
+
+ ) : null}
+
+ );
+}
+
function NeedsRow({ icon, title, body, onPress }: { icon: "lock" | "key" | "warning" | "person"; title: string; body: string; onPress?: () => void }) {
const c = useColors();
return (
@@ -302,6 +357,23 @@ const styles = StyleSheet.create({
alignItems: "center",
justifyContent: "center",
},
+ decide: {
+ gap: space.sm,
+ paddingLeft: space.lg + 28 + space.md,
+ paddingBottom: space.md,
+ marginTop: -4,
+ },
+ decision: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 6,
+ height: 34,
+ minWidth: 92,
+ paddingHorizontal: space.md,
+ borderRadius: radius.pill,
+ borderWidth: StyleSheet.hairlineWidth,
+ },
needsIcon: {
width: 28,
height: 28,
diff --git a/apps/mobile/src/app/(tabs)/agents/index.tsx b/apps/mobile/src/app/(tabs)/agents/index.tsx
index 286a9066..0a25674a 100644
--- a/apps/mobile/src/app/(tabs)/agents/index.tsx
+++ b/apps/mobile/src/app/(tabs)/agents/index.tsx
@@ -1,74 +1,92 @@
+import { FlashList, type ListRenderItem } from "@shopify/flash-list";
import { router, Stack } from "expo-router";
-import { useMemo } from "react";
-import { RefreshControl, ScrollView, View } from "react-native";
+import { memo, useCallback, useMemo } from "react";
+import { RefreshControl, View } from "react-native";
+import type { Agent } from "@godmode/shared";
+import { HeaderActions } from "@/components/header-actions";
import { AgentRow } from "@/components/rows";
import { EmptyState, ErrorState, Hairline, SkeletonRows, tap } from "@/components/ui";
import { errorText } from "@/lib/api";
import { WorkspaceChip } from "@/components/workspace-chip";
import { useAgents } from "@/lib/hooks";
-import { useLive } from "@/lib/live";
+import { useRunCounts } from "@/lib/live";
import { usePullRefresh } from "@/lib/use-pull-refresh";
import { agentsFor, useWorkspace } from "@/lib/workspace";
import { space } from "@/lib/theme";
+const NONE = { running: 0, queued: 0 };
+
+function openAgent(id: string) {
+ tap();
+ router.push({ pathname: "/agent/[id]", params: { id } });
+}
+
+const AgentItem = memo(function AgentItem({ agent, running, queued }: { agent: Agent; running: number; queued: number }) {
+ return openAgent(agent.id)} />;
+});
+
+const Separator = () => ;
+
+const Header = () => (
+
+
+
+);
+
export default function Agents() {
const agents = useAgents();
const { id: workspaceId, projectId } = useWorkspace();
- const runs = useLive((s) => s.runs);
// Working means running; a run waiting for a free slot is only queued.
- const counts = useMemo(() => {
- const m = new Map();
- for (const { run } of Object.values(runs)) {
- const n = m.get(run.agentId) ?? { running: 0, queued: 0 };
- if (run.status === "running") n.running++;
- else if (run.status === "queued") n.queued++;
- m.set(run.agentId, n);
- }
- return m;
- }, [runs]);
- const busy = useMemo(() => new Set([...counts].flatMap(([id, n]) => (n.running ? [id] : []))), [counts]);
+ const counts = useRunCounts();
const pull = usePullRefresh(agents.refetch);
const data = useMemo(
() =>
[...agentsFor(agents.data ?? [], workspaceId)].sort(
(a, b) =>
Number(!!projectId && b.projectId === projectId) - Number(!!projectId && a.projectId === projectId) ||
- Number(busy.has(b.id)) - Number(busy.has(a.id)) || Number(b.isDefault) - Number(a.isDefault) || a.name.localeCompare(b.name),
+ Number(!!counts.get(b.id)?.running) - Number(!!counts.get(a.id)?.running) ||
+ Number(b.isDefault) - Number(a.isDefault) ||
+ a.name.localeCompare(b.name),
),
- [agents.data, busy, workspaceId, projectId],
+ [agents.data, counts, workspaceId, projectId],
+ );
+ const renderItem: ListRenderItem = useCallback(
+ ({ item }) => {
+ const n = counts.get(item.id) ?? NONE;
+ return ;
+ },
+ [counts],
);
return (
- }
- >
+ <>
Agents
-
-
-
- {data.map((agent, i) => (
-
- {i > 0 && }
- {
- tap();
- router.push({ pathname: "/agent/[id]", params: { id: agent.id } });
- }}
- />
-
- ))}
- {agents.isLoading ? (
-
- ) : agents.isError && !data.length ? (
- void agents.refetch()} />
- ) : data.length === 0 ? (
-
- ) : null}
-
+ router.push("/automations") },
+ { icon: "pulse", label: "Activity", onPress: () => router.push("/activity") },
+ ]}
+ />
+ agent.id}
+ renderItem={renderItem}
+ extraData={counts}
+ contentInsetAdjustmentBehavior="automatic"
+ contentContainerStyle={{ paddingHorizontal: space.sm, paddingBottom: 140 }}
+ refreshControl={}
+ ListHeaderComponent={Header}
+ ItemSeparatorComponent={Separator}
+ ListEmptyComponent={
+ agents.isLoading ? (
+
+ ) : agents.isError ? (
+ void agents.refetch()} />
+ ) : (
+
+ )
+ }
+ />
+ >
);
}
diff --git a/apps/mobile/src/app/(tabs)/chats/index.tsx b/apps/mobile/src/app/(tabs)/chats/index.tsx
index 7c09e9b9..544b9a5b 100644
--- a/apps/mobile/src/app/(tabs)/chats/index.tsx
+++ b/apps/mobile/src/app/(tabs)/chats/index.tsx
@@ -1,15 +1,16 @@
-import { useMutation, useQuery } from "@tanstack/react-query";
+import { FlashList, type ListRenderItem } from "@shopify/flash-list";
+import { useQuery } from "@tanstack/react-query";
import { Link, router, Stack } from "expo-router";
-import { useDeferredValue, useMemo, useState } from "react";
-import { Alert, FlatList, RefreshControl, View } from "react-native";
+import { memo, useCallback, useEffect, useMemo, useState } from "react";
+import { Alert, 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 { EmptyState, ErrorState, Hairline, SkeletonRows } from "@/components/ui";
import { WorkspaceChip } from "@/components/workspace-chip";
import { api, errorText } from "@/lib/api";
-import { useAgents } from "@/lib/hooks";
-import { useLive } from "@/lib/live";
+import { CHAT_LIST_LIMIT, useAgents } from "@/lib/hooks";
+import { useRunningChats } from "@/lib/live";
import { qk, queryClient } from "@/lib/query";
import { usePullRefresh } from "@/lib/use-pull-refresh";
import { inProject, useProjectIndex, useWorkspace, chatProject } from "@/lib/workspace";
@@ -17,42 +18,54 @@ import { space } from "@/lib/theme";
export default function Chats() {
const [search, setSearch] = useState("");
- const q = useDeferredValue(search.trim());
+ // The search reads every message on the computer: ask once the typing pauses.
+ const [q, setQ] = useState("");
+ useEffect(() => {
+ const next = search.trim();
+ if (!next) return setQ("");
+ const timer = setTimeout(() => setQ(next), 300);
+ return () => clearTimeout(timer);
+ }, [search]);
const { id: workspaceId, workspace, projectId, project } = useWorkspace();
const projects = useProjectIndex();
const list = useQuery({
queryKey: qk.conversationList(q, workspaceId),
- queryFn: () => api.conversations.list({ search: q || undefined, limit: 200, workspaceId }),
+ queryFn: () => api.conversations.list({ search: q || undefined, limit: CHAT_LIST_LIMIT, workspaceId }),
+ placeholderData: (previous) => previous,
});
const { byId } = useAgents();
const pull = usePullRefresh(list.refetch);
- const runs = useLive((s) => s.runs);
- const running = useMemo(() => new Set(Object.values(runs).map((r) => r.run.conversationId)), [runs]);
- const data = inProject(list.data ?? [], projectId, byId).filter((conv) => conv.origin !== "dream");
- const projectOf = (conv: Conversation) => {
- if (projectId) return undefined;
- const id = chatProject(conv, byId);
- return id ? projects.get(id)?.project : undefined;
- };
+ const running = useRunningChats();
+ const data = useMemo(() => inProject(list.data ?? [], projectId, byId).filter((conv) => conv.origin !== "dream"), [list.data, projectId, byId]);
+ const renderItem: ListRenderItem = useCallback(
+ ({ item }) => {
+ const id = projectId ? null : chatProject(item, byId);
+ return ;
+ },
+ [byId, running, projectId, projects],
+ );
return (
<>
Chats
setSearch(e.nativeEvent.text)} onCancelButtonPress={() => setSearch("")} />
- router.push("/compose") }]} />
- router.push("/archived") },
+ { icon: "compose", label: "New chat", onPress: () => router.push("/compose") },
+ ]}
+ />
+ conv.id}
+ renderItem={renderItem}
+ extraData={running}
contentInsetAdjustmentBehavior="automatic"
+ keyboardDismissMode="on-drag"
contentContainerStyle={{ paddingHorizontal: space.sm, paddingBottom: 140 }}
refreshControl={}
- ListHeaderComponent={
-
-
-
- }
- ItemSeparatorComponent={() => }
- renderItem={({ item }) => }
+ ListHeaderComponent={Header}
+ ItemSeparatorComponent={Separator}
ListEmptyComponent={
list.isLoading ? (
@@ -79,12 +92,23 @@ export default function Chats() {
);
}
-function ChatItem({ conversation, agent, running, project }: { conversation: Conversation; agent?: Agent; running: boolean; project?: Project }) {
- const update = useMutation({
- mutationFn: (patch: { pinned?: boolean; archived?: boolean }) => api.conversations.update(conversation.id, patch),
- onSettled: () => queryClient.invalidateQueries({ queryKey: qk.conversations }),
- onError: (err) => Alert.alert("Couldn't change the chat", errorText(err)),
- });
+const Separator = () => ;
+
+const Header = () => (
+
+
+
+);
+
+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 }));
+}
+
+const ChatItem = memo(function ChatItem({ conversation, agent, running, project }: { conversation: Conversation; agent?: Agent; running: 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.", [
{ text: "Cancel", style: "cancel" },
@@ -114,4 +138,4 @@ function ChatItem({ conversation, agent, running, project }: { conversation: Con
);
-}
+});
diff --git a/apps/mobile/src/app/(tabs)/screens/index.tsx b/apps/mobile/src/app/(tabs)/screens/index.tsx
index 17bcebc3..05339274 100644
--- a/apps/mobile/src/app/(tabs)/screens/index.tsx
+++ b/apps/mobile/src/app/(tabs)/screens/index.tsx
@@ -18,6 +18,7 @@ export default function Screens() {
const groups: [string, LiveScreen[]][] = [
["Browsers", idle.filter((s) => s.kind === "browser")],
["Virtual machines", idle.filter((s) => s.kind === "vm")],
+ ["Android phones", idle.filter((s) => s.kind === "android")],
];
return (
@@ -43,7 +44,7 @@ export default function Screens() {
)}
diff --git a/apps/mobile/src/app/(tabs)/tasks/index.tsx b/apps/mobile/src/app/(tabs)/tasks/index.tsx
index fcdc6783..17863659 100644
--- a/apps/mobile/src/app/(tabs)/tasks/index.tsx
+++ b/apps/mobile/src/app/(tabs)/tasks/index.tsx
@@ -1,7 +1,8 @@
-import { useMutation, useQuery } from "@tanstack/react-query";
+import { FlashList, type ListRenderItem } from "@shopify/flash-list";
+import { useQuery } from "@tanstack/react-query";
import { Link, router, Stack } from "expo-router";
-import { useMemo } from "react";
-import { Alert, RefreshControl, SectionList, View } from "react-native";
+import { memo, useCallback, useMemo } from "react";
+import { Alert, RefreshControl, View } from "react-native";
import type { Agent, Task, TaskStatus } from "@godmode/shared";
import { HeaderActions } from "@/components/header-actions";
import { HumanTaskRow } from "@/components/human-task-row";
@@ -15,6 +16,8 @@ import { usePullRefresh } from "@/lib/use-pull-refresh";
import { tasksInProject, useProjectIndex, useWorkspace } from "@/lib/workspace";
import { space } from "@/lib/theme";
+type Item = { kind: "header"; key: string; title: string } | { kind: "task"; key: string; task: Task };
+
const GROUPS: { title: string; statuses: TaskStatus[] }[] = [
{ title: "Needs you", statuses: ["in_review", "blocked"] },
{ title: "In progress", statuses: ["in_progress", "todo"] },
@@ -33,12 +36,36 @@ export default function Tasks() {
const pull = usePullRefresh(() => Promise.all([tasks.refetch(), mine.refetch()]));
const newTask = () => router.push("/new-task");
- const sections = useMemo(() => {
+ const items = useMemo(() => {
const list = [...tasksInProject(tasks.data ?? [], projectId)].sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
- return GROUPS.map((g) => ({ title: g.title, data: list.filter((t) => g.statuses.includes(t.status)) })).filter((s) => s.data.length);
+ const out: Item[] = [];
+ for (const g of GROUPS) {
+ const group = list.filter((t) => g.statuses.includes(t.status));
+ if (!group.length) continue;
+ out.push({ kind: "header", key: `h-${g.title}`, title: g.title });
+ for (const task of group) out.push({ kind: "task", key: task.id, task });
+ }
+ return out;
}, [tasks.data, projectId]);
const forYou = mine.data ?? [];
+ const renderItem: ListRenderItem- = useCallback(
+ ({ item, index }) => {
+ if (item.kind === "header") return ;
+ const t = item.task;
+ return (
+
+ );
+ },
+ [agents, workspaceId, names, projectId, projects, items],
+ );
+
return (
<>
Tasks
@@ -47,14 +74,16 @@ export default function Tasks() {
{ icon: "person", label: "My tasks", onPress: () => router.push("/my-tasks") },
{ icon: "plus", label: "New task", onPress: newTask },
]}
+ menu={[{ icon: "archive", title: "Archived tasks", onPress: () => router.push("/archived-tasks") }]}
/>
-
- sections={sections}
- keyExtractor={(t) => t.id}
+ item.key}
+ getItemType={(item) => item.kind}
+ renderItem={renderItem}
contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={{ paddingHorizontal: space.sm, paddingBottom: 140 }}
refreshControl={}
- stickySectionHeadersEnabled={false}
ListHeaderComponent={
@@ -73,20 +102,6 @@ export default function Tasks() {
)}
}
- renderSectionHeader={({ section }) => (
-
-
-
- )}
- ItemSeparatorComponent={() => }
- renderItem={({ item }) => (
-
- )}
ListEmptyComponent={
tasks.isLoading ? (
@@ -108,22 +123,49 @@ export default function Tasks() {
);
}
+function GroupTitle({ title }: { title: string }) {
+ return (
+
+
+
+ );
+}
+
+function archiveTask(id: string) {
+ api.tasks
+ .update(id, { archived: true })
+ .catch((err) => Alert.alert("Couldn't archive the task", errorText(err)))
+ .finally(() => void queryClient.invalidateQueries({ queryKey: qk.tasks }));
+}
+
/** Long press on iOS: archive the task (off the board, restorable on the computer). */
-function TaskItem({ task, agent, workspaceName, projectName }: { task: Task; agent?: Agent; workspaceName?: string; projectName?: string }) {
- const archive = useMutation({
- mutationFn: () => api.tasks.update(task.id, { archived: true }),
- onSettled: () => queryClient.invalidateQueries({ queryKey: qk.tasks }),
- onError: (err) => Alert.alert("Couldn't archive the task", errorText(err)),
- });
+const TaskItem = memo(function TaskItem({
+ task,
+ agent,
+ workspaceName,
+ projectName,
+ divided,
+}: {
+ task: Task;
+ agent?: Agent;
+ workspaceName?: string;
+ projectName?: string;
+ divided: boolean;
+}) {
const confirmArchive = () => {
- if (task.status !== "in_progress") return archive.mutate();
+ if (task.status !== "in_progress") return archiveTask(task.id);
Alert.alert("Stop the agent?", `${agent?.name ?? "The agent"} is still working on it. Archiving it stops the run.`, [
{ text: "Keep working", style: "cancel" },
- { text: "Stop and archive", style: "destructive", onPress: () => archive.mutate() },
+ { text: "Stop and archive", style: "destructive", onPress: () => archiveTask(task.id) },
]);
};
- const row = ;
+ const row = (
+ <>
+ {divided && }
+
+ >
+ );
if (process.env.EXPO_OS !== "ios") return row;
return (
@@ -136,4 +178,4 @@ function TaskItem({ task, agent, workspaceName, projectName }: { task: Task; age
);
-}
+});
diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx
index 7f6a2aea..630991b5 100644
--- a/apps/mobile/src/app/_layout.tsx
+++ b/apps/mobile/src/app/_layout.tsx
@@ -1,11 +1,13 @@
import { QueryClientProvider } from "@tanstack/react-query";
+import { Image } from "expo-image";
import { Stack, ThemeProvider } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import { StatusBar } from "expo-status-bar";
-import { useEffect } from "react";
+import { useEffect, useState } from "react";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { AppLock } from "@/components/app-lock";
+import { forgetQueries, persistQueries, restoreQueries } from "@/lib/persist";
import { queryClient } from "@/lib/query";
import { startRealtime } from "@/lib/realtime";
import { useSession } from "@/lib/session";
@@ -24,19 +26,36 @@ export default function RootLayout() {
void useSession.getState().load();
}, []);
+ // What the last session saw is on screen before the computer answers.
+ const [restored, setRestored] = useState(false);
useEffect(() => {
- if (ready) void SplashScreen.hideAsync();
- }, [ready]);
+ if (!ready || restored) return;
+ const connection = useSession.getState().connection;
+ if (!connection) return setRestored(true);
+ void restoreQueries(connection.deviceId).finally(() => setRestored(true));
+ }, [ready, restored]);
useEffect(() => {
+ if (restored) void SplashScreen.hideAsync();
+ }, [restored]);
+
+ useEffect(() => {
+ if (!restored) return;
if (!deviceId) {
queryClient.clear();
+ forgetQueries();
+ void Image.clearDiskCache().catch(() => undefined);
return;
}
- return startRealtime();
- }, [deviceId]);
+ const stopRealtime = startRealtime();
+ const stopSaving = persistQueries(deviceId);
+ return () => {
+ stopRealtime();
+ stopSaving();
+ };
+ }, [deviceId, restored]);
- if (!ready) return null;
+ if (!ready || !restored) return null;
return (
@@ -52,6 +71,10 @@ export default function RootLayout() {
+
+
+
+
diff --git a/apps/mobile/src/app/activity.tsx b/apps/mobile/src/app/activity.tsx
new file mode 100644
index 00000000..4136c242
--- /dev/null
+++ b/apps/mobile/src/app/activity.tsx
@@ -0,0 +1,194 @@
+import { FlashList, type ListRenderItem } from "@shopify/flash-list";
+import { useQuery } from "@tanstack/react-query";
+import { isToday } from "date-fns";
+import { Stack } from "expo-router";
+import { memo, useCallback, useMemo, useState, type ReactNode } from "react";
+import { Pressable, RefreshControl, StyleSheet, View } from "react-native";
+import { useShallow } from "zustand/react/shallow";
+import type { Run } from "@godmode/shared";
+import { Icon } from "@/components/icon";
+import { isLiveRun, RunRow } from "@/components/run-row";
+import { Card, EmptyState, ErrorState, Hairline, LiveDot, Row, SkeletonRows, T, tap } from "@/components/ui";
+import { api, errorText } from "@/lib/api";
+import { useAgents } from "@/lib/hooks";
+import { useLive } from "@/lib/live";
+import { qk } from "@/lib/query";
+import { WorkspaceChip } from "@/components/workspace-chip";
+import { usePullRefresh } from "@/lib/use-pull-refresh";
+import { useWorkspace } from "@/lib/workspace";
+import { radius, space, useColors } from "@/lib/theme";
+
+const LIMIT = 60;
+
+type Filter = "all" | "failed" | "running";
+
+const FILTERS: { id: Filter; label: string }[] = [
+ { id: "all", label: "All" },
+ { id: "failed", label: "Failed" },
+ { id: "running", label: "Running" },
+];
+
+const EMPTY: Record = {
+ all: { title: "Nothing ran yet", body: "When your agents chat, run automations or hand over work, every run shows up here." },
+ failed: { title: "No failed runs" },
+ running: { title: "Nothing is running right now" },
+};
+
+interface Stats {
+ working: number;
+ done: number;
+ failed: number;
+}
+
+const ended = (run: Run) => run.status === "succeeded" || run.status === "failed" || run.status === "cancelled";
+const endedToday = (run: Run) => isToday(new Date(run.finishedAt ?? run.createdAt));
+
+/** The latest runs of the picked workspace's agents (or project's), with the ones working right now. */
+export default function Activity() {
+ const [filter, setFilter] = useState("all");
+ const { id: workspaceId, projectId } = useWorkspace();
+ const list = useQuery({ queryKey: qk.recentRuns, queryFn: () => api.runs.list({ limit: LIMIT }) });
+ const agents = useAgents();
+ 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);
+ // A workspace's runs are known once its agents are.
+ const loading = list.isLoading || (!!workspaceId && agents.isLoading);
+
+ const runs = useMemo(() => {
+ const merged = new Map((list.data ?? []).map((run) => [run.id, run]));
+ // A live run is fresher than the fetched one, unless the list already knows how it ended.
+ for (const run of liveRuns) {
+ const fetched = merged.get(run.id);
+ if (!fetched || !ended(fetched)) merged.set(run.id, run);
+ }
+ return [...merged.values()]
+ .filter((run) => {
+ if (!workspaceId) return true;
+ const agent = byId.get(run.agentId);
+ if (!agent) return false;
+ return projectId ? agent.projectId === projectId : !agent.workspaceId || agent.workspaceId === workspaceId;
+ })
+ .sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
+ }, [list.data, liveRuns, workspaceId, projectId, byId]);
+
+ const stats = useMemo(
+ () => ({
+ working: runs.filter(isLiveRun).length,
+ done: runs.filter((run) => run.status === "succeeded" && endedToday(run)).length,
+ failed: runs.filter((run) => run.status === "failed" && endedToday(run)).length,
+ }),
+ [runs],
+ );
+ const data = useMemo(() => (filter === "all" ? runs : runs.filter((run) => (filter === "failed" ? run.status === "failed" : isLiveRun(run)))), [runs, filter]);
+
+ const renderItem: ListRenderItem = useCallback(({ item }) => , [byId]);
+
+ return (
+ <>
+ Activity
+ run.id}
+ renderItem={renderItem}
+ contentInsetAdjustmentBehavior="automatic"
+ contentContainerStyle={{ paddingHorizontal: space.sm, paddingBottom: 80 }}
+ refreshControl={}
+ ListHeaderComponent={!loading && runs.length > 0 ? : null}
+ ItemSeparatorComponent={Separator}
+ ListEmptyComponent={
+ loading ? (
+
+
+
+ ) : list.isError ? (
+ void list.refetch()} />
+ ) : (
+
+ )
+ }
+ />
+ >
+ );
+}
+
+const Separator = () => ;
+
+/** 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} />} />
+ } />
+ 0 ? c.danger : c.textFaint} />} />
+
+
+ {FILTERS.map((f) => (
+ onFilter(f.id)} />
+ ))}
+
+
+ );
+});
+
+/** The number in the text color; its mark carries the state, so it never reads by color alone. */
+function Stat({ label, value, mark }: { label: string; value: number; mark: ReactNode }) {
+ return (
+
+
+ {value}
+
+ {mark}
+
+ {label}
+
+
+
+
+ );
+}
+
+function Chip({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
+ const c = useColors();
+ return (
+ {
+ tap();
+ onPress();
+ }}
+ style={[styles.chip, { backgroundColor: active ? c.primary : c.sunken }]}
+ >
+
+ {label}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ header: {
+ paddingHorizontal: space.sm,
+ paddingTop: space.md,
+ paddingBottom: space.sm,
+ gap: space.md,
+ },
+ stat: {
+ paddingVertical: space.md,
+ paddingHorizontal: space.md,
+ gap: 2,
+ borderRadius: radius.md,
+ },
+ chip: {
+ justifyContent: "center",
+ height: 32,
+ paddingHorizontal: 14,
+ borderRadius: radius.pill,
+ },
+});
diff --git a/apps/mobile/src/app/archived-tasks.tsx b/apps/mobile/src/app/archived-tasks.tsx
new file mode 100644
index 00000000..e940d699
--- /dev/null
+++ b/apps/mobile/src/app/archived-tasks.tsx
@@ -0,0 +1,128 @@
+import { FlashList, type ListRenderItem } from "@shopify/flash-list";
+import { useQuery } from "@tanstack/react-query";
+import { Link, Stack } from "expo-router";
+import { memo, useCallback, useMemo } from "react";
+import { Alert, Pressable, RefreshControl, StyleSheet, View } from "react-native";
+import type { Agent, Task } from "@godmode/shared";
+import { TaskRow } from "@/components/task-row";
+import { EmptyState, ErrorState, Hairline, SkeletonRows, T, tap } from "@/components/ui";
+import { api, errorText } from "@/lib/api";
+import { useAgents } from "@/lib/hooks";
+import { qk, queryClient } from "@/lib/query";
+import { usePullRefresh } from "@/lib/use-pull-refresh";
+import { tasksInProject, useProjectIndex, useWorkspace } from "@/lib/workspace";
+import { radius, space, useColors } from "@/lib/theme";
+
+/** Tasks taken off the board of the picked workspace (or project), the latest archived first. */
+export default function ArchivedTasks() {
+ const { id: workspaceId, projectId, workspaces } = useWorkspace();
+ const projects = useProjectIndex();
+ const tasks = useQuery({ queryKey: qk.taskList(workspaceId, true), queryFn: () => api.tasks.list({ workspaceId, archived: true }) });
+ const { byId: agents } = useAgents();
+ const names = useMemo(() => new Map(workspaces.map((w) => [w.id, w.name])), [workspaces]);
+ const pull = usePullRefresh(tasks.refetch);
+
+ const data = useMemo(
+ () => [...tasksInProject(tasks.data ?? [], projectId)].sort((a, b) => ((a.archivedAt ?? a.updatedAt) < (b.archivedAt ?? b.updatedAt) ? 1 : -1)),
+ [tasks.data, projectId],
+ );
+
+ const renderItem: ListRenderItem = useCallback(
+ ({ item: t }) => (
+
+ ),
+ [agents, workspaceId, names, projectId, projects],
+ );
+
+ return (
+ <>
+ Archived tasks
+ t.id}
+ renderItem={renderItem}
+ contentInsetAdjustmentBehavior="automatic"
+ contentContainerStyle={{ paddingHorizontal: space.sm, paddingTop: space.sm, paddingBottom: 80 }}
+ refreshControl={}
+ ItemSeparatorComponent={Separator}
+ ListEmptyComponent={
+ tasks.isLoading ? (
+
+ ) : tasks.isError ? (
+ void tasks.refetch()} />
+ ) : (
+
+ )
+ }
+ />
+ >
+ );
+}
+
+const Separator = () => ;
+
+/** Out of every cached archive right away; the refetch brings it back if the computer refused. */
+function restoreTask(id: string) {
+ queryClient.setQueriesData({ queryKey: [...qk.tasks, "list"], predicate: (q) => q.queryKey[3] === "archived" }, (list) => list?.filter((t) => t.id !== id));
+ api.tasks
+ .update(id, { archived: false })
+ .catch((err) => Alert.alert("Couldn't put the task back", errorText(err)))
+ .finally(() => void queryClient.invalidateQueries({ queryKey: qk.tasks }));
+}
+
+/** Long press on iOS: put it back on the board. Android has no such menu, so the row carries a Restore button. */
+const ArchivedTask = memo(function ArchivedTask({ task, agent, workspaceName, projectName }: { task: Task; agent?: Agent; workspaceName?: string; projectName?: string }) {
+ const c = useColors();
+ const row = ;
+ if (process.env.EXPO_OS !== "ios") {
+ return (
+
+ {row}
+ {
+ tap();
+ restoreTask(task.id);
+ }}
+ style={({ pressed }) => [styles.action, { backgroundColor: c.sunken, opacity: pressed ? 0.6 : 1 }]}
+ >
+
+ Restore
+
+
+
+ );
+ }
+ return (
+
+
+ {row}
+
+
+
+ restoreTask(task.id)} />
+
+
+ );
+});
+
+const styles = StyleSheet.create({
+ withAction: {
+ flexDirection: "row",
+ alignItems: "center",
+ paddingRight: space.sm,
+ },
+ action: {
+ height: 30,
+ paddingHorizontal: space.md,
+ borderRadius: radius.pill,
+ justifyContent: "center",
+ },
+});
diff --git a/apps/mobile/src/app/archived.tsx b/apps/mobile/src/app/archived.tsx
new file mode 100644
index 00000000..e66d6fb4
--- /dev/null
+++ b/apps/mobile/src/app/archived.tsx
@@ -0,0 +1,199 @@
+import { FlashList, type ListRenderItem } from "@shopify/flash-list";
+import { useQuery } from "@tanstack/react-query";
+import { Link, Stack } from "expo-router";
+import { memo, useCallback, useMemo } from "react";
+import { Alert, Pressable, RefreshControl, StyleSheet, View } from "react-native";
+import type { Agent, Conversation, Project } from "@godmode/shared";
+import { ConversationRow } from "@/components/rows";
+import { EmptyState, ErrorState, Hairline, SectionTitle, SkeletonRows, T, tap } from "@/components/ui";
+import { api, errorText } from "@/lib/api";
+import { useAgents } from "@/lib/hooks";
+import { useRunningChats } from "@/lib/live";
+import { qk, queryClient } from "@/lib/query";
+import { usePullRefresh } from "@/lib/use-pull-refresh";
+import { chatProject, inProject, useProjectIndex, useWorkspace } from "@/lib/workspace";
+import { radius, space, useColors } from "@/lib/theme";
+
+type Item = { kind: "header"; key: string; title: string; hint?: string } | { kind: "chat"; key: string; conversation: Conversation; divided: boolean };
+
+const LIMIT = 200;
+
+/** Chats taken out of the list. Archiving stops nothing, so the ones with work ahead come first. */
+export default function ArchivedChats() {
+ const { id: workspaceId, projectId } = useWorkspace();
+ const projects = useProjectIndex();
+ const list = useQuery({ queryKey: qk.archivedChats(workspaceId), queryFn: () => api.conversations.list({ archived: true, limit: LIMIT, workspaceId }) });
+ const { byId } = useAgents();
+ const pull = usePullRefresh(list.refetch);
+ const running = useRunningChats();
+
+ const items = useMemo(() => {
+ const chats = inProject(list.data ?? [], projectId, byId).filter((conv) => conv.origin !== "dream");
+ const isGoing = (conv: Conversation) => running.has(conv.id) || !!conv.paused || !!conv.followup;
+ const going = chats.filter(isGoing);
+ const out: Item[] = [];
+ const add = (group: Conversation[]) => group.forEach((conversation, i) => out.push({ kind: "chat", key: conversation.id, conversation, divided: i > 0 }));
+ if (going.length) {
+ out.push({ kind: "header", key: "h-going", title: "Still going", hint: "Archived, but the agent keeps working or comes back to them." });
+ add(going);
+ if (going.length < chats.length) out.push({ kind: "header", key: "h-archived", title: "Archived" });
+ }
+ add(chats.filter((conv) => !isGoing(conv)));
+ return out;
+ }, [list.data, projectId, byId, running]);
+
+ const renderItem: ListRenderItem- = useCallback(
+ ({ item }) => {
+ if (item.kind === "header") return ;
+ const conv = item.conversation;
+ const id = projectId ? null : chatProject(conv, byId);
+ return (
+
+ );
+ },
+ [byId, running, projectId, projects],
+ );
+
+ return (
+ <>
+ Archived chats
+ item.key}
+ getItemType={(item) => item.kind}
+ renderItem={renderItem}
+ extraData={running}
+ contentInsetAdjustmentBehavior="automatic"
+ contentContainerStyle={{ paddingHorizontal: space.sm, paddingBottom: 80 }}
+ refreshControl={}
+ ListEmptyComponent={
+ list.isLoading ? (
+
+
+
+ ) : list.isError ? (
+ void list.refetch()} />
+ ) : (
+
+ )
+ }
+ />
+ >
+ );
+}
+
+function GroupTitle({ title, hint }: { title: string; hint?: string }) {
+ return (
+
+
+ {hint ? (
+
+ {hint}
+
+ ) : null}
+
+ );
+}
+
+const refreshChats = () => void queryClient.invalidateQueries({ queryKey: qk.conversations });
+
+/** Out of every cached archive right away; the refetch brings it back if the computer refused. */
+function dropFromArchive(id: string) {
+ queryClient.setQueriesData({ queryKey: [...qk.conversations, "archived"] }, (list) => list?.filter((conv) => conv.id !== id));
+}
+
+function unarchiveChat(id: string) {
+ dropFromArchive(id);
+ api.conversations
+ .update(id, { archived: false })
+ .catch((err) => Alert.alert("Couldn't unarchive the chat", errorText(err)))
+ .finally(refreshChats);
+}
+
+function deleteChat(id: string) {
+ Alert.alert("Delete this chat?", "It's removed on your computer too.", [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Delete",
+ style: "destructive",
+ onPress: () => {
+ dropFromArchive(id);
+ api.conversations
+ .delete(id)
+ .catch((err) => Alert.alert("Couldn't delete the chat", errorText(err)))
+ .finally(refreshChats);
+ },
+ },
+ ]);
+}
+
+/** Long press on iOS: unarchive or delete. Android has no such menu, so the row carries an Unarchive button. */
+const ArchivedChat = memo(function ArchivedChat({
+ conversation,
+ agent,
+ running,
+ project,
+ divided,
+}: {
+ conversation: Conversation;
+ agent?: Agent;
+ running: boolean;
+ project?: Project;
+ divided: boolean;
+}) {
+ const c = useColors();
+ const row = ;
+ if (process.env.EXPO_OS !== "ios") {
+ return (
+ <>
+ {divided && }
+
+ {row}
+ {
+ tap();
+ unarchiveChat(conversation.id);
+ }}
+ style={({ pressed }) => [styles.action, { backgroundColor: c.sunken, opacity: pressed ? 0.6 : 1 }]}
+ >
+
+ Unarchive
+
+
+
+ >
+ );
+ }
+ return (
+
+
+
+ {divided && }
+ {row}
+
+
+
+
+ unarchiveChat(conversation.id)} />
+ deleteChat(conversation.id)} />
+
+
+ );
+});
+
+const styles = StyleSheet.create({
+ withAction: {
+ flexDirection: "row",
+ alignItems: "center",
+ paddingRight: space.sm,
+ },
+ action: {
+ height: 30,
+ paddingHorizontal: space.md,
+ borderRadius: radius.pill,
+ justifyContent: "center",
+ },
+});
diff --git a/apps/mobile/src/app/automations.tsx b/apps/mobile/src/app/automations.tsx
new file mode 100644
index 00000000..764ab587
--- /dev/null
+++ b/apps/mobile/src/app/automations.tsx
@@ -0,0 +1,115 @@
+import { FlashList, type ListRenderItem } from "@shopify/flash-list";
+import { useQuery } from "@tanstack/react-query";
+import { Stack } from "expo-router";
+import { useCallback, useMemo } from "react";
+import { RefreshControl, View } from "react-native";
+import type { Routine } from "@godmode/shared";
+import { RoutineRow } from "@/components/routine-row";
+import { EmptyState, ErrorState, Hairline, SectionTitle, SkeletonRows } from "@/components/ui";
+import { WorkspaceChip } from "@/components/workspace-chip";
+import { api, errorText } from "@/lib/api";
+import { useAgents } from "@/lib/hooks";
+import { useLive } from "@/lib/live";
+import { qk } from "@/lib/query";
+import { usePullRefresh } from "@/lib/use-pull-refresh";
+import { useWorkspace } from "@/lib/workspace";
+import { space } from "@/lib/theme";
+
+type Item = { kind: "header"; key: string; title: string } | { kind: "routine"; key: string; routine: Routine; divided: boolean };
+
+/** The next to run first, then the ones without a time by name. */
+function nextFirst(a: Routine, b: Routine): number {
+ if (a.nextRunAt && b.nextRunAt && a.nextRunAt !== b.nextRunAt) return a.nextRunAt < b.nextRunAt ? -1 : 1;
+ if (!a.nextRunAt !== !b.nextRunAt) return a.nextRunAt ? -1 : 1;
+ return a.name.localeCompare(b.name);
+}
+
+/** Automations with a run in them right now, re-rendering only when that set changes. */
+function useWorkingRoutines(): Set {
+ const key = useLive((s) => [...new Set(Object.values(s.runs).flatMap(({ run }) => (run.routineId ? [run.routineId] : [])))].sort().join("\n"));
+ 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. */
+export default function Automations() {
+ const { id: workspaceId, projectId } = useWorkspace();
+ const list = useQuery({ queryKey: qk.allRoutines, queryFn: () => api.routines.list() });
+ const agents = useAgents();
+ const { byId } = agents;
+ const working = useWorkingRoutines();
+ const pull = usePullRefresh(list.refetch);
+ // A workspace's automations are known once its agents are.
+ const loading = list.isLoading || (!!workspaceId && agents.isLoading);
+
+ const items = useMemo(() => {
+ const scoped = (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;
+ });
+ const out: Item[] = [];
+ for (const on of [true, false]) {
+ const group = scoped.filter((r) => r.enabled === on).sort(nextFirst);
+ if (!group.length) continue;
+ out.push({ kind: "header", key: on ? "h-on" : "h-off", title: on ? "On" : "Off" });
+ group.forEach((routine, i) => out.push({ kind: "routine", key: routine.id, routine, divided: i > 0 }));
+ }
+ return out;
+ }, [list.data, workspaceId, projectId, byId]);
+
+ const renderItem: ListRenderItem
- = useCallback(
+ ({ item }) => {
+ if (item.kind === "header") return ;
+ return (
+ <>
+ {item.divided && }
+
+ >
+ );
+ },
+ [byId, working],
+ );
+
+ return (
+ <>
+ Automations
+ item.key}
+ getItemType={(item) => item.kind}
+ renderItem={renderItem}
+ extraData={working}
+ ListHeaderComponent={ScopeHeader}
+ contentInsetAdjustmentBehavior="automatic"
+ contentContainerStyle={{ paddingHorizontal: space.sm, paddingBottom: 80 }}
+ refreshControl={}
+ ListEmptyComponent={
+ loading ? (
+
+
+
+ ) : list.isError ? (
+ void list.refetch()} />
+ ) : (
+
+ )
+ }
+ />
+ >
+ );
+}
+
+const ScopeHeader = () => (
+
+
+
+);
+
+function GroupTitle({ title }: { title: string }) {
+ return (
+
+
+
+ );
+}
diff --git a/apps/mobile/src/app/chat/[id].tsx b/apps/mobile/src/app/chat/[id].tsx
index f462e42f..91fa3d9f 100644
--- a/apps/mobile/src/app/chat/[id].tsx
+++ b/apps/mobile/src/app/chat/[id].tsx
@@ -1,13 +1,14 @@
-import { FlashList, type FlashListRef } from "@shopify/flash-list";
+import { FlashList, type FlashListRef, type ListRenderItem } from "@shopify/flash-list";
import { useQuery } from "@tanstack/react-query";
import { router, Stack, useFocusEffect, useLocalSearchParams } from "expo-router";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { Alert, Pressable, ScrollView, StyleSheet, View } from "react-native";
+import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { ActivityIndicator, Alert, Modal, Pressable, ScrollView, StyleSheet, TextInput, View } from "react-native";
import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
characterGreeting,
type Agent,
+ type ConversationFollowup,
type ConversationWithMessages,
type HumanTask,
type Message,
@@ -21,43 +22,72 @@ import {
} from "@godmode/shared";
import { CharacterAvatar } from "@/components/character";
import { Composer, ComposerDock, type ComposerHandle } from "@/components/composer";
-import { HeaderActions } from "@/components/header-actions";
+import { HeaderActions, MenuSheet, type HeaderMenuItem } from "@/components/header-actions";
import { LiveStrip } from "@/components/live-strip";
import { AssistantMessage, UserMessage } from "@/components/message";
import { Icon } from "@/components/icon";
import { ModelButton } from "@/components/model-button";
+import { ChatScope, type ChatScopeValue } from "@/components/question-card";
import { QueueTray } from "@/components/queue-tray";
-import { ErrorState, Skeleton, T, tap } from "@/components/ui";
+import { Button, ErrorState, Skeleton, T, tap } from "@/components/ui";
import { openHumanTask } from "@/components/human-task-row";
import { ApiError, api, errorText } from "@/lib/api";
import { encodeFiles, type PendingFile } from "@/lib/attachments";
+import { fetchConversation, loadEarlier } from "@/lib/chat-cache";
import { setQueue } from "@/lib/composer";
+import { whenText } from "@/lib/format";
import { newQueueId, pendingQueued, withPending } from "@/lib/pending-queue";
import { useAgents } from "@/lib/hooks";
-import { useConversationRun, useLive } from "@/lib/live";
+import { useChatRun, useLive, useRunActivity } from "@/lib/live";
import { qk, queryClient } from "@/lib/query";
import { subscribeConversation, viewConversation } from "@/lib/realtime";
-import { screenHref, useChatScreens } from "@/lib/screens";
-import { radius, space, useColors } from "@/lib/theme";
+import { screenHref, useChatScreens, type LiveScreen } from "@/lib/screens";
+import { radius, space, type, useColors } from "@/lib/theme";
type Item =
| { key: string; role: "intro" }
+ | { key: string; role: "earlier" }
| { key: string; role: "user"; message: Message }
- | { key: string; role: "assistant"; blocks: MessageBlock[]; content: string; streaming: boolean };
+ | { key: string; role: "assistant"; blocks: MessageBlock[]; content: string; streaming: boolean }
+ | { key: string; role: "live" };
+
+const NO_BLOCKS: MessageBlock[] = [];
+/** What marks a place in the chat where it went on without the human writing. */
+const MARKERS = new Set(["human_task", "followup", "retry"]);
+
+/** A stored message's row, the same object for as long as the message is: rows that didn't change don't render again. */
+const settled = new WeakMap();
+
+function itemOf(m: Message): Item | null {
+ let item = settled.get(m);
+ if (item !== undefined) return item;
+ if (m.role === "user") item = { key: m.id, role: "user", message: m };
+ else if (m.role === "assistant") item = { key: m.id, role: "assistant", blocks: m.blocks, content: m.content, streaming: false };
+ else {
+ const markers = m.blocks.filter((b) => MARKERS.has(b.type));
+ item = markers.length ? { key: m.id, role: "assistant", blocks: markers, content: "", streaming: false } : null;
+ }
+ settled.set(m, item);
+ return item;
+}
+
+const Gap = () => ;
export default function Chat() {
const { id } = useLocalSearchParams<{ id: string }>();
const insets = useSafeAreaInsets();
const keyboardOpen = useKeyboardState((s) => s.isVisible);
const list = useRef>(null);
- const conversation = useQuery({ queryKey: qk.conversation(id), queryFn: () => api.conversations.get(id) });
+ const conversation = useQuery({ queryKey: qk.conversation(id), queryFn: () => fetchConversation(id) });
const forYou = useQuery({ queryKey: qk.chatHumanTasks(id), queryFn: () => api.humanTasks.list({ status: "active", conversationId: id }) });
const { byId } = useAgents();
const agent = conversation.data ? byId.get(conversation.data.agentId) : undefined;
- const run = useConversationRun(id);
- const draft = useLive((s) => s.drafts[id]);
+ const run = useChatRun(id);
+ // Only which message streams: its text lives in the row that shows it, so the screen doesn't render with every word.
+ const liveMessageId = useLive((s) => (run && s.drafts[id]?.runId === run.id ? s.drafts[id]!.messageId : null));
const screens = useChatScreens(conversation.data, agent);
const composer = useRef(null);
+ const [renaming, setRenaming] = useState(false);
useEffect(() => subscribeConversation(id), [id]);
// Read while it is the screen in front (not while another screen covers it).
@@ -68,43 +98,45 @@ export default function Chat() {
}, [id]),
);
+ const messages = conversation.data?.messages;
+ const hasMore = !!conversation.data?.hasMore;
+ const runId = run?.id;
+ const running = run?.status === "running";
const items = useMemo
- (() => {
- const messages = conversation.data?.messages ?? [];
- const live = run && draft && draft.runId === run.run.id ? draft : null;
- const out: Item[] = [{ key: "intro", role: "intro" }];
- for (const m of messages) {
- if (m.role === "user") out.push({ key: m.id, role: "user", message: m });
- else if (m.role === "assistant" && m.id !== live?.messageId) {
- out.push({ key: m.id, role: "assistant", blocks: m.blocks, content: m.content, streaming: !!run && m.runId === run.run.id });
- }
- else if (m.role === "system") {
- // Where the human closed a task the agent gave them; the chat continued from there.
- const marker = m.blocks.filter((b) => b.type === "human_task");
- if (marker.length) out.push({ key: m.id, role: "assistant", blocks: marker, content: "", streaming: false });
- }
- }
- if (live) {
- out.push({ key: live.messageId, role: "assistant", blocks: live.blocks, content: "", streaming: true });
- } else if (run && run.run.status === "running" && !messages.some((m) => m.runId === run.run.id && m.role === "assistant")) {
- out.push({ key: `pending-${run.run.id}`, role: "assistant", blocks: [], content: "", streaming: true });
+ const out: Item[] = [hasMore ? { key: "earlier", role: "earlier" } : { key: "intro", role: "intro" }];
+ let answered = false;
+ for (const m of messages ?? []) {
+ if (m.id === liveMessageId) continue;
+ const item = itemOf(m);
+ if (!item) continue;
+ if (runId && m.role === "assistant" && m.runId === runId) {
+ answered = true;
+ out.push({ ...item, streaming: true } as Item);
+ } else out.push(item);
}
+ if (liveMessageId || (running && !answered)) out.push({ key: liveMessageId ?? `pending-${runId}`, role: "live" });
return out;
- }, [conversation.data, draft, run]);
+ }, [messages, hasMore, liveMessageId, runId, running]);
// The chat's run stands still (paused on the computer, or waiting for Claude's usage limit).
const pause = conversation.data?.paused;
- const paused = (pause && pause.runId !== run?.run.id && pause) || null;
+ const paused = (pause && pause.runId !== run?.id && pause) || null;
const queue = conversation.data?.queue ?? [];
// The run waits for the human's answer to a question: the next message is that answer.
const waiting = paused?.reason === "question";
// While the agent works, is paused or older messages still wait, a new message joins the queue.
const queueing = !waiting && (!!run || !!paused || queue.length > 0);
+ const followup = !run && !paused ? (conversation.data?.followup ?? null) : null;
+
+ const scope = useMemo(
+ () => ({ conversationId: id, agentName: agent?.name ?? "The agent", openQuestionId: (pause?.reason === "question" && pause.question?.id) || null }),
+ [id, agent?.name, pause],
+ );
const send = async (content: string, files: PendingFile[]) => {
const key = qk.conversation(id);
const queueId = newQueueId();
const tempId = `pending-${queueId}`;
- await queryClient.cancelQueries({ queryKey: key });
const draft = {
conversationId: id,
content,
@@ -151,7 +183,6 @@ export default function Chat() {
};
});
useLive.getState().runStarted(result.run);
- void queryClient.invalidateQueries({ queryKey: key });
requestAnimationFrame(() => list.current?.scrollToEnd({ animated: true }));
} catch (err) {
pendingQueued.delete(queueId);
@@ -166,7 +197,7 @@ export default function Chat() {
const onLost = useCallback((text: string) => composer.current?.insert(text), []);
const stop = () => {
- if (run) void api.runs.cancel(run.run.id).catch((err) => Alert.alert("Couldn't stop", errorText(err)));
+ if (run) void api.runs.cancel(run.id).catch((err) => Alert.alert("Couldn't stop", errorText(err)));
};
const resume = () => {
@@ -200,13 +231,84 @@ export default function Chat() {
.finally(() => setRetrying(false));
};
+ const change = (patch: { title?: string; pinned?: boolean; archived?: boolean }) =>
+ api.conversations
+ .update(id, patch)
+ .then((next) => {
+ // Only what changed: a runner's chat answers with every message.
+ queryClient.setQueryData(qk.conversation(id), (old) => (old ? { ...old, title: next.title, pinned: next.pinned, archived: next.archived } : old));
+ void queryClient.invalidateQueries({ queryKey: qk.conversations, predicate: (q) => q.queryKey[1] !== "detail" });
+ return true;
+ })
+ .catch((err) => {
+ Alert.alert("Couldn't change the chat", errorText(err));
+ return false;
+ });
+
+ const remove = () =>
+ Alert.alert("Delete this chat?", "It's removed on your computer too.", [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Delete",
+ style: "destructive",
+ onPress: () =>
+ void api.conversations
+ .delete(id)
+ .then(() => {
+ void queryClient.invalidateQueries({ queryKey: qk.conversations, predicate: (q) => q.queryKey[1] !== "detail" });
+ router.back();
+ })
+ .catch((err) => Alert.alert("Couldn't delete the chat", errorText(err))),
+ },
+ ]);
+
+ const rename = () => {
+ if (process.env.EXPO_OS !== "ios") return setRenaming(true);
+ Alert.prompt("Rename chat", undefined, (text) => text.trim() && void change({ title: text.trim() }), "plain-text", conversation.data?.title ?? "");
+ };
+
const title = conversation.data?.title || "Chat";
const primary = screens[0];
+ const data = conversation.data;
+ const menu: HeaderMenuItem[] = data
+ ? [
+ ...(running && !paused
+ ? [{ icon: "pause" as const, title: "Pause", onPress: () => void api.conversations.pause(id).catch((err) => Alert.alert("Couldn't pause", errorText(err))) }]
+ : []),
+ { icon: "pencil", title: "Rename", onPress: rename },
+ { icon: "pin", title: data.pinned ? "Unpin" : "Pin", onPress: () => void change({ pinned: !data.pinned }) },
+ data.archived
+ ? { icon: "unarchive", title: "Unarchive", onPress: () => void change({ archived: false }) }
+ : { icon: "archive", title: "Archive", onPress: () => void change({ archived: true }).then((done) => done && router.back()) },
+ { icon: "trash", title: "Delete", destructive: true, onPress: remove },
+ ]
+ : [];
+
+ const renderItem: ListRenderItem
- = useCallback(
+ ({ item }) => {
+ switch (item.role) {
+ case "intro":
+ return agent ? : null;
+ case "earlier":
+ return ;
+ case "user":
+ return ;
+ case "live":
+ return ;
+ default:
+ return ;
+ }
+ },
+ [agent, id, runId],
+ );
return (
<>
{title}
- {primary && router.push(screenHref(primary)) }]} />}
+ router.push(screenHref(primary)) }] : []}
+ menu={menu}
+ />
{!conversation.data ? (
@@ -217,29 +319,25 @@ export default function Chat() {
)}
) : (
- item.key}
- contentInsetAdjustmentBehavior="automatic"
- keyboardDismissMode="interactive"
- maintainVisibleContentPosition={{ startRenderingFromBottom: true, autoscrollToBottomThreshold: 0.25 }}
- contentContainerStyle={styles.list}
- ItemSeparatorComponent={() => }
- renderItem={({ item }) =>
- item.role === "intro" ? (
- agent ? : null
- ) : item.role === "user" ? (
-
- ) : (
-
- )
- }
- getItemType={(item) => item.role}
- />
+
+ item.key}
+ contentInsetAdjustmentBehavior="automatic"
+ keyboardDismissMode="interactive"
+ maintainVisibleContentPosition={{ startRenderingFromBottom: true, autoscrollToBottomThreshold: 0.25 }}
+ onStartReached={() => void loadEarlier(id).catch(() => undefined)}
+ onStartReachedThreshold={0.6}
+ contentContainerStyle={styles.list}
+ ItemSeparatorComponent={Gap}
+ renderItem={renderItem}
+ getItemType={(item) => item.role}
+ />
+
)}
{forYou.data?.length ? : null}
- {run && primary ? : null}
+ {run && primary ? : null}
{paused?.reason === "question" ? (
) : paused ? (
@@ -251,6 +349,8 @@ export default function Chat() {
/>
) : ended ? (
+ ) : followup ? (
+
) : null}
+ setRenaming(false)} onSave={(text) => void change({ title: text })} />
+ >
+ );
+}
+
+/** The answer being written: it alone renders with every word that arrives. */
+const LiveMessage = memo(function LiveMessage({ conversationId, runId }: { conversationId: string; runId?: string }) {
+ const blocks = useLive((s) => {
+ const draft = s.drafts[conversationId];
+ return draft && draft.runId === runId ? draft.blocks : NO_BLOCKS;
+ });
+ return ;
+});
+
+function ChatLiveStrip({ screen, runId }: { screen: LiveScreen; runId: string }) {
+ return ;
+}
+
+/** At the top while older messages exist: they load when the list gets there, or with a tap when that failed. */
+function Earlier({ conversationId }: { conversationId: string }) {
+ const c = useColors();
+ const [failed, setFailed] = useState(false);
+ const load = () => {
+ setFailed(false);
+ loadEarlier(conversationId).catch(() => setFailed(true));
+ };
+ // The list only says once that its start was reached: this row asks for the page itself when it appears.
+ useEffect(() => {
+ loadEarlier(conversationId).catch(() => setFailed(true));
+ }, [conversationId]);
+ return (
+
+ {failed ? : }
+
+ {failed ? "Couldn't load earlier messages. Tap to try again" : "Earlier messages"}
+
+
+ );
+}
+
+/** Android has no text prompt of its own. */
+function RenameDialog({ open, title, onClose, onSave }: { open: boolean; title: string; onClose: () => void; onSave: (title: string) => void }) {
+ const c = useColors();
+ const [text, setText] = useState(title);
+ useEffect(() => {
+ if (open) setText(title);
+ }, [open, title]);
+ const save = () => {
+ if (text.trim()) onSave(text.trim());
+ onClose();
+ };
+ return (
+
+
+
+
+ Rename chat
+
+
+
+
+
+
+
+
+ );
+}
+
+/** Above the composer while the agent plans to come back to the chat on its own. */
+function FollowupStrip({ conversationId, followup, agentName }: { conversationId: string; followup: ConversationFollowup; agentName: string }) {
+ const c = useColors();
+ const [open, setOpen] = useState(false);
+ const refresh = () => {
+ 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));
+ const move = (dueAt: Date) => void api.conversations.followup.move(conversationId, dueAt.toISOString()).then(refresh).catch(failed("Couldn't move the follow-up"));
+ const inAnHour = new Date(Date.now() + 3600_000);
+ const tomorrow = new Date();
+ tomorrow.setDate(tomorrow.getDate() + 1);
+ tomorrow.setHours(9, 0, 0, 0);
+ const items: HeaderMenuItem[] = [
+ {
+ icon: "play",
+ title: "Continue now",
+ onPress: () =>
+ void api.conversations.followup
+ .runNow(conversationId)
+ .then((started) => {
+ useLive.getState().runStarted(started);
+ refresh();
+ })
+ .catch(failed("Couldn't continue now")),
+ },
+ { icon: "clock", title: "In an hour", onPress: () => move(inAnHour) },
+ { icon: "clock", title: "Tomorrow at 09:00", onPress: () => move(tomorrow) },
+ {
+ icon: "close",
+ title: "Cancel the follow-up",
+ destructive: true,
+ onPress: () => void api.conversations.followup.cancel(conversationId).then(refresh).catch(failed("Couldn't cancel the follow-up")),
+ },
+ ];
+ return (
+ <>
+ {
+ tap();
+ setOpen(true);
+ }}
+ style={({ pressed }) => [styles.paused, { backgroundColor: c.surface, borderColor: c.border, opacity: pressed ? 0.75 : 1 }]}
+ >
+
+
+
+ Continues {whenText(followup.dueAt)}
+
+ {followup.note ? ` ${followup.note}` : ""}
+
+
+ Change
+
+
+ setOpen(false)} />
>
);
}
@@ -484,6 +722,31 @@ const styles = StyleSheet.create({
borderCurve: "continuous",
borderWidth: StyleSheet.hairlineWidth,
},
+ earlier: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 8,
+ paddingTop: space.xl,
+ paddingBottom: space.sm,
+ },
+ dialogWrap: {
+ flex: 1,
+ justifyContent: "center",
+ paddingHorizontal: space.xl,
+ },
+ dialog: {
+ gap: space.lg,
+ padding: space.xl,
+ borderRadius: radius.lg,
+ borderCurve: "continuous",
+ },
+ dialogInput: {
+ height: 46,
+ paddingHorizontal: space.md,
+ borderRadius: radius.sm,
+ borderCurve: "continuous",
+ },
intro: {
alignItems: "center",
gap: 6,
diff --git a/apps/mobile/src/app/human-task/[id].tsx b/apps/mobile/src/app/human-task/[id].tsx
index 4550550d..eb15719f 100644
--- a/apps/mobile/src/app/human-task/[id].tsx
+++ b/apps/mobile/src/app/human-task/[id].tsx
@@ -1,6 +1,6 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { router, useLocalSearchParams } from "expo-router";
-import { useState } from "react";
+import { useMemo, useState } from "react";
import { Alert, Linking, Pressable, ScrollView, StyleSheet, TextInput, View } from "react-native";
import { humanTaskRef, isHumanTaskActive, type HumanTask } from "@godmode/shared";
import { CharacterAvatar } from "@/components/character";
@@ -20,12 +20,19 @@ import { radius, space, useColors } from "@/lib/theme";
export default function HumanTaskSheet() {
const c = useColors();
const { id, fromChat } = useLocalSearchParams<{ id: string; fromChat?: string }>();
- const list = useQuery({ queryKey: qk.humanTaskList("all"), queryFn: () => api.humanTasks.list({ status: "all" }) });
- const task = list.data?.find((t) => t.id === id);
+ const [settled, setSettled] = useState();
+ // Whichever list brought the human here already has the task: the sheet opens from that copy while every task loads.
+ const seed = useMemo(() => {
+ const hit = settled?.id === id ? settled : cachedHumanTask(id);
+ return hit ? [hit] : undefined;
+ }, [id, settled]);
+ const list = useQuery({ queryKey: qk.humanTaskList("all"), queryFn: () => api.humanTasks.list({ status: "all" }), placeholderData: seed });
+ const task = (list.data ?? seed)?.find((t) => t.id === id);
const { byId } = useAgents();
const [note, setNote] = useState("");
const settle = (next: HumanTask) => {
+ setSettled(next);
queryClient.setQueryData(qk.humanTaskList("all"), (old) => old?.map((t) => (t.id === next.id ? next : t)));
void queryClient.invalidateQueries({ queryKey: qk.humanTasks });
void queryClient.invalidateQueries({ queryKey: qk.tasks });
@@ -184,6 +191,15 @@ export default function HumanTaskSheet() {
);
}
+/** A human task from any list that is cached (the open ones, a chat's, all of them). */
+function cachedHumanTask(id: string): HumanTask | undefined {
+ for (const [, list] of queryClient.getQueriesData({ queryKey: qk.humanTasks })) {
+ const hit = Array.isArray(list) ? list.find((t) => t.id === id) : undefined;
+ if (hit) return hit;
+ }
+ return undefined;
+}
+
/** The sheet goes first, so the screen opens in front instead of under it. */
function leaveFor(open: () => void) {
router.dismiss();
diff --git a/apps/mobile/src/app/live.tsx b/apps/mobile/src/app/live.tsx
index c13a5c4d..55282b09 100644
--- a/apps/mobile/src/app/live.tsx
+++ b/apps/mobile/src/app/live.tsx
@@ -10,14 +10,14 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { browserView, type ComputerInputEvent } from "@godmode/shared";
import { Glass, GlassGroup } from "@/components/glass";
import { Icon } from "@/components/icon";
-import { frameUri, vmState } from "@/components/screen-tile";
+import { frameUri, phoneState, vmState } from "@/components/screen-tile";
import { Button, GlassIconButton, SMOKE, LiveDot, T, tap } from "@/components/ui";
import { api, errorText, type BrowserInput } from "@/lib/api";
import { hostOf } from "@/lib/format";
import { useNow } from "@/lib/hooks";
import { useLive, type Frame } from "@/lib/live";
import { qk, queryClient } from "@/lib/query";
-import { useStreamFrame, useVmFrame, type LiveScreen } from "@/lib/screens";
+import { useAndroidFrame, useStreamFrame, useVmFrame, type LiveScreen } from "@/lib/screens";
const WHITE = "#FFFFFF";
const DIM = "rgba(255,255,255,0.7)";
@@ -61,10 +61,14 @@ export default function Live() {
: params.kind === "share"
? { kind: "share", key: `computer:${params.id}`, view: params.id, title: params.title ?? "Shared screen", conversationId: "" }
: null;
+ const phones = useQuery({ queryKey: qk.androidDevices, queryFn: api.android.devices, enabled: params.kind === "android", retry: false });
+ const phone = params.kind === "android" ? (phones.data?.find((d) => d.id === params.id) ?? null) : null;
const stream = useStreamFrame(screen);
const shot = useVmFrame(vm, true, 1500);
+ const phoneShot = useAndroidFrame(phone, true, 1500);
// A browser that stopped leaves its last frame behind; don't show it as live.
- const frame: Frame | undefined = params.kind === "vm" ? shot.data : profile?.running === false ? undefined : stream;
+ const frame: Frame | undefined =
+ params.kind === "vm" ? shot.data : params.kind === "android" ? phoneShot.data : profile?.running === false ? undefined : stream;
const uri = frameUri(frame);
const online = useLive((s) => s.status === "online");
const fresh = !!frame && (params.kind === "browser" ? online : now - frame.at < 8000);
@@ -73,7 +77,8 @@ export default function Live() {
const keyboard = useKeyboardState((k) => (k.isVisible ? k.height : 0));
const [text, setText] = useState("");
const [ripple, setRipple] = useState<{ x: number; y: number; id: number; secondary: boolean } | null>(null);
- const canControl = params.kind !== "vm" || vm?.state === "running";
+ // An Android phone is watched from here; taking it over stays on the computer.
+ const canControl = params.kind === "android" ? false : params.kind !== "vm" || vm?.state === "running";
// Shared screens and VMs are whole Macs: they take right clicks and shortcuts; a browser tab doesn't.
const mac = params.kind !== "browser";
@@ -149,6 +154,10 @@ export default function Live() {
? vm
? vmState(vm.state)
: "Virtual machine"
+ : params.kind === "android"
+ ? phone
+ ? phoneState(phone)
+ : "Android phone"
: frame?.error
? frame.error
: params.kind === "browser"
@@ -179,7 +188,7 @@ export default function Live() {
accessibilityHint={mac ? "In control: tap to click, hold to right-click" : "In control: tap to click"}
style={shown}
>
-
+
{ripple && (
)}
>
+ ) : phone && phone.state !== "connected" ? (
+ <>
+
+
+ {`${phone.name} isn't connected`}
+
+
+ Plug it in or connect it over Wi-Fi in Godmode on your computer.
+
+ >
) : profile && !profile.running ? (
<>
@@ -220,9 +239,9 @@ export default function Live() {
{params.kind === "browser" && chat ? "Waiting for the browser. It shows up here as soon as an agent uses it." : "Waiting for the picture…"}
- {params.kind === "vm" && shot.error ? (
+ {(params.kind === "vm" && shot.error) || (params.kind === "android" && phoneShot.error) ? (
- {errorText(shot.error)}
+ {errorText(shot.error ?? phoneShot.error)}
) : null}
>
@@ -237,7 +256,7 @@ export default function Live() {
{fresh ? : }
- {params.title ?? vm?.name ?? "Screen"}
+ {params.title ?? vm?.name ?? phone?.name ?? "Screen"}
{subtitle}
diff --git a/apps/mobile/src/app/model.tsx b/apps/mobile/src/app/model.tsx
index db94af49..29f1b8b0 100644
--- a/apps/mobile/src/app/model.tsx
+++ b/apps/mobile/src/app/model.tsx
@@ -6,6 +6,7 @@ import { EFFORT_LABELS, ULTRACODE_HINT, type ClaudeModel, type ConversationWithM
import { Icon } from "@/components/icon";
import { Card, Hairline, T, tap } from "@/components/ui";
import { api, errorText, type ModelChoicePatch } from "@/lib/api";
+import { fetchConversation } from "@/lib/chat-cache";
import { NO_CHOICE, useEffectiveModel, useNewChatChoice, type ModelChoice } from "@/lib/composer";
import { useAgents } from "@/lib/hooks";
import { qk, queryClient } from "@/lib/query";
@@ -26,8 +27,10 @@ export default function ModelSheet() {
const { byId } = useAgents();
const conversation = useQuery({
queryKey: qk.conversation(conversationId ?? ""),
- queryFn: () => api.conversations.get(conversationId!),
+ queryFn: () => fetchConversation(conversationId!),
enabled: !!conversationId,
+ // The chat it was opened from has it: the sheet only reads the model from it.
+ staleTime: Infinity,
});
const newChoice = useNewChatChoice((s) => s.choice);
const agent = byId.get(conversation.data?.agentId ?? agentId ?? "");
diff --git a/apps/mobile/src/app/my-tasks.tsx b/apps/mobile/src/app/my-tasks.tsx
index 79309172..1846cb0a 100644
--- a/apps/mobile/src/app/my-tasks.tsx
+++ b/apps/mobile/src/app/my-tasks.tsx
@@ -13,6 +13,8 @@ import { space } from "@/lib/theme";
const DONE_SHOWN = 20;
+const Separator = () => ;
+
/** What agents need the human to do: a passkey, a CAPTCHA, a signature. Closing one continues the agent's chat. */
export default function MyTasks() {
const list = useQuery({ queryKey: qk.humanTaskList("all"), queryFn: () => api.humanTasks.list({ status: "all" }) });
@@ -45,7 +47,7 @@ export default function MyTasks() {
)}
- ItemSeparatorComponent={() => }
+ ItemSeparatorComponent={Separator}
renderItem={({ item }) => }
ListEmptyComponent={
list.isLoading ? (
diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx
index c271c46b..ee4d29d1 100644
--- a/apps/mobile/src/app/task/[id].tsx
+++ b/apps/mobile/src/app/task/[id].tsx
@@ -1,6 +1,6 @@
import { isWaiting, mergesOnApprove, reopenStatus, waitsForAnswer, waitsForSubtasks, waitsForTickets, type TaskBlockedKind } from "@godmode/shared";
import { useMutation, useQuery } from "@tanstack/react-query";
-import { useState } from "react";
+import { useMemo, useState } from "react";
import { router, Stack, useLocalSearchParams } from "expo-router";
import { Alert, Linking, Pressable, ScrollView, StyleSheet, View } from "react-native";
import type { Task, TaskStatus } from "@godmode/shared";
@@ -11,7 +11,8 @@ import { Markdown } from "@/components/markdown";
import { openChat } from "@/components/rows";
import { ProjectPicker } from "@/components/project-picker";
import { STATUS_META, TaskStatusBadge, TYPE_META } from "@/components/task-row";
-import { Avatar, Badge, Button, Card, ErrorState, LoadingState, Row, SectionTitle, T, tap } from "@/components/ui";
+import { TaskTimeline } from "@/components/task-timeline";
+import { Avatar, Badge, Button, Card, ErrorState, Row, SectionTitle, Skeleton, T, tap } from "@/components/ui";
import { ApiError, api, errorText } from "@/lib/api";
import { encodeFiles, type PendingFile } from "@/lib/attachments";
import { activityText } from "@/lib/format";
@@ -24,9 +25,11 @@ import { radius, space, useColors } from "@/lib/theme";
export default function TaskScreen() {
const c = useColors();
const { id } = useLocalSearchParams<{ id: string }>();
- const task = useQuery({ queryKey: qk.task(id), queryFn: () => api.tasks.get(id) });
+ // The board already has the ticket, without its long texts: the screen opens from that copy while the whole one loads.
+ const seed = useMemo(() => boardTicket(id), [id]);
+ const task = useQuery({ queryKey: qk.task(id), queryFn: () => api.tasks.get(id), placeholderData: seed });
const { data: agents, byId } = useAgents();
- const { workspaces } = useWorkspace();
+ const { workspaces, id: pickedWorkspace } = useWorkspace();
const [picking, setPicking] = useState(false);
const onDone = (next: Task) => {
@@ -61,20 +64,24 @@ export default function TaskScreen() {
},
});
- const t = task.data;
+ const gone = task.error instanceof ApiError && task.error.status === 404;
+ const t = task.data ?? (gone ? undefined : seed);
if (!t) {
+ if (!task.isError) return ;
return (
- {task.isError ? "Task not found" : "Task"}
- {task.isError ? (
- void task.refetch()} />
- ) : (
-
- )}
+ Task not found
+ void task.refetch()} />
);
}
+ // A board's copy has no description and a clipped result; one a live event put there is whole.
+ const slim = (!task.data || task.isPlaceholderData) && !t.description;
+ const unrefreshed = (!task.data || task.isPlaceholderData) && task.isError && !task.isFetching;
+ const whole = !slim;
+ const loadingTexts = slim && !unrefreshed;
+
const agent = t.agentId ? byId.get(t.agentId) : undefined;
const workspace = workspaces.find((w) => w.id === t.workspaceId);
const projects = workspace?.projects ?? [];
@@ -82,7 +89,8 @@ export default function TaskScreen() {
const pickProject = () => {
tap();
setPicking((v) => !v);
- }; // In progress isn't working: the ticket may wait for its follow-up, for an answer, or stand still.
+ };
+ // In progress isn't working: the ticket may wait for its follow-up, for an answer, or stand still.
const working = t.status === "in_progress" && (t.runStatus === "running" || t.runStatus === "queued" || !!t.activity);
const waiting = t.status === "in_progress" && (isWaiting(t) || waitsForAnswer(t) || !!t.pause);
const canFollowUp =
@@ -156,6 +164,34 @@ export default function TaskScreen() {
)}
+ {unrefreshed ? (
+
+
+
+
+ {"Couldn't refresh"}
+
+
+ {slim ? "This is the board's copy, without its texts." : errorText(task.error)}
+
+
+ {
+ tap();
+ void task.refetch();
+ }}
+ style={({ pressed }) => [styles.retry, { backgroundColor: c.surface, opacity: pressed ? 0.6 : 1 }]}
+ >
+
+ Try again
+
+
+
+ ) : null}
+
router.push({ pathname: "/agent/[id]", params: { id: agent.id } }) : undefined}>
{agent ? : }
@@ -246,7 +282,7 @@ export default function TaskScreen() {
)}
-
+
{t.pullRequest && (
)}
- {t.summary ? (
-
-
-
-
-
-
- ) : null}
-
- {t.description ? (
-
-
-
-
-
-
- ) : null}
+ {t.summary && (whole || loadingTexts) ? : null}
+ {(whole && t.description) || loadingTexts ? : null}
{canFollowUp && (
@@ -294,10 +315,86 @@ export default function TaskScreen() {
)}
+
+
+
+
+
+
+ );
+}
+
+/** The board's copy of a ticket: there at once, but without its description and with a clipped result. */
+function boardTicket(id: string): Task | undefined {
+ for (const [, list] of queryClient.getQueriesData({ queryKey: [...qk.tasks, "list"] })) {
+ const hit = Array.isArray(list) ? list.find((t) => t.id === id) : undefined;
+ if (hit) return hit;
+ }
+ return undefined;
+}
+
+/** The screen's shape while a ticket nothing is known about yet loads. */
+function TaskSkeleton() {
+ return (
+
+ Task
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
+function TextSkeleton() {
+ return (
+
+
+
+
+
+ );
+}
+
+/** A titled card of Markdown; without `text` it is still loading. */
+function TextSection({ title, text }: { title: string; text?: string }) {
+ return (
+
+
+
+ {text ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ );
+}
+
type Action = { title: string; icon: Parameters[0]["icon"]; status?: TaskStatus; archived?: boolean; primary?: boolean; onPress?: () => void };
function Actions({
@@ -391,12 +488,13 @@ function ActionButtons({
}
/** What the ticket is part of, and its own parts (a lead's ticket waits until they're finished). */
-function Parts({ task }: { task: Task }) {
+function Parts({ task, workspaceId }: { task: Task; workspaceId: string | null }) {
const c = useColors();
const { byId } = useAgents();
- const board = useQuery({ queryKey: qk.taskList(null), queryFn: () => api.tasks.list(), enabled: !!task.subtasks || !!task.parentId });
- const parts = (board.data ?? []).filter((p) => p.parentId === task.id).sort((a, b) => a.number - b.number);
- const parent = task.parentId ? board.data?.find((p) => p.id === task.parentId) : undefined;
+ // The board the Tasks tab keeps (and live events patch), so this is the same request, not one more.
+ const board = useQuery({ queryKey: qk.taskList(workspaceId), queryFn: () => api.tasks.list({ workspaceId }), enabled: !!task.subtasks || !!task.parentId });
+ const parts = useMemo(() => (board.data ?? []).filter((p) => p.parentId === task.id).sort((a, b) => a.number - b.number), [board.data, task.id]);
+ const parentTitle = useMemo(() => (task.parentId ? board.data?.find((p) => p.id === task.parentId)?.title : undefined), [board.data, task.parentId]);
if (!task.parentId && !task.subtasks) return null;
const open = (id: string) => {
tap();
@@ -412,7 +510,7 @@ function Parts({ task }: { task: Task }) {
Part of
- #{task.parentNumber} {parent?.title ?? ""}
+ #{task.parentNumber} {parentTitle ?? ""}
@@ -422,8 +520,21 @@ function Parts({ task }: { task: Task }) {
+ {!parts.length && board.isPending ? (
+
+ {Array.from({ length: Math.min(task.subtasks.total, 4) }, (_, i) => (
+
+ ))}
+
+ ) : null}
{parts.map((p) => (
- open(p.id)} accessibilityRole="button" style={({ pressed }) => [styles.part, { opacity: pressed ? 0.6 : 1 }]}>
+ open(p.id)}
+ accessibilityRole="button"
+ accessibilityLabel={`Part #${p.number}, ${p.title}, ${STATUS_META[p.status].label}`}
+ style={({ pressed }) => [styles.part, { opacity: pressed ? 0.6 : 1 }]}
+ >
#{p.number}
@@ -445,6 +556,21 @@ function Parts({ task }: { task: Task }) {
const styles = StyleSheet.create({
part: { flexDirection: "row", alignItems: "center", gap: 10, paddingVertical: 10 },
+ partsLoading: { gap: space.md, paddingVertical: space.md, paddingHorizontal: space.lg },
+ stale: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: space.md,
+ padding: space.md,
+ borderRadius: radius.md,
+ borderCurve: "continuous",
+ },
+ retry: {
+ height: 32,
+ paddingHorizontal: space.md,
+ borderRadius: radius.pill,
+ justifyContent: "center",
+ },
content: {
paddingHorizontal: space.lg,
paddingTop: space.md,
diff --git a/apps/mobile/src/components/character.tsx b/apps/mobile/src/components/character.tsx
index 17dfe548..bb493d95 100644
--- a/apps/mobile/src/components/character.tsx
+++ b/apps/mobile/src/components/character.tsx
@@ -1,3 +1,4 @@
+import { useIsFocused } from "expo-router";
import { memo, useEffect } from "react";
import { View, type StyleProp, type ViewStyle } from "react-native";
import Animated, {
@@ -76,7 +77,13 @@ export function agentMood(agent: Pick, running?: bo
return agent.status === "error" ? "error" : "idle";
}
-/** A character, alive: it breathes, blinks, bobs while working and hops when happy. Under Reduce Motion it holds still. */
+/** Idle breathing and blinking only from this size up: a list of small avatars that all breathe keeps the phone busy. */
+const LIVELY_SIZE = 60;
+
+/**
+ * A character, alive: it breathes, blinks, bobs while working and hops when happy. Small ones only move while they work,
+ * and every one holds still while its screen is behind another (tabs stay mounted) or under Reduce Motion.
+ */
export const Character = memo(function Character({
character,
color,
@@ -94,10 +101,13 @@ export const Character = memo(function Character({
style?: StyleProp;
}) {
const reduceMotion = useReducedMotion();
+ const focused = useIsFocused();
+ const still = reduceMotion || !focused;
+ const lively = size >= LIVELY_SIZE;
const outline = useIsDark() ? OUTLINE.dark : OUTLINE.light;
const faceScale = size <= 28 ? 1.25 : 1;
const open = drawing(character, color, mood, faceScale);
- const blinks = !reduceMotion && canBlink(character, mood);
+ const blinks = lively && !still && canBlink(character, mood);
const closed = blinks ? drawing({ ...character, eyes: "lines" }, color, mood, faceScale) : null;
const phase = characterPhase(seed) * 1000;
@@ -106,12 +116,14 @@ export const Character = memo(function Character({
const blink = useSharedValue(0);
useEffect(() => {
- if (reduceMotion) return;
+ if (still) return;
const slow = mood === "sleeping";
- breath.value = withDelay(
- phase % 1600,
- withRepeat(withTiming(1, { duration: slow ? 2600 : 1900, easing: Easing.inOut(Easing.sin) }), -1, true),
- );
+ if (lively) {
+ breath.value = withDelay(
+ phase % 1600,
+ withRepeat(withTiming(1, { duration: slow ? 2600 : 1900, easing: Easing.inOut(Easing.sin) }), -1, true),
+ );
+ }
if (mood === "working") {
move.value = withRepeat(withTiming(1, { duration: 420, easing: Easing.inOut(Easing.quad) }), -1, true);
} else if (mood === "happy") {
@@ -129,7 +141,7 @@ export const Character = memo(function Character({
breath.value = 0;
move.value = 0;
};
- }, [mood, reduceMotion, phase, breath, move]);
+ }, [mood, still, lively, phase, breath, move]);
useEffect(() => {
if (!blinks) return;
diff --git a/apps/mobile/src/components/header-actions.tsx b/apps/mobile/src/components/header-actions.tsx
index 58fbfa03..f21b1a2d 100644
--- a/apps/mobile/src/components/header-actions.tsx
+++ b/apps/mobile/src/components/header-actions.tsx
@@ -1,8 +1,10 @@
import { Stack } from "expo-router";
-import { Pressable, View } from "react-native";
+import { useState } from "react";
+import { Modal, Pressable, StyleSheet, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Icon, sfSymbol, type IconName } from "./icon";
-import { tap } from "./ui";
-import { useColors } from "@/lib/theme";
+import { Hairline, T, tap } from "./ui";
+import { radius, space, useColors } from "@/lib/theme";
export interface HeaderAction {
icon: IconName;
@@ -12,9 +14,20 @@ export interface HeaderAction {
prominent?: boolean;
}
-/** Header buttons: native Liquid Glass toolbar items on iOS, plain icon buttons on Android. */
-export function HeaderActions({ actions, placement = "right" }: { actions: HeaderAction[]; placement?: "left" | "right" }) {
+export interface HeaderMenuItem {
+ icon: IconName;
+ title: string;
+ onPress: () => void;
+ destructive?: boolean;
+}
+
+/**
+ * Header buttons: native Liquid Glass toolbar items on iOS, plain icon buttons on Android. `menu`: more actions behind a
+ * "more" button — the system menu on iOS, a sheet from the bottom on Android.
+ */
+export function HeaderActions({ actions = [], menu = [], placement = "right" }: { actions?: HeaderAction[]; menu?: HeaderMenuItem[]; placement?: "left" | "right" }) {
const c = useColors();
+ const [open, setOpen] = useState(false);
if (process.env.EXPO_OS === "ios") {
return (
@@ -31,17 +44,88 @@ export function HeaderActions({ actions, placement = "right" }: { actions: Heade
}}
/>
))}
+ {menu.length > 0 && (
+
+ {menu.map((m) => (
+
+ {m.title}
+
+ ))}
+
+ )}
);
}
const render = () => (
{actions.map((a) => (
-
+
))}
+ {menu.length > 0 && (
+ setOpen(true)} style={{ padding: 8 }}>
+
+
+ )}
);
- return ;
+ return (
+ <>
+
+ setOpen(false)} />
+ >
+ );
}
+
+/** A short list of actions from the bottom of the screen. */
+export function MenuSheet({ items, open, onClose }: { items: HeaderMenuItem[]; open: boolean; onClose: () => void }) {
+ const c = useColors();
+ const insets = useSafeAreaInsets();
+ return (
+
+
+
+ {items.map((m, i) => (
+
+ {i > 0 && }
+ {
+ onClose();
+ tap();
+ m.onPress();
+ }}
+ style={styles.item}
+ >
+
+
+ {m.title}
+
+
+
+ ))}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ sheet: {
+ position: "absolute",
+ left: 0,
+ right: 0,
+ bottom: 0,
+ paddingTop: space.sm,
+ borderTopLeftRadius: radius.xl,
+ borderTopRightRadius: radius.xl,
+ },
+ item: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: space.lg,
+ minHeight: 52,
+ paddingHorizontal: space.lg,
+ },
+});
diff --git a/apps/mobile/src/components/live-strip.tsx b/apps/mobile/src/components/live-strip.tsx
index 03b08e71..99c1afbe 100644
--- a/apps/mobile/src/components/live-strip.tsx
+++ b/apps/mobile/src/components/live-strip.tsx
@@ -13,7 +13,7 @@ import { radius, space, useColors } from "@/lib/theme";
/** While the agent works: what it's doing and a live thumbnail of where, above the composer. */
export function LiveStrip({ screen, activity }: { screen: LiveScreen; activity: string | null }) {
const c = useColors();
- const stream = useStreamFrame(screen.kind === "vm" ? null : screen);
+ const stream = useStreamFrame(screen);
const vm = useVmFrame(screen.kind === "vm" ? screen.vm : null, true, 4000);
const frame = screen.kind === "vm" ? vm.data : stream;
const uri = frameUri(frame);
@@ -30,9 +30,9 @@ export function LiveStrip({ screen, activity }: { screen: LiveScreen; activity:
{uri ? (
-
+
) : (
-
+
)}
diff --git a/apps/mobile/src/components/markdown.tsx b/apps/mobile/src/components/markdown.tsx
index 09dacab0..6f0d78ea 100644
--- a/apps/mobile/src/components/markdown.tsx
+++ b/apps/mobile/src/components/markdown.tsx
@@ -1,10 +1,8 @@
-import { Image } from "expo-image";
import { marked, type Token, type Tokens } from "marked";
-import { memo, useMemo, useState, type ReactNode } from "react";
+import { memo, useMemo, type ReactNode } from "react";
import { Linking, ScrollView, StyleSheet, Text, View } from "react-native";
import { TASK_ATTACHMENT_PATH } from "@godmode/shared";
-import { Icon } from "./icon";
-import { addressOrder, useSession } from "@/lib/session";
+import { RemoteImage } from "./remote-image";
import { radius, space, type, useColors, type Colors } from "@/lib/theme";
/** Agent answers: Markdown rendered with native text (paragraphs, lists, code, quotes, tables, links, task pictures). */
@@ -37,7 +35,7 @@ function block(t: Token, key: number, c: Colors, color: string): ReactNode {
return (
{pictures.map((p, i) => (
-
+
))}
);
@@ -169,37 +167,6 @@ function isTaskPicture(t: Token): t is Tokens.Image {
return t.type === "image" && (t as Tokens.Image).href.startsWith(TASK_ATTACHMENT_PATH) && !(t as Tokens.Image).href.includes("..");
}
-/** A picture attached to a task: served by the computer, so it is loaded with this phone's key, from each address in turn. */
-function TaskPicture({ path, alt }: { path: string; alt: string }) {
- const c = useColors();
- const connection = useSession((s) => s.connection);
- const [ratio, setRatio] = useState(4 / 3);
- const [attempt, setAttempt] = useState(0);
- if (!connection) return null;
- const bases = addressOrder(connection);
- if (attempt >= bases.length) {
- return (
-
-
-
- {alt || "Picture"} couldn't load
-
-
- );
- }
- return (
- e.source.width && e.source.height && setRatio(Math.max(0.5, e.source.width / e.source.height))}
- onError={() => setAttempt((a) => a + 1)}
- accessibilityLabel={alt}
- contentFit="contain"
- transition={150}
- style={[styles.picture, { aspectRatio: ratio, backgroundColor: c.sunken }]}
- />
- );
-}
-
function decode(s: string): string {
return s
.replace(/&/g, "&")
@@ -210,20 +177,6 @@ function decode(s: string): string {
}
const styles = StyleSheet.create({
- picture: {
- width: "100%",
- maxHeight: 420,
- borderRadius: radius.sm,
- borderCurve: "continuous",
- },
- missing: {
- flexDirection: "row",
- alignItems: "center",
- gap: 8,
- padding: space.md,
- borderRadius: radius.sm,
- borderCurve: "continuous",
- },
listItem: {
flexDirection: "row",
gap: 6,
diff --git a/apps/mobile/src/components/message.tsx b/apps/mobile/src/components/message.tsx
index cbbabdfa..d112f6c7 100644
--- a/apps/mobile/src/components/message.tsx
+++ b/apps/mobile/src/components/message.tsx
@@ -1,6 +1,8 @@
import * as Clipboard from "expo-clipboard";
import { Image } from "expo-image";
-import { memo, useEffect, useState } from "react";
+import { useRecyclingState } from "@shopify/flash-list";
+import { format } from "date-fns";
+import { memo, useEffect, useMemo } from "react";
import { LayoutAnimation, Pressable, StyleSheet, View } from "react-native";
import Animated, { FadeIn, useAnimatedStyle, useSharedValue, withRepeat, withSequence, withTiming } from "react-native-reanimated";
import type { Attachment, Message, MessageBlock } from "@godmode/shared";
@@ -8,8 +10,10 @@ import { toolActivity } from "@godmode/shared";
import { FileChip } from "./attachments";
import { Icon } from "./icon";
import { Markdown } from "./markdown";
+import { QuestionCard } from "./question-card";
+import { RemoteImage } from "./remote-image";
import { Row, T, tap } from "./ui";
-import { toolLabel } from "@/lib/format";
+import { shortTime, toolLabel } from "@/lib/format";
import { useNow } from "@/lib/hooks";
import { radius, space, useColors } from "@/lib/theme";
@@ -80,7 +84,7 @@ export const UserMessage = memo(function UserMessage({ message }: { message: Mes
});
export const AssistantMessage = memo(function AssistantMessage({ blocks, streaming, content }: { blocks: MessageBlock[]; streaming?: boolean; content?: string }) {
- const parts = group(blocks);
+ const parts = useMemo(() => group(blocks), [blocks]);
const last = parts[parts.length - 1];
return (
copy(content) : undefined} style={styles.assistant}>
@@ -91,7 +95,7 @@ export const AssistantMessage = memo(function AssistantMessage({ blocks, streami
) : p.kind === "subagent" ? (
) : (
-
+
),
)}
{streaming ? : null}
@@ -117,7 +121,7 @@ function copy(text: string) {
tap();
}
-function Block({ block }: { block: MessageBlock }) {
+const Block = memo(function Block({ block, streaming }: { block: MessageBlock; streaming: boolean }) {
const c = useColors();
switch (block.type) {
case "text":
@@ -136,10 +140,32 @@ function Block({ block }: { block: MessageBlock }) {
+ {block.mod ? (
+
+ {block.mod}{" "}
+
+ ) : null}
{block.text}
);
+ case "followup":
+ return (
+
+ );
+ case "retry":
+ return (
+
+ );
case "command":
return (
@@ -166,55 +192,8 @@ function Block({ block }: { block: MessageBlock }) {
);
- case "question": {
- // Answered by writing into the chat: a suggested answer by its number or its words.
- const open = block.status === "open";
- const approval = block.kind === "approval";
- const status =
- block.status === "open"
- ? approval
- ? "Reply “approve” or “decline” below — or what to do instead."
- : block.options.length
- ? "Reply below with a number or your own answer."
- : "Reply below to answer."
- : block.status === "approved"
- ? `Approved${block.answer?.text ? ` — ${block.answer.text}` : ""}`
- : block.status === "declined"
- ? `Declined${block.answer?.text ? ` — ${block.answer.text}` : ""}`
- : block.status === "answered"
- ? `Answered: ${block.answer?.text ?? ""}`
- : "Withdrawn — the run was stopped before this was answered.";
- return (
-
-
- {approval ? "NEEDS YOUR OK" : "QUESTION"}
-
-
- {block.title}
-
- {block.body ? (
-
- {approval ? `Why: ${block.body}` : block.body}
-
- ) : null}
- {approval && block.affects ? (
-
- Affects: {block.affects}
-
- ) : null}
- {block.options.map((o, i) => (
-
- {i + 1}. {o.label}
- {o.recommended ? " (recommended)" : ""}
- {o.description ? ` — ${o.description}` : ""}
-
- ))}
-
- {status}
-
-
- );
- }
+ case "question":
+ return ;
case "human_task": {
const done = block.outcome === "done";
return (
@@ -251,12 +230,36 @@ function Block({ block }: { block: MessageBlock }) {
default:
return null;
}
+});
+
+/** Where the chat went on without the human: a follow-up the agent set, or a turn that was picked up again. */
+function Marker({ icon, text, detail, at }: { icon: "clock" | "refresh"; text: string; detail?: string; at: string }) {
+ const c = useColors();
+ return (
+
+
+
+
+
+
+ {text} · {shortTime(at) === "now" ? "just now" : format(new Date(at), "MMM d, HH:mm")}
+
+
+ {detail ? (
+
+ {detail}
+
+ ) : null}
+
+
+
+ );
}
-function SubagentCard({ block, childBlocks, streaming }: { block: ToolUse; childBlocks: MessageBlock[]; streaming: boolean }) {
+const SubagentCard = memo(function SubagentCard({ block, childBlocks, streaming }: { block: ToolUse; childBlocks: MessageBlock[]; streaming: boolean }) {
const c = useColors();
const { task, background, status, running, failed, result } = subagentState(block, streaming);
- const [open, setOpen] = useState(null);
+ const [open, setOpen] = useRecyclingState(null, [block.id]);
const expanded = open ?? false;
const input = (block.input && typeof block.input === "object" ? block.input : {}) as { description?: unknown; subagent_type?: unknown };
const title = (typeof input.description === "string" && input.description) || task?.description || "Helper";
@@ -316,7 +319,7 @@ function SubagentCard({ block, childBlocks, streaming }: { block: ToolUse; child
)}
);
-}
+});
/** Helpers still at work in the background, at the foot of the turn: without this the chat would look idle. */
function BackgroundAgents({ parts }: { parts: Part[] }) {
@@ -350,9 +353,9 @@ function duration(ms: number): string {
return m < 60 ? `${m}m ${String(s % 60).padStart(2, "0")}s` : `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`;
}
-function Steps({ steps, live }: { steps: Step[]; live?: boolean }) {
+const Steps = memo(function Steps({ steps, live }: { steps: Step[]; live?: boolean }) {
const c = useColors();
- const [open, setOpen] = useState(false);
+ const [open, setOpen] = useRecyclingState(false, [steps.find((s) => s.type === "tool_use")?.id ?? (steps[0]?.type === "thinking" ? steps[0].text.slice(0, 40) : "")]);
const tools = steps.filter((s): s is Extract => s.type === "tool_use");
const latest = tools[tools.length - 1];
const label = latest ? toolLabel(latest) : null;
@@ -390,15 +393,18 @@ function Steps({ steps, live }: { steps: Step[]; live?: boolean }) {
)}
);
-}
+});
-function ToolStep({ step }: { step: ToolUse }) {
+const ToolStep = memo(function ToolStep({ step }: { step: ToolUse }) {
const c = useColors();
const label = toolLabel(step);
- const [more, setMore] = useState(false);
+ const [more, setMore] = useRecyclingState(false, [step.id]);
+ const todos = step.name === "TodoWrite" ? todoItems(step.input) : null;
+ const edit = more ? fileEdit(step) : null;
+ const expandable = !!step.result || step.name === "Edit" || step.name === "MultiEdit" || step.name === "Write";
return (
- step.result && setMore((m) => !m)}>
+ expandable && setMore((m) => !m)} accessibilityRole={expandable ? "button" : undefined} accessibilityState={expandable ? { expanded: more } : undefined}>
@@ -413,14 +419,93 @@ function ToolStep({ step }: { step: ToolUse }) {
- {step.image ? : null}
- {more && step.result ? (
+ {todos?.length ? (
+
+ {todos.slice(0, 12).map((t, i) => (
+
+
+
+ {t.content}
+
+
+ ))}
+
+ ) : null}
+ {step.imageUrl ? (
+
+ ) : step.image ? (
+
+ ) : null}
+ {edit ? (
+
+ {edit.lines.map((line, i) => (
+
+ {line.kind === "add" ? "+ " : line.kind === "remove" ? "- " : " "}
+ {line.text}
+
+ ))}
+
+ ) : more && step.result ? (
{step.result.slice(0, 2000)}
+ {step.resultTruncated || step.result.length > 2000 ? "…" : ""}
) : null}
);
+});
+
+function imageUri(image: string): string {
+ if (image.startsWith("data:")) return image;
+ const mime = image.startsWith("/9j/") ? "image/jpeg" : image.startsWith("R0lG") ? "image/gif" : image.startsWith("UklG") ? "image/webp" : "image/png";
+ return `data:${mime};base64,${image}`;
+}
+
+/** Plan items of a TodoWrite call. */
+function todoItems(input: unknown): { content: string; status: string }[] | null {
+ const todos = input && typeof input === "object" ? (input as { todos?: unknown }).todos : null;
+ if (!Array.isArray(todos)) return null;
+ return todos
+ .filter((t): t is Record => !!t && typeof t === "object")
+ .map((t) => ({ content: String(t.content ?? t.activeForm ?? t.subject ?? ""), status: typeof t.status === "string" ? t.status : "pending" }));
+}
+
+type DiffLine = { kind: "add" | "remove" | "fold"; text: string };
+
+const DIFF_LINES = 40;
+
+/** What a file edit took out and put in, as the first lines of each: enough to see the change on a phone. */
+function fileEdit(step: ToolUse): { lines: DiffLine[] } | null {
+ const input = (step.input && typeof step.input === "object" ? step.input : {}) as { old_string?: unknown; new_string?: unknown; content?: unknown; edits?: unknown };
+ const lines: DiffLine[] = [];
+ const push = (kind: "add" | "remove", text: unknown) => {
+ if (typeof text !== "string" || !text) return;
+ for (const line of text.split("\n")) lines.push({ kind, text: line });
+ };
+ if (step.name === "Edit") {
+ push("remove", input.old_string);
+ push("add", input.new_string);
+ } else if (step.name === "Write") push("add", input.content);
+ else if (step.name === "MultiEdit" && Array.isArray(input.edits)) {
+ for (const [i, e] of (input.edits as { old_string?: unknown; new_string?: unknown }[]).entries()) {
+ if (i > 0) lines.push({ kind: "fold", text: "⋯" });
+ push("remove", e?.old_string);
+ push("add", e?.new_string);
+ }
+ } else return null;
+ if (!lines.length) return null;
+ const more = lines.length - DIFF_LINES;
+ return { lines: more > 0 ? [...lines.slice(0, DIFF_LINES), { kind: "fold", text: `⋯ ${more} more lines` }] : lines };
}
function ThinkingStep({ text }: { text: string }) {
@@ -549,6 +634,17 @@ const styles = StyleSheet.create({
borderWidth: StyleSheet.hairlineWidth,
borderStyle: "dashed",
},
+ marker: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 10,
+ paddingVertical: 2,
+ },
+ markerLine: {
+ flex: 1,
+ height: StyleSheet.hairlineWidth,
+ },
shot: {
width: "100%",
aspectRatio: 16 / 10,
diff --git a/apps/mobile/src/components/question-card.tsx b/apps/mobile/src/components/question-card.tsx
new file mode 100644
index 00000000..4c4ac1e6
--- /dev/null
+++ b/apps/mobile/src/components/question-card.tsx
@@ -0,0 +1,312 @@
+import { format } from "date-fns";
+import { useRecyclingState } from "@shopify/flash-list";
+import { createContext, useContext } from "react";
+import { ActivityIndicator, Alert, Pressable, StyleSheet, TextInput, View } from "react-native";
+import type { AnswerQuestionInput, MessageBlock } from "@godmode/shared";
+import { Icon } from "./icon";
+import { Markdown } from "./markdown";
+import { T, tap } from "./ui";
+import { ApiError, api, errorText } from "@/lib/api";
+import { useLive } from "@/lib/live";
+import { qk, queryClient } from "@/lib/query";
+import { radius, space, type, useColors } from "@/lib/theme";
+
+type QuestionBlock = Extract;
+
+/** What a chat tells its messages: whose chat it is and which question its run stands still for. */
+export interface ChatScopeValue {
+ conversationId: string;
+ agentName: string;
+ openQuestionId: string | null;
+}
+
+export const ChatScope = createContext(null);
+
+/** Answer a question with an option, a decision or text; the run that asked continues with it. */
+export async function answerQuestion(id: string, input: AnswerQuestionInput, conversationId?: string): Promise {
+ try {
+ const { question, run } = await api.questions.answer(id, input);
+ useLive.getState().runStarted(run);
+ void queryClient.invalidateQueries({ queryKey: qk.questions });
+ void queryClient.invalidateQueries({ queryKey: qk.bootstrap });
+ void queryClient.invalidateQueries({ queryKey: qk.conversation(conversationId ?? question.conversationId) });
+ return true;
+ } catch (err) {
+ void queryClient.invalidateQueries({ queryKey: qk.questions });
+ if (conversationId) void queryClient.invalidateQueries({ queryKey: qk.conversation(conversationId) });
+ const settled = err instanceof ApiError && err.code === "question_closed";
+ Alert.alert(settled ? "Already settled" : "Couldn't send your answer", errorText(err));
+ return false;
+ }
+}
+
+/**
+ * A question or an approval the agent asked. While the run waits for it: one tap on an option, or Approve / Decline
+ * (with a note). Afterwards it is the record of what was asked and answered.
+ */
+export function QuestionCard({ question: q, streaming }: { question: QuestionBlock; streaming?: boolean }) {
+ const c = useColors();
+ const scope = useContext(ChatScope);
+ // Rows are reused for other messages while scrolling: a note typed for one approval must not show up in the next.
+ const [pressed, setPressed] = useRecyclingState(null, [q.id]);
+ const [noting, setNoting] = useRecyclingState(false, [q.id]);
+ const [note, setNote] = useRecyclingState("", [q.id]);
+
+ const approval = q.kind === "approval";
+ const open = q.status === "open";
+ const answerable = open && !streaming && scope?.openQuestionId === q.id;
+ const lost = open && !streaming && !!scope && scope.openQuestionId !== q.id;
+ const agentName = scope?.agentName ?? "The agent";
+ const busy = pressed !== null;
+
+ const send = (key: string, input: AnswerQuestionInput) => {
+ if (busy) return;
+ tap();
+ setPressed(key);
+ void answerQuestion(q.id, input, scope?.conversationId).finally(() => setPressed(null));
+ };
+ const decide = (decision: "approve" | "decline") => send(decision, { decision, ...(note.trim() ? { note: note.trim() } : {}) });
+
+ if (q.status === "withdrawn" || lost) {
+ return (
+
+
+ {approval ? "Approval withdrawn" : "Question withdrawn"}
+
+
+ {q.title}
+
+
+ The run was stopped before this was answered.
+
+
+ );
+ }
+
+ const chosen = q.answer?.optionId ? q.options.find((o) => o.id === q.answer!.optionId) : undefined;
+ const approved = q.status === "approved";
+
+ return (
+
+
+
+
+ {approval ? (open ? "Needs your OK" : "Approval") : "Question"}
+
+
+
+ {q.title}
+
+ {q.body ? (
+ approval ? (
+
+
+ Why{" "}
+
+ {q.body}
+
+ ) : (
+
+ )
+ ) : null}
+ {approval && q.affects ? (
+
+
+ Affects{" "}
+
+ {q.affects}
+
+ ) : null}
+
+ {!approval && q.options.length > 0 && (
+
+ {q.options.map((o, i) => {
+ const picked = chosen?.id === o.id;
+ const primary = open && !!o.recommended;
+ const fg = primary ? c.onPrimary : c.text;
+ return (
+ send(o.id, { optionId: o.id })}
+ style={({ pressed: down }) => [
+ styles.option,
+ { backgroundColor: primary ? c.primary : c.surface, borderColor: picked ? c.brand : c.border, opacity: down ? 0.7 : !open && !picked ? 0.5 : 1 },
+ ]}
+ >
+
+ {pressed === o.id ? (
+
+ ) : picked ? (
+
+ ) : (
+
+ {i + 1}
+
+ )}
+
+
+
+ {o.label}
+
+ {o.description ? (
+
+ {o.description}
+
+ ) : null}
+
+ {o.recommended ? (
+
+ Recommended
+
+ ) : null}
+
+ );
+ })}
+
+ )}
+
+ {approval && open && (
+
+ {noting && (
+
+ )}
+
+ decide("approve")} />
+ decide("decline")} />
+ {!noting && answerable ? (
+ setNoting(true)} style={{ paddingHorizontal: 6 }}>
+
+ Add a note
+
+
+ ) : null}
+
+
+ )}
+
+ {open ? (
+
+ {streaming || !answerable
+ ? `${agentName} is finishing the step it was in…`
+ : approval
+ ? "Or reply below: your message counts as the answer."
+ : q.options.length
+ ? "Or type your own answer below."
+ : "Reply below to answer."}
+
+ ) : q.answer ? (
+
+
+
+ {approved ? "You approved" : q.status === "declined" ? "You declined" : chosen ? `You chose ${chosen.label}` : "You answered"}
+ {q.answer.via === "phone" ? " from your phone" : ""} · {format(new Date(q.answer.at), "HH:mm")}
+ {q.answer.text && !chosen ? `\n${q.answer.text}` : ""}
+
+
+ ) : null}
+
+ );
+}
+
+function Decision({ label, icon, primary, busy, disabled, onPress }: { label: string; icon: "check" | "close"; primary?: boolean; busy: boolean; disabled: boolean; onPress: () => void }) {
+ const c = useColors();
+ const fg = primary ? c.onPrimary : c.text;
+ return (
+ [styles.decision, { backgroundColor: primary ? c.primary : c.surface, borderColor: primary ? c.primary : c.borderStrong, opacity: disabled && !busy ? 0.45 : pressed ? 0.75 : 1 }]}
+ >
+ {busy ? : }
+
+ {label}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ card: {
+ gap: 8,
+ padding: space.md,
+ borderRadius: radius.md,
+ borderCurve: "continuous",
+ borderWidth: StyleSheet.hairlineWidth,
+ },
+ withdrawn: {
+ borderStyle: "dashed",
+ gap: 4,
+ },
+ head: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 6,
+ },
+ option: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 10,
+ minHeight: 44,
+ paddingHorizontal: space.md,
+ paddingVertical: 9,
+ borderRadius: radius.sm,
+ borderCurve: "continuous",
+ borderWidth: StyleSheet.hairlineWidth,
+ },
+ number: {
+ width: 22,
+ height: 22,
+ borderRadius: 7,
+ borderWidth: StyleSheet.hairlineWidth,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ note: {
+ minHeight: 64,
+ padding: space.md,
+ borderRadius: radius.sm,
+ borderCurve: "continuous",
+ borderWidth: StyleSheet.hairlineWidth,
+ textAlignVertical: "top",
+ },
+ decide: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: space.sm,
+ },
+ decision: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 6,
+ height: 40,
+ minWidth: 104,
+ paddingHorizontal: space.lg,
+ borderRadius: radius.pill,
+ borderWidth: StyleSheet.hairlineWidth,
+ },
+ settled: {
+ flexDirection: "row",
+ alignItems: "flex-start",
+ gap: 6,
+ paddingTop: 8,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ },
+});
diff --git a/apps/mobile/src/components/remote-image.tsx b/apps/mobile/src/components/remote-image.tsx
new file mode 100644
index 00000000..9bb656ff
--- /dev/null
+++ b/apps/mobile/src/components/remote-image.tsx
@@ -0,0 +1,58 @@
+import { Image } from "expo-image";
+import { useState } from "react";
+import { StyleSheet, Text, View, type ImageStyle, type StyleProp } from "react-native";
+import { Icon } from "./icon";
+import { addressOrder, useSession } from "@/lib/session";
+import { radius, space, type, useColors } from "@/lib/theme";
+
+/**
+ * A picture the computer serves (a task's attachment, a screenshot a tool took): loaded with this phone's key, from each
+ * address in turn, and kept on disk so it loads once.
+ */
+export function RemoteImage({ path, alt, style }: { path: string; alt: string; style?: StyleProp }) {
+ const c = useColors();
+ const connection = useSession((s) => s.connection);
+ const [ratio, setRatio] = useState(4 / 3);
+ const [attempt, setAttempt] = useState(0);
+ if (!connection) return null;
+ const bases = addressOrder(connection);
+ if (attempt >= bases.length) {
+ return (
+
+
+
+ {alt || "Picture"} couldn't load
+
+
+ );
+ }
+ return (
+ e.source.width && e.source.height && setRatio(Math.max(0.5, e.source.width / e.source.height))}
+ onError={() => setAttempt((a) => a + 1)}
+ accessibilityLabel={alt}
+ contentFit="contain"
+ cachePolicy="disk"
+ transition={150}
+ style={[styles.picture, { aspectRatio: ratio, backgroundColor: c.sunken }, style]}
+ />
+ );
+}
+
+const styles = StyleSheet.create({
+ picture: {
+ width: "100%",
+ maxHeight: 420,
+ borderRadius: radius.sm,
+ borderCurve: "continuous",
+ },
+ missing: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 8,
+ padding: space.md,
+ borderRadius: radius.sm,
+ borderCurve: "continuous",
+ },
+});
diff --git a/apps/mobile/src/components/routine-row.tsx b/apps/mobile/src/components/routine-row.tsx
new file mode 100644
index 00000000..e088f2e2
--- /dev/null
+++ b/apps/mobile/src/components/routine-row.tsx
@@ -0,0 +1,153 @@
+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 type { Agent, Routine } from "@godmode/shared";
+import { CharacterAvatar } from "./character";
+import { Icon } from "./icon";
+import { T, tap } from "./ui";
+import { api, errorText } from "@/lib/api";
+import { whenText } from "@/lib/format";
+import { useLive } from "@/lib/live";
+import { qk, queryClient } from "@/lib/query";
+import { radius, space, useColors } from "@/lib/theme";
+
+/** "gmail" → "Gmail", "google_calendar" → "Google Calendar". */
+function appName(slug: string): string {
+ return slug.replace(/[_-]+/g, " ").replace(/\b\w/g, (ch) => ch.toUpperCase());
+}
+
+/** What starts an automation, in one line: its next run or check, the app event, the condition or the webhook. */
+export function triggerText(r: Routine): string {
+ const next = r.enabled && r.nextRunAt ? whenText(r.nextRunAt) : null;
+ switch (r.trigger.type) {
+ case "schedule":
+ return next ? `Next run ${next}` : "On a schedule";
+ case "app":
+ return `${r.trigger.triggerName} · ${appName(r.trigger.toolkit)}`;
+ case "condition":
+ return `When ${r.trigger.condition}${next ? ` · next check ${next}` : ""}`;
+ case "webhook":
+ return "When its webhook is called";
+ }
+}
+
+function patchRoutine(id: string, patch: Partial) {
+ queryClient.setQueriesData({ queryKey: qk.routines }, (list) => (Array.isArray(list) ? list.map((r) => (r.id === id ? { ...r, ...patch } : r)) : list));
+}
+
+const refreshRoutines = () => void queryClient.invalidateQueries({ queryKey: qk.routines });
+
+/** Flips the switch in every cached list right away and takes it back if the computer refuses. */
+export function setRoutineEnabled(routine: Routine, enabled: boolean) {
+ void queryClient.cancelQueries({ queryKey: qk.routines });
+ patchRoutine(routine.id, { enabled });
+ api.routines
+ .setEnabled(routine.id, enabled)
+ .catch((err) => {
+ patchRoutine(routine.id, { enabled: !enabled });
+ Alert.alert("Couldn't change the automation", errorText(err));
+ })
+ .finally(refreshRoutines);
+}
+
+export function runRoutine(routine: Routine): Promise {
+ return api.routines
+ .run(routine.id)
+ .then((run) => {
+ useLive.getState().runStarted(run);
+ tap();
+ })
+ .catch((err) => Alert.alert("Couldn't run it", errorText(err)))
+ .finally(refreshRoutines);
+}
+
+/** One automation in a list: whose it is, what starts it, a switch and Run now. Tapping it opens its agent. */
+export const RoutineRow = memo(function RoutineRow({ routine, agent, working }: { routine: Routine; agent?: Agent; working?: boolean }) {
+ const c = useColors();
+ const [starting, setStarting] = useState(false);
+ const failed = routine.lastStatus === "failed";
+ const last = routine.lastRunAt ? `last run ${failed ? "failed " : ""}${formatDistanceToNowStrict(new Date(routine.lastRunAt), { addSuffix: true })}` : null;
+ const broken = routine.enabled && routine.triggerStatus?.state === "error";
+ const canRun = routine.enabled && !working && !starting;
+
+ return (
+ {
+ tap();
+ router.push({ pathname: "/agent/[id]", params: { id: routine.agentId } });
+ }}
+ style={({ pressed }) => [styles.row, pressed && { backgroundColor: c.sunken }]}
+ >
+
+
+
+ {routine.name}
+
+
+ {agent ? `${agent.name} · ` : ""}
+ {working ? (
+
+ Running now
+
+ ) : (
+ triggerText(routine)
+ )}
+ {last ? " · " : ""}
+ {last && failed ? (
+
+ {last}
+
+ ) : (
+ last
+ )}
+
+ {broken ? (
+
+ {routine.triggerStatus.message ?? "The trigger isn't working."}
+
+ ) : null}
+
+ {
+ if (!canRun) return;
+ setStarting(true);
+ void runRoutine(routine).finally(() => setStarting(false));
+ }}
+ style={({ pressed }) => [styles.runNow, { backgroundColor: c.sunken, opacity: starting ? 1 : !canRun ? 0.4 : pressed ? 0.6 : 1 }]}
+ >
+ {starting ? : }
+
+ setRoutineEnabled(routine, enabled)}
+ trackColor={{ true: c.brand, false: c.sunken }}
+ />
+
+ );
+});
+
+const styles = StyleSheet.create({
+ row: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: space.md,
+ paddingVertical: space.md,
+ paddingHorizontal: space.lg,
+ borderRadius: radius.md,
+ borderCurve: "continuous",
+ },
+ runNow: {
+ width: 34,
+ height: 34,
+ borderRadius: radius.pill,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+});
diff --git a/apps/mobile/src/components/run-row.tsx b/apps/mobile/src/components/run-row.tsx
new file mode 100644
index 00000000..428c09a7
--- /dev/null
+++ b/apps/mobile/src/components/run-row.tsx
@@ -0,0 +1,117 @@
+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 } from "./ui";
+import { activityText, shortTime } from "@/lib/format";
+import { useRunActivity } from "@/lib/live";
+import { radius, space, useColors } from "@/lib/theme";
+
+type Tone = "neutral" | "brand" | "warning" | "danger";
+
+const STATUS_META: Record = {
+ queued: { label: "Queued", tone: "neutral" },
+ running: { label: "Running", tone: "brand" },
+ paused: { label: "Paused", tone: "neutral" },
+ succeeded: { label: "Done", tone: "neutral" },
+ failed: { label: "Failed", tone: "danger" },
+ cancelled: { label: "Stopped", tone: "neutral" },
+};
+
+/** What started a run, in the desktop's words. */
+export const TRIGGER_LABEL: Record = {
+ chat: "Chat",
+ routine: "Automation",
+ check: "Condition check",
+ dream: "Dream",
+ delegation: "Handed over",
+ manual: "Manual",
+ api: "API",
+ task: "Task",
+ followup: "Follow-up",
+ heartbeat: "Heartbeat",
+};
+
+/** A run still ahead of its end: working, or waiting for its turn. */
+export function isLiveRun(run: Pick): boolean {
+ return run.status === "running" || run.status === "queued";
+}
+
+function firstLine(text: string | null | undefined, max = 120): string {
+ const line = (text ?? "").split("\n").find((l) => l.trim())?.replace(/\s+/g, " ").trim() ?? "";
+ return line.length > max ? `${line.slice(0, max - 1)}…` : line;
+}
+
+/** "850ms", "12s", "3m 04s", "1h 12m" */
+function duration(ms: number): string {
+ if (ms < 1000) return `${Math.max(0, Math.round(ms))}ms`;
+ const s = Math.round(ms / 1000);
+ if (s < 60) return `${s}s`;
+ const m = Math.floor(s / 60);
+ if (m < 60) return `${m}m ${String(s % 60).padStart(2, "0")}s`;
+ 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 ;
+}
+
+/** 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. */
+export const RunRow = memo(function RunRow({ run, agent }: { run: Run; agent?: Agent }) {
+ const c = useColors();
+ const live = isLiveRun(run);
+ // Only a row that is working listens for what its agent does right now.
+ const activity = useRunActivity(live ? run.id : undefined);
+ const finished = run.status === "succeeded" || run.status === "failed" || run.status === "cancelled";
+ const meta = [
+ agent?.name ?? "Agent",
+ TRIGGER_LABEL[run.trigger] ?? "Manual",
+ shortTime(run.createdAt),
+ finished && run.durationMs != null ? duration(run.durationMs) : null,
+ 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 }]}>
+
+
+
+
+ {firstLine(run.prompt) || "No prompt"}
+
+
+
+
+ {meta.join(" · ")}
+
+ {error ? (
+
+ {error}
+
+ ) : live ? (
+
+ {run.status === "queued" ? "Waiting to start" : activityText(activity)}
+
+ ) : null}
+
+
+ );
+});
+
+const styles = StyleSheet.create({
+ row: {
+ flexDirection: "row",
+ gap: space.md,
+ paddingVertical: space.md,
+ paddingHorizontal: space.lg,
+ borderRadius: radius.md,
+ borderCurve: "continuous",
+ },
+});
diff --git a/apps/mobile/src/components/screen-tile.tsx b/apps/mobile/src/components/screen-tile.tsx
index 55493cc5..c8ffc203 100644
--- a/apps/mobile/src/components/screen-tile.tsx
+++ b/apps/mobile/src/components/screen-tile.tsx
@@ -1,16 +1,17 @@
import { Image } from "expo-image";
import { router, useIsFocused } from "expo-router";
import { Pressable, StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
+import type { AndroidDevice } from "@godmode/shared";
import { Glass } from "./glass";
import { Icon, type IconName } from "./icon";
import { LiveDot, T, tap } from "./ui";
import type { Frame } from "@/lib/live";
import { hostOf } from "@/lib/format";
-import { isLive, screenHref, useStreamFrame, useVmFrame, type LiveScreen } from "@/lib/screens";
+import { isLive, screenHref, useAndroidFrame, useStreamFrame, useVmFrame, type LiveScreen } from "@/lib/screens";
import { radius, space, useColors } from "@/lib/theme";
const LABEL_MUTED = "rgba(255, 255, 255, 0.72)";
-const KIND_ICON: Record = { browser: "globe", vm: "vm", share: "display" };
+const KIND_ICON: Record = { browser: "globe", vm: "vm", share: "display", android: "phone" };
export function frameUri(frame: Frame | undefined): string | undefined {
return frame && frame.data ? `data:${frame.mime};base64,${frame.data}` : undefined;
@@ -21,9 +22,12 @@ export function ScreenTile({ screen, style, compact }: { screen: LiveScreen; sty
const c = useColors();
const focused = useIsFocused();
const live = isLive(screen);
- const stream = useStreamFrame(screen.kind === "vm" ? null : screen, focused && live);
+ const stream = useStreamFrame(screen, focused && live);
const vmShot = useVmFrame(screen.kind === "vm" ? screen.vm : null, focused && live, compact ? 5000 : 3000);
- const frame = screen.kind === "vm" ? vmShot.data : stream;
+ // A phone agents use shows its screen: live while one is on it, else one picture when the tab opens.
+ const phone = screen.kind === "android" && (screen.device.busy || screen.device.assignments.length > 0) ? screen.device : null;
+ const phoneShot = useAndroidFrame(phone, focused, live ? 3000 : false);
+ const frame = screen.kind === "vm" ? vmShot.data : screen.kind === "android" ? phoneShot.data : stream;
const uri = frameUri(frame);
const subtitle =
screen.kind === "browser"
@@ -32,7 +36,9 @@ export function ScreenTile({ screen, style, compact }: { screen: LiveScreen; sty
: "Not running"
: screen.kind === "vm"
? vmState(screen.vm.state)
- : "Shared in a chat";
+ : screen.kind === "android"
+ ? phoneState(screen.device)
+ : "Shared in a chat";
return (
[styles.tile, { backgroundColor: c.sunken, opacity: pressed ? 0.85 : 1, transform: [{ scale: pressed ? 0.985 : 1 }] }, style]}
>
{uri ? (
-
+
) : (
@@ -68,6 +74,11 @@ export function ScreenTile({ screen, style, compact }: { screen: LiveScreen; sty
);
}
+export function phoneState(device: AndroidDevice): string {
+ if (device.state !== "connected") return device.state === "unauthorized" ? "Waiting for its OK" : "Not connected";
+ return device.busy ? "An agent is on it" : device.awake === false ? "Screen off" : "Connected";
+}
+
export function vmState(state: string): string {
return (
{ running: "Running", stopped: "Stopped", suspended: "Paused", starting: "Starting…", stopping: "Stopping…", creating: "Setting up…", error: "Needs attention" }[
diff --git a/apps/mobile/src/components/task-timeline.tsx b/apps/mobile/src/components/task-timeline.tsx
new file mode 100644
index 00000000..c833b364
--- /dev/null
+++ b/apps/mobile/src/components/task-timeline.tsx
@@ -0,0 +1,311 @@
+import { useQuery } from "@tanstack/react-query";
+import { memo, useMemo, useState, type ReactNode } from "react";
+import { Pressable, StyleSheet, View } from "react-native";
+import { fileNameSummary, taskEventText, type Agent, type Task, type TaskEvent, type TaskEventKind } from "@godmode/shared";
+import { Icon, type IconName } from "./icon";
+import { Markdown } from "./markdown";
+import { Skeleton, T, tap } from "./ui";
+import { ApiError, api } from "@/lib/api";
+import { shortTime, whenText } from "@/lib/format";
+import { qk } from "@/lib/query";
+import { radius, space, useColors } from "@/lib/theme";
+
+type Tone = "neutral" | "brand" | "warning" | "danger";
+
+const ICONS: Record = {
+ status: "tasks",
+ assigned: "person",
+ archived: "archive",
+ started: "play",
+ waiting: "clock",
+ delivered: "check",
+ blocked: "warning",
+ feedback: "chats",
+ note: "pencil",
+ pr_opened: "branch",
+ pr_merged: "branch",
+ pr_closed: "close",
+ asked: "bell",
+ answered: "chats",
+};
+
+const TONES: Partial> = { delivered: "brand", pr_merged: "brand", blocked: "danger", asked: "warning" };
+
+/** Kinds whose text is shown under the line; a move or a start only when it carries one. */
+const BODIED: readonly TaskEventKind[] = ["feedback", "note", "asked", "answered", "blocked", "waiting", "status", "started"];
+
+const SHOWN = 30;
+const CLIP = 132;
+const SKELETON = ["72%", "56%", "64%"] as const;
+
+/**
+ * What happened on a ticket, oldest first: who filed it, moves, starts, deliveries, blocks with their reason, the
+ * human's feedback, notes agents left, pull requests, questions and answers. The latest 30, earlier ones on request.
+ */
+export const TaskTimeline = memo(function TaskTimeline({ task, agents }: { task: Task; agents: Agent[] | undefined }) {
+ const events = useQuery({ queryKey: qk.taskEvents(task.id), queryFn: () => api.tasks.events(task.id) });
+ const [all, setAll] = useState(false);
+ // A computer on an older Godmode keeps no timeline: who filed it is all there is to show.
+ const none = events.error instanceof ApiError && (events.error.status === 404 || events.error.code === "device_forbidden");
+ const list = events.data;
+ const hidden = all || !list ? 0 : Math.max(0, list.length - SHOWN);
+ const shown = useMemo(() => (list && hidden ? list.slice(hidden) : (list ?? [])), [list, hidden]);
+ const filer =
+ task.createdBy === "user"
+ ? "You"
+ : task.createdBy === "system"
+ ? "Godmode"
+ : (agents?.find((a) => `agent:${a.id}` === task.createdBy)?.name ?? "An agent that no longer exists");
+
+ if (!list && !none) {
+ if (events.isError) {
+ return (
+
+
+ {"Couldn't load the activity."}
+
+ {
+ tap();
+ void events.refetch();
+ }}
+ style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
+ >
+
+ Try again
+
+
+
+ );
+ }
+ return (
+
+ {SKELETON.map((width) => (
+
+ ))}
+
+ );
+ }
+
+ return (
+
+
+ {hidden > 0 && (
+
+ {
+ tap();
+ setAll(true);
+ }}
+ style={({ pressed }) => [styles.earlier, { opacity: pressed ? 0.6 : 1 }]}
+ >
+
+ Show earlier ({hidden})
+
+
+
+ )}
+ {shown.map((e, i) => (
+
+ ))}
+
+ );
+});
+
+/** `current`: a delivery whose result is the one the ticket shows above. */
+const EventRow = memo(function EventRow({ event: e, current, last }: { event: TaskEvent; current: boolean; last: boolean }) {
+ const [open, setOpen] = useState(false);
+ const text = taskEventText(e, { you: "You", youObject: "you", when: whenText });
+ const tone = TONES[e.kind];
+
+ if (e.kind === "delivered") {
+ const meta = [
+ e.data.durationMs ? `${Math.max(1, Math.round(e.data.durationMs / 60_000))}m` : null,
+ e.data.costUsd ? `$${e.data.costUsd.toFixed(2)}` : null,
+ current ? "the result above" : null,
+ ]
+ .filter(Boolean)
+ .join(" · ");
+ return (
+
+ {meta ? (
+
+ {meta}
+
+ ) : null}
+ {!current && e.body ? setOpen((v) => !v)} more="Show this result" less="Hide this result" /> : null}
+ {!current && e.body && open ? : null}
+
+ );
+ }
+
+ return (
+
+ {e.body && BODIED.includes(e.kind) ? (
+ 0 ? `Files: ${fileNameSummary(e.data.files)}` : undefined} />
+ ) : null}
+
+ );
+});
+
+/** One step on the rail: its dot, what happened and when, and what belongs under it. */
+function Line({ icon, tone = "neutral", text, strong, at, last, children }: { icon: IconName; tone?: Tone; text?: string; strong?: boolean; at?: string; last?: boolean; children?: ReactNode }) {
+ const c = useColors();
+ const tones = {
+ neutral: { bg: c.sunken, fg: c.textMuted },
+ brand: { bg: c.brandSoft, fg: c.brandStrong },
+ warning: { bg: c.warningSoft, fg: c.warning },
+ danger: { bg: c.dangerSoft, fg: c.danger },
+ }[tone];
+ return (
+
+
+
+
+
+ {!last && }
+
+
+ {text ? (
+
+
+ {text}
+
+
+ {shortTime(at)}
+
+
+ ) : null}
+ {children}
+
+
+ );
+}
+
+/** An event's text in a quiet box; a long one is cut until it's asked for (`whole`: never cut). */
+function Body({ text, note, whole }: { text: string; note?: string; whole?: boolean }) {
+ const c = useColors();
+ const [tall, setTall] = useState(false);
+ const [open, setOpen] = useState(false);
+ return (
+
+
+ setTall(e.nativeEvent.layout.height > CLIP + 1)}>
+
+
+
+ {note ? (
+
+ {note}
+
+ ) : null}
+ {tall ? setOpen((v) => !v)} more="Show more" less="Show less" /> : null}
+
+ );
+}
+
+function Toggle({ open, onPress, more, less }: { open: boolean; onPress: () => void; more: string; less: string }) {
+ const c = useColors();
+ return (
+ {
+ tap();
+ onPress();
+ }}
+ style={({ pressed }) => [styles.toggle, { opacity: pressed ? 0.6 : 1 }]}
+ >
+
+ {open ? less : more}
+
+
+
+ );
+}
+
+/** The board's copy of a result is cut short and ends in "…". */
+function sameResult(body: string, summary: string | null): boolean {
+ if (!summary) return false;
+ return body === summary || (summary.endsWith("…") && body.startsWith(summary.slice(0, -1)));
+}
+
+const styles = StyleSheet.create({
+ loading: {
+ gap: space.md,
+ paddingHorizontal: 4,
+ paddingVertical: 2,
+ },
+ failed: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: space.md,
+ paddingHorizontal: 4,
+ },
+ line: {
+ flexDirection: "row",
+ gap: space.md,
+ },
+ rail: {
+ width: 22,
+ alignItems: "center",
+ },
+ dot: {
+ width: 22,
+ height: 22,
+ borderRadius: 11,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ thread: {
+ flex: 1,
+ width: 1.5,
+ marginVertical: 3,
+ borderRadius: 1,
+ },
+ step: {
+ flex: 1,
+ minWidth: 0,
+ gap: 6,
+ },
+ head: {
+ flexDirection: "row",
+ alignItems: "flex-start",
+ gap: space.sm,
+ minHeight: 22,
+ paddingTop: 1.5,
+ },
+ time: {
+ lineHeight: 19,
+ fontVariant: ["tabular-nums"],
+ },
+ earlier: {
+ minHeight: 22,
+ justifyContent: "center",
+ alignSelf: "flex-start",
+ },
+ box: {
+ gap: space.sm,
+ paddingHorizontal: space.md,
+ paddingVertical: 10,
+ borderRadius: radius.md,
+ borderCurve: "continuous",
+ },
+ clipped: {
+ maxHeight: CLIP,
+ overflow: "hidden",
+ },
+ toggle: {
+ flexDirection: "row",
+ alignItems: "center",
+ alignSelf: "flex-start",
+ gap: 4,
+ },
+});
diff --git a/apps/mobile/src/components/ui.tsx b/apps/mobile/src/components/ui.tsx
index a59442c0..98fac812 100644
--- a/apps/mobile/src/components/ui.tsx
+++ b/apps/mobile/src/components/ui.tsx
@@ -1,4 +1,5 @@
import * as Haptics from "expo-haptics";
+import { useIsFocused } from "expo-router";
import { useEffect, type ReactNode } from "react";
import {
ActivityIndicator,
@@ -13,7 +14,7 @@ import {
type TextStyle,
type ViewStyle,
} from "react-native";
-import Animated, { Easing, useAnimatedStyle, useSharedValue, withRepeat, withTiming } from "react-native-reanimated";
+import Animated, { cancelAnimation, Easing, useAnimatedStyle, useSharedValue, withRepeat, withTiming } from "react-native-reanimated";
import { Glass } from "./glass";
import { Icon, type IconName } from "./icon";
import { radius, space, type, useColors } from "@/lib/theme";
@@ -66,12 +67,20 @@ export function SectionTitle({ title, action, onAction }: { title: string; actio
);
}
+/** Pulses only while its screen is in front: tabs and screens under others stay mounted. */
export function LiveDot({ live = true, color, size = 8 }: { live?: boolean; color?: ColorValue; size?: number }) {
const c = useColors();
+ const focused = useIsFocused();
const pulse = useSharedValue(0);
+ const moving = live && focused;
useEffect(() => {
- pulse.value = live ? withRepeat(withTiming(1, { duration: 1400, easing: Easing.out(Easing.quad) }), -1, false) : 0;
- }, [live, pulse]);
+ if (!moving) return;
+ pulse.value = withRepeat(withTiming(1, { duration: 1400, easing: Easing.out(Easing.quad) }), -1, false);
+ return () => {
+ cancelAnimation(pulse);
+ pulse.value = 0;
+ };
+ }, [moving, pulse]);
const ring = useAnimatedStyle(() => ({ opacity: 0.55 * (1 - pulse.value), transform: [{ scale: 1 + pulse.value * 1.6 }] }));
const fill = color ?? (live ? c.brand : c.textFaint);
return (
@@ -256,10 +265,13 @@ export function EmptyState({ icon, title, body, action }: { icon: IconName; titl
/** A placeholder block that breathes while its content loads. */
export function Skeleton({ width, height = 12, radius: r = 6, style }: { width?: ViewStyle["width"]; height?: number; radius?: number; style?: StyleProp }) {
const c = useColors();
+ const focused = useIsFocused();
const v = useSharedValue(0.55);
useEffect(() => {
+ if (!focused) return;
v.value = withRepeat(withTiming(1, { duration: 850, easing: Easing.inOut(Easing.quad) }), -1, true);
- }, [v]);
+ return () => cancelAnimation(v);
+ }, [v, focused]);
const pulse = useAnimatedStyle(() => ({ opacity: v.value }));
return ;
}
diff --git a/apps/mobile/src/lib/api.ts b/apps/mobile/src/lib/api.ts
index e4ca5e8d..153908b8 100644
--- a/apps/mobile/src/lib/api.ts
+++ b/apps/mobile/src/lib/api.ts
@@ -3,8 +3,11 @@ import Constants from "expo-constants";
import { File } from "expo-file-system";
import type {
Agent,
+ AnswerQuestionInput,
AnswerQuestionResult,
AgentQuestion,
+ AndroidDevice,
+ AndroidScreenshot,
AppNotification,
Bootstrap,
BrowserProfile,
@@ -15,6 +18,7 @@ import type {
HumanTask,
HumanTaskCloseResult,
ConversationWithMessages,
+ MessagePage,
MissingLogin,
MobilePairResult,
MobilePairingPayload,
@@ -32,6 +36,7 @@ import type {
StartChatResult,
Task,
TaskAttachment,
+ TaskEvent,
TaskStatus,
TaskType,
Vm,
@@ -126,25 +131,51 @@ function timeoutFor(bytes: number): number {
}
const verified = new Set();
+/** Probes on their way, shared by the requests that wait for the same address. */
+const probing = new Map>();
+/**
+ * Addresses whose probe didn't answer lately. They are skipped for a while — but only while another address is known to
+ * answer, so a hiccup never makes the phone give up on the only way to the computer.
+ */
+const unreachable = new Map();
+const UNREACHABLE_FOR_MS = 20_000;
+
+/** One of the addresses after `index` answered as the computer and still counts as reachable. */
+function laterAnswers(bases: string[], index: number, instanceId: string): boolean {
+ return bases.slice(index + 1).some((base) => verified.has(`${instanceId} ${base}`));
+}
/**
* Does this address answer as the paired computer? Asked (without the key) before the key goes to an address, so an
* address that isn't the computer can't collect it. True when it does; else the cloud gateway's own sentence on why the
* computer is out of reach there (offline, plan limit), when it gave one.
*/
-async function isInstance(base: string, instanceId: string): Promise {
+async function isInstance(base: string, instanceId: string, skipIfDown = false): Promise {
const key = `${instanceId} ${base}`;
if (verified.has(key)) return true;
if (!isPhoneUrlAllowed(base)) return null;
+ if (skipIfDown && (unreachable.get(key) ?? 0) > Date.now()) return null;
+ let probe = probing.get(key);
+ if (!probe) {
+ probe = probeInstance(base, instanceId).then((found) => {
+ if (found === true) verified.add(key);
+ else unreachable.set(key, Date.now() + UNREACHABLE_FOR_MS);
+ return found;
+ });
+ probing.set(key, probe);
+ void probe.finally(() => probing.delete(key));
+ }
+ return probe;
+}
+
+async function probeInstance(base: string, instanceId: string): Promise {
try {
const res = await send(`${base}/api/health`, { method: "GET" }, PROBE_TIMEOUT_MS);
if (!res.ok) {
const { error, code } = await failure(res);
return error && isUnreachable(res.status, code) ? new ApiError(res.status, error, code) : null;
}
- if (((await res.json()) as { instance?: string }).instance !== instanceId) return null;
- verified.add(key);
- return true;
+ return ((await res.json()) as { instance?: string }).instance === instanceId ? true : null;
} catch {
return null;
}
@@ -156,8 +187,9 @@ async function isInstance(base: string, instanceId: string): Promise {
let refused: ApiError | null = null;
- for (const base of addressOrder(connection)) {
- const found = await isInstance(base, connection.instance.id);
+ const bases = addressOrder(connection);
+ for (const [i, base] of bases.entries()) {
+ const found = await isInstance(base, connection.instance.id, laterAnswers(bases, i, connection.instance.id));
if (found === true) return base;
refused ??= found;
}
@@ -169,6 +201,11 @@ export function forget(base: string) {
for (const key of verified) if (key.endsWith(` ${base}`)) verified.delete(key);
}
+/** Every address gets a fresh chance (the app came back, the network changed, the human pulled to refresh). */
+export function retryAddresses() {
+ unreachable.clear();
+}
+
/**
* Calls the paired computer. Reads fall through to its other addresses; writes are sent once, so a slow answer never
* turns into a second message or run. A write moves on only when the answer shows it never reached the computer.
@@ -182,8 +219,9 @@ export async function request(method: string, path: string, body?: unknown, m
const payload = body === undefined ? undefined : form ? body : JSON.stringify(body);
const timeout = Math.max(minTimeoutMs, typeof payload === "string" ? timeoutFor(payload.length) : form ? timeoutFor(MAX_UPLOAD_BYTES) : TIMEOUT_MS);
let refused: ApiError | null = null;
- for (const base of addressOrder(connection)) {
- const found = await isInstance(base, connection.instance.id);
+ const bases = addressOrder(connection);
+ for (const [i, base] of bases.entries()) {
+ const found = await isInstance(base, connection.instance.id, laterAnswers(bases, i, connection.instance.id));
if (found !== true) {
refused ??= found;
continue;
@@ -377,13 +415,20 @@ export const api = {
models: (refresh = false) => get("/api/models", refresh ? { refresh: 1 } : {}),
conversations: {
- /** With `workspaceId`: the workspace's chats. */
- list: (q: { search?: string; limit?: number; agentId?: string; workspaceId?: string | null } = {}) => get("/api/conversations", q),
- /** With the queued messages this phone is still sending, so a refetch never drops them. */
- get: async (id: string) => {
- const conversation = await get(`/api/conversations/${id}`);
+ /** With `workspaceId`: the workspace's chats. `archived`: the archive; with `ongoing` only its chats that still go on. */
+ list: (q: { search?: string; limit?: number; agentId?: string; workspaceId?: string | null; archived?: boolean; ongoing?: boolean } = {}) =>
+ get("/api/conversations", q),
+ /**
+ * The chat with its latest `limit` messages, light: screenshots come as their own requests (`imageUrl`). With the
+ * queued messages this phone is still sending, so a refetch never drops them. An older Godmode answers with every
+ * message (and no `hasMore`).
+ */
+ get: async (id: string, limit: number) => {
+ const conversation = await get(`/api/conversations/${id}`, { limit, lite: 1 });
return { ...conversation, queue: withPending(id, conversation.queue) };
},
+ /** The page of messages before `before`. */
+ messages: (id: string, before: string, limit: number) => get(`/api/conversations/${id}/messages`, { before, limit, lite: 1 }),
/** While the agent works in the chat the message joins its queue (`queued`) instead of starting a run. */
send: (id: string, input: { content: string; attachments: UploadFile[]; queueId: string }) =>
post(`/api/conversations/${id}/messages`, {
@@ -402,6 +447,14 @@ export const api = {
delete: (id: string) => del<{ ok: true }>(`/api/conversations/${id}`),
/** 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. */
+ pause: (id: string) => post<{ ok: true }>(`/api/conversations/${id}/pause`, {}, 30_000),
+ /** The follow-up the agent set: moved, run now or cancelled. */
+ followup: {
+ move: (id: string, dueAt: string) => patch(`/api/conversations/${id}/followup`, { dueAt }),
+ runNow: (id: string) => post(`/api/conversations/${id}/followup/run`),
+ cancel: (id: string) => del<{ ok: true }>(`/api/conversations/${id}/followup`),
+ },
/** Pick up the chat's latest turn that ended early: continue where it stopped, or send it again. */
retry: (id: string, runId: string) => post(`/api/conversations/${id}/retry`, { runId }),
},
@@ -413,9 +466,11 @@ export const api = {
},
tasks: {
- /** `workspaceId`: a workspace's board, or all of them when omitted. */
- list: (q: { workspaceId?: string | null } = {}) => get("/api/tasks", q),
+ /** `workspaceId`: a workspace's board, or all of them when omitted. `archived`: the tasks taken off the board. */
+ list: (q: { workspaceId?: string | null; archived?: boolean } = {}) => get("/api/tasks", { ...q, fields: "summary" }),
get: (id: string) => get(`/api/tasks/${id}`),
+ /** What happened on the ticket, oldest first. */
+ events: (id: string) => get(`/api/tasks/${id}/events`, { limit: 200 }),
/** With an agent (and no status) the task goes to To do and the agent starts right away. */
create: (input: {
workspaceId: string | null;
@@ -475,6 +530,12 @@ export const api = {
input: (id: string, event: ComputerInputEvent, frame: { width: number; height: number }) => post<{ ok: true }>(`/api/vms/${id}/input`, { event, frame }),
},
+ android: {
+ /** Android phones connected to the computer for agents. An older Godmode refuses this (403). */
+ devices: () => get("/api/android/devices"),
+ screenshot: (id: string, size = 720) => get(`/api/android/devices/${id}/screenshot`, { size }),
+ },
+
notifications: {
list: () => get("/api/notifications", { limit: 30 }),
},
@@ -495,6 +556,8 @@ export const api = {
questions: {
/** What agents asked and wait for; answered by writing into the chat (or with optionId / decision). */
open: () => get("/api/questions", { status: "open" }),
+ /** One of optionId, decision (with an optional note) or text; the run that asked continues with it. */
+ answer: (id: string, input: AnswerQuestionInput) => post(`/api/questions/${id}/answer`, input),
},
};
diff --git a/apps/mobile/src/lib/chat-cache.ts b/apps/mobile/src/lib/chat-cache.ts
new file mode 100644
index 00000000..74dea1bf
--- /dev/null
+++ b/apps/mobile/src/lib/chat-cache.ts
@@ -0,0 +1,109 @@
+import type { ConversationWithMessages, Message, QueuedMessage } from "@godmode/shared";
+import { api } from "./api";
+import { withPending } from "./pending-queue";
+import { qk, queryClient } from "./query";
+
+/** Messages a chat opens with; older ones come a page at a time when scrolling up. A long chat is megabytes. */
+export const CHAT_WINDOW = 40;
+
+interface Arrived {
+ messages: Map;
+ queue: QueuedMessage[] | null;
+}
+
+/** What live events brought while a chat was being fetched: the answer may predate them. */
+const fetching = new Map>();
+
+export const isPending = (m: Message) => m.id.startsWith("pending-");
+
+/**
+ * A stored message into a chat's list; the optimistic copy of a message the human just sent goes. `windowed`: older
+ * messages exist that aren't loaded, so one from before the loaded ones stays out (its page brings it) instead of
+ * landing on top with a hole behind it.
+ */
+export function mergeMessage(messages: Message[], message: Message, windowed = false): Message[] {
+ if (messages.some((m) => m.id === message.id)) return messages.map((m) => (m.id === message.id ? message : m));
+ const oldest = messages.find((m) => !isPending(m));
+ if (windowed && oldest && message.createdAt < oldest.createdAt) return messages;
+ let rest = messages;
+ if (message.role === "user") {
+ const optimistic = rest.findIndex((m) => isPending(m) && m.content.trim() === message.content.trim());
+ if (optimistic >= 0) rest = rest.filter((_, i) => i !== optimistic);
+ }
+ const stored = rest.filter((m) => !isPending(m));
+ const at = stored.findIndex((m) => m.createdAt > message.createdAt);
+ const sorted = at < 0 ? [...stored, message] : [...stored.slice(0, at), message, ...stored.slice(at)];
+ return [...sorted, ...rest.filter(isPending)];
+}
+
+/** A message event: into the cached chat, and into any fetch of it that is on its way. */
+export function upsertMessage(message: Message) {
+ for (const arrived of fetching.get(message.conversationId) ?? []) arrived.messages.set(message.id, message);
+ queryClient.setQueryData(qk.conversation(message.conversationId), (old) =>
+ old ? { ...old, messages: mergeMessage(old.messages, message, !!old.hasMore) } : old,
+ );
+}
+
+export function noteQueue(conversationId: string, queue: QueuedMessage[]) {
+ for (const arrived of fetching.get(conversationId) ?? []) arrived.queue = queue;
+}
+
+/**
+ * A chat with its latest messages. Older pages that were loaded stay, and so does what arrived live meanwhile, so a
+ * refetch never takes anything off the screen.
+ */
+export async function fetchConversation(id: string): Promise {
+ const arrived: Arrived = { messages: new Map(), queue: null };
+ const waiting = fetching.get(id) ?? new Set();
+ waiting.add(arrived);
+ fetching.set(id, waiting);
+ try {
+ const fresh = await api.conversations.get(id, CHAT_WINDOW);
+ const old = queryClient.getQueryData(qk.conversation(id));
+ let messages = fresh.messages;
+ let hasMore = fresh.hasMore ?? false;
+ if (old) {
+ const known = new Set(messages.map((m) => m.id));
+ const first = messages[0];
+ // Only when the two overlap: otherwise more arrived than one window holds and there would be a hole in between.
+ if (first && old.messages.some((m) => known.has(m.id))) {
+ const earlier = old.messages.filter((m) => !isPending(m) && !known.has(m.id) && m.createdAt <= first.createdAt);
+ if (earlier.length) {
+ messages = [...earlier, ...messages];
+ hasMore = old.hasMore ?? false;
+ }
+ }
+ // A message still on its way out stays until the computer's copy of it is in the answer.
+ const had = new Set(old.messages.map((m) => m.id));
+ const landed = new Set(messages.filter((m) => m.role === "user" && !had.has(m.id)).map((m) => m.content.trim()));
+ messages = [...messages, ...old.messages.filter((m) => isPending(m) && !landed.has(m.content.trim()))];
+ }
+ for (const message of arrived.messages.values()) messages = mergeMessage(messages, message, hasMore);
+ return { ...fresh, messages, hasMore, queue: arrived.queue ? withPending(id, arrived.queue) : fresh.queue };
+ } finally {
+ waiting.delete(arrived);
+ if (!waiting.size) fetching.delete(id);
+ }
+}
+
+const loadingEarlier = new Set();
+
+/** The page before the oldest message on screen. False when there is nothing older (or it is loading already). */
+export async function loadEarlier(id: string): Promise {
+ const key = qk.conversation(id);
+ const have = queryClient.getQueryData(key);
+ const first = have?.messages.find((m) => !isPending(m));
+ if (!have?.hasMore || !first || loadingEarlier.has(id)) return false;
+ loadingEarlier.add(id);
+ try {
+ const page = await api.conversations.messages(id, first.id, CHAT_WINDOW);
+ queryClient.setQueryData(key, (old) => {
+ if (!old) return old;
+ const known = new Set(old.messages.map((m) => m.id));
+ return { ...old, messages: [...page.messages.filter((m) => !known.has(m.id)), ...old.messages], hasMore: page.hasMore };
+ });
+ return true;
+ } finally {
+ loadingEarlier.delete(id);
+ }
+}
diff --git a/apps/mobile/src/lib/format.ts b/apps/mobile/src/lib/format.ts
index 00f96c2e..60ceadb2 100644
--- a/apps/mobile/src/lib/format.ts
+++ b/apps/mobile/src/lib/format.ts
@@ -1,5 +1,5 @@
import { gatewayDone } from "@godmode/shared";
-import { format, isThisYear, isToday, isYesterday } from "date-fns";
+import { format, isThisYear, isToday, isTomorrow, isYesterday } from "date-fns";
import type { MessageBlock } from "@godmode/shared";
import type { IconName } from "@/components/icon";
@@ -14,6 +14,16 @@ export function shortTime(iso: string | null | undefined): string {
return format(date, isThisYear(date) ? "MMM d" : "MMM d, yyyy");
}
+/** "today at 16:00", "tomorrow at 09:00", "Thu, Oct 1 at 09:00" */
+export function whenText(iso: string): string {
+ const date = new Date(iso);
+ const time = format(date, "HH:mm");
+ if (isToday(date)) return `today at ${time}`;
+ if (isTomorrow(date)) return `tomorrow at ${time}`;
+ if (isYesterday(date)) return `yesterday at ${time}`;
+ return `${format(date, isThisYear(date) ? "EEE, MMM d" : "EEE, MMM d yyyy")} at ${time}`;
+}
+
export function elapsed(fromIso: string | null | undefined, now = Date.now()): string {
if (!fromIso) return "";
const s = Math.max(0, Math.floor((now - new Date(fromIso).getTime()) / 1000));
diff --git a/apps/mobile/src/lib/hooks.ts b/apps/mobile/src/lib/hooks.ts
index fd05716c..80afb1eb 100644
--- a/apps/mobile/src/lib/hooks.ts
+++ b/apps/mobile/src/lib/hooks.ts
@@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
-import type { Agent } from "@godmode/shared";
+import type { Agent, HumanTask } from "@godmode/shared";
import { api } from "./api";
import { qk } from "./query";
@@ -13,17 +13,22 @@ export function useNow(intervalMs = 1000): number {
return now;
}
+/** One page of chats for every list that shows them (Home, Chats, live runs, screens), so they share one request. */
+export const CHAT_LIST_LIMIT = 200;
+
+export function chatListQuery(workspaceId: string | null) {
+ return { queryKey: qk.conversationList("", workspaceId), queryFn: () => api.conversations.list({ limit: CHAT_LIST_LIMIT, workspaceId }) };
+}
+
export function useAgents() {
const query = useQuery({ queryKey: qk.agents, queryFn: api.agents.list });
const byId = useMemo(() => new Map((query.data ?? []).map((a) => [a.id, a])), [query.data]);
return { ...query, byId };
}
+const urgentFirst = (list: HumanTask[]) => [...list].sort((a, b) => Number(b.priority === "high") - Number(a.priority === "high") || (a.createdAt < b.createdAt ? 1 : -1));
+
/** Tasks agents handed to the human that still wait (open or doing), most urgent first. */
export function useOpenHumanTasks() {
- return useQuery({
- queryKey: qk.humanTaskList("active"),
- queryFn: () => api.humanTasks.list({ status: "active" }),
- select: (list) => [...list].sort((a, b) => Number(b.priority === "high") - Number(a.priority === "high") || (a.createdAt < b.createdAt ? 1 : -1)),
- });
+ return useQuery({ queryKey: qk.humanTaskList("active"), queryFn: () => api.humanTasks.list({ status: "active" }), select: urgentFirst });
}
diff --git a/apps/mobile/src/lib/live.ts b/apps/mobile/src/lib/live.ts
index 9902cabf..9429aca2 100644
--- a/apps/mobile/src/lib/live.ts
+++ b/apps/mobile/src/lib/live.ts
@@ -1,5 +1,6 @@
+import { useMemo } from "react";
import { create } from "zustand";
-import type { MessageBlock, Run } from "@godmode/shared";
+import { applyRunDelta, type MessageBlock, type Run, type RunDelta } from "@godmode/shared";
export type LinkStatus = "connecting" | "online" | "offline";
@@ -13,6 +14,8 @@ export interface Draft {
runId: string;
messageId: string;
blocks: MessageBlock[];
+ seq: number;
+ stream?: string;
}
export interface Frame {
@@ -42,12 +45,13 @@ interface LiveState {
runStarted: (run: Run) => void;
runActivity: (runId: string, label: string) => void;
runFinished: (run: Run) => void;
- delta: (conversationId: string, draft: Draft) => void;
+ /** False when the delta doesn't fit what the phone has of the run (it missed one): ask for the whole list then. */
+ delta: (event: RunDelta) => boolean;
frame: (key: string, frame: Frame) => void;
dropFrame: (key: string) => void;
}
-export const useLive = create((set) => ({
+export const useLive = create((set, get) => ({
status: "connecting",
offlineReason: null,
runs: {},
@@ -79,7 +83,13 @@ export const useLive = create((set) => ({
if (drafts[run.conversationId]?.runId === run.id) delete drafts[run.conversationId];
return { runs, labels, drafts, ended: [...s.ended.slice(-49), run.id] };
}),
- delta: (conversationId, draft) => set((s) => ({ drafts: { ...s.drafts, [conversationId]: draft } })),
+ delta: (event) => {
+ const have = get().drafts[event.conversationId];
+ const next = applyRunDelta(have?.runId === event.runId ? have : null, event);
+ if (!next) return false;
+ set((s) => ({ drafts: { ...s.drafts, [event.conversationId]: { runId: event.runId, messageId: event.messageId, ...next } } }));
+ return true;
+ },
frame: (key, frame) => set((s) => ({ frames: { ...s.frames, [key]: frame } })),
dropFrame: (key) =>
set((s) => {
@@ -96,3 +106,59 @@ export function useConversationRun(conversationId: string | undefined): LiveRun
return runs.find((r) => r.run.status === "running") ?? runs[0];
});
}
+
+export interface RunCount {
+ running: number;
+ queued: number;
+}
+
+/**
+ * Running and queued runs per agent. Selected as a string so a screen re-renders when a number changes, not on every
+ * activity label of every run.
+ */
+export function useRunCounts(): Map {
+ const key = useLive((s) =>
+ Object.values(s.runs)
+ .map(({ run }) => `${run.agentId} ${run.status}`)
+ .sort()
+ .join("\n"),
+ );
+ return useMemo(() => {
+ const counts = new Map();
+ for (const line of key ? key.split("\n") : []) {
+ const [agentId, status] = line.split(" ");
+ const n = counts.get(agentId!) ?? { running: 0, queued: 0 };
+ if (status === "running") n.running++;
+ else if (status === "queued") n.queued++;
+ counts.set(agentId!, n);
+ }
+ return counts;
+ }, [key]);
+}
+
+/** Chats with a run in them, re-rendering only when that set changes. */
+export function useRunningChats(): Set {
+ const key = useLive((s) => [...new Set(Object.values(s.runs).map(({ run }) => run.conversationId))].sort().join("\n"));
+ return useMemo(() => new Set(key ? key.split("\n") : []), [key]);
+}
+
+/**
+ * The run working on a chat, as its id and status only: a chat screen re-renders when the run starts, pauses or ends,
+ * not with every activity label.
+ */
+export function useChatRun(conversationId: string): { id: string; status: Run["status"] } | null {
+ const key = useLive((s) => {
+ const runs = Object.values(s.runs).filter((r) => r.run.conversationId === conversationId);
+ const run = (runs.find((r) => r.run.status === "running") ?? runs[0])?.run;
+ return run ? `${run.id} ${run.status}` : "";
+ });
+ return useMemo(() => {
+ if (!key) return null;
+ const [id, status] = key.split(" ");
+ return { id: id!, status: status as Run["status"] };
+ }, [key]);
+}
+
+export function useRunActivity(runId: string | undefined): string | null {
+ return useLive((s) => (runId ? (s.runs[runId]?.activity ?? null) : null));
+}
diff --git a/apps/mobile/src/lib/persist.ts b/apps/mobile/src/lib/persist.ts
new file mode 100644
index 00000000..68b782e3
--- /dev/null
+++ b/apps/mobile/src/lib/persist.ts
@@ -0,0 +1,71 @@
+import { dehydrate, hydrate, type DehydratedState, type Query } from "@tanstack/react-query";
+import { File, Paths } from "expo-file-system";
+import { AppState } from "react-native";
+import { queryClient } from "./query";
+
+/**
+ * The lists the app opens with (chats, tasks, agents, what needs you) are kept on the phone between launches, so a cold
+ * start shows them at once and refreshes them behind. Chats themselves, settings and live pictures are not kept. The
+ * file is in the app's cache folder: not backed up, and gone with the pairing.
+ */
+const KEPT = new Set(["workspaces", "agents", "conversations", "tasks", "routines", "human-tasks", "questions", "missing-logins", "browser-profiles", "vms", "models"]);
+const VERSION = 1;
+const MAX_AGE_MS = 7 * 24 * 3600_000;
+const SAVE_AFTER_MS = 3000;
+
+const file = () => new File(Paths.cache, "godmode-lists.json");
+
+function kept(query: Query): boolean {
+ const [scope, kind, search] = query.queryKey as unknown[];
+ if (query.state.status !== "success" || !KEPT.has(String(scope))) return false;
+ if (kind === "detail" || kind === "events") return false;
+ return !(scope === "conversations" && kind === "list" && search);
+}
+
+/** Before the first screen: what the last session saw, when it was this pairing's. */
+export async function restoreQueries(deviceId: string): Promise {
+ try {
+ const saved = file();
+ if (!saved.exists) return;
+ const { v, device, at, state } = JSON.parse(await saved.text()) as { v: number; device: string; at: number; state: DehydratedState };
+ if (v !== VERSION || device !== deviceId || Date.now() - at > MAX_AGE_MS) return saved.delete();
+ hydrate(queryClient, state);
+ } catch {
+ forgetQueries();
+ }
+}
+
+/** Saves the lists a moment after they change, and when the app leaves the screen. */
+export function persistQueries(deviceId: string): () => void {
+ let timer: ReturnType | null = null;
+ const save = () => {
+ if (timer) clearTimeout(timer);
+ timer = null;
+ try {
+ file().write(JSON.stringify({ v: VERSION, device: deviceId, at: Date.now(), state: dehydrate(queryClient, { shouldDehydrateQuery: kept }) }));
+ } catch {
+ /* the next change tries again */
+ }
+ };
+ const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
+ if (event.type !== "updated" || event.action.type !== "success" || !kept(event.query)) return;
+ timer ??= setTimeout(save, SAVE_AFTER_MS);
+ });
+ const app = AppState.addEventListener("change", (state) => {
+ if (state !== "active" && timer) save();
+ });
+ return () => {
+ unsubscribe();
+ app.remove();
+ if (timer) clearTimeout(timer);
+ };
+}
+
+export function forgetQueries() {
+ try {
+ const saved = file();
+ if (saved.exists) saved.delete();
+ } catch {
+ /* nothing to forget */
+ }
+}
diff --git a/apps/mobile/src/lib/query.ts b/apps/mobile/src/lib/query.ts
index a1cf20f6..509a3899 100644
--- a/apps/mobile/src/lib/query.ts
+++ b/apps/mobile/src/lib/query.ts
@@ -23,16 +23,23 @@ export const qk = {
conversations: ["conversations"],
conversationList: (search: string, workspaceId: string | null = null) => ["conversations", "list", search, workspaceId ?? "all"],
conversation: (id: string) => ["conversations", "detail", id],
+ archivedChats: (workspaceId: string | null) => ["conversations", "archived", workspaceId ?? "all"],
tasks: ["tasks"],
- taskList: (workspaceId: string | null) => ["tasks", "list", workspaceId ?? "all"],
+ /** Boards carry the tickets without their long texts; `archived`: the ones taken off the board. */
+ taskList: (workspaceId: string | null, archived = false) => ["tasks", "list", workspaceId ?? "all", archived ? "archived" : "board"],
task: (id: string) => ["tasks", "detail", id],
+ taskEvents: (id: string) => ["tasks", "events", id],
runs: ["runs"],
+ recentRuns: ["runs", "recent"],
agentRuns: (agentId: string) => ["runs", "agent", agentId],
routines: ["routines"],
+ allRoutines: ["routines", "all"],
agentRoutines: (agentId: string) => ["routines", agentId],
browserProfiles: ["browser-profiles"],
vms: ["vms"],
vmScreen: (id: string) => ["vm-screen", id],
+ androidDevices: ["android-devices"],
+ androidScreen: (id: string) => ["android-screen", id],
notifications: ["notifications"],
missingLogins: ["missing-logins"],
questions: ["questions"],
diff --git a/apps/mobile/src/lib/realtime.ts b/apps/mobile/src/lib/realtime.ts
index b9163dcd..1f837fc8 100644
--- a/apps/mobile/src/lib/realtime.ts
+++ b/apps/mobile/src/lib/realtime.ts
@@ -1,6 +1,8 @@
import { AppState, type AppStateStatus } from "react-native";
-import { browserView, type ClientEvent, type ConversationWithMessages, type EntityName, type Message, type ServerEvent, type Vm } from "@godmode/shared";
-import { api, forget, reachableBase } from "./api";
+import type { InvalidateQueryFilters } from "@tanstack/react-query";
+import { browserView, type Agent, type AgentQuestion, type ClientEvent, type EntityName, type ServerEvent, type Task, type TaskEvent, type Vm } from "@godmode/shared";
+import { api, forget, reachableBase, retryAddresses } from "./api";
+import { noteQueue, upsertMessage } from "./chat-cache";
import { setQueue } from "./composer";
import { withPending } from "./pending-queue";
import { useLive } from "./live";
@@ -23,6 +25,7 @@ const ENTITY_KEYS: Partial>
notifications: [qk.notifications, qk.bootstrap],
runs: [qk.runs],
vms: [qk.vms],
+ android: [qk.androidDevices],
settings: [qk.bootstrap, qk.settings],
workspaces: [qk.workspaces, qk.agents],
tasks: [qk.tasks],
@@ -33,6 +36,17 @@ function sendEvent(event: ClientEvent) {
if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(event));
}
+const resyncAsked = new Map();
+
+/** A delta didn't fit what the phone has of the run (it missed one): ask for the whole list, once a second at most. */
+function resync(runId: string) {
+ const now = Date.now();
+ if (now - (resyncAsked.get(runId) ?? 0) < 1000) return;
+ if (resyncAsked.size > 100) resyncAsked.clear();
+ resyncAsked.set(runId, now);
+ sendEvent({ type: "run.resync", runId });
+}
+
/** Ref-counted subscription (browser/computer live views, open chats), replayed after every reconnect. */
function subscription(key: string, on: ClientEvent, off: ClientEvent): () => void {
const n = (counts.get(key) ?? 0) + 1;
@@ -105,21 +119,47 @@ async function catchUp() {
*/
const conversationLists = { queryKey: qk.conversations, predicate: (q: { queryKey: readonly unknown[] }) => q.queryKey[1] !== "detail" };
-/** A stored message into the cached chat right away; the optimistic copy of a message the human just sent goes. */
-function upsertMessage(message: Message) {
- queryClient.setQueryData(qk.conversation(message.conversationId), (old) => {
- if (!old) return old;
- if (old.messages.some((m) => m.id === message.id)) return { ...old, messages: old.messages.map((m) => (m.id === message.id ? message : m)) };
- let rest = old.messages;
- if (message.role === "user") {
- const optimistic = rest.findIndex((m) => m.id.startsWith("pending-") && m.content.trim() === message.content.trim());
- if (optimistic >= 0) rest = rest.filter((_, i) => i !== optimistic);
- }
- const stored = rest.filter((m) => !m.id.startsWith("pending-"));
- const at = stored.findIndex((m) => m.createdAt > message.createdAt);
- const sorted = at < 0 ? [...stored, message] : [...stored.slice(0, at), message, ...stored.slice(at)];
- return { ...old, messages: [...sorted, ...rest.filter((m) => m.id.startsWith("pending-"))] };
- });
+const soon = new Map>();
+
+/** Refetch once a burst of events is over: a run's start, its messages and its end each touch the same lists. */
+function refreshSoon(filters: InvalidateQueryFilters & { queryKey: readonly unknown[] }) {
+ const id = JSON.stringify(filters.queryKey) + (filters.predicate ? ":lists" : "");
+ if (soon.has(id)) return;
+ soon.set(
+ id,
+ setTimeout(() => {
+ soon.delete(id);
+ // A fetch on its way started before the event: let it land (restarting it with every event could keep a slow
+ // one from ever finishing), then ask again.
+ if (queryClient.isFetching(filters) > 0) return refreshSoon(filters);
+ void queryClient.invalidateQueries(filters);
+ }, 350),
+ );
+}
+
+/**
+ * Patch cached lists from an event. A fetch of one that is on its way started before the event and would land without
+ * it: that one is cancelled and fetched again afterwards.
+ */
+async function patchLists(filters: { queryKey: readonly unknown[] }, patch: () => void) {
+ const fetching = queryClient.isFetching(filters) > 0;
+ if (fetching) await queryClient.cancelQueries(filters);
+ patch();
+ if (fetching) void queryClient.invalidateQueries(filters);
+}
+
+/** A ticket from its event into every cached board it belongs on, without fetching the boards again. */
+function upsertTask(task: Task) {
+ queryClient.setQueryData(qk.task(task.id), task);
+ for (const [key, list] of queryClient.getQueriesData({ queryKey: [...qk.tasks, "list"] })) {
+ if (!Array.isArray(list)) continue;
+ const scope = key[2];
+ const belongs = (key[3] === "archived") === !!task.archivedAt && (scope === "all" || task.workspaceId === scope);
+ const at = list.findIndex((t) => t.id === task.id);
+ if (!belongs) {
+ if (at >= 0) queryClient.setQueryData(key, list.filter((t) => t.id !== task.id));
+ } else queryClient.setQueryData(key, at >= 0 ? list.map((t) => (t.id === task.id ? task : t)) : [task, ...list]);
+ }
}
function handle(event: ServerEvent) {
@@ -128,64 +168,66 @@ function handle(event: ServerEvent) {
case "run.started":
live.runStarted(event.run);
void queryClient.invalidateQueries({ queryKey: qk.conversation(event.run.conversationId) });
- void queryClient.invalidateQueries(conversationLists);
- void queryClient.invalidateQueries({ queryKey: qk.runs });
+ refreshSoon(conversationLists);
+ refreshSoon({ queryKey: qk.runs });
break;
case "run.activity":
live.runActivity(event.runId, event.label);
break;
case "run.delta":
- // The whole list: this app doesn't ask for patches.
- if (event.blocks) live.delta(event.conversationId, { runId: event.runId, messageId: event.messageId, blocks: event.blocks });
+ if (!live.delta(event)) resync(event.runId);
break;
case "run.paused":
- // It stands still: nothing works in the chat until it continues.
- void queryClient.invalidateQueries({ queryKey: qk.conversation(event.run.conversationId) }).then(() => live.runFinished(event.run));
- void queryClient.invalidateQueries(conversationLists);
- void queryClient.invalidateQueries({ queryKey: qk.runs });
- void queryClient.invalidateQueries({ queryKey: qk.agents });
- break;
case "run.finished":
+ // The chat shows the stored answer before the streaming copy goes (a paused run stands still until it continues).
void queryClient.invalidateQueries({ queryKey: qk.conversation(event.run.conversationId) }).then(() => live.runFinished(event.run));
- void queryClient.invalidateQueries(conversationLists);
- void queryClient.invalidateQueries({ queryKey: qk.runs });
- void queryClient.invalidateQueries({ queryKey: qk.agents });
- void queryClient.invalidateQueries({ queryKey: qk.bootstrap });
+ refreshSoon(conversationLists);
+ refreshSoon({ queryKey: qk.runs });
+ refreshSoon({ queryKey: qk.agents });
+ refreshSoon({ queryKey: qk.bootstrap });
break;
case "message.created":
case "message.updated":
+ // Into the cached chat as it is; a fetch on its way gets it too (see chat-cache), so nothing is fetched again.
upsertMessage(event.message);
- // A fetch that started before this message must not take it away again.
- void queryClient.invalidateQueries({ queryKey: qk.conversation(event.message.conversationId) });
break;
case "queue.updated":
setQueue(event.conversationId, () => withPending(event.conversationId, event.queue));
- // A fetch that started before this change must not bring the old queue back.
- void queryClient.invalidateQueries({ queryKey: qk.conversation(event.conversationId) });
+ noteQueue(event.conversationId, event.queue);
break;
case "conversation.updated":
void queryClient.invalidateQueries({ queryKey: qk.conversation(event.conversation.id) });
- void queryClient.invalidateQueries(conversationLists);
+ refreshSoon(conversationLists);
break;
case "conversation.deleted":
- void queryClient.invalidateQueries({ queryKey: qk.conversation(event.id) });
- void queryClient.invalidateQueries(conversationLists);
+ queryClient.removeQueries({ queryKey: qk.conversation(event.id) });
+ refreshSoon(conversationLists);
break;
case "agent.updated":
+ queryClient.setQueryData(qk.agents, (old) => (old?.some((a) => a.id === event.agent.id) ? old.map((a) => (a.id === event.agent.id ? event.agent : a)) : old));
+ refreshSoon({ queryKey: qk.agents });
+ break;
case "agent.deleted":
- void queryClient.invalidateQueries({ queryKey: qk.agents });
+ queryClient.setQueryData(qk.agents, (old) => old?.filter((a) => a.id !== event.id));
break;
case "task.updated":
- queryClient.setQueryData(qk.task(event.task.id), event.task);
- void queryClient.invalidateQueries({ queryKey: [...qk.tasks, "list"] });
+ void patchLists({ queryKey: [...qk.tasks, "list"] }, () => upsertTask(event.task));
+ refreshSoon({ queryKey: qk.bootstrap });
+ break;
+ case "task.event": {
+ const e = event.event;
+ queryClient.setQueryData(qk.taskEvents(e.taskId), (list) =>
+ !Array.isArray(list) || list.some((x) => x.id === e.id) ? list : [...list, e].sort((a, b) => a.createdAt.localeCompare(b.createdAt)),
+ );
break;
+ }
case "task.deleted":
queryClient.removeQueries({ queryKey: qk.task(event.id) });
- void queryClient.invalidateQueries({ queryKey: qk.tasks });
+ queryClient.setQueriesData({ queryKey: [...qk.tasks, "list"] }, (list) => list?.filter((t) => t.id !== event.id));
break;
case "routine.updated":
case "routine.deleted":
- void queryClient.invalidateQueries({ queryKey: qk.routines });
+ refreshSoon({ queryKey: qk.routines });
break;
case "vm.updated":
queryClient.setQueryData(qk.vms, (old) => old?.map((v) => (v.id === event.vm.id ? event.vm : v)));
@@ -194,7 +236,7 @@ function handle(event: ServerEvent) {
queryClient.setQueryData(qk.vms, (old) => old?.filter((v) => v.id !== event.id));
break;
case "browser.updated":
- void queryClient.invalidateQueries({ queryKey: qk.browserProfiles });
+ refreshSoon({ queryKey: qk.browserProfiles });
break;
case "browser.frame":
live.frame(`browser:${browserView(event.profileId, event.conversationId)}`, {
@@ -219,26 +261,33 @@ function handle(event: ServerEvent) {
});
break;
case "notification":
- void queryClient.invalidateQueries({ queryKey: qk.notifications });
+ refreshSoon({ queryKey: qk.notifications });
break;
case "question.created":
- case "question.updated":
- void queryClient.invalidateQueries({ queryKey: qk.questions });
- void queryClient.invalidateQueries({ queryKey: qk.bootstrap });
- void queryClient.invalidateQueries({ queryKey: qk.agents });
- void queryClient.invalidateQueries({ queryKey: qk.conversation(event.question.conversationId) });
- void queryClient.invalidateQueries(conversationLists);
+ case "question.updated": {
+ const q = event.question;
+ // The lists are of open questions: a settled one leaves at once.
+ queryClient.setQueriesData({ queryKey: qk.questions }, (list) => {
+ if (!Array.isArray(list) || !list.some((x) => x.id === q.id)) return list;
+ return q.status === "open" ? list.map((x) => (x.id === q.id ? q : x)) : list.filter((x) => x.id !== q.id);
+ });
+ refreshSoon({ queryKey: qk.questions });
+ refreshSoon({ queryKey: qk.bootstrap });
+ refreshSoon({ queryKey: qk.agents });
+ void queryClient.invalidateQueries({ queryKey: qk.conversation(q.conversationId) });
+ refreshSoon(conversationLists);
break;
+ }
case "missing-login.created":
case "missing-login.updated":
- void queryClient.invalidateQueries({ queryKey: qk.missingLogins });
- void queryClient.invalidateQueries({ queryKey: qk.bootstrap });
+ refreshSoon({ queryKey: qk.missingLogins });
+ refreshSoon({ queryKey: qk.bootstrap });
break;
case "vault.status":
- void queryClient.invalidateQueries({ queryKey: qk.bootstrap });
+ refreshSoon({ queryKey: qk.bootstrap });
break;
case "entity.changed":
- for (const key of ENTITY_KEYS[event.entity] ?? []) void queryClient.invalidateQueries({ queryKey: key });
+ for (const key of ENTITY_KEYS[event.entity] ?? []) refreshSoon({ queryKey: key });
break;
}
}
@@ -251,6 +300,8 @@ function clearTimers() {
}
let connecting = false;
+/** The socket was open before in this pairing: the next open is a reconnect. */
+let wasOpen = false;
function scheduleReconnect() {
if (!running) return;
@@ -283,10 +334,15 @@ async function connect() {
attempt = 0;
useSession.getState().setActiveUrl(base);
useLive.getState().setStatus("online");
+ // Streaming replies as what changed, not the whole block list five times a second (megabytes in a long run).
+ sendEvent({ type: "deltas.patch" });
replaySubscriptions();
if (viewing) sendView();
void catchUp();
- void queryClient.invalidateQueries();
+ // Events were missed while the link was down, so everything is asked again (lists are small and chats come as a
+ // window). Not on the first connect: every screen has just fetched its own.
+ if (wasOpen) void queryClient.invalidateQueries();
+ wasOpen = true;
pingTimer = setInterval(() => sendEvent({ type: "ping" }), 25_000);
};
ws.onmessage = (msg) => {
@@ -317,6 +373,7 @@ function onAppState(state: AppStateStatus) {
if (viewing) sendView();
if (state === "active") {
if (running && !socket) {
+ retryAddresses();
clearTimers();
attempt = 0;
void connect();
@@ -334,6 +391,7 @@ function onAppState(state: AppStateStatus) {
export function startRealtime(): () => void {
running = true;
attempt = 0;
+ wasOpen = false;
void connect();
const sub = AppState.addEventListener("change", onAppState);
return () => {
@@ -349,6 +407,7 @@ export function startRealtime(): () => void {
/** Try right away instead of waiting for the backoff (pull to refresh, "Retry"). */
export function reconnectNow() {
if (!running || socket) return;
+ retryAddresses();
clearTimers();
attempt = 0;
void connect();
diff --git a/apps/mobile/src/lib/screens.ts b/apps/mobile/src/lib/screens.ts
index da733c62..73675fb8 100644
--- a/apps/mobile/src/lib/screens.ts
+++ b/apps/mobile/src/lib/screens.ts
@@ -1,8 +1,9 @@
import { useQuery } from "@tanstack/react-query";
import { useEffect, useMemo } from "react";
-import { browserView, computerTargetLabel, computerView, type Agent, type BrowserProfile, type Conversation, type Vm } from "@godmode/shared";
+import { browserView, computerTargetLabel, computerView, type Agent, type AndroidDevice, type BrowserProfile, type Conversation, type Vm } from "@godmode/shared";
import { api } from "./api";
import { useLive, type Frame } from "./live";
+import { chatListQuery } from "./hooks";
import { qk } from "./query";
import { subscribeBrowser, subscribeComputer } from "./realtime";
@@ -11,7 +12,9 @@ export type LiveScreen =
/** `conversationId`: that chat's own tab (chats browse in parallel, each in its tabs). */
| { kind: "browser"; key: string; id: string; conversationId: string | null; title: string; running: boolean }
| { kind: "vm"; key: string; id: string; title: string; vm: Vm }
- | { kind: "share"; key: string; view: string; title: string; conversationId: string };
+ | { kind: "share"; key: string; view: string; title: string; conversationId: string }
+ /** An Android phone agents work on: watched from here, not controlled. */
+ | { kind: "android"; key: string; id: string; title: string; device: AndroidDevice };
export function screenHref(screen: LiveScreen) {
const params =
@@ -26,7 +29,9 @@ export function screenHref(screen: LiveScreen) {
export function useLiveScreens() {
const profiles = useQuery({ queryKey: qk.browserProfiles, queryFn: api.browser.profiles });
const vms = useQuery({ queryKey: qk.vms, queryFn: api.vms.list, retry: false });
- const conversations = useQuery({ queryKey: qk.conversationList(""), queryFn: () => api.conversations.list({ limit: 100 }) });
+ const conversations = useQuery(chatListQuery(null));
+ // An older Godmode has no phones for the app: no retry, no error.
+ const phones = useQuery({ queryKey: qk.androidDevices, queryFn: api.android.devices, retry: false });
const screens = useMemo(() => {
const out: LiveScreen[] = [];
@@ -40,13 +45,14 @@ export function useLiveScreens() {
if (!chats.length) out.push(browserScreen(p, null, p.name));
}
for (const vm of vms.data ?? []) out.push({ kind: "vm", key: `vm:${vm.id}`, id: vm.id, title: vm.name, vm });
+ for (const device of phones.data ?? []) out.push({ kind: "android", key: `android:${device.id}`, id: device.id, title: device.name, device });
return out.sort((a, b) => rank(b) - rank(a));
- }, [profiles.data, vms.data, conversations.data]);
+ }, [profiles.data, vms.data, conversations.data, phones.data]);
return {
screens,
loading: profiles.isLoading || conversations.isLoading,
- refetch: () => Promise.all([profiles.refetch(), vms.refetch(), conversations.refetch()]),
+ refetch: () => Promise.all([profiles.refetch(), vms.refetch(), conversations.refetch(), phones.refetch()]),
};
}
@@ -61,16 +67,21 @@ function shared(list: Conversation[] | undefined) {
function rank(s: LiveScreen): number {
if (s.kind === "share") return 3;
if (s.kind === "browser") return s.running ? 2 : 0;
+ if (s.kind === "android") return s.device.busy ? 2.2 : s.device.state === "connected" ? 0.8 : 0.2;
return s.vm.state === "running" ? 2.5 : s.vm.state === "starting" ? 1 : 0.5;
}
+/** On screen right now: a shared screen, a running browser or VM, a phone an agent is working on. */
export function isLive(s: LiveScreen): boolean {
- return s.kind === "share" || (s.kind === "browser" ? s.running : s.vm.state === "running");
+ if (s.kind === "share") return true;
+ if (s.kind === "browser") return s.running;
+ if (s.kind === "android") return s.device.state === "connected" && s.device.busy;
+ return s.vm.state === "running";
}
/** The newest picture of a browser or shared screen, streamed while this is mounted. */
export function useStreamFrame(screen: LiveScreen | null, active = true): Frame | undefined {
- const key = screen && screen.kind !== "vm" ? screen.key : null;
+ const key = screen && (screen.kind === "browser" || screen.kind === "share") ? screen.key : null;
const target = screen?.kind === "browser" ? screen.id : screen?.kind === "share" ? screen.view : null;
const chat = screen?.kind === "browser" ? screen.conversationId : null;
const kind = screen?.kind;
@@ -96,6 +107,21 @@ export function useVmFrame(vm: Vm | null, active = true, intervalMs = 2500) {
});
}
+/** A connected Android phone's screen, polled while visible. */
+export function useAndroidFrame(device: AndroidDevice | null, active = true, intervalMs: number | false = 2500) {
+ return useQuery({
+ queryKey: qk.androidScreen(device?.id ?? "none"),
+ queryFn: async (): Promise => {
+ const shot = await api.android.screenshot(device!.id);
+ return { data: shot.data, mime: shot.mime, width: shot.width, height: shot.height, title: device!.name, at: Date.now() };
+ },
+ enabled: active && device?.state === "connected",
+ refetchInterval: active ? intervalMs : false,
+ placeholderData: (prev) => prev,
+ retry: false,
+ });
+}
+
/** Mirrors the core: the chat's profile, the agent's pinned one, the workspace default, the global default. */
function chatBrowser(conversation: Conversation, agent: Agent, profiles: BrowserProfile[]): BrowserProfile | undefined {
const workspaceId = agent.workspaceId ?? conversation.workspaceId;
diff --git a/apps/mobile/src/lib/workspace.ts b/apps/mobile/src/lib/workspace.ts
index 1bbd996e..8b8480c5 100644
--- a/apps/mobile/src/lib/workspace.ts
+++ b/apps/mobile/src/lib/workspace.ts
@@ -4,7 +4,7 @@ import { useEffect, useMemo } from "react";
import { create } from "zustand";
import type { Agent, Conversation, Project, Task, Workspace } from "@godmode/shared";
import { api } from "./api";
-import { useAgents } from "./hooks";
+import { chatListQuery, useAgents } from "./hooks";
import { type LiveRun, useLive } from "./live";
import { qk } from "./query";
@@ -118,11 +118,7 @@ export function useWorkspaceRuns(): LiveRun[] {
const { id, projectId } = useWorkspace();
const runs = useLive((s) => s.runs);
const { byId } = useAgents();
- const chats = useQuery({
- queryKey: qk.conversationList("", id),
- queryFn: () => api.conversations.list({ limit: 100, workspaceId: id }),
- enabled: !!id,
- });
+ const chats = useQuery({ ...chatListQuery(id), enabled: !!id });
return useMemo(() => {
const ids = new Set(inProject(chats.data ?? [], projectId, byId).map((c) => c.id));
return Object.values(runs)
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 744cf5a2..7149eb69 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -1347,9 +1347,11 @@ views. `mobile/` in the core pairs phones and serves them; the desktop's Setting
Pairing is audited (`mobile.pair`), notifies the human and emits `mobile.paired`.
* **Scope.** Device tokens only authenticate on the phones' listener while phone access is on, and open a fixed
allowlist of routes (`mobile/scope.ts`): bootstrap, workspaces, agents and their slash commands, the model catalog,
- conversations, messages (with files) and the message queue (edit, remove, send now), questions (list, answer without
- files), tasks and their attachments (upload and read), runs (cancel), routines (run, enable), browser profiles (launch,
- input), computer input, VMs (list, screenshot, start/stop, input), notifications, missing logins and
+ conversations (pause, continue, their follow-up), messages (with files) and the message queue (edit, remove, send
+ now), questions (list, answer without files), tasks (approve, their timeline) and their attachments (upload and read),
+ runs (list, cancel), routines (run, enable), browser profiles (launch,
+ input), computer input, VMs (list, screenshot, start/stop, input), Android phones (the list, and a screenshot of the ones an agent or a chat uses),
+ notifications, missing logins and
`GET/DELETE /api/mobile/me`; everything else answers 403 `device_forbidden`. Bodies are restricted too: a phone may
pick a chat's model, effort and Ultracode but can't set its folder, VM, browser, shared screen or instructions, or
change an automation beyond switching it on or off, and it only watches and controls screens that are shared in a chat
@@ -1361,9 +1363,23 @@ views. `mobile/` in the core pairs phones and serves them; the desktop's Setting
https anywhere, which is how the Godmode Cloud gateway is reached — `isPhoneUrlAllowed`), and first asks the address's `/api/health`, which on
the phones' listener returns the instance id; an address that answers as another instance never gets the key.
Writes are sent once (no retry on another address), so a slow network never duplicates a message or a run.
-* **Realtime.** Phone sockets get every event except `run.delta`, which only goes to conversations they subscribed to
- (`conversation.subscribe`) — streaming replies are large. New sockets are sent the current `run.activity` of every
- run. Browser and computer frames are opt-in as for the desktop; VM screens are polled (`/api/vms/:id/screenshot`).
+* **Realtime.** Phone sockets get every event except `run.delta` and `message.created` / `message.updated`, which
+ only go to conversations they subscribed to (`conversation.subscribe`) — streaming replies and stored turns are
+ large. The app asks for deltas as patches (`deltas.patch`) like the desktop. New sockets are sent the current
+ `run.activity` of every run. Browser and computer frames are opt-in as for the desktop; VM screens are polled
+ (`/api/vms/:id/screenshot`).
+* **Light answers.** Most of a long chat's bytes are screenshots tools returned, inline as base64. A client on a slow
+ link asks for less: `GET /api/conversations/:id?limit=40&lite=1` answers with the latest messages only (`hasMore`;
+ older pages from `GET /api/conversations/:id/messages?before=&limit=`), and `lite` replaces every
+ `tool_use.image` with an `imageUrl` (`GET /api/conversations/:id/messages/:messageId/images/:toolUseId`, cached
+ for good) and cuts tool output to its first 6,000 characters (`resultTruncated`). Message events to phones are light
+ too. `GET /api/tasks?fields=summary` leaves the descriptions out of a board. Without these parameters every route
+ answers as before. Answers on the phones' listener and through the cloud relay are gzipped (`hono/compress`); the
+ desktop on this computer gets them as they are.
+* **What the app keeps.** The lists it opens with (chats, tasks, agents, what needs you) are saved in the app's cache
+ folder between launches (`lib/persist.ts`) and shown at once on the next start; chats themselves and settings are
+ not. Events patch the cached lists instead of fetching them again, and coming back to the app only refetches what
+ predates the dropped socket.
* **Removing a phone** (`DELETE /api/mobile/devices/:id`, or the phone itself via `DELETE /api/mobile/me`) deletes the
row and closes its sockets with code 4003; the app then forgets its token. Backups carry neither the devices nor the
`mobile` settings.
diff --git a/packages/core/src/cloud/scope.ts b/packages/core/src/cloud/scope.ts
index c3f6acf0..239b0304 100644
--- a/packages/core/src/cloud/scope.ts
+++ b/packages/core/src/cloud/scope.ts
@@ -150,7 +150,8 @@ const RULES: [methods: string, path: string, rule: Rule][] = [
["POST", "/api/conversations/:id/move-here", A],
["GET", "/api/attention", A],
["GET", "/api/away", A],
- ["POST", "/api/conversations/:id/messages", A],
+ ["GET|POST", "/api/conversations/:id/messages", A],
+ ["GET", "/api/conversations/:id/messages/:messageId/images/:toolUseId", A],
["PATCH|DELETE", "/api/conversations/:id/queue/:messageId", A],
["POST", "/api/conversations/:id/queue/send", A],
["POST|PATCH", "/api/conversations/:id/pause", A],
diff --git a/packages/core/src/db/migrations.ts b/packages/core/src/db/migrations.ts
index 6a704b0c..252b872b 100644
--- a/packages/core/src/db/migrations.ts
+++ b/packages/core/src/db/migrations.ts
@@ -1393,6 +1393,14 @@ ALTER TABLE conversations ADD COLUMN android_device_ids TEXT NOT NULL DEFAULT '[
-- holds their manifest and hooks for reading. contents: what such a plugin brings (JSON, ModContents).
ALTER TABLE mods ADD COLUMN source TEXT;
ALTER TABLE mods ADD COLUMN contents TEXT;
+`,
+ },
+ {
+ id: 85,
+ // The latest runs of all agents (the phone's Activity) without reading and sorting the whole table.
+ name: "runs_created_index",
+ sql: /* sql */ `
+CREATE INDEX IF NOT EXISTS idx_runs_created ON runs(created_at);
`,
},
];
diff --git a/packages/core/src/mobile/scope.ts b/packages/core/src/mobile/scope.ts
index 7ace221a..e07b106c 100644
--- a/packages/core/src/mobile/scope.ts
+++ b/packages/core/src/mobile/scope.ts
@@ -49,6 +49,8 @@ const ROUTES: [method: string, path: RegExp][] = [
["GET", /^\/api\/conversations$/],
["GET", new RegExp(`^/api/conversations/${ID}$`)],
+ ["GET", new RegExp(`^/api/conversations/${ID}/messages$`)],
+ ["GET", new RegExp(`^/api/conversations/${ID}/messages/${ID}/images/${ID}$`)],
["PATCH", new RegExp(`^/api/conversations/${ID}$`)],
["DELETE", new RegExp(`^/api/conversations/${ID}$`)],
["POST", new RegExp(`^/api/conversations/${ID}/messages$`)],
@@ -56,6 +58,10 @@ const ROUTES: [method: string, path: RegExp][] = [
["DELETE", new RegExp(`^/api/conversations/${ID}/queue/${ID}$`)],
["POST", new RegExp(`^/api/conversations/${ID}/queue/send$`)],
["POST", new RegExp(`^/api/conversations/${ID}/(pause|continue|retry)$`)],
+ // A follow-up the agent set: moved, run now or cancelled.
+ ["PATCH", new RegExp(`^/api/conversations/${ID}/followup$`)],
+ ["DELETE", new RegExp(`^/api/conversations/${ID}/followup$`)],
+ ["POST", new RegExp(`^/api/conversations/${ID}/followup/run$`)],
["POST", /^\/api\/chat$/],
["GET", /^\/api\/tasks$/],
@@ -66,6 +72,7 @@ const ROUTES: [method: string, path: RegExp][] = [
["POST", /^\/api\/tasks$/],
["PATCH", new RegExp(`^/api/tasks/${ID}$`)],
["POST", new RegExp(`^/api/tasks/${ID}/messages$`)],
+ ["POST", new RegExp(`^/api/tasks/${ID}/approve$`)],
["GET", /^\/api\/runs$/],
["POST", new RegExp(`^/api/runs/${ID}/cancel$`)],
@@ -79,6 +86,10 @@ const ROUTES: [method: string, path: RegExp][] = [
["POST", new RegExp(`^/api/browser/profiles/${ID}/input$`)],
["POST", /^\/api\/computer\/input$/],
+ // Android phones agents use: seen and watched, never controlled, connected or changed from a phone.
+ ["GET", /^\/api\/android\/devices$/],
+ ["GET", new RegExp(`^/api/android/devices/${ID}/screenshot$`)],
+
["GET", /^\/api\/vms$/],
["GET", new RegExp(`^/api/vms/${ID}/screenshot$`)],
["POST", new RegExp(`^/api/vms/${ID}/(start|stop|input)$`)],
@@ -137,6 +148,9 @@ const BODIES: [method: string, path: RegExp, keys: string[]][] = [
["POST", /^\/api\/tasks$/, ["workspaceId", "projectId", "title", "description", "type", "status", "agentId"]],
["PATCH", new RegExp(`^/api/tasks/${ID}$`), ["title", "description", "status", "agentId", "archived", "projectId"]],
["POST", new RegExp(`^/api/tasks/${ID}/messages$`), ["content", "attachments"]],
+ ["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", new RegExp(`^/api/questions/${ID}/answer$`), ["optionId", "decision", "note", "text"]],
["PATCH", new RegExp(`^/api/human-tasks/${ID}$`), ["status", "beforeId"]],
diff --git a/packages/core/src/remote/routing.ts b/packages/core/src/remote/routing.ts
index 5b01f2ca..3bac03a9 100644
--- a/packages/core/src/remote/routing.ts
+++ b/packages/core/src/remote/routing.ts
@@ -10,7 +10,7 @@ import type { Context, MiddlewareHandler } from "hono";
import { parseRunnerView, type ConversationWithMessages, type Run } from "@godmode/shared";
import { get, run as sql } from "../db";
import { logger } from "../log";
-import { conversationExists, deleteConversation, emitConversationUpdated, getConversation } from "../services/conversations";
+import { conversationExists, deleteConversation, emitConversationUpdated, getConversation, messageWindow, windowConversation } from "../services/conversations";
import { HttpError, badRequest } from "../util";
import { requireLicense } from "../license/license";
import { reconcileConversation } from "./mirror";
@@ -141,11 +141,11 @@ async function conversationRequest(c: Context, runnerId: string, id: string, res
if (method === "GET") {
label(c, "GET /api/conversations/:id");
try {
- return c.json(await freshConversation(runnerId, id));
+ return c.json(windowConversation(await freshConversation(runnerId, id), messageWindow((name) => c.req.query(name))));
} catch (err) {
// Offline, too slow, or the runner no longer has it (deleted there, or set up again): the copy here is what there is.
if (!isUnanswered(err) && !(err instanceof HttpError && err.status === 404)) throw err;
- return c.json(getConversation(id));
+ return c.json(getConversation(id, messageWindow((name) => c.req.query(name))));
}
}
if (method === "PATCH") {
@@ -185,6 +185,8 @@ async function conversationRequest(c: Context, runnerId: string, id: string, res
}
return null;
}
+ // Pages of older messages and pictures come from the copy here: the runner's chat was just mirrored for its window.
+ if (method === "GET" && rest.startsWith("/messages")) return null;
if (!FORWARDED.test(rest)) return null;
if (method === "POST" && (STARTS_RUN.test(rest) || rest === "/continue") && chatIsMoving(id)) {
throw new HttpError(409, "This chat is moving to this computer — send it again in a moment.", "runner_chat_moving");
diff --git a/packages/core/src/server/app.ts b/packages/core/src/server/app.ts
index 9ec93bd1..1ac35696 100644
--- a/packages/core/src/server/app.ts
+++ b/packages/core/src/server/app.ts
@@ -1,9 +1,10 @@
import { Hono } from "hono";
+import { compress } from "hono/compress";
import { cors } from "hono/cors";
import { secureHeaders } from "hono/secure-headers";
import { HttpError } from "../util";
import { logger } from "../log";
-import { hostGuard, isAllowedOrigin, requireAuth } from "./auth";
+import { hostGuard, isAllowedOrigin, isMobileChannel, isRelayed, requireAuth } from "./auth";
import { registerAuthRoutes } from "./routes/auth";
import { registerSystemRoutes } from "./routes/system";
import { registerVaultRoutes } from "./routes/vault";
@@ -115,6 +116,11 @@ export function createApp() {
maxAge: 600,
}),
);
+ // Phones and the cloud relay get their JSON gzipped (chats and boards are megabytes over Tailscale or the relay); the
+ // desktop on this computer doesn't spend the CPU on it.
+ const gzip = compress({ encoding: "gzip", threshold: 2048 });
+ // Not the screenshots polled every second or two: pictures as base64 don't get smaller.
+ app.use("/api/*", (c, next) => ((isMobileChannel(c) || isRelayed(c)) && !c.req.path.endsWith("/screenshot") ? gzip(c, next) : next()));
app.onError((err, c) => {
const route = c.req.routePath;
diff --git a/packages/core/src/server/routes/android.ts b/packages/core/src/server/routes/android.ts
index 66238d69..3f64b854 100644
--- a/packages/core/src/server/routes/android.ts
+++ b/packages/core/src/server/routes/android.ts
@@ -20,7 +20,8 @@ import { frameToScreen, longPress, pressKey, screenSize, screenshot, swipe, tap,
import { requestKeyframe, setViewers } from "../../android/stream";
import { disableIdleTimeout } from "../../mcp/http";
import { setAndroidViewHandlers } from "../ws";
-import { badRequest, conflict } from "../../util";
+import { HttpError, badRequest, conflict } from "../../util";
+import { requestDevice } from "../auth";
import { body, z } from "../validate";
const point = { x: z.number(), y: z.number() };
@@ -122,6 +123,13 @@ export function registerAndroidRoutes(app: Hono): void {
app.get("/api/android/devices/:id/screenshot", async (c) => {
const size = Number(c.req.query("size") ?? 480);
if (!Number.isFinite(size) || size < 64 || size > 1568) throw badRequest("size must be between 64 and 1568");
+ // A paired phone only sees the phones agents work with, not any phone that happens to be plugged in.
+ if (requestDevice(c)) {
+ const device = getDevice(c.req.param("id"));
+ if (!device.busy && !device.assignments.length) {
+ throw new HttpError(403, "The phone app shows an Android phone once an agent or a chat uses it.", "device_forbidden");
+ }
+ }
const serial = serialOf(c.req.param("id"));
try {
const shot = await screenshot(serial, size, { level: 1 });
diff --git a/packages/core/src/server/routes/chat.ts b/packages/core/src/server/routes/chat.ts
index 59d06325..90356d0e 100644
--- a/packages/core/src/server/routes/chat.ts
+++ b/packages/core/src/server/routes/chat.ts
@@ -7,12 +7,16 @@ import {
getConversation,
getConversationSummary,
listConversations,
+ listMessagePage,
+ messageImage,
+ messageWindow,
markConversationsRead,
sendMessage,
startChat,
updateConversation,
} from "../../services/conversations";
import { editQueued, removeQueued, sendQueuedNow, submitMessage } from "../../services/messageQueue";
+import { liteMessage } from "../../services/liteMessage";
import { answerByMessage } from "../../services/questions";
import { answererOf } from "./questions";
import { continueConversation, pauseConversation, setAutoContinue } from "../../services/pauses";
@@ -108,7 +112,26 @@ export function registerChatRoutes(app: Hono): void {
return c.json(createConversation({ ...input, origin: "chat" }), 201);
});
- app.get("/api/conversations/:id", (c) => c.json(getConversation(c.req.param("id"))));
+ app.get("/api/conversations/:id", (c) => c.json(getConversation(c.req.param("id"), messageWindow((name) => c.req.query(name)))));
+
+ // Older messages of a chat, a page at a time (the chat itself answers with the latest ones when asked for `limit`).
+ app.get("/api/conversations/:id/messages", (c) => {
+ const id = c.req.param("id");
+ getConversationSummary(id);
+ const { limit, lite } = messageWindow((name) => c.req.query(name));
+ const page = listMessagePage(id, { limit: limit ?? 50, before: c.req.query("before") || undefined });
+ return c.json(lite ? { ...page, messages: page.messages.map(liteMessage) } : page);
+ });
+
+ // A picture a tool returned (a screenshot), for light answers that leave it out of the message.
+ app.get("/api/conversations/:id/messages/:messageId/images/:toolUseId", (c) => {
+ const { data, mime } = messageImage(c.req.param("id"), c.req.param("messageId"), c.req.param("toolUseId"));
+ return c.body(new Uint8Array(data), 200, {
+ "Content-Type": mime,
+ "X-Content-Type-Options": "nosniff",
+ "Cache-Control": "private, max-age=31536000, immutable",
+ });
+ });
// The human has seen these chats ("Mark all read", or a client without a live socket).
app.post("/api/conversations/read", async (c) => {
diff --git a/packages/core/src/server/routes/tasks.ts b/packages/core/src/server/routes/tasks.ts
index 193cbdea..c737350e 100644
--- a/packages/core/src/server/routes/tasks.ts
+++ b/packages/core/src/server/routes/tasks.ts
@@ -87,6 +87,8 @@ const goalSchema = z.object({
status: z.enum(["active", "achieved", "dropped"]).optional(),
});
+const SUMMARY_CHARS = 280;
+
export function registerTaskRoutes(app: Hono): void {
// Goals: what the work is for.
app.get("/api/goals", (c) => c.json(listGoals({ workspaceId: c.req.query("workspaceId") || "all" })));
@@ -97,9 +99,12 @@ export function registerTaskRoutes(app: Hono): void {
return c.json({ ok: true });
});
- app.get("/api/tasks", (c) =>
- c.json(listTasks({ workspaceId: c.req.query("workspaceId") || "all", archived: ["1", "true"].includes(c.req.query("archived") ?? "") })),
- );
+ app.get("/api/tasks", (c) => {
+ const tasks = listTasks({ workspaceId: c.req.query("workspaceId") || "all", archived: ["1", "true"].includes(c.req.query("archived") ?? "") });
+ // `fields=summary`: for boards on a slow link; the ticket itself (GET /api/tasks/:id) has the whole text.
+ if (c.req.query("fields") !== "summary") return c.json(tasks);
+ return c.json(tasks.map((t) => ({ ...t, description: "", summary: t.summary && t.summary.length > SUMMARY_CHARS ? `${t.summary.slice(0, SUMMARY_CHARS)}…` : t.summary })));
+ });
app.post("/api/tasks/archive", async (c) => {
const { ids, archived } = await body(c, archiveSchema);
diff --git a/packages/core/src/server/ws.ts b/packages/core/src/server/ws.ts
index e1c5849a..f03533b5 100644
--- a/packages/core/src/server/ws.ts
+++ b/packages/core/src/server/ws.ts
@@ -5,6 +5,7 @@ import { VERSION } from "../config";
import { logger } from "../log";
import { setDeviceSocketHooks } from "../mobile/devices";
import { deviceMayUseView } from "../mobile/scope";
+import { liteMessage } from "../services/liteMessage";
const log = logger("ws");
@@ -278,6 +279,7 @@ bus.on((event) => {
}
if (event.type === "android.video" || event.type === "android.action") return sendAndroid(event);
if (event.type === "run.delta") return sendDelta(event);
+ if (event.type === "message.created" || event.type === "message.updated") return sendMessage(event);
if (event.type === "run.finished" || event.type === "run.paused") forgetWhole(event.run.id);
const payload = timedSync(`send ${event.type}`, () => JSON.stringify(event));
for (const ws of clients) {
@@ -357,6 +359,27 @@ function sendDelta(event: RunDelta) {
if (others) sendWhole(event.runId);
}
+/**
+ * A stored message to every client, but to phones only for the chats they have open and light (screenshots as their own
+ * requests): a finished turn can be megabytes of pictures.
+ */
+function sendMessage(event: Extract) {
+ let full: string | null = null;
+ let lite: string | null = null;
+ for (const ws of clients) {
+ const device = ws.data.auth === "device";
+ if (device && !ws.data.conversations?.has(event.message.conversationId)) continue;
+ const payload = device
+ ? (lite ??= JSON.stringify({ ...event, message: liteMessage(event.message) }))
+ : (full ??= timedSync(`send ${event.type}`, () => JSON.stringify(event)));
+ try {
+ ws.send(payload);
+ } catch {
+ /* ignore */
+ }
+ }
+}
+
const MAX_CONVERSATIONS_PER_SOCKET = 20;
function deviceOnline(deviceId: string): boolean {
diff --git a/packages/core/src/services/conversations.ts b/packages/core/src/services/conversations.ts
index d4e72243..7ec18a9a 100644
--- a/packages/core/src/services/conversations.ts
+++ b/packages/core/src/services/conversations.ts
@@ -21,8 +21,9 @@ import type {
RunPause,
RunTrigger,
} from "@godmode/shared";
-import type { ComputerTarget, ConversationPatch, ConversationWithMessages, SendMessageInput, SendMessageResult, StartChatResult } from "@godmode/shared";
+import type { ComputerTarget, ConversationPatch, ConversationWithMessages, MessagePage, SendMessageInput, SendMessageResult, StartChatResult } from "@godmode/shared";
import { computerTargetLabel, MAX_MESSAGE_ATTACHMENT_BYTES } from "@godmode/shared";
+import { liteMessage } from "./liteMessage";
import { all, bool, get, insert, int, run as sql, update } from "../db";
import { bus } from "../events/bus";
import { logger } from "../log";
@@ -375,10 +376,91 @@ export function chatFillOnly(conversationId: string): boolean {
return !row || row.secret_access === "fill";
}
-export function getConversation(id: string): ConversationWithMessages {
+/**
+ * Which messages a client wants: all of them (default), or the latest `limit`. `lite`: screenshots as their own requests
+ * and long tool output cut, for phones and other slow links (a long chat is megabytes of base64 pictures).
+ */
+export interface MessageWindow {
+ limit?: number;
+ lite?: boolean;
+}
+
+/** From a request's `?limit=` and `?lite=1`. */
+export function messageWindow(query: (name: string) => string | undefined): MessageWindow {
+ const limit = Number(query("limit"));
+ const lite = query("lite");
+ return { limit: Number.isFinite(limit) && limit > 0 ? limit : undefined, lite: lite === "1" || lite === "true" };
+}
+
+export function getConversation(id: string, window: MessageWindow = {}): ConversationWithMessages {
const conversation = getConversationSummary(id);
const activeRunId = conversation.runnerId ? remoteRunForConversation(id) : activeRunForConversation(id);
- return { ...conversation, messages: listMessages(id), activeRunId, queue: listQueue(id) };
+ const page = window.limit ? listMessagePage(id, { limit: window.limit }) : null;
+ const messages = page?.messages ?? listMessages(id);
+ return {
+ ...conversation,
+ messages: window.lite ? messages.map(liteMessage) : messages,
+ activeRunId,
+ queue: listQueue(id),
+ ...(page ? { hasMore: page.hasMore } : {}),
+ };
+}
+
+/** A whole chat (a runner's, fetched over the link) cut to what the client asked for. */
+export function windowConversation(conversation: ConversationWithMessages, window: MessageWindow): ConversationWithMessages {
+ if (!window.limit && !window.lite) return conversation;
+ const limit = window.limit ? clampWindow(window.limit) : conversation.messages.length;
+ const messages = conversation.messages.slice(-limit);
+ return {
+ ...conversation,
+ messages: window.lite ? messages.map(liteMessage) : messages,
+ ...(window.limit ? { hasMore: conversation.messages.length > messages.length } : {}),
+ };
+}
+
+export const MAX_MESSAGE_PAGE = 500;
+
+const clampWindow = (limit: number) => Math.max(1, Math.min(MAX_MESSAGE_PAGE, Math.floor(limit)));
+
+/** The latest `limit` messages before `before` (or the newest), oldest first. */
+export function listMessagePage(conversationId: string, opts: { limit: number; before?: string }): MessagePage {
+ const limit = clampWindow(opts.limit);
+ let rows: MessageRow[];
+ if (opts.before) {
+ const anchor = get<{ created_at: string; rid: number }>(
+ "SELECT created_at, rowid AS rid FROM messages WHERE id = ? AND conversation_id = ?",
+ opts.before,
+ conversationId,
+ );
+ if (!anchor) throw notFound("Message");
+ rows = all(
+ "SELECT * FROM messages WHERE conversation_id = ? AND (created_at < ? OR (created_at = ? AND rowid < ?)) ORDER BY created_at DESC, rowid DESC LIMIT ?",
+ conversationId,
+ anchor.created_at,
+ anchor.created_at,
+ anchor.rid,
+ limit + 1,
+ );
+ } else {
+ rows = all("SELECT * FROM messages WHERE conversation_id = ? ORDER BY created_at DESC, rowid DESC LIMIT ?", conversationId, limit + 1);
+ }
+ return { messages: rows.slice(0, limit).reverse().map(toMessage), hasMore: rows.length > limit };
+}
+
+/** The picture a tool returned in a message, decoded, with its type. */
+export function messageImage(conversationId: string, messageId: string, toolUseId: string): { data: Uint8Array; mime: string } {
+ const row = get<{ blocks: string }>("SELECT blocks FROM messages WHERE id = ? AND conversation_id = ?", messageId, conversationId);
+ const block = parseJson(row?.blocks ?? "[]", []).find((b) => b.type === "tool_use" && b.id === toolUseId);
+ if (!block || block.type !== "tool_use" || !block.image) throw notFound("Picture");
+ const data = Buffer.from(block.image, "base64");
+ return { data: new Uint8Array(data), mime: imageMime(data) };
+}
+
+function imageMime(data: Uint8Array): string {
+ if (data[0] === 0xff && data[1] === 0xd8) return "image/jpeg";
+ if (data[0] === 0x47 && data[1] === 0x49 && data[2] === 0x46) return "image/gif";
+ if (data[8] === 0x57 && data[9] === 0x45 && data[10] === 0x42 && data[11] === 0x50) return "image/webp";
+ return "image/png";
}
/**
diff --git a/packages/core/src/services/liteMessage.ts b/packages/core/src/services/liteMessage.ts
new file mode 100644
index 00000000..ac94ae63
--- /dev/null
+++ b/packages/core/src/services/liteMessage.ts
@@ -0,0 +1,25 @@
+import type { Message } from "@godmode/shared";
+
+/** How much of a tool's output a light message keeps: the phone shows its first lines. */
+const LITE_RESULT_CHARS = 6000;
+
+export function messageImagePath(conversationId: string, messageId: string, toolUseId: string): string {
+ return `/api/conversations/${encodeURIComponent(conversationId)}/messages/${encodeURIComponent(messageId)}/images/${encodeURIComponent(toolUseId)}`;
+}
+
+/** A message for a slow link: each screenshot becomes its own request (`imageUrl`) and long tool output is cut. */
+export function liteMessage(message: Message): Message {
+ let changed = false;
+ const blocks = message.blocks.map((b) => {
+ if (b.type !== "tool_use" || (!b.image && (b.result?.length ?? 0) <= LITE_RESULT_CHARS)) return b;
+ changed = true;
+ const { image, ...rest } = b;
+ const cut = !!b.result && b.result.length > LITE_RESULT_CHARS;
+ return {
+ ...rest,
+ ...(image ? { imageUrl: messageImagePath(message.conversationId, message.id, b.id) } : {}),
+ ...(cut ? { result: b.result!.slice(0, LITE_RESULT_CHARS), resultTruncated: true } : {}),
+ };
+ });
+ return changed ? { ...message, blocks } : message;
+}
diff --git a/packages/core/test/conversations.test.ts b/packages/core/test/conversations.test.ts
index b57beabb..e05f63a1 100644
--- a/packages/core/test/conversations.test.ts
+++ b/packages/core/test/conversations.test.ts
@@ -1,6 +1,6 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { join } from "node:path";
-import type { Agent, Conversation, ConversationWithMessages, Run, StartChatResult } from "@godmode/shared";
+import type { Agent, Conversation, ConversationWithMessages, MessageBlock, MessagePage, Run, StartChatResult } from "@godmode/shared";
import { makeAgent, setupEnv, until, type TestEnv } from "./fixtures/runner-harness";
import { run as sql } from "../src/db";
import {
@@ -9,6 +9,7 @@ import {
createConversation,
getConversation,
listConversations,
+ listMessagePage,
safeFileName,
sendMessage,
setConversationState,
@@ -178,6 +179,35 @@ describe("HTTP routes", () => {
expect((await fetch(`${env.baseUrl}/api/conversations`)).status).toBe(401);
});
+ test("a window of the latest messages, older pages and the pictures they left out", async () => {
+ const conv = createConversation({ agentId: agent.id });
+ const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 9]);
+ const first = addMessage({ conversationId: conv.id, role: "user", content: "look" });
+ const shot = addMessage({
+ conversationId: conv.id,
+ role: "assistant",
+ content: "",
+ blocks: [{ type: "tool_use", id: "toolu_1", name: "screenshot", input: {}, result: "ok", image: png.toString("base64") }],
+ });
+ addMessage({ conversationId: conv.id, role: "user", content: "thanks" });
+
+ const latest = (await (await api("GET", `/api/conversations/${conv.id}?limit=2&lite=1`)).json()) as ConversationWithMessages;
+ expect(latest.messages.map((m) => m.id)).toEqual([shot.id, latest.messages[1]!.id]);
+ expect(latest.hasMore).toBe(true);
+ const block = latest.messages[0]!.blocks[0] as Extract;
+ expect(block.image).toBeUndefined();
+
+ const picture = await api("GET", block.imageUrl!);
+ expect(picture.status).toBe(200);
+ expect(picture.headers.get("content-type")).toBe("image/png");
+ expect(new Uint8Array(await picture.arrayBuffer())).toEqual(new Uint8Array(png));
+ expect((await api("GET", `/api/conversations/${conv.id}/messages/${shot.id}/images/toolu_missing`)).status).toBe(404);
+
+ const page = (await (await api("GET", `/api/conversations/${conv.id}/messages?before=${shot.id}&limit=5`)).json()) as MessagePage;
+ expect(page).toEqual({ messages: [expect.objectContaining({ id: first.id })], hasMore: false });
+ expect((await api("GET", `/api/conversations/${conv.id}/messages?before=msg_missing`)).status).toBe(404);
+ });
+
test("chat lifecycle over HTTP", async () => {
const defaultAgent = await ensureDefaultAgent();
const started = await api("POST", "/api/chat", { content: "Plan my week" });
@@ -270,6 +300,57 @@ describe("HTTP routes", () => {
});
});
+describe("message windows", () => {
+ const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]).toString("base64");
+
+ function chatWith(count: number) {
+ const conv = createConversation({ agentId: agent.id, title: "Long chat" });
+ const ids: string[] = [];
+ for (let i = 0; i < count; i++) ids.push(addMessage({ conversationId: conv.id, role: i % 2 ? "assistant" : "user", content: `m${i}` }).id);
+ return { conv, ids };
+ }
+
+ test("the latest messages, then older pages until the first", () => {
+ const { conv, ids } = chatWith(7);
+ const latest = getConversation(conv.id, { limit: 3 });
+ expect(latest.messages.map((m) => m.content)).toEqual(["m4", "m5", "m6"]);
+ expect(latest.hasMore).toBe(true);
+ const older = listMessagePage(conv.id, { limit: 3, before: ids[4] });
+ expect(older.messages.map((m) => m.content)).toEqual(["m1", "m2", "m3"]);
+ expect(older.hasMore).toBe(true);
+ const first = listMessagePage(conv.id, { limit: 3, before: ids[1] });
+ expect(first).toEqual({ messages: [expect.objectContaining({ content: "m0" })], hasMore: false });
+ expect(getConversation(conv.id).hasMore).toBeUndefined();
+ expect(getConversation(conv.id).messages).toHaveLength(7);
+ });
+
+ test("messages written in the same millisecond keep their order across pages", () => {
+ const conv = createConversation({ agentId: agent.id });
+ const ids = [1, 2, 3, 4].map((n) => addMessage({ conversationId: conv.id, role: "user", content: `same${n}` }).id);
+ sql("UPDATE messages SET created_at = '2026-01-01T00:00:00.000Z' WHERE conversation_id = ?", conv.id);
+ expect(getConversation(conv.id, { limit: 2 }).messages.map((m) => m.content)).toEqual(["same3", "same4"]);
+ expect(listMessagePage(conv.id, { limit: 2, before: ids[2] }).messages.map((m) => m.content)).toEqual(["same1", "same2"]);
+ });
+
+ test("light messages leave pictures out and cut long tool output", () => {
+ const conv = createConversation({ agentId: agent.id });
+ const blocks: MessageBlock[] = [
+ { type: "tool_use", id: "toolu_shot", name: "mcp__browser__browser_screenshot", input: {}, result: "ok", image: PNG },
+ { type: "tool_use", id: "toolu_long", name: "Bash", input: {}, result: "x".repeat(10_000) },
+ { type: "text", text: "Done" },
+ ];
+ const msg = addMessage({ conversationId: conv.id, role: "assistant", content: "Done", blocks });
+ const [lite] = getConversation(conv.id, { lite: true }).messages;
+ const [shot, long, text] = lite!.blocks as Extract[];
+ expect(shot!.image).toBeUndefined();
+ expect(shot!.imageUrl).toBe(`/api/conversations/${conv.id}/messages/${msg.id}/images/toolu_shot`);
+ expect(long!.result).toHaveLength(6000);
+ expect(long!.resultTruncated).toBe(true);
+ expect(text).toEqual({ type: "text", text: "Done" } as never);
+ expect(getConversation(conv.id).messages[0]!.blocks[0]).toMatchObject({ image: PNG });
+ });
+});
+
describe("plainPreview", () => {
test("strips markdown formatting for one-line previews", async () => {
const { plainPreview } = await import("../src/services/conversations");
diff --git a/packages/core/test/mobile.test.ts b/packages/core/test/mobile.test.ts
index 2948b969..41e3e758 100644
--- a/packages/core/test/mobile.test.ts
+++ b/packages/core/test/mobile.test.ts
@@ -135,6 +135,12 @@ describe("device scope", () => {
expect(deviceMayCall("POST", "/api/browser/profiles/bp_1/launch")).toBe(true);
expect(deviceMayCall("POST", "/api/browser/profiles/bp_1/stop")).toBe(false);
expect(deviceMayCall("POST", "/api/vms/vm_1/exec")).toBe(false);
+ expect(deviceMayCall("GET", "/api/android/devices")).toBe(true);
+ expect(deviceMayCall("GET", "/api/android/devices/adr_1/screenshot")).toBe(true);
+ expect(deviceMayCall("POST", "/api/android/devices/adr_1/input")).toBe(false);
+ expect(deviceMayCall("POST", "/api/android/devices/adr_1/exec")).toBe(false);
+ expect(deviceMayCall("POST", "/api/android/connect")).toBe(false);
+ expect(deviceMayCall("PATCH", "/api/android/devices/adr_1")).toBe(false);
expect(deviceMayCall("DELETE", "/api/vms/vm_1")).toBe(false);
expect(deviceMayCall("GET", "/api/credentials")).toBe(false);
expect(deviceMayCall("POST", "/api/credentials/c1/reveal")).toBe(false);
@@ -151,6 +157,14 @@ describe("device scope", () => {
expect(deviceMayCall("PATCH", "/api/tasks/tsk_1")).toBe(true);
expect(deviceMayCall("POST", "/api/tasks/tsk_1/messages")).toBe(true);
expect(deviceMayCall("DELETE", "/api/tasks/tsk_1")).toBe(false);
+ expect(deviceMayCall("POST", "/api/tasks/tsk_1/approve")).toBe(true);
+ 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/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);
expect(deviceMayCall("POST", "/api/tasks/attachments")).toBe(true);
expect(deviceMayCall("GET", "/api/tasks/attachments/tat_1/shot.png")).toBe(true);
expect(deviceMayCall("GET", "/api/files/image")).toBe(false);
@@ -238,6 +252,16 @@ describe("phone access", () => {
expect((await app.request("http://127.0.0.1/api/conversations", { headers: { authorization: `Bearer ${device}` } })).status).toBe(401);
});
+ test("answers to phones are gzipped, the desktop's are not", async () => {
+ const { token: device } = await pair("Zipped");
+ const gz = await phone("/api/settings", { bearer: device, headers: { "accept-encoding": "gzip" }, decompress: false } as never);
+ expect(gz.status).toBe(200);
+ expect(gz.headers.get("content-encoding")).toBe("gzip");
+ expect(JSON.parse(new TextDecoder().decode(Bun.gunzipSync(new Uint8Array(await gz.arrayBuffer()))))).toHaveProperty("runner");
+ const plain = await desktop("/api/settings", { headers: { "accept-encoding": "gzip" } });
+ expect(plain.headers.get("content-encoding")).toBeNull();
+ });
+
test("phones can't widen what a chat or an automation may do", async () => {
const { token: device } = await pair("Scoped");
const json = (method: string, body: unknown) => ({ bearer: device, method, body: JSON.stringify(body) });
@@ -348,6 +372,8 @@ describe("phone access", () => {
const task = (await created.json()) as { id: string; workspaceId: string; status: string };
expect(task).toMatchObject({ workspaceId: ws.id, status: "backlog" });
const listed = (await (await phone(`/api/tasks?workspaceId=${ws.id}`, { bearer: device })).json()) as { id: string }[];
+ const board = (await (await phone(`/api/tasks?workspaceId=${ws.id}&fields=summary`, { bearer: device })).json()) as { id: string; title: string; description: string }[];
+ expect(board.find((t) => t.id === task.id)).toMatchObject({ title: "Write the release notes", description: "" });
expect(listed.map((t) => t.id)).toEqual([task.id]);
const moved = await phone(`/api/tasks/${task.id}`, json("PATCH", { status: "cancelled" }));
expect(((await moved.json()) as { status: string }).status).toBe("cancelled");
diff --git a/packages/core/test/ws-subscriptions.test.ts b/packages/core/test/ws-subscriptions.test.ts
index c3dce26f..b0329fca 100644
--- a/packages/core/test/ws-subscriptions.test.ts
+++ b/packages/core/test/ws-subscriptions.test.ts
@@ -111,3 +111,46 @@ describe("chat-scoped browser live views", () => {
websocketHandler.close(ws);
});
});
+
+describe("stored messages", () => {
+ test("reach a phone only for the chats it has open, without the pictures", () => {
+ const got: Record = { desk: [], phone: [] };
+ const socket = (id: string, auth?: "device") =>
+ ({
+ data: { id, auth, subscriptions: new Set() },
+ send: (raw: string) => {
+ const event = JSON.parse(raw) as { type: string; message?: { conversationId: string; blocks: { image?: string; imageUrl?: string }[] } };
+ if (event.type === "message.updated") got[id]!.push({ conversationId: event.message!.conversationId, ...event.message!.blocks[0] });
+ return 0;
+ },
+ close: () => {},
+ }) as unknown as ServerWebSocket;
+ const desk = socket("desk");
+ const phone = socket("phone", "device");
+ websocketHandler.open(desk);
+ websocketHandler.open(phone);
+ msg(phone, { type: "conversation.subscribe", conversationId: "cnv_open" });
+
+ const message = (conversationId: string) => ({
+ id: "msg_1",
+ conversationId,
+ role: "assistant" as const,
+ content: "",
+ blocks: [{ type: "tool_use" as const, id: "toolu_1", name: "screenshot", input: {}, result: "ok", image: "AAAA" }],
+ runId: null,
+ attachments: [],
+ createdAt: "2026-01-01T00:00:00.000Z",
+ });
+ bus.emit({ type: "message.updated", message: message("cnv_open") });
+ bus.emit({ type: "message.updated", message: message("cnv_other") });
+
+ expect(got.desk!.map((m) => m.conversationId)).toEqual(["cnv_open", "cnv_other"]);
+ expect(got.desk![0]!.image).toBe("AAAA");
+ expect(got.phone).toHaveLength(1);
+ expect(got.phone![0]).toMatchObject({ conversationId: "cnv_open", imageUrl: "/api/conversations/cnv_open/messages/msg_1/images/toolu_1" });
+ expect(got.phone![0]!.image).toBeUndefined();
+
+ websocketHandler.close(desk);
+ websocketHandler.close(phone);
+ });
+});
diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts
index 68a8c771..5c0ac454 100644
--- a/packages/shared/src/api.ts
+++ b/packages/shared/src/api.ts
@@ -249,6 +249,14 @@ export interface ConversationWithMessages extends Conversation {
activeRunId: ID | null;
/** Messages waiting for the agent, oldest first. */
queue: QueuedMessage[];
+ /** Asked for the latest messages only (`limit`): older ones exist (GET /api/conversations/:id/messages?before=). */
+ hasMore?: boolean;
+}
+
+/** A page of a chat's messages, oldest first, and whether there are older ones still. */
+export interface MessagePage {
+ messages: Message[];
+ hasMore: boolean;
}
export interface SendMessageInput {
diff --git a/packages/shared/src/models.ts b/packages/shared/src/models.ts
index 5b9e0dcb..d00452c8 100644
--- a/packages/shared/src/models.ts
+++ b/packages/shared/src/models.ts
@@ -564,6 +564,10 @@ export type MessageBlock =
isError?: boolean;
/** Optional base64 image result (e.g. browser screenshot). */
image?: string;
+ /** In a light answer (`lite`): where to fetch `image` instead, with the same credentials. */
+ imageUrl?: string;
+ /** In a light answer: `result` is only its beginning. */
+ resultTruncated?: boolean;
/** Work the tool started in the background (a Claude Code workflow), kept up to date while it runs. */
task?: ToolTask;
parentToolUseId?: string | null;