Skip to content
Merged
17 changes: 10 additions & 7 deletions apps/mobile/README.md
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
```
152 changes: 112 additions & 40 deletions apps/mobile/src/app/(tabs)/(home)/index.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
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";
import { Card, Hairline, LiveDot, Row, SectionTitle, SkeletonRows, T, tap } from "@/components/ui";
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";
Expand All @@ -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))
Expand Down Expand Up @@ -117,32 +119,7 @@ export default function Home() {
</Card>
)}

<Animated.View layout={LinearTransition} style={styles.section}>
<SectionTitle title="Working now" />
{working.length ? (
<View style={{ gap: space.md }}>
{working.map((w) => (
<Animated.View key={w.run.id} entering={FadeIn}>
<RunCard live={w} agent={agents.get(w.run.agentId)} title={titleOf(w.run.conversationId)} />
</Animated.View>
))}
</View>
) : (
<Card style={styles.idle}>
<View style={[styles.idleIcon, { backgroundColor: c.sunken }]}>
{status === "connecting" ? <ActivityIndicator size="small" color={c.textMuted} /> : <Icon name="check" size={15} color={c.textMuted} weight="bold" />}
</View>
<View style={{ flex: 1 }}>
<T variant="subhead" style={{ fontWeight: "600" }}>
{status === "connecting" ? "Checking in…" : "All quiet"}
</T>
<T variant="footnote" muted>
{status === "connecting" ? `Asking ${computer} what the team is doing.` : "No agent is working right now."}
</T>
</View>
</Card>
)}
</Animated.View>
<WorkingNow computer={computer} />

{(vaultLocked || (missing.data?.length ?? 0) > 0 || (questions.data?.length ?? 0) > 0 || (mine.data?.length ?? 0) > 0) && (
<View style={styles.section}>
Expand All @@ -164,13 +141,7 @@ export default function Home() {
<NeedsRow icon="person" title={`${mine.data!.length - 4} more tasks for you`} body="Open My tasks" onPress={() => router.push("/my-tasks")} />
)}
{questions.data?.slice(0, 4).map((q) => (
<NeedsRow
key={q.id}
icon="warning"
title={q.kind === "approval" ? `Needs your OK: ${q.title}` : q.title}
body={q.taskNumber != null ? `Task #${q.taskNumber} — reply to answer` : "Open the chat and reply to answer"}
onPress={() => router.push(`/chat/${q.conversationId}`)}
/>
<QuestionRow key={q.id} question={q} agentName={agents.get(q.agentId)?.name} />
))}
{missing.data?.slice(0, 4).map((m) => (
<NeedsRow key={m.id} icon="key" title={`${m.service}: ${m.kind === "missing_totp" ? "2FA code missing" : m.kind === "invalid_credential" ? "login doesn't work" : "login missing"}`} body={m.reason} />
Expand Down Expand Up @@ -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 (
<Animated.View layout={LinearTransition} style={styles.section}>
<SectionTitle title="Working now" />
{working.length ? (
<View style={{ gap: space.md }}>
{working.map((w) => (
<Animated.View key={w.run.id} entering={FadeIn}>
<RunCard live={w} agent={agents.get(w.run.agentId)} title={recent.data?.find((conv) => conv.id === w.run.conversationId)?.title} />
</Animated.View>
))}
</View>
) : (
<Card style={styles.idle}>
<View style={[styles.idleIcon, { backgroundColor: c.sunken }]}>
{status === "connecting" ? <ActivityIndicator size="small" color={c.textMuted} /> : <Icon name="check" size={15} color={c.textMuted} weight="bold" />}
</View>
<View style={{ flex: 1 }}>
<T variant="subhead" style={{ fontWeight: "600" }}>
{status === "connecting" ? "Checking in…" : "All quiet"}
</T>
<T variant="footnote" muted>
{status === "connecting" ? `Asking ${computer} what the team is doing.` : "No agent is working right now."}
</T>
</View>
</Card>
)}
</Animated.View>
);
});

/** 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 (
<View>
<NeedsRow
icon="warning"
title={approval ? `Needs your OK: ${q.title}` : q.title}
body={[agentName, q.taskNumber != null ? `Task #${q.taskNumber}` : null, approval ? (q.body ?? "") : "Open the chat to answer"].filter(Boolean).join(" · ")}
onPress={() => router.push(`/chat/${q.conversationId}`)}
/>
{approval ? (
<Row style={styles.decide}>
{(["approve", "decline"] as const).map((decision) => (
<Pressable
key={decision}
accessibilityRole="button"
accessibilityLabel={decision === "approve" ? "Approve" : "Decline"}
accessibilityState={{ busy: sending === decision, disabled: !!sending }}
disabled={!!sending}
onPress={() => 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 ? <ActivityIndicator size="small" color={decision === "approve" ? c.onPrimary : c.text} /> : null}
<T variant="footnote" color={decision === "approve" ? c.onPrimary : c.text} style={{ fontWeight: "600" }}>
{decision === "approve" ? "Approve" : "Decline"}
</T>
</Pressable>
))}
</Row>
) : null}
</View>
);
}

function NeedsRow({ icon, title, body, onPress }: { icon: "lock" | "key" | "warning" | "person"; title: string; body: string; onPress?: () => void }) {
const c = useColors();
return (
Expand Down Expand Up @@ -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,
Expand Down
112 changes: 65 additions & 47 deletions apps/mobile/src/app/(tabs)/agents/index.tsx
Original file line number Diff line number Diff line change
@@ -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 <AgentRow agent={agent} running={running} queued={queued} onPress={() => openAgent(agent.id)} />;
});

const Separator = () => <Hairline inset={78} />;

const Header = () => (
<View style={{ paddingHorizontal: space.sm, paddingVertical: space.sm }}>
<WorkspaceChip />
</View>
);

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<string, { running: number; queued: number }>();
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<Agent> = useCallback(
({ item }) => {
const n = counts.get(item.id) ?? NONE;
return <AgentItem agent={item} running={n.running} queued={n.queued} />;
},
[counts],
);

return (
<ScrollView
contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={{ paddingHorizontal: space.sm, paddingBottom: 140 }}
refreshControl={<RefreshControl {...pull} />}
>
<>
<Stack.Title large>Agents</Stack.Title>
<View style={{ paddingHorizontal: space.sm, paddingVertical: space.sm }}>
<WorkspaceChip />
</View>
{data.map((agent, i) => (
<View key={agent.id}>
{i > 0 && <Hairline inset={78} />}
<AgentRow
agent={agent}
running={counts.get(agent.id)?.running ?? 0}
queued={counts.get(agent.id)?.queued ?? 0}
onPress={() => {
tap();
router.push({ pathname: "/agent/[id]", params: { id: agent.id } });
}}
/>
</View>
))}
{agents.isLoading ? (
<SkeletonRows count={7} avatar={46} />
) : agents.isError && !data.length ? (
<ErrorState title="Couldn't load your agents" error={errorText(agents.error)} onRetry={() => void agents.refetch()} />
) : data.length === 0 ? (
<EmptyState icon="agents" title="No agents yet" body="Create agents in Godmode on your computer." />
) : null}
</ScrollView>
<HeaderActions
actions={[
{ icon: "clock", label: "Automations", onPress: () => router.push("/automations") },
{ icon: "pulse", label: "Activity", onPress: () => router.push("/activity") },
]}
/>
<FlashList
data={data}
keyExtractor={(agent) => agent.id}
renderItem={renderItem}
extraData={counts}
contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={{ paddingHorizontal: space.sm, paddingBottom: 140 }}
refreshControl={<RefreshControl {...pull} />}
ListHeaderComponent={Header}
ItemSeparatorComponent={Separator}
ListEmptyComponent={
agents.isLoading ? (
<SkeletonRows count={7} avatar={46} />
) : agents.isError ? (
<ErrorState title="Couldn't load your agents" error={errorText(agents.error)} onRetry={() => void agents.refetch()} />
) : (
<EmptyState icon="agents" title="No agents yet" body="Create agents in Godmode on your computer." />
)
}
/>
</>
);
}
Loading
Loading