From 2541d633364224931a7fc31d35ae21f7c050b8f5 Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Sun, 11 Oct 2026 14:56:54 +0200 Subject: [PATCH 1/8] Phone app: calm list avatars, stream replies as patches, virtualize the Agents, Chats and Tasks lists Small avatars no longer breathe and blink in every row, and every loop (avatars, live dots, skeletons) holds still while its screen is behind another; the tabs stay mounted, so their rows animated all the time. Streaming replies come as what changed (deltas.patch) instead of the whole block list five times a second. Agents, Chats and Tasks render with FlashList and memoized rows, re-render only when run counts change, and share one chat list request. --- apps/mobile/src/app/(tabs)/(home)/index.tsx | 4 +- apps/mobile/src/app/(tabs)/agents/index.tsx | 105 ++++++++++--------- apps/mobile/src/app/(tabs)/chats/index.tsx | 70 +++++++------ apps/mobile/src/app/(tabs)/tasks/index.tsx | 107 ++++++++++++++------ apps/mobile/src/components/character.tsx | 28 +++-- apps/mobile/src/components/ui.tsx | 20 +++- apps/mobile/src/lib/hooks.ts | 7 ++ apps/mobile/src/lib/live.ts | 53 +++++++++- apps/mobile/src/lib/realtime.ts | 16 ++- apps/mobile/src/lib/screens.ts | 3 +- apps/mobile/src/lib/workspace.ts | 8 +- 11 files changed, 285 insertions(+), 136 deletions(-) diff --git a/apps/mobile/src/app/(tabs)/(home)/index.tsx b/apps/mobile/src/app/(tabs)/(home)/index.tsx index 5f46ddab..bc9e8909 100644 --- a/apps/mobile/src/app/(tabs)/(home)/index.tsx +++ b/apps/mobile/src/app/(tabs)/(home)/index.tsx @@ -13,7 +13,7 @@ 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 { chatListQuery, useAgents, useOpenHumanTasks } from "@/lib/hooks"; import { useLive } from "@/lib/live"; import { qk, queryClient } from "@/lib/query"; import { reconnectNow } from "@/lib/realtime"; @@ -35,7 +35,7 @@ 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 }); diff --git a/apps/mobile/src/app/(tabs)/agents/index.tsx b/apps/mobile/src/app/(tabs)/agents/index.tsx index 286a9066..1b5992cb 100644 --- a/apps/mobile/src/app/(tabs)/agents/index.tsx +++ b/apps/mobile/src/app/(tabs)/agents/index.tsx @@ -1,74 +1,85 @@ +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 { 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} - + 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..61569f09 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, useDeferredValue, 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"; @@ -22,37 +23,37 @@ export default function Chats() { 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") }]} /> - 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 +80,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 +126,4 @@ function ChatItem({ conversation, agent, running, project }: { conversation: Con ); -} +}); diff --git a/apps/mobile/src/app/(tabs)/tasks/index.tsx b/apps/mobile/src/app/(tabs)/tasks/index.tsx index fcdc6783..e4b1660c 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 @@ -48,13 +75,14 @@ export default function Tasks() { { icon: "plus", label: "New task", onPress: newTask }, ]} /> - - 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 +101,6 @@ export default function Tasks() { )} } - renderSectionHeader={({ section }) => ( - - - - )} - ItemSeparatorComponent={() => } - renderItem={({ item }) => ( - - )} ListEmptyComponent={ tasks.isLoading ? ( @@ -108,22 +122,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 +177,4 @@ function TaskItem({ task, agent, workspaceName, projectName }: { task: Task; age ); -} +}); 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/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/hooks.ts b/apps/mobile/src/lib/hooks.ts index fd05716c..88a9e3ad 100644 --- a/apps/mobile/src/lib/hooks.ts +++ b/apps/mobile/src/lib/hooks.ts @@ -13,6 +13,13 @@ 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]); diff --git a/apps/mobile/src/lib/live.ts b/apps/mobile/src/lib/live.ts index 9902cabf..af800f7e 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,38 @@ 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]); +} diff --git a/apps/mobile/src/lib/realtime.ts b/apps/mobile/src/lib/realtime.ts index b9163dcd..948ec3b5 100644 --- a/apps/mobile/src/lib/realtime.ts +++ b/apps/mobile/src/lib/realtime.ts @@ -33,6 +33,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; @@ -135,8 +146,7 @@ function handle(event: ServerEvent) { 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. @@ -283,6 +293,8 @@ 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(); diff --git a/apps/mobile/src/lib/screens.ts b/apps/mobile/src/lib/screens.ts index da733c62..0830d751 100644 --- a/apps/mobile/src/lib/screens.ts +++ b/apps/mobile/src/lib/screens.ts @@ -3,6 +3,7 @@ import { useEffect, useMemo } from "react"; import { browserView, computerTargetLabel, computerView, type Agent, 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"; @@ -26,7 +27,7 @@ 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)); const screens = useMemo(() => { const out: LiveScreen[] = []; 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) From fa9135e0e3277084df242bed6bd2259e55023322 Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Sun, 11 Oct 2026 15:06:07 +0200 Subject: [PATCH 2/8] Core: answer phones faster and with less - A chat can be asked for its latest messages only (?limit=) with older pages from GET /api/conversations/:id/messages?before=, and light (?lite=1): screenshots become their own cached requests and long tool output is cut. Most of a long chat's bytes are base64 screenshots. - Answers to phones and through the cloud relay are gzipped. - Stored messages go to a phone only for the chats it has open, light. - GET /api/tasks?fields=summary leaves the long texts out of a board. - Phones may approve a ticket (the app's button always got a 403) and move, run or cancel a chat's follow-up. - An index for the latest runs of all agents. --- packages/core/src/cloud/scope.ts | 3 +- packages/core/src/db/migrations.ts | 8 ++ packages/core/src/mobile/scope.ts | 10 +++ packages/core/src/remote/routing.ts | 8 +- packages/core/src/server/app.ts | 7 +- packages/core/src/server/routes/chat.ts | 25 +++++- packages/core/src/server/routes/tasks.ts | 11 ++- packages/core/src/server/ws.ts | 23 ++++++ packages/core/src/services/conversations.ts | 88 ++++++++++++++++++++- packages/core/src/services/liteMessage.ts | 25 ++++++ packages/core/test/conversations.test.ts | 83 ++++++++++++++++++- packages/core/test/mobile.test.ts | 20 +++++ packages/core/test/ws-subscriptions.test.ts | 43 ++++++++++ packages/shared/src/api.ts | 8 ++ packages/shared/src/models.ts | 4 + 15 files changed, 353 insertions(+), 13 deletions(-) create mode 100644 packages/core/src/services/liteMessage.ts diff --git a/packages/core/src/cloud/scope.ts b/packages/core/src/cloud/scope.ts index 0cac4b3d..55ad1427 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 c51e266f..7322290b 100644 --- a/packages/core/src/db/migrations.ts +++ b/packages/core/src/db/migrations.ts @@ -1383,6 +1383,14 @@ CREATE TABLE IF NOT EXISTS android_devices ( -- Phones an agent controls in every run, and the ones a chat adds (JSON arrays of ids). ALTER TABLE agents ADD COLUMN android_device_ids TEXT NOT NULL DEFAULT '[]'; ALTER TABLE conversations ADD COLUMN android_device_ids TEXT NOT NULL DEFAULT '[]'; +`, + }, + { + id: 84, + // 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..b7743e2c 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$`)], @@ -137,6 +144,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..5119bd44 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,10 @@ 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 }); + app.use("/api/*", (c, next) => (isMobileChannel(c) || isRelayed(c) ? gzip(c, next) : next())); app.onError((err, c) => { const route = c.req.routePath; 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..ef8c140d 100644 --- a/packages/core/test/mobile.test.ts +++ b/packages/core/test/mobile.test.ts @@ -151,6 +151,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 +246,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 +366,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 2a802494..e9570699 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -229,6 +229,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; From 144e31d9ff4e358b1523492b6a398dc7d48eab3c Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Sun, 11 Oct 2026 15:07:58 +0200 Subject: [PATCH 3/8] Phone app: open chats with their latest messages, patch caches from events, probe addresses once A chat loads its latest messages (older pages on demand) with screenshots left out; message, task, agent and question events go into the caches instead of refetching them, bursts of events refetch a list once, and coming back to the app only refetches what predates the dropped link. Address probes are shared between requests and a dead address is skipped for a while. --- apps/mobile/src/app/_layout.tsx | 4 + apps/mobile/src/app/chat/[id].tsx | 3 +- apps/mobile/src/app/model.tsx | 5 +- apps/mobile/src/lib/api.ts | 89 ++++++++++++++---- apps/mobile/src/lib/chat-cache.ts | 101 ++++++++++++++++++++ apps/mobile/src/lib/query.ts | 7 +- apps/mobile/src/lib/realtime.ts | 147 ++++++++++++++++++------------ 7 files changed, 278 insertions(+), 78 deletions(-) create mode 100644 apps/mobile/src/lib/chat-cache.ts diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 7f6a2aea..13e110d4 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -52,6 +52,10 @@ export default function RootLayout() { + + + + diff --git a/apps/mobile/src/app/chat/[id].tsx b/apps/mobile/src/app/chat/[id].tsx index f462e42f..a8b49633 100644 --- a/apps/mobile/src/app/chat/[id].tsx +++ b/apps/mobile/src/app/chat/[id].tsx @@ -31,6 +31,7 @@ import { 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 } from "@/lib/chat-cache"; import { setQueue } from "@/lib/composer"; import { newQueueId, pendingQueued, withPending } from "@/lib/pending-queue"; import { useAgents } from "@/lib/hooks"; @@ -50,7 +51,7 @@ export default function Chat() { 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; 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/lib/api.ts b/apps/mobile/src/lib/api.ts index e4ca5e8d..13b04880 100644 --- a/apps/mobile/src/lib/api.ts +++ b/apps/mobile/src/lib/api.ts @@ -3,6 +3,7 @@ import Constants from "expo-constants"; import { File } from "expo-file-system"; import type { Agent, + AnswerQuestionInput, AnswerQuestionResult, AgentQuestion, AppNotification, @@ -15,6 +16,7 @@ import type { HumanTask, HumanTaskCloseResult, ConversationWithMessages, + MessagePage, MissingLogin, MobilePairResult, MobilePairingPayload, @@ -32,6 +34,7 @@ import type { StartChatResult, Task, TaskAttachment, + TaskEvent, TaskStatus, TaskType, Vm, @@ -126,25 +129,43 @@ 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 that didn't answer lately: skipped for a while instead of waiting out the probe's timeout on every request. */ +const unreachable = new Map(); +const UNREACHABLE_FOR_MS = 20_000; /** * 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,17 +177,27 @@ 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, i < bases.length - 1); if (found === true) return base; refused ??= found; } return refused; } -/** Asks this address for the computer again before its next use. */ -export function forget(base: string) { - for (const key of verified) if (key.endsWith(` ${base}`)) verified.delete(key); +/** Asks this address for the computer again before its next use; `down`: it just didn't answer, so try the others first. */ +export function forget(base: string, down = false) { + for (const key of verified) { + if (!key.endsWith(` ${base}`)) continue; + verified.delete(key); + if (down) unreachable.set(key, Date.now() + UNREACHABLE_FOR_MS); + } +} + +/** Every address gets a fresh chance (the app came back, the network changed, the human pulled to refresh). */ +export function retryAddresses() { + unreachable.clear(); } /** @@ -182,8 +213,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, i < bases.length - 1); if (found !== true) { refused ??= found; continue; @@ -192,7 +224,7 @@ export async function request(method: string, path: string, body?: unknown, m try { res = await send(base + path, { method, headers, body: payload }, timeout); } catch { - forget(base); + forget(base, true); if (method === "GET") continue; throw new ApiError(0, NO_ANSWER_MESSAGE, "timeout"); } @@ -377,13 +409,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 +441,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 +460,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; @@ -495,6 +544,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..5678d043 --- /dev/null +++ b/apps/mobile/src/lib/chat-cache.ts @@ -0,0 +1,101 @@ +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. */ +export function mergeMessage(messages: Message[], message: Message): Message[] { + if (messages.some((m) => m.id === message.id)) return messages.map((m) => (m.id === message.id ? message : m)); + 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, + ); +} + +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; + } + } + const sent = new Set(messages.filter((m) => m.role === "user").slice(-5).map((m) => m.content.trim())); + messages = [...messages, ...old.messages.filter((m) => isPending(m) && !sent.has(m.content.trim()))]; + } + for (const message of arrived.messages.values()) messages = mergeMessage(messages, message); + 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/query.ts b/apps/mobile/src/lib/query.ts index a1cf20f6..6bb4bb9b 100644 --- a/apps/mobile/src/lib/query.ts +++ b/apps/mobile/src/lib/query.ts @@ -23,12 +23,17 @@ 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"], diff --git a/apps/mobile/src/lib/realtime.ts b/apps/mobile/src/lib/realtime.ts index 948ec3b5..2b049fe0 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"; @@ -116,21 +118,36 @@ 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. A fetch on + * its way keeps going instead of starting over with every event. + */ +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); + void queryClient.invalidateQueries(filters, { cancelRefetch: false }); + }, 350), + ); +} + +/** 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) { @@ -138,9 +155,9 @@ function handle(event: ServerEvent) { switch (event.type) { 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 }); + void queryClient.invalidateQueries({ queryKey: qk.conversation(event.run.conversationId) }, { cancelRefetch: false }); + refreshSoon(conversationLists); + refreshSoon({ queryKey: qk.runs }); break; case "run.activity": live.runActivity(event.runId, event.label); @@ -149,53 +166,56 @@ function handle(event: ServerEvent) { 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); + void queryClient.invalidateQueries({ queryKey: qk.conversation(event.conversation.id) }, { cancelRefetch: false }); + 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"] }); + 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))); @@ -204,7 +224,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)}`, { @@ -229,26 +249,31 @@ 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; + queryClient.setQueriesData({ queryKey: qk.questions }, (list) => + Array.isArray(list) && list.some((x) => x.id === q.id) ? list.map((x) => (x.id === q.id ? q : x)) : list, + ); + refreshSoon({ queryKey: qk.questions }); + refreshSoon({ queryKey: qk.bootstrap }); + refreshSoon({ queryKey: qk.agents }); + void queryClient.invalidateQueries({ queryKey: qk.conversation(q.conversationId) }, { cancelRefetch: false }); + 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; } } @@ -261,6 +286,8 @@ function clearTimers() { } let connecting = false; +/** When the open socket last went away (0 = it never was open). */ +let closedAt = 0; function scheduleReconnect() { if (!running) return; @@ -298,7 +325,10 @@ async function connect() { replaySubscriptions(); if (viewing) sendView(); void catchUp(); - void queryClient.invalidateQueries(); + // What was fetched before the link dropped may have missed events; what came after is current. Nothing on the first + // connect: every screen has just fetched its own. + const since = closedAt; + if (since) void queryClient.invalidateQueries({ predicate: (q) => q.state.dataUpdatedAt < since }, { cancelRefetch: false }); pingTimer = setInterval(() => sendEvent({ type: "ping" }), 25_000); }; ws.onmessage = (msg) => { @@ -311,6 +341,7 @@ async function connect() { ws.onclose = (e) => { if (socket !== ws) return; socket = null; + closedAt = Date.now(); clearTimers(); // Ask again before reconnecting: the gateway may have lost the computer while Tailscale still reaches it. forget(base); @@ -329,6 +360,7 @@ function onAppState(state: AppStateStatus) { if (viewing) sendView(); if (state === "active") { if (running && !socket) { + retryAddresses(); clearTimers(); attempt = 0; void connect(); @@ -336,6 +368,7 @@ function onAppState(state: AppStateStatus) { } else if (state === "background" && socket) { const ws = socket; socket = null; + closedAt = Date.now(); clearTimers(); ws.close(); useLive.getState().setStatus("offline"); @@ -346,6 +379,7 @@ function onAppState(state: AppStateStatus) { export function startRealtime(): () => void { running = true; attempt = 0; + closedAt = 0; void connect(); const sub = AppState.addEventListener("change", onAppState); return () => { @@ -361,6 +395,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(); From 76739b4e41905135598be573a75c79d172ce1d16 Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Sun, 11 Oct 2026 15:18:46 +0200 Subject: [PATCH 4/8] Phone app: what the desktop can do, and lists from the last session at once - Questions and approvals are answered with one tap (options, Approve / Decline with a note), in the chat and on Home. - A chat's menu: rename, pin, archive, delete and pause; its follow-up can be run now, moved or cancelled; follow-up and retry markers, plans and file edits show in the thread; screenshots load on their own when a step is opened. - Archived chats (with the ones that still go on) and archived tasks, all automations, and the activity of every agent. - A ticket opens from the board's copy at once and shows its timeline. - The lists are kept on the phone between launches; the chat renders only the row that streams; Home's sections render on their own. --- apps/mobile/src/app/(tabs)/(home)/index.tsx | 148 +++++-- apps/mobile/src/app/(tabs)/agents/index.tsx | 7 + apps/mobile/src/app/(tabs)/chats/index.tsx | 18 +- apps/mobile/src/app/(tabs)/tasks/index.tsx | 1 + apps/mobile/src/app/_layout.tsx | 29 +- apps/mobile/src/app/activity.tsx | 194 ++++++++++ apps/mobile/src/app/archived-tasks.tsx | 128 ++++++ apps/mobile/src/app/archived.tsx | 199 ++++++++++ apps/mobile/src/app/automations.tsx | 115 ++++++ apps/mobile/src/app/chat/[id].tsx | 363 +++++++++++++++--- apps/mobile/src/app/human-task/[id].tsx | 22 +- apps/mobile/src/app/task/[id].tsx | 198 ++++++++-- apps/mobile/src/components/header-actions.tsx | 98 ++++- apps/mobile/src/components/markdown.tsx | 53 +-- apps/mobile/src/components/message.tsx | 227 +++++++---- apps/mobile/src/components/question-card.tsx | 310 +++++++++++++++ apps/mobile/src/components/remote-image.tsx | 58 +++ apps/mobile/src/components/routine-row.tsx | 153 ++++++++ apps/mobile/src/components/run-row.tsx | 117 ++++++ apps/mobile/src/components/task-timeline.tsx | 311 +++++++++++++++ apps/mobile/src/lib/format.ts | 12 +- apps/mobile/src/lib/live.ts | 21 + apps/mobile/src/lib/persist.ts | 71 ++++ 23 files changed, 2587 insertions(+), 266 deletions(-) create mode 100644 apps/mobile/src/app/activity.tsx create mode 100644 apps/mobile/src/app/archived-tasks.tsx create mode 100644 apps/mobile/src/app/archived.tsx create mode 100644 apps/mobile/src/app/automations.tsx create mode 100644 apps/mobile/src/components/question-card.tsx create mode 100644 apps/mobile/src/components/remote-image.tsx create mode 100644 apps/mobile/src/components/routine-row.tsx create mode 100644 apps/mobile/src/components/run-row.tsx create mode 100644 apps/mobile/src/components/task-timeline.tsx create mode 100644 apps/mobile/src/lib/persist.ts diff --git a/apps/mobile/src/app/(tabs)/(home)/index.tsx b/apps/mobile/src/app/(tabs)/(home)/index.tsx index bc9e8909..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"; @@ -14,7 +17,7 @@ import { WorkspaceChip } from "@/components/workspace-chip"; import { api, errorText } from "@/lib/api"; import { greeting } from "@/lib/format"; import { chatListQuery, useAgents, useOpenHumanTasks } from "@/lib/hooks"; -import { useLive } from "@/lib/live"; +import { useLive, useRunningChats } from "@/lib/live"; import { qk, queryClient } from "@/lib/query"; import { reconnectNow } from "@/lib/realtime"; import { isLive, useLiveScreens } from "@/lib/screens"; @@ -40,18 +43,17 @@ export default function Home() { 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 1b5992cb..0a25674a 100644 --- a/apps/mobile/src/app/(tabs)/agents/index.tsx +++ b/apps/mobile/src/app/(tabs)/agents/index.tsx @@ -3,6 +3,7 @@ import { router, Stack } from "expo-router"; 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"; @@ -60,6 +61,12 @@ export default function Agents() { return ( <> Agents + router.push("/automations") }, + { icon: "pulse", label: "Activity", onPress: () => router.push("/activity") }, + ]} + /> agent.id} diff --git a/apps/mobile/src/app/(tabs)/chats/index.tsx b/apps/mobile/src/app/(tabs)/chats/index.tsx index 61569f09..544b9a5b 100644 --- a/apps/mobile/src/app/(tabs)/chats/index.tsx +++ b/apps/mobile/src/app/(tabs)/chats/index.tsx @@ -1,7 +1,7 @@ import { FlashList, type ListRenderItem } from "@shopify/flash-list"; import { useQuery } from "@tanstack/react-query"; import { Link, router, Stack } from "expo-router"; -import { memo, useCallback, useDeferredValue, useMemo, useState } from "react"; +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"; @@ -18,7 +18,14 @@ 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({ @@ -42,7 +49,12 @@ export default function Chats() { <> Chats setSearch(e.nativeEvent.text)} onCancelButtonPress={() => setSearch("")} /> - router.push("/compose") }]} /> + router.push("/archived") }, + { icon: "compose", label: "New chat", onPress: () => router.push("/compose") }, + ]} + /> conv.id} diff --git a/apps/mobile/src/app/(tabs)/tasks/index.tsx b/apps/mobile/src/app/(tabs)/tasks/index.tsx index e4b1660c..17863659 100644 --- a/apps/mobile/src/app/(tabs)/tasks/index.tsx +++ b/apps/mobile/src/app/(tabs)/tasks/index.tsx @@ -74,6 +74,7 @@ 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") }]} /> { - 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(); 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 ( 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 a8b49633..f1e435d8 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,30 +22,56 @@ 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 } from "@/lib/chat-cache"; +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 }>(); @@ -55,10 +82,12 @@ export default function Chat() { 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). @@ -69,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, @@ -152,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); @@ -167,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 = () => { @@ -201,13 +231,83 @@ export default function Chat() { .finally(() => setRetrying(false)); }; + const change = (patch: { title?: string; pinned?: boolean; archived?: boolean }) => + api.conversations + .update(id, patch) + .then((next) => { + queryClient.setQueryData(qk.conversation(id), (old) => (old ? { ...old, ...next } : 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 ? ( @@ -218,29 +318,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 ? ( @@ -252,6 +348,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 }: { conversationId: string }) { + const blocks = useLive((s) => s.drafts[conversationId]?.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. */ +function Earlier() { + const c = useColors(); + return ( + + + + 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 + + +