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 + + +