diff --git a/.claude/settings.json b/.claude/settings.json index a98a38348..e73b7b901 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -4,10 +4,7 @@ "deny": [ "Read(./apps/cli/**)", "Edit(./apps/cli/**)", - "Write(./apps/cli/**)", - "Read(./apps/mobile/**)", - "Edit(./apps/mobile/**)", - "Write(./apps/mobile/**)" + "Write(./apps/cli/**)" ] } } diff --git a/apps/mobile/app.json b/apps/mobile/app.json index cb58a4bfc..6014e79f3 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -1,6 +1,6 @@ { "expo": { - "name": "PostHog AI", + "name": "PostHog Code", "slug": "posthog", "version": "1.0.0", "orientation": "portrait", @@ -16,10 +16,14 @@ "ios": { "icon": "./assets/posthog.icon", "supportsTablet": true, - "bundleIdentifier": "com.posthog.mobile", + "bundleIdentifier": "com.posthog.code.mobile", "infoPlist": { "NSMicrophoneUsageDescription": "Allow PostHog to use your microphone for voice-to-text input", - "ITSAppUsesNonExemptEncryption": false + "NSSpeechRecognitionUsageDescription": "Allow PostHog to transcribe your voice input on-device", + "NSPhotoLibraryUsageDescription": "Allow PostHog to access your photos for attaching images to tasks", + "NSCameraUsageDescription": "Allow PostHog to use your camera to scan QR codes for sign-in", + "ITSAppUsesNonExemptEncryption": false, + "LSApplicationQueriesSchemes": ["posthog-code"] } }, "android": { @@ -29,10 +33,11 @@ }, "edgeToEdgeEnabled": true, "predictiveBackGestureEnabled": false, - "package": "com.posthog.mobile", + "package": "com.posthog.code.mobile", "permissions": [ "android.permission.RECORD_AUDIO", - "android.permission.MODIFY_AUDIO_SETTINGS" + "android.permission.MODIFY_AUDIO_SETTINGS", + "android.permission.CAMERA" ] }, "web": { @@ -46,6 +51,12 @@ "microphonePermission": "Allow PostHog to use your microphone for voice-to-text input" } ], + [ + "expo-camera", + { + "cameraPermission": "Allow PostHog to use your camera to scan QR codes for sign-in" + } + ], [ "expo-font", { @@ -152,7 +163,14 @@ } } ], - "expo-localization" + "expo-localization", + [ + "expo-speech-recognition", + { + "microphonePermission": "Allow PostHog to use your microphone for voice-to-text input", + "speechRecognitionPermission": "Allow PostHog to transcribe your voice input on-device" + } + ] ], "extra": { "router": {}, diff --git a/apps/mobile/assets/sounds/meep.mp3 b/apps/mobile/assets/sounds/meep.mp3 new file mode 100644 index 000000000..fd7b4cf7e Binary files /dev/null and b/apps/mobile/assets/sounds/meep.mp3 differ diff --git a/apps/mobile/index.js b/apps/mobile/index.js new file mode 100644 index 000000000..80d3d998f --- /dev/null +++ b/apps/mobile/index.js @@ -0,0 +1 @@ +import "expo-router/entry"; diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 2652f9df1..5484f36f3 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -1,7 +1,7 @@ { "name": "@posthog/mobile", "version": "1.0.0", - "main": "expo-router/entry", + "main": "./index.js", "scripts": { "start": "expo start", "start:clear": "expo start --clear", @@ -24,12 +24,15 @@ "dependencies": { "@expo/ui": "0.2.0-beta.9", "@react-native-async-storage/async-storage": "^2.2.0", + "@react-native-community/netinfo": "^12.0.1", "@tanstack/react-query": "^5.90.12", "date-fns": "^4.1.0", "expo": "~54.0.27", "expo-application": "~7.0.8", "expo-auth-session": "^7.0.10", "expo-av": "~16.0.8", + "expo-camera": "^55.0.15", + "expo-clipboard": "^55.0.13", "expo-constants": "~18.0.11", "expo-crypto": "^15.0.8", "expo-dev-client": "~6.0.20", @@ -37,15 +40,18 @@ "expo-file-system": "~19.0.21", "expo-font": "^14.0.10", "expo-glass-effect": "~0.1.8", + "expo-haptics": "^55.0.14", "expo-linear-gradient": "^15.0.8", "expo-linking": "~8.0.10", "expo-localization": "~17.0.8", "expo-router": "~6.0.17", "expo-secure-store": "^15.0.8", + "expo-speech-recognition": "^3.1.2", "expo-splash-screen": "~31.0.12", "expo-status-bar": "~3.0.9", "expo-system-ui": "~6.0.9", "expo-web-browser": "^15.0.10", + "highlight.js": "^11.11.1", "nativewind": "^4.2.1", "phosphor-react-native": "^3.0.2", "posthog-react-native": "^4.18.0", diff --git a/apps/mobile/src/app/(tabs)/_layout.tsx b/apps/mobile/src/app/(tabs)/_layout.tsx index 9641fa233..1e4bc6033 100644 --- a/apps/mobile/src/app/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(tabs)/_layout.tsx @@ -1,9 +1,11 @@ import { Icon, Label, NativeTabs } from "expo-router/unstable-native-tabs"; import { DynamicColorIOS, Platform } from "react-native"; +import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; import { useThemeColors } from "@/lib/theme"; export default function TabsLayout() { const themeColors = useThemeColors(); + const aiChatEnabled = usePreferencesStore((s) => s.aiChatEnabled); // Dynamic colors for liquid glass effect on iOS const dynamicTextColor = @@ -30,21 +32,23 @@ export default function TabsLayout() { tintColor={dynamicTintColor} minimizeBehavior="onScrollDown" > - {/* Conversations - First Tab (default landing) */} - - - - + {/* Conversations - Chats tab, hidden by default to focus on Code */} + {aiChatEnabled && ( + + + + + )} - {/* Tasks Tab */} + {/* Code tab (task list for PostHog Code) */} - + s.aiChatEnabled); + + if (!aiChatEnabled) { + return ; + } const handleConversationPress = (conversation: ConversationDetail) => { router.push(`/chat/${conversation.id}`); diff --git a/apps/mobile/src/app/(tabs)/settings.tsx b/apps/mobile/src/app/(tabs)/settings.tsx index 5c3907b3f..df35d9eb4 100644 --- a/apps/mobile/src/app/(tabs)/settings.tsx +++ b/apps/mobile/src/app/(tabs)/settings.tsx @@ -2,15 +2,21 @@ import { router } from "expo-router"; import { Linking, ScrollView, + Switch, Text, TouchableOpacity, View, } from "react-native"; import { useAuthStore, useUserQuery } from "@/features/auth"; +import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; export default function SettingsScreen() { const { logout, cloudRegion, getCloudUrlFromRegion } = useAuthStore(); const { data: userData } = useUserQuery(); + const aiChatEnabled = usePreferencesStore((s) => s.aiChatEnabled); + const setAiChatEnabled = usePreferencesStore((s) => s.setAiChatEnabled); + const pingsEnabled = usePreferencesStore((s) => s.pingsEnabled); + const setPingsEnabled = usePreferencesStore((s) => s.setPingsEnabled); const handleLogout = async () => { await logout(); @@ -88,6 +94,36 @@ export default function SettingsScreen() { + {/* Labs */} + + Labs + + Experimental features + + + + + PostHog AI chat + + + Show the Chats tab for PostHog AI conversations + + + + + + + + Enable pings + + + Play a sound when a task completes + + + + + + {/* All Settings Button */} { + // Block navigation while a modal dismiss animation is in progress. + // When the screen loses focus (modal opens), readyRef is false. + // When focus returns (modal dismissed), we wait for all native + // animations to finish before allowing the next push. + useFocusEffect( + useCallback(() => { + const handle = InteractionManager.runAfterInteractions(() => { + readyRef.current = true; + }); + return () => { + readyRef.current = false; + handle.cancel(); + }; + }, []), + ); + + const handleCreateTask = useCallback(() => { + if (!readyRef.current) return; + readyRef.current = false; router.push("/task"); - }; + }, [router]); - const handleTaskPress = (taskId: string) => { - router.push(`/task/${taskId}`); - }; + const handleTaskPress = useCallback( + (taskId: string) => { + if (!readyRef.current) return; + readyRef.current = false; + router.push(`/task/${taskId}`); + }, + [router], + ); return ( @@ -20,8 +45,10 @@ export default function TasksScreen() { - Tasks - Your PostHog tasks + Code + + Your PostHog Code sessions + s.aiChatEnabled); const themeColors = useThemeColors(); useScreenTracking(); @@ -47,25 +50,29 @@ function RootLayoutNav() { - {/* Chat routes - regular stack navigation */} - - + {/* Chat routes - only registered when AI chat feature is enabled */} + {aiChatEnabled && ( + <> + + + + )} {/* Task routes - modal presentation */} + diff --git a/apps/mobile/src/app/auth.tsx b/apps/mobile/src/app/auth.tsx index b5c8f9838..0992d2634 100644 --- a/apps/mobile/src/app/auth.tsx +++ b/apps/mobile/src/app/auth.tsx @@ -1,7 +1,15 @@ import { router } from "expo-router"; import { useMemo, useState } from "react"; -import { ActivityIndicator, Text, TouchableOpacity, View } from "react-native"; +import { + ActivityIndicator, + ScrollView, + Text, + TextInput, + TouchableOpacity, + View, +} from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; +import { QrScanModal, type QrScanResult } from "@/components/QrScanModal"; import { type CloudRegion, useAuthStore } from "@/features/auth"; import { useThemeColors } from "@/lib/theme"; @@ -28,8 +36,52 @@ export default function AuthScreen() { const [selectedRegion, setSelectedRegion] = useState("us"); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + const [devToken, setDevToken] = useState(""); + const [devProjectId, setDevProjectId] = useState(""); + const [scannerVisible, setScannerVisible] = useState(false); - const { loginWithOAuth } = useAuthStore(); + const { loginWithOAuth, loginWithPersonalApiKey } = useAuthStore(); + + const handleQrScan = async (result: QrScanResult) => { + setScannerVisible(false); + setDevToken(result.apiKey); + setDevProjectId(String(result.projectId)); + setIsLoading(true); + setError(null); + try { + await loginWithPersonalApiKey({ + token: result.apiKey, + projectId: result.projectId, + region: selectedRegion, + }); + router.replace("/(tabs)"); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to sign in"); + } finally { + setIsLoading(false); + } + }; + + const handleDevSignIn = async () => { + setIsLoading(true); + setError(null); + try { + const projectIdNum = Number(devProjectId); + if (!Number.isFinite(projectIdNum) || projectIdNum <= 0) { + throw new Error("Project ID must be a positive number"); + } + await loginWithPersonalApiKey({ + token: devToken, + projectId: projectIdNum, + region: selectedRegion, + }); + router.replace("/(tabs)"); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to sign in"); + } finally { + setIsLoading(false); + } + }; const handleSignIn = async () => { setIsLoading(true); @@ -57,7 +109,11 @@ export default function AuthScreen() { return ( - + {/* Header */} @@ -131,8 +187,69 @@ export default function AuthScreen() { )} + + {__DEV__ && ( + + + Dev sign-in (personal API key) + + + Skips OAuth. Create a personal API key at Settings → User API + keys with scopes: user:read, project:read, task:write, + integration:read, conversation:write, query:read. + + + + + + Dev sign in + + + { + setError(null); + setScannerVisible(true); + }} + disabled={isLoading} + > + + Scan QR code + + + + )} - + + setScannerVisible(false)} + onScan={handleQrScan} + /> ); } diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 35a1aa2d7..85d2f0ed2 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -1,30 +1,51 @@ import { Text } from "@components/text"; +import { useQueryClient } from "@tanstack/react-query"; +import * as Haptics from "expo-haptics"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; -import { useCallback, useEffect, useState } from "react"; -import { ActivityIndicator, Pressable, View } from "react-native"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + ActionSheetIOS, + ActivityIndicator, + Alert, + Pressable, + View, +} from "react-native"; import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; import Animated, { useAnimatedStyle } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Composer } from "@/features/chat"; import { getTask, + runTaskInCloud, type Task, TaskSessionView, + taskKeys, useTaskSessionStore, } from "@/features/tasks"; +import { logger } from "@/lib/logger"; import { useThemeColors } from "@/lib/theme"; +const log = logger.scope("task-detail"); + export default function TaskDetailScreen() { const { id: taskId } = useLocalSearchParams<{ id: string }>(); const router = useRouter(); + const queryClient = useQueryClient(); const insets = useSafeAreaInsets(); const themeColors = useThemeColors(); const [task, setTask] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [retrying, setRetrying] = useState(false); - const { connectToTask, disconnectFromTask, sendPrompt, getSessionForTask } = - useTaskSessionStore(); + const { + connectToTask, + disconnectFromTask, + sendPrompt, + cancelPrompt, + sendPermissionResponse, + getSessionForTask, + } = useTaskSessionStore(); const session = taskId ? getSessionForTask(taskId) : undefined; @@ -47,66 +68,216 @@ export default function TaskDetailScreen() { useEffect(() => { if (!taskId) return; + let cancelled = false; setLoading(true); setError(null); getTask(taskId) .then((fetchedTask) => { + if (cancelled) return; setTask(fetchedTask); return connectToTask(fetchedTask); }) .catch((err) => { - console.error("Failed to load task:", err); + if (cancelled) return; + log.error("Failed to load task", err); setError("Failed to load task"); }) .finally(() => { - setLoading(false); + if (cancelled) return; + // Brief delay for FlatList to render its initial batch behind + // the loading overlay before revealing. + setTimeout(() => setLoading(false), 150); }); return () => { + cancelled = true; disconnectFromTask(taskId); }; }, [taskId, connectToTask, disconnectFromTask]); + // Auto-reconnect if the session disappears while the screen is active + // (e.g., cloud sandbox expired and the session was cleaned up). + // Re-fetches the task to get a fresh S3 presigned URL. + useEffect(() => { + if (!taskId || !task || loading) return; + if (session) return; + if (retrying) return; + + let cancelled = false; + getTask(taskId) + .then((freshTask) => { + if (cancelled) return; + setTask(freshTask); + return connectToTask(freshTask); + }) + .catch((err) => { + if (cancelled) return; + log.error("Failed to reconnect to task", err); + }); + + return () => { + cancelled = true; + }; + }, [taskId, task, loading, session, connectToTask, retrying]); + const handleSendPrompt = useCallback( (text: string) => { if (!taskId) return; + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); sendPrompt(taskId, text).catch((err) => { - console.error("Failed to send prompt:", err); + log.error("Failed to send prompt", err); + Alert.alert( + "Failed to send", + "Your message could not be delivered. Please try again.", + ); }); }, [taskId, sendPrompt], ); + const handleStop = useCallback(() => { + if (!taskId) return; + // cancelPrompt returns false on failure — no need to alert, + // the agent may have already finished or the sandbox expired. + cancelPrompt(taskId).catch(() => {}); + }, [taskId, cancelPrompt]); + + const updateTaskInCache = useCallback( + (updated: Task) => { + // Directly patch the task in all list query caches so the task list + // reflects the change immediately (e.g., environment: local → cloud). + queryClient.setQueriesData( + { queryKey: taskKeys.lists() }, + (old) => old?.map((t) => (t.id === updated.id ? updated : t)), + ); + }, + [queryClient], + ); + + const handleRetry = useCallback(async () => { + if (!taskId || !task) return; + try { + setRetrying(true); + disconnectFromTask(taskId); + + const updatedTask = await runTaskInCloud(taskId, { + resumeFromRunId: task.latest_run?.id, + }); + setTask(updatedTask); + await connectToTask(updatedTask); + updateTaskInCache(updatedTask); + // Don't clear retrying here — the effect below clears it + // once the session shows meaningful state (thinking or terminal). + } catch (err) { + log.error("Failed to retry task", err); + setRetrying(false); + Alert.alert( + "Retry failed", + "Could not restart the task. Please try again.", + ); + } + }, [taskId, task, disconnectFromTask, connectToTask, updateTaskInCache]); + + // Clear retrying once the agent finishes a turn or the run terminates. + useEffect(() => { + if (!retrying || !session) return; + if (!session.isPromptPending || session.terminalStatus) { + setRetrying(false); + } + }, [retrying, session]); + + const handleSendPermissionResponse = useCallback( + (args: Parameters[1]) => { + if (!taskId) return; + sendPermissionResponse(taskId, args).catch((err) => { + log.error("Failed to send permission response", err); + Alert.alert( + "Failed to respond", + "Your permission response could not be sent. Please try again.", + ); + }); + }, + [taskId, sendPermissionResponse], + ); + const handleOpenTask = useCallback( (newTaskId: string) => { - router.push(`/task/${newTaskId}`); + router.replace(`/task/${newTaskId}`); }, [router], ); - if (loading) { - return ( - <> - - - - Loading task... - - - ); - } + // Stale detection for local tasks: if no new S3 data arrives for 30s + // while the agent is supposedly working, the desktop may be offline. + const isLocal = task?.latest_run?.environment === "local"; + const [isStale, setIsStale] = useState(false); + useEffect(() => { + if (!isLocal || !session?.isPromptPending) { + setIsStale(false); + return; + } + const interval = setInterval(() => { + const lastEvent = session.lastEventAt ?? 0; + setIsStale(lastEvent > 0 && Date.now() - lastEvent > 30_000); + }, 5_000); + return () => clearInterval(interval); + }, [isLocal, session?.isPromptPending, session?.lastEventAt]); + + const handleContinueInCloud = useCallback(async () => { + if (!taskId || !task) return; + try { + setRetrying(true); + disconnectFromTask(taskId); + const updatedTask = await runTaskInCloud(taskId, { + resumeFromRunId: task.latest_run?.id, + }); + setTask(updatedTask); + await connectToTask(updatedTask); + updateTaskInCache(updatedTask); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + } catch (err) { + log.error("Failed to continue in cloud", err); + setRetrying(false); + Alert.alert( + "Failed to switch", + "Could not continue this task in the cloud. Please try again.", + ); + } + }, [taskId, task, disconnectFromTask, connectToTask, updateTaskInCache]); + + const environment = task?.latest_run?.environment; - if (error || !task) { + const visibleAgentTypes = [ + "agent_message_chunk", + "agent_message", + "agent_thought_chunk", + "tool_call", + ]; + const hasAnyAgentOutput = + session?.events.some((e) => { + if (e.type !== "session_update") return false; + const su = (e.notification as Record)?.update; + return visibleAgentTypes.includes( + (su as Record)?.sessionUpdate as string, + ); + }) ?? false; + + const isConnecting = + retrying || (!!session?.awaitingAgentOutput && !hasAnyAgentOutput); + const isThinking = !!session?.awaitingAgentOutput && hasAnyAgentOutput; + + // Haptic pulse when connecting/thinking indicators dismiss + const prevWaiting = useRef(false); + useEffect(() => { + const waiting = isConnecting || isThinking; + if (prevWaiting.current && !waiting) { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + } + prevWaiting.current = waiting; + }, [isConnecting, isThinking]); + + if (error || (!task && !loading)) { return ( <> ( + + ActionSheetIOS.showActionSheetWithOptions( + { + options: ["Keep locally", "Move to Cloud"], + cancelButtonIndex: 0, + title: isStale + ? "Desktop may be offline" + : "Running on your desktop", + }, + (index) => { + if (index === 1) handleContinueInCloud(); + }, + ) + : undefined + } + className={`rounded-full px-3 py-1 ${ + environment === "cloud" ? "bg-accent-3" : "bg-gray-4" + }`} + > + + {environment === "cloud" ? "Cloud" : "Local"} + + + ) + : undefined, }} /> + {/* Always render TaskSessionView so the FlatList can layout behind + the loading overlay. This prevents the "flash of messages" when + switching from loading spinner to rendered content. */} - {/* Fixed input at bottom */} - - - + {/* Loading overlay — covers the list while it does initial layout */} + {loading && ( + + + + {task?.latest_run ? "Connecting..." : "Loading task..."} + + + )} + + {/* Fixed input at bottom — hidden when run is terminal */} + {!session?.terminalStatus && ( + + + + )} ); diff --git a/apps/mobile/src/app/task/index.tsx b/apps/mobile/src/app/task/index.tsx index 42e535e15..38023a5ae 100644 --- a/apps/mobile/src/app/task/index.tsx +++ b/apps/mobile/src/app/task/index.tsx @@ -1,11 +1,14 @@ import { Text } from "@components/text"; import { Stack, useRouter } from "expo-router"; import * as WebBrowser from "expo-web-browser"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { ActivityIndicator, - FlatList, + Keyboard, + KeyboardAvoidingView, + Platform, Pressable, + ScrollView, TextInput, View, } from "react-native"; @@ -17,8 +20,11 @@ import { type Integration, runTaskInCloud, } from "@/features/tasks"; +import { logger } from "@/lib/logger"; import { useThemeColors } from "@/lib/theme"; +const log = logger.scope("task-create"); + interface ConnectGitHubPromptProps { onConnected?: () => void; } @@ -79,10 +85,17 @@ export default function NewTaskScreen() { const [integrations, setIntegrations] = useState([]); const [repositories, setRepositories] = useState([]); const [selectedRepo, setSelectedRepo] = useState(null); + const [repoSearch, setRepoSearch] = useState(""); const [prompt, setPrompt] = useState(""); const [creating, setCreating] = useState(false); const [loadingRepos, setLoadingRepos] = useState(true); + const filteredRepositories = useMemo(() => { + const query = repoSearch.trim().toLowerCase(); + if (!query) return repositories; + return repositories.filter((repo) => repo.toLowerCase().includes(query)); + }, [repositories, repoSearch]); + const loadIntegrations = useCallback(async () => { try { setLoadingRepos(true); @@ -99,7 +112,7 @@ export default function NewTaskScreen() { setRepositories(allRepos.sort()); } } catch (error) { - console.error("Failed to fetch integrations:", error); + log.error("Failed to fetch integrations", error); } finally { setLoadingRepos(false); } @@ -116,19 +129,26 @@ export default function NewTaskScreen() { try { const githubIntegration = integrations.find((i) => i.kind === "github"); + const trimmedPrompt = prompt.trim(); const task = await createTask({ - description: prompt.trim(), - title: prompt.trim().slice(0, 100), + description: trimmedPrompt, + title: trimmedPrompt.slice(0, 100), repository: selectedRepo, github_integration: githubIntegration?.id, }); - await runTaskInCloud(task.id); + // Pass the prompt as pending_user_message so the cloud agent has + // something to process on start — matches how the desktop launches + // new cloud runs. Without this the sandbox starts idle and the UI + // stays stuck on "Thinking...". + await runTaskInCloud(task.id, { + pendingUserMessage: trimmedPrompt, + }); // Navigate to task detail (replaces current modal) router.replace(`/task/${task.id}`); } catch (error) { - console.error("Failed to create task:", error); + log.error("Failed to create task", error); } finally { setCreating(false); } @@ -148,78 +168,113 @@ export default function NewTaskScreen() { presentation: "modal", }} /> - - {loadingRepos ? ( - - - - Loading repositories... - - - ) : !hasGithubIntegration ? ( - - ) : ( - <> - Repository - - item} - renderItem={({ item }) => ( - setSelectedRepo(item)} - className={`border-gray-6 border-b px-3 py-3 ${ - selectedRepo === item ? "bg-accent-3" : "" - }`} - > + + + + {loadingRepos ? ( + + + + Loading repositories... + + + ) : !hasGithubIntegration ? ( + + ) : ( + <> + Repository + + + {filteredRepositories.length === 0 ? ( + + + {repoSearch + ? `No repositories match "${repoSearch}"` + : "No repositories available"} + + + ) : ( + filteredRepositories.map((item) => ( + setSelectedRepo(item)} + className={`border-gray-6 border-b px-3 py-3 ${ + selectedRepo === item ? "bg-accent-3" : "" + }`} + > + + {item} + + + )) + )} + + + + Task description + + + + + {creating ? ( + + ) : ( - {item} + Create task - - )} - /> - - - Task description - - - - {creating ? ( - - ) : ( - - Create task - - )} - - - )} - + )} + + + )} + + + ); } diff --git a/apps/mobile/src/components/OfflineBanner.tsx b/apps/mobile/src/components/OfflineBanner.tsx new file mode 100644 index 000000000..e70814fa0 --- /dev/null +++ b/apps/mobile/src/components/OfflineBanner.tsx @@ -0,0 +1,23 @@ +import { WifiSlash } from "phosphor-react-native"; +import { Text, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useNetworkStatus } from "@/hooks/useNetworkStatus"; + +export function OfflineBanner() { + const { isConnected } = useNetworkStatus(); + const insets = useSafeAreaInsets(); + + if (isConnected) return null; + + return ( + + + + No internet connection + + + ); +} diff --git a/apps/mobile/src/components/QrScanModal.tsx b/apps/mobile/src/components/QrScanModal.tsx new file mode 100644 index 000000000..b9cdc9c8a --- /dev/null +++ b/apps/mobile/src/components/QrScanModal.tsx @@ -0,0 +1,134 @@ +import { CameraView, useCameraPermissions } from "expo-camera"; +import { useCallback, useRef, useState } from "react"; +import { + ActivityIndicator, + Modal, + Text, + TouchableOpacity, + View, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; + +export type QrScanResult = { + apiKey: string; + projectId: number; +}; + +type QrScanModalProps = { + visible: boolean; + onClose: () => void; + onScan: (result: QrScanResult) => void; +}; + +function parseQrPayload(raw: string): QrScanResult | null { + try { + const parsed = JSON.parse(raw); + const apiKey = typeof parsed.apiKey === "string" ? parsed.apiKey : null; + const projectIdRaw = parsed.projectId; + const projectId = + typeof projectIdRaw === "number" + ? projectIdRaw + : typeof projectIdRaw === "string" + ? Number(projectIdRaw) + : NaN; + if (!apiKey || !Number.isFinite(projectId) || projectId <= 0) { + return null; + } + return { apiKey, projectId }; + } catch { + return null; + } +} + +export function QrScanModal({ visible, onClose, onScan }: QrScanModalProps) { + const [permission, requestPermission] = useCameraPermissions(); + const [error, setError] = useState(null); + const hasScannedRef = useRef(false); + + const handleBarcode = useCallback( + (event: { data: string }) => { + if (hasScannedRef.current) { + return; + } + const parsed = parseQrPayload(event.data); + if (!parsed) { + setError("QR code is not a valid PostHog sign-in code"); + return; + } + hasScannedRef.current = true; + onScan(parsed); + }, + [onScan], + ); + + const handleClose = useCallback(() => { + hasScannedRef.current = false; + setError(null); + onClose(); + }, [onClose]); + + return ( + { + hasScannedRef.current = false; + setError(null); + }} + > + + + + Scan sign-in QR + + + Close + + + + + {!permission ? ( + + + + ) : !permission.granted ? ( + + + PostHog needs camera access to scan the QR code shown on the web + app. + + + + Grant camera access + + + + ) : ( + + + {error ? ( + + {error} + + ) : null} + + Point your camera at the QR code shown in the PostHog web app + after creating a personal API key. + + + + )} + + + + ); +} diff --git a/apps/mobile/src/features/auth/stores/authStore.ts b/apps/mobile/src/features/auth/stores/authStore.ts index a9d105dbe..79a029415 100644 --- a/apps/mobile/src/features/auth/stores/authStore.ts +++ b/apps/mobile/src/features/auth/stores/authStore.ts @@ -29,6 +29,11 @@ interface AuthState { // Methods loginWithOAuth: (region: CloudRegion) => Promise; + loginWithPersonalApiKey: (params: { + token: string; + projectId: number; + region: CloudRegion; + }) => Promise; refreshAccessToken: () => Promise; scheduleTokenRefresh: () => void; initializeAuth: () => Promise; @@ -96,6 +101,40 @@ export const useAuthStore = create()( get().scheduleTokenRefresh(); }, + loginWithPersonalApiKey: async ({ token, projectId, region }) => { + if (!__DEV__) { + throw new Error( + "Dev sign-in is only available in development builds", + ); + } + const trimmed = token.trim(); + if (!trimmed) { + throw new Error("Personal API key is required"); + } + if (!Number.isFinite(projectId) || projectId <= 0) { + throw new Error("Valid project ID is required"); + } + + const storedTokens: StoredTokens = { + accessToken: trimmed, + refreshToken: "", + expiresAt: Number.MAX_SAFE_INTEGER, + cloudRegion: region, + scopedTeams: [projectId], + }; + + await saveTokens(storedTokens); + + set({ + oauthAccessToken: trimmed, + oauthRefreshToken: null, + tokenExpiry: null, + cloudRegion: region, + projectId, + isAuthenticated: true, + }); + }, + refreshAccessToken: async () => { const state = get(); @@ -140,7 +179,8 @@ export const useAuthStore = create()( refreshTimeoutId = null; } - if (!state.tokenExpiry) { + // Personal API key sessions have no refresh token — nothing to schedule. + if (!state.tokenExpiry || !state.oauthRefreshToken) { return; } diff --git a/apps/mobile/src/features/chat/components/AgentMessage.tsx b/apps/mobile/src/features/chat/components/AgentMessage.tsx index 96f3f1bc8..0a71bdd05 100644 --- a/apps/mobile/src/features/chat/components/AgentMessage.tsx +++ b/apps/mobile/src/features/chat/components/AgentMessage.tsx @@ -1,10 +1,14 @@ +import * as Clipboard from "expo-clipboard"; +import * as Haptics from "expo-haptics"; import { Brain } from "phosphor-react-native"; -import { useState } from "react"; -import { Pressable, Text, View } from "react-native"; +import { useCallback, useState } from "react"; +import { Alert, Pressable, Text, View } from "react-native"; +import { formatRelativeTime } from "@/lib/format"; import { useThemeColors } from "@/lib/theme"; import { usePeriodicRerender } from "../hooks/usePeriodicRerender"; import type { AssistantToolCall } from "../types"; import { getRandomThinkingMessage } from "../utils/thinkingMessages"; +import { MarkdownText } from "./MarkdownText"; import { ToolMessage } from "./ToolMessage"; interface AgentMessageProps { @@ -14,6 +18,7 @@ interface AgentMessageProps { toolCalls?: AssistantToolCall[]; hasHumanMessageAfter?: boolean; onOpenTask?: (taskId: string) => void; + timestamp?: number; } interface ReasoningBlockProps { @@ -67,12 +72,21 @@ export function AgentMessage({ toolCalls, hasHumanMessageAfter, onOpenTask, + timestamp, }: AgentMessageProps) { usePeriodicRerender(isLoading ? THINKING_MESSAGE_INTERVAL_MS : 0); const hasContent = !!content; const isComplete = !isLoading && hasContent; + const handleLongPress = useCallback(() => { + if (!content) return; + Clipboard.setStringAsync(content).then(() => { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + Alert.alert("Copied", "Message copied to clipboard."); + }); + }, [content]); + return ( {toolCalls && toolCalls.length > 0 && ( @@ -104,13 +118,19 @@ export function AgentMessage({ )} - {/* Show final content */} + {/* Show final content — long-press to copy */} {content && ( - - - {content} - - + + + + + + )} + + {timestamp && !isLoading && ( + + {formatRelativeTime(timestamp)} + )} ); diff --git a/apps/mobile/src/features/chat/components/Composer.tsx b/apps/mobile/src/features/chat/components/Composer.tsx index d7f5bb9ef..6d12d35bf 100644 --- a/apps/mobile/src/features/chat/components/Composer.tsx +++ b/apps/mobile/src/features/chat/components/Composer.tsx @@ -1,8 +1,12 @@ import { GlassContainer, GlassView } from "expo-glass-effect"; +import * as Haptics from "expo-haptics"; import { ArrowUp, Microphone, Stop } from "phosphor-react-native"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { ActivityIndicator, + Animated, + Easing, + Keyboard, Platform, TextInput, TouchableOpacity, @@ -13,14 +17,72 @@ import { useVoiceRecording } from "../hooks/useVoiceRecording"; interface ComposerProps { onSend: (message: string) => void; + onStop?: () => void; disabled?: boolean; placeholder?: string; + isUserTurn?: boolean; +} + +function PulsingBorder({ active, color }: { active: boolean; color: string }) { + const opacity = useRef(new Animated.Value(0)).current; + const animRef = useRef(null); + + useEffect(() => { + if (active) { + opacity.setValue(0); + animRef.current = Animated.loop( + Animated.sequence([ + Animated.timing(opacity, { + toValue: 1, + duration: 1500, + easing: Easing.inOut(Easing.ease), + useNativeDriver: true, + }), + Animated.timing(opacity, { + toValue: 0, + duration: 1500, + easing: Easing.inOut(Easing.ease), + useNativeDriver: true, + }), + ]), + ); + animRef.current.start(); + } else { + animRef.current?.stop(); + animRef.current = null; + opacity.setValue(0); + } + return () => { + animRef.current?.stop(); + }; + }, [active, opacity]); + + if (!active) return null; + + return ( + + ); } export function Composer({ onSend, + onStop, disabled = false, placeholder = "Ask a question", + isUserTurn = false, }: ComposerProps) { const themeColors = useThemeColors(); const [message, setMessage] = useState(""); @@ -33,8 +95,9 @@ export function Composer({ const handleSend = () => { const trimmed = message.trim(); if (!trimmed || disabled) return; - onSend(trimmed); setMessage(""); + Keyboard.dismiss(); + onSend(trimmed); }; const handleMicPress = async () => { @@ -55,6 +118,14 @@ export function Composer({ }; const canSend = message.trim().length > 0 && !disabled && !isRecording; + const showStop = + !isUserTurn && !canSend && !isRecording && !isTranscribing && !!onStop; + + const handleStop = () => { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + onStop?.(); + }; + const effectivePlaceholder = placeholder; if (Platform.OS === "ios") { return ( @@ -85,44 +156,49 @@ export function Composer({ gap: 8, }} > - {/* Input field with rounded glass background */} - - + + - + isInteractive + > + + + - {/* Mic / Send button */} + {/* Mic / Send / Stop button */} ) : canSend ? ( - ) : isRecording ? ( + ) : isRecording || showStop ? ( { + Clipboard.setStringAsync(content).then(() => { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + Alert.alert("Copied", "Message copied to clipboard."); + }); + }, [content]); + return ( - - - {content} + + + + + + {timestamp && ( + + {formatRelativeTime(timestamp)} - + )} ); } diff --git a/apps/mobile/src/features/chat/components/MarkdownText.tsx b/apps/mobile/src/features/chat/components/MarkdownText.tsx new file mode 100644 index 000000000..0ce7301be --- /dev/null +++ b/apps/mobile/src/features/chat/components/MarkdownText.tsx @@ -0,0 +1,432 @@ +import { useMemo } from "react"; +import { Linking, ScrollView, Text, View } from "react-native"; +import { getColorForClass, highlightCode } from "@/lib/syntax-highlight"; +import { useThemeColors } from "@/lib/theme"; + +interface MarkdownTextProps { + content: string; +} + +function HighlightedCode({ + code, + language, +}: { + code: string; + language: string; +}) { + const themeColors = useThemeColors(); + const segments = useMemo( + () => highlightCode(code, language), + [code, language], + ); + + if (!segments) { + return ( + + {code} + + ); + } + + return ( + + {segments.map((segment, i) => ( + + {segment.text} + + ))} + + ); +} + +interface Block { + type: + | "paragraph" + | "code" + | "heading" + | "list" + | "table" + | "blockquote" + | "hr"; + content: string; + language?: string; + level?: number; + items?: string[]; + ordered?: boolean; + rows?: string[][]; +} + +function parseBlocks(text: string): Block[] { + const lines = text.split("\n"); + const blocks: Block[] = []; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + + // Code block + if (line.startsWith("```")) { + const language = line.slice(3).trim() || undefined; + const codeLines: string[] = []; + i++; + while (i < lines.length && !lines[i].startsWith("```")) { + codeLines.push(lines[i]); + i++; + } + i++; // skip closing ``` + blocks.push({ type: "code", content: codeLines.join("\n"), language }); + continue; + } + + // Heading + const headingMatch = line.match(/^(#{1,6})\s+(.+)$/); + if (headingMatch) { + blocks.push({ + type: "heading", + content: headingMatch[2], + level: headingMatch[1].length, + }); + i++; + continue; + } + + // Horizontal rule (---, ***, ___ with optional spaces, 3+ chars) + if (/^([-*_])\s*\1\s*\1[\s1]*$/.test(line)) { + blocks.push({ type: "hr", content: "" }); + i++; + continue; + } + + // Blockquote (consecutive > lines) + if (/^>\s?/.test(line)) { + const quoteLines: string[] = []; + while (i < lines.length && /^>\s?/.test(lines[i])) { + quoteLines.push(lines[i].replace(/^>\s?/, "")); + i++; + } + blocks.push({ type: "blockquote", content: quoteLines.join("\n") }); + continue; + } + + // Unordered list (consecutive - or * lines) + if (/^\s*[-*]\s/.test(line)) { + const items: string[] = []; + while (i < lines.length && /^\s*[-*]\s/.test(lines[i])) { + items.push(lines[i].replace(/^\s*[-*]\s+/, "")); + i++; + } + blocks.push({ type: "list", content: "", items }); + continue; + } + + // Ordered list (consecutive 1. 2. lines) + if (/^\s*\d+[.)]\s/.test(line)) { + const items: string[] = []; + while (i < lines.length && /^\s*\d+[.)]\s/.test(lines[i])) { + items.push(lines[i].replace(/^\s*\d+[.)]\s+/, "")); + i++; + } + blocks.push({ type: "list", content: "", items, ordered: true }); + continue; + } + + // Table: lines with pipes, second line is separator (|---|---|) + if ( + line.includes("|") && + i + 1 < lines.length && + /^\s*\|?[\s-:|]+\|/.test(lines[i + 1]) + ) { + const rows: string[][] = []; + while (i < lines.length && lines[i].includes("|")) { + const row = lines[i] + .replace(/^\s*\|/, "") + .replace(/\|\s*$/, "") + .split("|") + .map((cell) => cell.trim()); + // Skip the separator row + if (!/^[\s-:|]+$/.test(lines[i].replace(/\|/g, ""))) { + rows.push(row); + } + i++; + } + if (rows.length > 0) { + blocks.push({ type: "table", content: "", rows }); + } + continue; + } + + // Empty line + if (line.trim() === "") { + i++; + continue; + } + + // Paragraph: collect consecutive non-special lines + const paraLines: string[] = []; + while ( + i < lines.length && + lines[i].trim() !== "" && + !lines[i].startsWith("```") && + !lines[i].match(/^#{1,6}\s/) && + !/^\s*[-*]\s/.test(lines[i]) && + !/^\s*\d+[.)]\s/.test(lines[i]) && + !/^>\s?/.test(lines[i]) && + !/^([-*_])\s*\1\s*\1[\s1]*$/.test(lines[i]) && + !( + lines[i].includes("|") && + i + 1 < lines.length && + /^\s*\|?[\s-:|]+\|/.test(lines[i + 1]) + ) + ) { + paraLines.push(lines[i]); + i++; + } + if (paraLines.length > 0) { + blocks.push({ type: "paragraph", content: paraLines.join("\n") }); + } + } + + return blocks; +} + +function openUrl(url: string) { + Linking.openURL(url); +} + +function renderInline(text: string): React.ReactNode[] { + const nodes: React.ReactNode[] = []; + // Links must come first to avoid bold/italic consuming text inside [] + const pattern = + /(\[([^\]]+)\]\(([^)]+)\)|\*\*(.+?)\*\*|\*(.+?)\*|`([^`]+)`)/g; + let lastIndex = 0; + let match: RegExpExecArray | null = null; + + // biome-ignore lint/suspicious/noAssignInExpressions: regex exec loop + while ((match = pattern.exec(text)) !== null) { + if (match.index > lastIndex) { + nodes.push(text.slice(lastIndex, match.index)); + } + + if (match[2] && match[3]) { + // Link: [text](url) + const url = match[3]; + nodes.push( + openUrl(url)} + > + {match[2]} + , + ); + } else if (match[4]) { + // Bold + nodes.push( + + {match[4]} + , + ); + } else if (match[5]) { + // Italic + nodes.push( + + {match[5]} + , + ); + } else if (match[6]) { + // Inline code + nodes.push( + + {match[6]} + , + ); + } + + lastIndex = match.index + match[0].length; + } + + if (lastIndex < text.length) { + nodes.push(text.slice(lastIndex)); + } + + return nodes.length > 0 ? nodes : [text]; +} + +export function MarkdownText({ content }: MarkdownTextProps) { + const blocks = parseBlocks(content); + + return ( + + {blocks.map((block, i) => { + const key = `block-${i}`; + + switch (block.type) { + case "code": + return ( + + {block.language && ( + + + {block.language} + + + )} + + {block.language ? ( + + ) : ( + + {block.content} + + )} + + + ); + + case "heading": + return ( + + {renderInline(block.content)} + + ); + + case "list": + return ( + + {block.items?.map((item, idx) => ( + + + {block.ordered ? `${idx + 1}.` : "•"} + + + {renderInline(item)} + + + ))} + + ); + + case "table": { + const rows = block.rows ?? []; + const header = rows[0]; + const body = rows.slice(1); + return ( + + + {header && ( + + {header.map((cell, col) => { + const colKey = `${key}-h${col}-${cell}`; + return ( + 0 + ? { + borderLeftWidth: 1, + borderLeftColor: "#3333", + } + : undefined + } + > + + {renderInline(cell)} + + + ); + })} + + )} + {body.map((row, ri) => { + const rowKey = `${key}-r${ri}`; + return ( + + {row.map((cell, col) => { + const cellKey = `${rowKey}-c${col}-${cell}`; + return ( + 0 + ? { + borderLeftWidth: 1, + borderLeftColor: "#3333", + } + : undefined + } + > + + {renderInline(cell)} + + + ); + })} + + ); + })} + + + ); + } + + case "blockquote": + return ( + + + {renderInline(block.content)} + + + ); + + case "hr": + return ; + + default: + return ( + + {renderInline(block.content)} + + ); + } + })} + + ); +} diff --git a/apps/mobile/src/features/chat/components/ToolMessage.tsx b/apps/mobile/src/features/chat/components/ToolMessage.tsx index ee6085fcd..de3417823 100644 --- a/apps/mobile/src/features/chat/components/ToolMessage.tsx +++ b/apps/mobile/src/features/chat/components/ToolMessage.tsx @@ -14,7 +14,7 @@ import { Trash, Wrench, } from "phosphor-react-native"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { ActivityIndicator, Pressable, @@ -22,6 +22,11 @@ import { TouchableOpacity, View, } from "react-native"; +import { + getColorForClass, + highlightCode, + languageFromPath, +} from "@/lib/syntax-highlight"; import { useThemeColors } from "@/lib/theme"; export type ToolStatus = "pending" | "running" | "completed" | "error"; @@ -54,6 +59,81 @@ const kindIcons: Record = { other: Wrench, }; +export function deriveToolKind(toolName: string): ToolKind { + // Agent titles can include file paths, e.g. "Edit `src/foo.ts`" or + // "Read 200 lines in `bar.ts`", so match on prefix / keyword. + const name = toolName.toLowerCase(); + if (name.startsWith("read") || name === "read_file") return "read"; + if ( + name.startsWith("edit") || + name.startsWith("write") || + name.startsWith("multiedit") || + name.startsWith("multi_edit") || + name === "search_replace" + ) + return "edit"; + if (name.startsWith("delete")) return "delete"; + if ( + name.startsWith("grep") || + name.startsWith("search") || + name.startsWith("glob") || + name.startsWith("find") || + name.startsWith("list") + ) + return "search"; + if ( + name.startsWith("bash") || + name.startsWith("execute") || + name.startsWith("terminal") + ) + return "execute"; + if (name.startsWith("think")) return "think"; + if (name.startsWith("webfetch") || name.startsWith("fetch")) return "fetch"; + if (name === "create_task") return "create_task"; + return "other"; +} + +export function getToolSubtitle( + toolName: string, + args?: Record, +): string | null { + if (!args) return null; + const kind = deriveToolKind(toolName); + + switch (kind) { + case "read": + case "edit": + case "delete": + case "move": + if (typeof args.file_path === "string") + return shortenPath(args.file_path); + if (typeof args.target_file === "string") + return shortenPath(args.target_file); + return null; + case "search": + if (typeof args.pattern === "string") return `"${args.pattern}"`; + return null; + case "execute": + if (typeof args.command === "string") + return args.command.length > 60 + ? `${args.command.slice(0, 60)}...` + : args.command; + return null; + case "fetch": + if (typeof args.url === "string") + return args.url.length > 60 ? `${args.url.slice(0, 60)}...` : args.url; + return null; + case "think": + if (typeof args.content === "string") + return args.content.length > 60 + ? `${args.content.slice(0, 60)}...` + : args.content; + return null; + default: + return null; + } +} + interface CreateTaskArgs { title?: string; description?: string; @@ -93,6 +173,456 @@ export function formatToolTitle( return toolName; } +// Shape guards for file-editing tool args. The agent forwards Claude's raw +// tool input through the ACP `rawInput` field, so we can detect Edit / Write / +// MultiEdit by the keys present in args. +interface EditArgs { + file_path: string; + old_string: string; + new_string: string; +} + +interface MultiEditArgs { + file_path: string; + edits: Array<{ old_string: string; new_string: string }>; +} + +interface WriteArgs { + file_path: string; + content: string; +} + +function asEditArgs( + args: Record | undefined, +): EditArgs | null { + if (!args) return null; + if ( + typeof args.file_path === "string" && + typeof args.old_string === "string" && + typeof args.new_string === "string" + ) { + return { + file_path: args.file_path, + old_string: args.old_string, + new_string: args.new_string, + }; + } + return null; +} + +function asMultiEditArgs( + args: Record | undefined, +): MultiEditArgs | null { + if (!args || typeof args.file_path !== "string") return null; + if (!Array.isArray(args.edits)) return null; + const edits: MultiEditArgs["edits"] = []; + for (const raw of args.edits) { + if ( + raw && + typeof raw === "object" && + typeof (raw as Record).old_string === "string" && + typeof (raw as Record).new_string === "string" + ) { + edits.push({ + old_string: (raw as Record).old_string as string, + new_string: (raw as Record).new_string as string, + }); + } + } + if (edits.length === 0) return null; + return { file_path: args.file_path, edits }; +} + +function asWriteArgs( + args: Record | undefined, +): WriteArgs | null { + if (!args) return null; + if ( + typeof args.file_path === "string" && + typeof args.content === "string" && + args.old_string === undefined + ) { + return { file_path: args.file_path, content: args.content }; + } + return null; +} + +// Strip ANSI escape codes from terminal output +function stripAnsi(text: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping ANSI codes requires matching control chars + return text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ""); +} + +function extractResultText(result: unknown): string | null { + if (typeof result === "string") return result; + if (result && typeof result === "object") { + const obj = result as Record; + for (const key of ["stdout", "output", "text", "content"] as const) { + if (typeof obj[key] === "string") return obj[key] as string; + } + } + return null; +} + +function countDiffLines( + editArgs: EditArgs | null, + multiEditArgs: MultiEditArgs | null, + writeArgs: WriteArgs | null, +): { added: number; removed: number } { + let added = 0; + let removed = 0; + + const countFromDiff = (oldText: string, newText: string) => { + const lines = computeLineDiff(oldText, newText, Number.MAX_SAFE_INTEGER); + for (const line of lines) { + if (line.kind === "added") added++; + else if (line.kind === "removed") removed++; + } + }; + + if (editArgs) { + countFromDiff(editArgs.old_string ?? "", editArgs.new_string ?? ""); + } else if (multiEditArgs) { + for (const edit of multiEditArgs.edits) { + countFromDiff(edit.old_string ?? "", edit.new_string ?? ""); + } + } else if (writeArgs) { + added = writeArgs.content ? writeArgs.content.split("\n").length : 0; + } + + return { added, removed }; +} + +// Extract a file path from agent tool titles like "Read `src/foo.ts`" or +// "Read 200 lines in `bar.ts`" when rawInput/args are unavailable. +function extractPathFromTitle(title: string): string | null { + const backtickMatch = title.match(/`([^`]+)`/); + if (backtickMatch) return backtickMatch[1]; + // Fallback: strip common prefixes like "Read file", "Read 200 lines in" + const stripped = title + .replace(/^read\s+/i, "") + .replace(/^file\s*/i, "") + .replace(/^\d+\s+lines?\s+in\s+/i, "") + .trim(); + // Only treat the remainder as a path if it looks like one + if (stripped.includes("/") || stripped.includes(".")) return stripped; + return null; +} + +function shortenPath(path: string, maxLen = 48): string { + if (path.length <= maxLen) return path; + const parts = path.split("/"); + if (parts.length <= 2) return `…${path.slice(-(maxLen - 1))}`; + return `…/${parts.slice(-2).join("/")}`; +} + +// Unified diff support — detects and renders `git diff` output when the agent +// runs commands like `git diff` through the Bash tool and the result comes +// back as stdout rather than a structured tool content block. +type UnifiedDiffLine = + | { kind: "file"; text: string } + | { kind: "hunk"; text: string } + | { kind: "added"; text: string } + | { kind: "removed"; text: string } + | { kind: "context"; text: string } + | { kind: "meta"; text: string }; + +function looksLikeUnifiedDiff(text: string): boolean { + if (!text) return false; + if (/(^|\n)diff --git /.test(text)) return true; + return /(^|\n)--- /.test(text) && /(^|\n)\+\+\+ /.test(text); +} + +function extractDiffFromResult(result: unknown): string | null { + if (typeof result === "string") { + return looksLikeUnifiedDiff(result) ? result : null; + } + if (result && typeof result === "object") { + const obj = result as Record; + for (const key of ["stdout", "output", "text", "content"] as const) { + const value = obj[key]; + if (typeof value === "string" && looksLikeUnifiedDiff(value)) { + return value; + } + } + } + return null; +} + +function parseUnifiedDiff(text: string): UnifiedDiffLine[] { + const result: UnifiedDiffLine[] = []; + for (const line of text.split("\n")) { + if ( + line.startsWith("diff --git ") || + line.startsWith("--- ") || + line.startsWith("+++ ") || + line.startsWith("index ") || + line.startsWith("new file mode") || + line.startsWith("deleted file mode") || + line.startsWith("similarity index") || + line.startsWith("rename ") + ) { + result.push({ kind: "file", text: line }); + } else if (line.startsWith("@@")) { + result.push({ kind: "hunk", text: line }); + } else if (line.startsWith("+")) { + result.push({ kind: "added", text: line }); + } else if (line.startsWith("-")) { + result.push({ kind: "removed", text: line }); + } else if (line.startsWith(" ")) { + result.push({ kind: "context", text: line }); + } else { + result.push({ kind: "meta", text: line }); + } + } + return result; +} + +interface UnifiedDiffBlockProps { + diffText: string; + maxLines?: number; +} + +function UnifiedDiffBlock({ diffText, maxLines = 120 }: UnifiedDiffBlockProps) { + const allLines = parseUnifiedDiff(diffText); + const truncated = allLines.length > maxLines; + const lines = truncated ? allLines.slice(0, maxLines) : allLines; + + return ( + + {lines.map((line, i) => { + let cls = "font-mono text-[11px] leading-4 text-gray-11 px-2"; + if (line.kind === "file") { + cls += " text-gray-9"; + } else if (line.kind === "hunk") { + cls += " bg-accent-3 text-accent-11"; + } else if (line.kind === "added") { + cls += " bg-status-success/10 text-status-success"; + } else if (line.kind === "removed") { + cls += " bg-status-error/10 text-status-error"; + } else if (line.kind === "context") { + cls += " text-gray-11"; + } else { + cls += " text-gray-9"; + } + return ( + + {line.text || " "} + + ); + })} + {truncated && ( + + … {allLines.length - maxLines} more lines + + )} + + ); +} + +// LCS-based line diff: correctly identifies unchanged lines even when +// changes are scattered throughout the block, then collapses distant +// context into separators. +type DiffLine = + | { kind: "context"; text: string } + | { kind: "added"; text: string } + | { kind: "removed"; text: string } + | { kind: "separator" }; + +// O(n*m) LCS — fine for typical edit blocks (< 200 lines). +function lcsBacktrack(a: string[], b: string[]): DiffLine[] { + const m = a.length; + const n = b.length; + + // Build LCS table + const dp: number[][] = []; + for (let i = 0; i <= m; i++) { + dp[i] = new Array(n + 1).fill(0); + } + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + dp[i][j] = + a[i - 1] === b[j - 1] + ? dp[i - 1][j - 1] + 1 + : Math.max(dp[i - 1][j], dp[i][j - 1]); + } + } + + // Backtrack to produce diff + const result: DiffLine[] = []; + let i = m; + let j = n; + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) { + result.push({ kind: "context", text: a[i - 1] }); + i--; + j--; + } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { + result.push({ kind: "added", text: b[j - 1] }); + j--; + } else { + result.push({ kind: "removed", text: a[i - 1] }); + i--; + } + } + result.reverse(); + return result; +} + +// Collapse context lines far from changes into separators. +function collapseContext(lines: DiffLine[], contextLines: number): DiffLine[] { + // Mark which lines are near a change + const isChange = lines.map((l) => l.kind === "added" || l.kind === "removed"); + const nearChange = new Array(lines.length).fill(false); + for (let i = 0; i < lines.length; i++) { + if (isChange[i]) { + for ( + let k = Math.max(0, i - contextLines); + k <= Math.min(lines.length - 1, i + contextLines); + k++ + ) { + nearChange[k] = true; + } + } + } + + const result: DiffLine[] = []; + let inSkip = false; + for (let i = 0; i < lines.length; i++) { + if (nearChange[i] || isChange[i]) { + inSkip = false; + result.push(lines[i]); + } else if (!inSkip) { + inSkip = true; + result.push({ kind: "separator" }); + } + } + return result; +} + +function computeLineDiff( + oldText: string, + newText: string, + contextLines = 2, +): DiffLine[] { + const oldLines = oldText.length > 0 ? oldText.split("\n") : []; + const newLines = newText.length > 0 ? newText.split("\n") : []; + + if (oldLines.length === 0) { + return newLines.map((l) => ({ kind: "added" as const, text: l })); + } + if (newLines.length === 0) { + return oldLines.map((l) => ({ kind: "removed" as const, text: l })); + } + + const raw = lcsBacktrack(oldLines, newLines); + return collapseContext(raw, contextLines); +} + +interface DiffBlockProps { + oldText: string; + newText: string; + language?: string | null; + maxLines?: number; +} + +function HighlightedDiffLine({ + text, + language, + fallbackColor, +}: { + text: string; + language?: string | null; + fallbackColor: string; +}) { + const segments = useMemo( + () => (language ? highlightCode(text, language) : null), + [text, language], + ); + + if (!segments) { + return <>{text || " "}; + } + + return ( + <> + {segments.map((seg, i) => { + const color = getColorForClass(seg.className); + return ( + + {seg.text} + + ); + })} + + ); +} + +function DiffBlock({ + oldText, + newText, + language, + maxLines = 60, +}: DiffBlockProps) { + const themeColors = useThemeColors(); + const [expanded, setExpanded] = useState(false); + const allLines = computeLineDiff(oldText, newText); + const truncated = !expanded && allLines.length > maxLines; + const lines = truncated ? allLines.slice(0, maxLines) : allLines; + + return ( + + {lines.map((line, i) => { + const key = `${line.kind}-${i}`; + if (line.kind === "separator") { + return ( + + ··· + + ); + } + let cls = "font-mono text-[11px] leading-4 px-2"; + const fallbackColor = + line.kind === "added" + ? themeColors.status.success + : line.kind === "removed" + ? themeColors.status.error + : themeColors.gray[11]; + if (line.kind === "added") { + cls += " bg-status-success/10"; + } else if (line.kind === "removed") { + cls += " bg-status-error/10"; + } + const prefix = + line.kind === "added" ? "+ " : line.kind === "removed" ? "- " : " "; + return ( + + {prefix} + + + ); + })} + {truncated && ( + setExpanded(true)}> + + Show all {allLines.length} lines + + + )} + + ); +} + function CreateTaskPreview({ args, showAction, @@ -234,13 +764,131 @@ export function ToolMessage({ const isLoading = status === "pending" || status === "running"; const isFailed = status === "error"; - const hasDetails = args || result !== undefined; const displayTitle = formatToolTitle(toolName, args); const KindIcon = kind ? kindIcons[kind] : Wrench; const isCreateTask = toolName.toLowerCase() === "create_task" || kind === "create_task"; + // File-editing tools get a proper diff view using the rawInput we already + // receive on the wire. Detection is by shape, not tool name, so it works + // regardless of how the agent labels the tool. + const editArgs = asEditArgs(args); + const multiEditArgs = !editArgs ? asMultiEditArgs(args) : null; + const writeArgs = !editArgs && !multiEditArgs ? asWriteArgs(args) : null; + const fileToolArgs = editArgs ?? multiEditArgs ?? writeArgs; + + // Unified-diff-in-result: when the agent runs commands like `git diff` + // via the Bash tool, the result comes back as stdout containing a unified + // diff string. Detect that and render it as a real diff view. + const unifiedDiffText = !fileToolArgs ? extractDiffFromResult(result) : null; + + if (fileToolArgs && !isCreateTask) { + const stats = countDiffLines(editArgs, multiEditArgs, writeArgs); + const diffLanguage = languageFromPath(fileToolArgs.file_path); + // Collapse diffs for failed edits (retries make them noise) + const showDiff = !isFailed || isOpen; + + return ( + + {/* Header row */} + isFailed && setIsOpen(!isOpen)} + className="flex-row items-center gap-2" + disabled={!isFailed} + > + {isLoading ? ( + + ) : ( + + )} + + {shortenPath(fileToolArgs.file_path)} + + {stats.added > 0 && !isFailed && ( + + +{stats.added} + + )} + {stats.removed > 0 && !isFailed && ( + + -{stats.removed} + + )} + {diffLanguage && !isFailed && ( + + {diffLanguage} + + )} + {isFailed && ( + + Failed + + )} + + + {/* Diff content — collapsed when failed */} + {showDiff && ( + <> + {editArgs && ( + + )} + {multiEditArgs?.edits.map((edit, i) => ( + + ))} + {writeArgs && ( + + )} + + )} + + ); + } + + // Unified-diff-in-result renderer (e.g. `git diff` via Bash) + if (unifiedDiffText && !isCreateTask) { + return ( + + + {isLoading ? ( + + ) : ( + + )} + + {displayTitle} + + {isFailed && ( + (Failed) + )} + + + + ); + } + // For create_task, show rich preview instead of expandable if (isCreateTask && args) { return ( @@ -264,18 +912,169 @@ export function ToolMessage({ ); } + const resolvedKind = kind ?? deriveToolKind(toolName); + const isPending = status === "pending"; + const isRunning = status === "running"; + const isCompleted = status === "completed"; + const resultText = extractResultText(result); + + // Execute/Bash: show description + command subtitle + expandable output + if (resolvedKind === "execute") { + const command = typeof args?.command === "string" ? args.command : null; + const description = + typeof args?.description === "string" ? args.description : null; + const outputText = resultText ? stripAnsi(resultText) : null; + const hasOutput = outputText && outputText.trim().length > 0; + + return ( + + {/* Header */} + hasOutput && setIsOpen(!isOpen)} + className="flex-row items-center gap-2" + disabled={!hasOutput} + > + {isLoading ? ( + + ) : ( + + )} + + {description ?? displayTitle} + + {isFailed && ( + + Failed + + )} + + + {/* Command as subtitle line */} + {command && ( + + $ {command} + + )} + + {/* Output */} + {isOpen && hasOutput && ( + + + {outputText} + + + )} + + ); + } + + // Read: show file path, line range, and expandable content preview + if (resolvedKind === "read") { + // Try args first, then extract a path from the tool title (e.g. + // "Read `src/foo.ts`" or "Read 200 lines in `bar.ts`"). + const filePath = + typeof args?.file_path === "string" + ? args.file_path + : typeof args?.target_file === "string" + ? args.target_file + : extractPathFromTitle(toolName); + const hasContent = resultText && resultText.trim().length > 0; + const lineCount = hasContent ? resultText.split("\n").length : null; + const offset = typeof args?.offset === "number" ? args.offset : null; + const limit = typeof args?.limit === "number" ? args.limit : null; + const lineRange = offset + ? `lines ${offset}–${offset + (limit ?? lineCount ?? 0)}` + : lineCount + ? `${lineCount} lines` + : null; + + return ( + + hasContent && setIsOpen(!isOpen)} + className="flex-row items-center gap-2" + disabled={!hasContent} + > + {isLoading ? ( + + ) : ( + + )} + + Read + + {filePath ? ( + + {shortenPath(filePath, 36)} + + ) : null} + {lineRange && isCompleted && ( + + {lineRange} + + )} + {isFailed && ( + + Failed + + )} + + + {/* Content preview */} + {isOpen && hasContent && ( + + + {resultText} + + + )} + + ); + } + + // Default: all other tools (search, think, fetch, etc.) + const subtitle = getToolSubtitle(toolName, args); + return ( - - hasDetails && setIsOpen(!isOpen)} - className="flex-row items-center gap-2" - disabled={!hasDetails} - > + + {/* Status indicator */} {isLoading ? ( ) : ( - + )} {/* Tool name */} @@ -283,45 +1082,28 @@ export function ToolMessage({ {displayTitle} + {/* Queued label */} + {isPending && ( + Queued + )} + {/* Failed indicator */} {isFailed && ( - (Failed) + + Failed + )} - - - {/* Expanded content */} - {isOpen && hasDetails && ( - - {args && ( - - - Arguments - - - - {JSON.stringify(args, null, 2)} - - - - )} - {result !== undefined && ( - - - Result - - - - {typeof result === "string" - ? result - : JSON.stringify(result, null, 2)} - - - - )} - + + + {/* Contextual subtitle */} + {subtitle && !isPending && ( + + {subtitle} + )} ); diff --git a/apps/mobile/src/features/chat/hooks/useVoiceRecording.ts b/apps/mobile/src/features/chat/hooks/useVoiceRecording.ts index 5683fbe39..1708f0414 100644 --- a/apps/mobile/src/features/chat/hooks/useVoiceRecording.ts +++ b/apps/mobile/src/features/chat/hooks/useVoiceRecording.ts @@ -1,7 +1,8 @@ -import { Audio } from "expo-av"; -import { File } from "expo-file-system"; +import { ExpoSpeechRecognitionModule } from "expo-speech-recognition"; import { useCallback, useRef, useState } from "react"; -import { useAuthStore } from "@/features/auth"; +import { logger } from "@/lib/logger"; + +const log = logger.scope("voice-recording"); type RecordingStatus = "idle" | "recording" | "transcribing" | "error"; @@ -16,148 +17,142 @@ interface UseVoiceRecordingReturn { export function useVoiceRecording(): UseVoiceRecordingReturn { const [status, setStatus] = useState("idle"); const [error, setError] = useState(null); - const recordingRef = useRef(null); + const transcriptRef = useRef(""); + const resolveRef = useRef<((text: string | null) => void) | null>(null); + const listenersRef = useRef<(() => void)[]>([]); + + const cleanup = useCallback(() => { + for (const remove of listenersRef.current) { + remove(); + } + listenersRef.current = []; + resolveRef.current = null; + transcriptRef.current = ""; + }, []); const startRecording = useCallback(async () => { try { setError(null); + transcriptRef.current = ""; - // Request permissions - const { granted } = await Audio.requestPermissionsAsync(); - if (!granted) { - setError("Microphone permission is required"); + if (!ExpoSpeechRecognitionModule.isRecognitionAvailable()) { + setError("Speech recognition is not available on this device"); setStatus("error"); return; } - // Configure audio mode for recording - await Audio.setAudioModeAsync({ - allowsRecordingIOS: true, - playsInSilentModeIOS: true, - }); - - // Create and start recording - const recording = new Audio.Recording(); - await recording.prepareToRecordAsync( - Audio.RecordingOptionsPresets.HIGH_QUALITY, - ); - await recording.startAsync(); - recordingRef.current = recording; - setStatus("recording"); - } catch (err) { - console.error("Failed to start recording:", err); - setError("Failed to start recording"); - setStatus("error"); - } - }, []); - - const stopRecording = useCallback(async (): Promise => { - if (!recordingRef.current) { - return null; - } - - try { - setStatus("transcribing"); - - // Stop recording and get URI - await recordingRef.current.stopAndUnloadAsync(); - const uri = recordingRef.current.getURI(); - recordingRef.current = null; - - // Reset audio mode - await Audio.setAudioModeAsync({ - allowsRecordingIOS: false, - }); - - if (!uri) { - setError("No recording found"); + const { granted } = + await ExpoSpeechRecognitionModule.requestPermissionsAsync(); + if (!granted) { + setError("Speech recognition permission is required"); setStatus("error"); - return null; + return; } - const { - oauthAccessToken, - cloudRegion, - projectId, - getCloudUrlFromRegion, - } = useAuthStore.getState(); - - if (!oauthAccessToken || !cloudRegion || !projectId) { - setError("Not authenticated"); - setStatus("error"); - return null; - } + // Listen for results — accumulate the latest transcript + const resultSub = ExpoSpeechRecognitionModule.addListener( + "result", + (event) => { + const best = event.results[0]?.transcript; + if (best) { + transcriptRef.current = best; + } + if (event.isFinal && resolveRef.current) { + resolveRef.current(transcriptRef.current || null); + cleanup(); + setStatus("idle"); + } + }, + ); - const cloudUrl = getCloudUrlFromRegion(cloudRegion); - - // Create form data with the recording file - const formData = new FormData(); - formData.append("file", { - uri, - type: "audio/mp4", - name: "recording.m4a", - } as unknown as Blob); - - // Call PostHog LLM Gateway transcription API - const response = await fetch( - `${cloudUrl}/api/projects/${projectId}/llm_gateway/v1/audio/transcriptions`, - { - method: "POST", - headers: { - Authorization: `Bearer ${oauthAccessToken}`, - }, - body: formData, + const errorSub = ExpoSpeechRecognitionModule.addListener( + "error", + (event) => { + // "no-speech" is not a real error — just means the user didn't say anything + if (event.error === "no-speech") { + if (resolveRef.current) { + resolveRef.current(null); + } + cleanup(); + setStatus("idle"); + return; + } + setError(event.message || "Speech recognition failed"); + if (resolveRef.current) { + resolveRef.current(null); + } + cleanup(); + setStatus("error"); }, ); - // Clean up the temp file - const recordingFile = new File(uri); - if (recordingFile.exists) { - await recordingFile.delete(); - } + // If recognition ends without a final result (e.g. silence timeout) + const endSub = ExpoSpeechRecognitionModule.addListener("end", () => { + if (resolveRef.current) { + resolveRef.current(transcriptRef.current || null); + cleanup(); + setStatus("idle"); + } + }); - if (!response.ok) { - const errorData = await response.text(); - throw new Error(`Transcription failed: ${errorData}`); - } + listenersRef.current = [ + () => resultSub.remove(), + () => errorSub.remove(), + () => endSub.remove(), + ]; + + const useOnDevice = + ExpoSpeechRecognitionModule.supportsOnDeviceRecognition(); - const data = await response.json(); - setStatus("idle"); - return data.text; + ExpoSpeechRecognitionModule.start({ + lang: "en-US", + interimResults: true, + requiresOnDeviceRecognition: useOnDevice, + addsPunctuation: true, + }); + + setStatus("recording"); } catch (err) { - console.error("Failed to transcribe:", err); - const errorMessage = - err instanceof Error ? err.message : "Transcription failed"; - setError(errorMessage); + log.error("Failed to start speech recognition", err); + setError("Failed to start speech recognition"); setStatus("error"); - return null; } - }, []); + }, [cleanup]); - const cancelRecording = useCallback(async () => { - if (recordingRef.current) { - try { - await recordingRef.current.stopAndUnloadAsync(); - const uri = recordingRef.current.getURI(); - if (uri) { - const file = new File(uri); - if (file.exists) { - await file.delete(); - } - } - } catch { - // Ignore cleanup errors - } - recordingRef.current = null; + const stopRecording = useCallback(async (): Promise => { + if (status !== "recording") { + return null; } - await Audio.setAudioModeAsync({ - allowsRecordingIOS: false, + setStatus("transcribing"); + + return new Promise((resolve) => { + // Some Android engines go silent (e.g. backgrounded mid-recognition) + // and never fire result/error/end — without this timeout the UI + // would stay stuck on "Transcribing…" with no way out. + let timedOut = false; + const timeoutId = setTimeout(() => { + timedOut = true; + log.warn("Speech recognition did not finalize, falling back"); + cleanup(); + setStatus("idle"); + resolve(transcriptRef.current || null); + }, 5000); + resolveRef.current = (value) => { + if (timedOut) return; + clearTimeout(timeoutId); + resolve(value); + }; + ExpoSpeechRecognitionModule.stop(); }); + }, [status, cleanup]); + const cancelRecording = useCallback(async () => { + ExpoSpeechRecognitionModule.abort(); + cleanup(); setStatus("idle"); setError(null); - }, []); + }, [cleanup]); return { status, diff --git a/apps/mobile/src/features/chat/index.ts b/apps/mobile/src/features/chat/index.ts index 3ca5c7509..cc4d7c8d1 100644 --- a/apps/mobile/src/features/chat/index.ts +++ b/apps/mobile/src/features/chat/index.ts @@ -11,7 +11,7 @@ export type { ToolMessageProps, ToolStatus, } from "./components/ToolMessage"; -export { ToolMessage } from "./components/ToolMessage"; +export { deriveToolKind, ToolMessage } from "./components/ToolMessage"; export { VisualizationArtifact } from "./components/VisualizationArtifact"; // Hooks diff --git a/apps/mobile/src/features/preferences/stores/preferencesStore.ts b/apps/mobile/src/features/preferences/stores/preferencesStore.ts new file mode 100644 index 000000000..1e44c1cce --- /dev/null +++ b/apps/mobile/src/features/preferences/stores/preferencesStore.ts @@ -0,0 +1,29 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +interface PreferencesState { + aiChatEnabled: boolean; + setAiChatEnabled: (enabled: boolean) => void; + pingsEnabled: boolean; + setPingsEnabled: (enabled: boolean) => void; +} + +export const usePreferencesStore = create()( + persist( + (set) => ({ + aiChatEnabled: false, + setAiChatEnabled: (enabled) => set({ aiChatEnabled: enabled }), + pingsEnabled: true, + setPingsEnabled: (enabled) => set({ pingsEnabled: enabled }), + }), + { + name: "posthog-preferences", + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => ({ + aiChatEnabled: state.aiChatEnabled, + pingsEnabled: state.pingsEnabled, + }), + }, + ), +); diff --git a/apps/mobile/src/features/tasks/api.ts b/apps/mobile/src/features/tasks/api.ts index 10f023349..e86fcdd5c 100644 --- a/apps/mobile/src/features/tasks/api.ts +++ b/apps/mobile/src/features/tasks/api.ts @@ -1,5 +1,6 @@ import { fetch } from "expo/fetch"; import { getBaseUrl, getHeaders, getProjectId } from "@/lib/api"; +import { logger } from "@/lib/logger"; import type { CreateTaskOptions, Integration, @@ -8,6 +9,18 @@ import type { TaskRun, } from "./types"; +const log = logger.scope("tasks-api"); + +export class HttpError extends Error { + readonly status: number; + + constructor(status: number, statusText: string, prefix: string) { + super(`${prefix}: ${status} ${statusText}`); + this.name = "HttpError"; + this.status = status; + } +} + async function withRetry( fn: () => Promise, options: { @@ -37,9 +50,15 @@ async function withRetry( } function isRetryableError(error: unknown): boolean { + if ( + error instanceof Error && + "status" in error && + typeof error.status === "number" + ) { + return error.status >= 500 && error.status < 600; + } if (error instanceof Error) { const message = error.message.toLowerCase(); - if (/\b5\d{2}\b/.test(message)) return true; if (message.includes("network")) return true; if (message.includes("timeout")) return true; if (message.includes("econnreset")) return true; @@ -69,7 +88,11 @@ export async function getTasks(filters?: { ); if (!response.ok) { - throw new Error(`Failed to fetch tasks: ${response.statusText}`); + throw new HttpError( + response.status, + response.statusText, + "Failed to fetch tasks", + ); } const data = await response.json(); @@ -87,7 +110,11 @@ export async function getTask(taskId: string): Promise { ); if (!response.ok) { - throw new Error(`Failed to fetch task: ${response.statusText}`); + throw new HttpError( + response.status, + response.statusText, + "Failed to fetch task", + ); } return await response.json(); @@ -109,9 +136,11 @@ export async function createTask(options: CreateTaskOptions): Promise { if (!response.ok) { const errorText = await response.text(); - console.error("Create task error:", errorText); - throw new Error( - `Failed to create task: ${response.statusText} - ${errorText}`, + log.error("Create task error", errorText); + throw new HttpError( + response.status, + `${response.statusText} - ${errorText}`, + "Failed to create task", ); } @@ -136,7 +165,11 @@ export async function updateTask( ); if (!response.ok) { - throw new Error(`Failed to update task: ${response.statusText}`); + throw new HttpError( + response.status, + response.statusText, + "Failed to update task", + ); } return await response.json(); @@ -156,25 +189,69 @@ export async function deleteTask(taskId: string): Promise { ); if (!response.ok) { - throw new Error(`Failed to delete task: ${response.statusText}`); + throw new HttpError( + response.status, + response.statusText, + "Failed to delete task", + ); } } -export async function runTaskInCloud(taskId: string): Promise { +export interface RunTaskInCloudOptions { + branch?: string | null; + resumeFromRunId?: string; + pendingUserMessage?: string; + mode?: "interactive" | "background"; +} + +export async function runTaskInCloud( + taskId: string, + options?: RunTaskInCloudOptions, +): Promise { const baseUrl = getBaseUrl(); const projectId = getProjectId(); const headers = getHeaders(); + // Only serialize a body when we have options to send. Sending an empty + // or minimal body on the initial run historically changed backend + // behavior, so we preserve the "no body" path for the common case. + const hasOptions = + !!options && + (options.branch !== undefined || + options.resumeFromRunId !== undefined || + options.pendingUserMessage !== undefined || + options.mode !== undefined); + + let body: string | undefined; + if (hasOptions) { + const payload: Record = { + mode: options?.mode ?? "interactive", + }; + if (options?.branch) payload.branch = options.branch; + if (options?.resumeFromRunId) { + payload.resume_from_run_id = options.resumeFromRunId; + } + if (options?.pendingUserMessage) { + payload.pending_user_message = options.pendingUserMessage; + } + body = JSON.stringify(payload); + } + const response = await fetch( `${baseUrl}/api/projects/${projectId}/tasks/${taskId}/run/`, { method: "POST", headers, + body, }, ); if (!response.ok) { - throw new Error(`Failed to run task: ${response.statusText}`); + throw new HttpError( + response.status, + response.statusText, + "Failed to run task", + ); } return await response.json(); @@ -194,7 +271,11 @@ export async function getTaskRun( ); if (!response.ok) { - throw new Error(`Failed to fetch task run: ${response.statusText}`); + throw new HttpError( + response.status, + response.statusText, + "Failed to fetch task run", + ); } return await response.json(); @@ -221,23 +302,134 @@ export async function appendTaskRunLog( ); if (!response.ok) { - throw new Error(`Failed to append log: ${response.statusText}`); + throw new HttpError( + response.status, + response.statusText, + "Failed to append log", + ); } }, { shouldRetry: isRetryableError }, ); } +/** + * Structured error thrown by `sendCloudCommand`. Exposes the HTTP status and + * the backend error payload so callers can branch on specific failure modes + * (e.g. "No active sandbox for this task run" → trigger a resume flow). + */ +export class CloudCommandError extends Error { + readonly status: number; + readonly backendError: string | null; + readonly method: string; + + constructor( + method: string, + status: number, + backendError: string | null, + message: string, + ) { + super(message); + this.name = "CloudCommandError"; + this.method = method; + this.status = status; + this.backendError = backendError; + } + + /** True when the cloud sandbox for this run has terminated. */ + isSandboxInactive(): boolean { + return ( + !!this.backendError?.includes("No active sandbox") || + !!this.backendError?.includes("returned 404") || + this.status === 404 + ); + } +} + +/** + * Sends a JSON-RPC command to a running cloud task. This is the correct path + * for delivering follow-up user prompts to the agent — it gets translated into + * `session/prompt` on the agent side. Note: `appendTaskRunLog` only writes to + * S3 for display; it does NOT notify the agent. + */ +export async function sendCloudCommand( + taskId: string, + runId: string, + method: string, + params: Record = {}, +): Promise { + const baseUrl = getBaseUrl(); + const projectId = getProjectId(); + const headers = getHeaders(); + + const body = { + jsonrpc: "2.0", + method, + params, + id: `posthog-mobile-${Date.now()}`, + }; + + const response = await fetch( + `${baseUrl}/api/projects/${projectId}/tasks/${taskId}/runs/${runId}/command/`, + { + method: "POST", + headers, + body: JSON.stringify(body), + }, + ); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + let backendError: string | null = null; + try { + const parsed = JSON.parse(text); + backendError = + typeof parsed?.error === "string" + ? parsed.error + : (parsed?.error?.message ?? null); + } catch { + backendError = text || null; + } + throw new CloudCommandError( + method, + response.status, + backendError, + `Cloud command '${method}' failed: ${response.status} ${response.statusText} ${text}`, + ); + } + + const data = await response.json(); + if (data?.error) { + const message = + typeof data.error === "string" + ? data.error + : (data.error.message ?? JSON.stringify(data.error)); + throw new CloudCommandError( + method, + 200, + message, + `Cloud command '${method}' error: ${message}`, + ); + } + return data?.result; +} + export async function fetchS3Logs(logUrl: string): Promise { return withRetry( async () => { - const response = await fetch(logUrl); + const response = await fetch(logUrl, { + signal: AbortSignal.timeout(10_000), + }); if (!response.ok) { if (response.status === 404) { return ""; } - throw new Error(`Failed to fetch logs: ${response.statusText}`); + throw new HttpError( + response.status, + response.statusText, + "Failed to fetch logs", + ); } return await response.text(); @@ -257,7 +449,11 @@ export async function getIntegrations(): Promise { ); if (!response.ok) { - throw new Error(`Failed to fetch integrations: ${response.statusText}`); + throw new HttpError( + response.status, + response.statusText, + "Failed to fetch integrations", + ); } const data = await response.json(); @@ -277,21 +473,21 @@ export async function getGithubRepositories( ); if (!response.ok) { - throw new Error(`Failed to fetch repositories: ${response.statusText}`); + throw new HttpError( + response.status, + response.statusText, + "Failed to fetch repositories", + ); } const data = await response.json(); - - const integrations = await getIntegrations(); - const integration = integrations.find((i) => i.id === integrationId); - const organization = - integration?.display_name || - integration?.config?.account?.login || - "unknown"; - - const repoNames = data.repositories ?? data.results ?? data ?? []; - return repoNames.map( - (repoName: string) => - `${organization.toLowerCase()}/${repoName.toLowerCase()}`, - ); + const repos: Array = + data.repositories ?? data.results ?? data ?? []; + + return repos + .map((repo) => { + if (typeof repo === "string") return repo.toLowerCase(); + return (repo.full_name ?? repo.name ?? "").toLowerCase(); + }) + .filter((name) => name.length > 0); } diff --git a/apps/mobile/src/features/tasks/components/PlanStatusBar.tsx b/apps/mobile/src/features/tasks/components/PlanStatusBar.tsx new file mode 100644 index 000000000..1db236aaa --- /dev/null +++ b/apps/mobile/src/features/tasks/components/PlanStatusBar.tsx @@ -0,0 +1,88 @@ +import { CheckCircle, CircleDashed, XCircle } from "phosphor-react-native"; +import { useMemo, useState } from "react"; +import { ActivityIndicator, Pressable, Text, View } from "react-native"; +import { useThemeColors } from "@/lib/theme"; +import type { PlanEntry } from "../types"; + +interface PlanStatusBarProps { + plan: PlanEntry[] | null; +} + +function StatusIcon({ status }: { status: string }) { + const themeColors = useThemeColors(); + + switch (status) { + case "completed": + return ; + case "in_progress": + return ; + case "failed": + return ; + default: + return ; + } +} + +export function PlanStatusBar({ plan }: PlanStatusBarProps) { + const [isExpanded, setIsExpanded] = useState(false); + const themeColors = useThemeColors(); + + const stats = useMemo(() => { + if (!plan?.length) return null; + + const completed = plan.filter((e) => e.status === "completed").length; + const total = plan.length; + const inProgress = plan.find((e) => e.status === "in_progress"); + const allCompleted = completed === total; + + return { completed, total, inProgress, allCompleted }; + }, [plan]); + + if (!stats || stats.allCompleted) return null; + + return ( + + setIsExpanded(!isExpanded)} + className="flex-row items-center gap-2 px-4 py-2.5" + > + + {stats.completed}/{stats.total} completed + + {stats.inProgress && ( + <> + · + + + {stats.inProgress.content} + + + )} + + + {isExpanded && plan && ( + + {plan.map((entry, index) => ( + + + + {entry.content} + + + ))} + + )} + + ); +} diff --git a/apps/mobile/src/features/tasks/components/QuestionCard.tsx b/apps/mobile/src/features/tasks/components/QuestionCard.tsx new file mode 100644 index 000000000..6cca5c807 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/QuestionCard.tsx @@ -0,0 +1,378 @@ +import { + ChatCircle, + CheckCircle, + CircleDashed, + RadioButton, +} from "phosphor-react-native"; +import { useState } from "react"; +import { + Pressable, + Text, + TextInput, + TouchableOpacity, + View, +} from "react-native"; +import type { ToolStatus } from "@/features/chat"; +import { useThemeColors } from "@/lib/theme"; + +interface QuestionOption { + label: string; + description?: string; +} + +interface QuestionItem { + question: string; + header?: string; + options: QuestionOption[]; + multiSelect?: boolean; +} + +interface ToolData { + toolName: string; + toolCallId: string; + status: ToolStatus; + args?: Record; + result?: unknown; +} + +interface PermissionResponseArgs { + toolCallId: string; + optionId: string; + answers?: Record; + customInput?: string; + displayText: string; +} + +interface QuestionCardProps { + toolData: ToolData; + onSendPermissionResponse?: (args: PermissionResponseArgs) => void; +} + +function extractQuestions(args?: Record): QuestionItem[] { + if (!args) return []; + // Questions may be at top level or nested under input + const raw = + args.questions ?? (args.input as Record)?.questions; + if (!Array.isArray(raw)) return []; + return raw.filter( + (q): q is QuestionItem => + q != null && + typeof q === "object" && + typeof (q as QuestionItem).question === "string" && + Array.isArray((q as QuestionItem).options), + ); +} + +function extractAnswer(result: unknown): string | null { + if (typeof result === "string") return result; + if (result && typeof result === "object") { + const obj = result as Record; + if (typeof obj.answer === "string") return obj.answer; + if (typeof obj.answers === "object" && obj.answers) { + const answers = obj.answers as Record; + return Object.values(answers).join(", "); + } + if (typeof obj.text === "string") return obj.text; + if (typeof obj.content === "string") return obj.content; + } + return null; +} + +export function QuestionCard({ + toolData, + onSendPermissionResponse, +}: QuestionCardProps) { + const themeColors = useThemeColors(); + const questions = extractQuestions(toolData.args); + const isCompleted = + toolData.status === "completed" || toolData.status === "error"; + + if (questions.length === 0) { + return null; + } + + if (isCompleted) { + const answer = extractAnswer(toolData.result); + return ( + + + + + {questions[0]?.header ?? "Question"} + + + + + {questions[0]?.question} + + {answer && ( + + + + {answer} + + + )} + + + ); + } + + return ( + + ); +} + +function InteractiveQuestion({ + questions, + toolCallId, + onSendPermissionResponse, +}: { + questions: QuestionItem[]; + toolCallId: string; + onSendPermissionResponse?: (args: PermissionResponseArgs) => void; +}) { + const themeColors = useThemeColors(); + const [currentIndex, setCurrentIndex] = useState(0); + const [selectedOptions, setSelectedOptions] = useState< + Map> + >(new Map()); + const [otherTexts, setOtherTexts] = useState>(new Map()); + const [showOtherInput, setShowOtherInput] = useState>( + new Map(), + ); + + const question = questions[currentIndex]; + if (!question) return null; + + const isMultiSelect = question.multiSelect ?? false; + const isLastQuestion = currentIndex === questions.length - 1; + const selected = selectedOptions.get(currentIndex) ?? new Set(); + const otherText = otherTexts.get(currentIndex) ?? ""; + const isOtherShown = showOtherInput.get(currentIndex) ?? false; + const hasSelection = selected.size > 0 || otherText.trim().length > 0; + + const toggleOption = (label: string) => { + const newSelected = new Map(selectedOptions); + const current = new Set(selected); + + if (isMultiSelect) { + if (current.has(label)) { + current.delete(label); + } else { + current.add(label); + } + } else { + if (current.has(label)) { + current.clear(); + } else { + current.clear(); + current.add(label); + } + // Clear "Other" when selecting a preset option + const newOther = new Map(showOtherInput); + newOther.set(currentIndex, false); + setShowOtherInput(newOther); + const newTexts = new Map(otherTexts); + newTexts.set(currentIndex, ""); + setOtherTexts(newTexts); + } + + newSelected.set(currentIndex, current); + setSelectedOptions(newSelected); + }; + + const toggleOther = () => { + const newOther = new Map(showOtherInput); + const isNowShown = !isOtherShown; + newOther.set(currentIndex, isNowShown); + setShowOtherInput(newOther); + + if (!isMultiSelect && isNowShown) { + // Clear preset selections when choosing "Other" in single-select + const newSelected = new Map(selectedOptions); + newSelected.set(currentIndex, new Set()); + setSelectedOptions(newSelected); + } + }; + + const handleSubmit = () => { + const parts: string[] = []; + for (const label of selected) { + parts.push(label); + } + const trimmedOther = otherText.trim(); + if (trimmedOther) { + parts.push(trimmedOther); + } + const answer = parts.join(", "); + + if (!isLastQuestion) { + setCurrentIndex(currentIndex + 1); + return; + } + + if (!answer || !onSendPermissionResponse) return; + + // Derive the ACP optionId the agent is expecting. Options are built + // server-side (buildQuestionOptions in packages/agent) as + // `${OPTION_PREFIX}${idx}` where OPTION_PREFIX is "option_". If the + // user only typed into "Other", fall back to option_0 — the answers + // map carries the actual content for the agent. + const firstSelectedLabel = parts[0]; + const selectedIdx = question.options.findIndex( + (o) => o.label === firstSelectedLabel, + ); + const optionIdx = selectedIdx >= 0 ? selectedIdx : 0; + const optionId = `option_${optionIdx}`; + + onSendPermissionResponse({ + toolCallId, + optionId, + answers: { [question.question]: answer }, + customInput: trimmedOther || undefined, + displayText: answer, + }); + }; + + return ( + + {/* Header */} + + + + {question.header ?? "Question"} + + {questions.length > 1 && ( + + {currentIndex + 1}/{questions.length} + + )} + + + {/* Question text */} + + + {question.question} + + + + {/* Options */} + + {question.options.map((option) => { + const isSelected = selected.has(option.label); + return ( + toggleOption(option.label)} + className={`mb-1.5 rounded-lg border px-3 py-2.5 ${ + isSelected + ? "border-accent-8 bg-accent-3" + : "border-gray-6 bg-gray-3" + }`} + > + + {isMultiSelect ? ( + isSelected ? ( + + ) : ( + + ) + ) : isSelected ? ( + + ) : ( + + )} + + {option.label} + + + {option.description && ( + + {option.description} + + )} + + ); + })} + + {/* Other option */} + + + Other... + + + + {isOtherShown && ( + { + const newTexts = new Map(otherTexts); + newTexts.set(currentIndex, text); + setOtherTexts(newTexts); + }} + multiline + autoFocus + /> + )} + + + {/* Submit */} + + + + {isLastQuestion ? "Submit" : "Next"} + + + + + ); +} diff --git a/apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx b/apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx new file mode 100644 index 000000000..244c2dfb1 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx @@ -0,0 +1,160 @@ +import * as Haptics from "expo-haptics"; +import { Archive, ArrowCounterClockwise } from "phosphor-react-native"; +import { useEffect, useRef } from "react"; +import { + Animated, + Easing, + LayoutAnimation, + PanResponder, + Text, + View, +} from "react-native"; +import { useThemeColors } from "@/lib/theme"; +import type { Task } from "../types"; +import { TaskItem } from "./TaskItem"; + +const SWIPE_THRESHOLD = 60; + +interface SwipeableTaskItemProps { + task: Task; + isArchived: boolean; + onPress: (task: Task) => void; + onArchive: (taskId: string) => void; + onUnarchive: (taskId: string) => void; + onSwipeStart?: () => void; + onSwipeEnd?: () => void; +} + +export function SwipeableTaskItem({ + task, + isArchived, + onPress, + onArchive, + onUnarchive, + onSwipeStart, + onSwipeEnd, +}: SwipeableTaskItemProps) { + const themeColors = useThemeColors(); + const translateX = useRef(new Animated.Value(0)).current; + const actionTriggeredRef = useRef(false); + + // PanResponder.create runs once per mount, so its callbacks close over the + // *initial* prop values. Route through a ref so props stay current without + // rebuilding the responder. + const propsRef = useRef({ + task, + isArchived, + onArchive, + onUnarchive, + onSwipeStart, + onSwipeEnd, + }); + propsRef.current = { + task, + isArchived, + onArchive, + onUnarchive, + onSwipeStart, + onSwipeEnd, + }; + + // Reset position when the item reappears (e.g. moved between sections) + useEffect(() => { + translateX.setValue(0); + actionTriggeredRef.current = false; + }, [translateX]); + + const panResponder = useRef( + PanResponder.create({ + // Start tracking immediately on horizontal movement + onStartShouldSetPanResponder: () => false, + onMoveShouldSetPanResponder: (_, gesture) => + Math.abs(gesture.dx) > 5 && + Math.abs(gesture.dx) > Math.abs(gesture.dy) && + gesture.dx < 0, + // Capture before children so FlatList doesn't steal + onMoveShouldSetPanResponderCapture: (_, gesture) => + Math.abs(gesture.dx) > 8 && + Math.abs(gesture.dx) > Math.abs(gesture.dy * 1.2) && + gesture.dx < 0, + // Never let go once we have the gesture + onPanResponderTerminationRequest: () => false, + onShouldBlockNativeResponder: () => true, + onPanResponderGrant: () => { + actionTriggeredRef.current = false; + propsRef.current.onSwipeStart?.(); + }, + onPanResponderMove: (_, gesture) => { + // Clamp to left-only swipe + translateX.setValue(gesture.dx > 0 ? 0 : gesture.dx); + }, + onPanResponderRelease: (_, gesture) => { + const p = propsRef.current; + p.onSwipeEnd?.(); + if (gesture.dx < -SWIPE_THRESHOLD && !actionTriggeredRef.current) { + actionTriggeredRef.current = true; + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + Animated.timing(translateX, { + toValue: -400, + duration: 150, + easing: Easing.in(Easing.ease), + useNativeDriver: true, + }).start(() => { + LayoutAnimation.configureNext( + LayoutAnimation.Presets.easeInEaseOut, + ); + if (p.isArchived) { + p.onUnarchive(p.task.id); + } else { + p.onArchive(p.task.id); + } + }); + } else { + Animated.spring(translateX, { + toValue: 0, + useNativeDriver: true, + tension: 40, + friction: 8, + }).start(); + } + }, + onPanResponderTerminate: () => { + propsRef.current.onSwipeEnd?.(); + Animated.spring(translateX, { + toValue: 0, + useNativeDriver: true, + }).start(); + }, + }), + ).current; + + const actionBg = isArchived ? themeColors.accent[9] : themeColors.gray[8]; + const ActionIcon = isArchived ? ArrowCounterClockwise : Archive; + const actionLabel = isArchived ? "Restore" : "Archive"; + + return ( + + {/* Action revealed behind the row */} + + + + {actionLabel} + + + + {/* Sliding task row */} + + + + + ); +} diff --git a/apps/mobile/src/features/tasks/components/TaskItem.tsx b/apps/mobile/src/features/tasks/components/TaskItem.tsx index 6b92195d7..e067abda7 100644 --- a/apps/mobile/src/features/tasks/components/TaskItem.tsx +++ b/apps/mobile/src/features/tasks/components/TaskItem.tsx @@ -36,7 +36,7 @@ function TaskItemComponent({ task, onPress }: TaskItemProps) { const prUrl = task.latest_run?.output?.pr_url as string | undefined; const hasPR = !!prUrl; const status = hasPR ? "completed" : task.latest_run?.status || "backlog"; - const isCloudTask = task.latest_run?.environment === "cloud"; + const environment = task.latest_run?.environment; const statusColors = statusColorMap[status] || statusColorMap.backlog; @@ -56,10 +56,15 @@ function TaskItemComponent({ task, onPress }: TaskItemProps) { - {/* Cloud indicator */} - {isCloudTask && ( - - ☁️ + {/* Environment badge */} + {environment === "cloud" && ( + + Cloud + + )} + {environment === "local" && ( + + Local )} diff --git a/apps/mobile/src/features/tasks/components/TaskList.tsx b/apps/mobile/src/features/tasks/components/TaskList.tsx index 88e91a10f..188714b6e 100644 --- a/apps/mobile/src/features/tasks/components/TaskList.tsx +++ b/apps/mobile/src/features/tasks/components/TaskList.tsx @@ -1,5 +1,7 @@ import { Text } from "@components/text"; import * as WebBrowser from "expo-web-browser"; +import { CaretRight } from "phosphor-react-native"; +import { useMemo, useState } from "react"; import { ActivityIndicator, FlatList, @@ -11,8 +13,9 @@ import { useAuthStore } from "@/features/auth"; import { useThemeColors } from "@/lib/theme"; import { useIntegrations } from "../hooks/useIntegrations"; import { useTasks } from "../hooks/useTasks"; +import { useArchivedTasksStore } from "../stores/archivedTasksStore"; import type { Task } from "../types"; -import { TaskItem } from "./TaskItem"; +import { SwipeableTaskItem } from "./SwipeableTaskItem"; interface TaskListProps { onTaskPress?: (taskId: string) => void; @@ -108,11 +111,18 @@ function CreateTaskEmptyState({ onCreateTask }: CreateTaskEmptyStateProps) { ); } +type ListItem = + | { type: "task"; task: Task; isArchived: boolean } + | { type: "archived-header"; count: number; expanded: boolean }; + export function TaskList({ onTaskPress, onCreateTask }: TaskListProps) { const { tasks, isLoading, error, refetch } = useTasks(); const { hasGithubIntegration, refetch: refetchIntegrations } = useIntegrations(); const themeColors = useThemeColors(); + const { archivedTasks, archive, unarchive } = useArchivedTasksStore(); + const [archivedExpanded, setArchivedExpanded] = useState(false); + const [scrollEnabled, setScrollEnabled] = useState(true); const handleTaskPress = (task: Task) => { onTaskPress?.(task.id); @@ -122,6 +132,46 @@ export function TaskList({ onTaskPress, onCreateTask }: TaskListProps) { await Promise.all([refetch(), refetchIntegrations()]); }; + const listItems = useMemo((): ListItem[] => { + const active: Task[] = []; + const archived: Task[] = []; + + for (const task of tasks) { + if (task.id in archivedTasks) { + archived.push(task); + } else { + active.push(task); + } + } + + // Sort archived by FIFO (earliest archived first) + archived.sort( + (a, b) => (archivedTasks[a.id] ?? 0) - (archivedTasks[b.id] ?? 0), + ); + + const items: ListItem[] = active.map((task) => ({ + type: "task", + task, + isArchived: false, + })); + + if (archived.length > 0) { + items.push({ + type: "archived-header", + count: archived.length, + expanded: archivedExpanded, + }); + + if (archivedExpanded) { + for (const task of archived) { + items.push({ type: "task", task, isArchived: true }); + } + } + } + + return items; + }, [tasks, archivedTasks, archivedExpanded]); + if (error) { return ( @@ -160,14 +210,49 @@ export function TaskList({ onTaskPress, onCreateTask }: TaskListProps) { return ; } - // Has tasks - show the list (regardless of GitHub connection status) return ( item.id} - renderItem={({ item }) => ( - - )} + scrollEnabled={scrollEnabled} + data={listItems} + keyExtractor={(item) => + item.type === "archived-header" + ? "__archived_header__" + : `${item.task.id}-${item.isArchived ? "a" : "v"}` + } + renderItem={({ item }) => { + if (item.type === "archived-header") { + return ( + setArchivedExpanded(!item.expanded)} + className="flex-row items-center gap-2 border-gray-6 border-t bg-gray-2 px-3 py-2.5" + > + + + Archived + + {item.count} + + ); + } + + return ( + setScrollEnabled(false)} + onSwipeEnd={() => setScrollEnabled(true)} + /> + ); + }} refreshControl={ ; + customInput?: string; + displayText: string; +} interface TaskSessionViewProps { events: SessionEvent[]; - isPromptPending: boolean; + isConnecting?: boolean; + isThinking?: boolean; + terminalStatus?: "failed" | "completed"; + lastError?: string | null; + onRetry?: () => void; onOpenTask?: (taskId: string) => void; + onSendPermissionResponse?: (args: PermissionResponseArgs) => void; contentContainerStyle?: object; } @@ -22,13 +51,17 @@ interface ToolData { status: ToolStatus; args?: Record; result?: unknown; + isAgent?: boolean; + parentToolCallId?: string; } interface ParsedMessage { id: string; - type: "user" | "agent" | "tool"; + type: "user" | "agent" | "thought" | "tool" | "connecting" | "thinking"; content: string; + ts?: number; toolData?: ToolData; + children?: ParsedMessage[]; } function mapToolStatus( @@ -48,11 +81,14 @@ function mapToolStatus( } } -function parseSessionNotification(notification: SessionNotification): { - type: "user" | "agent" | "tool" | "tool_update"; - content?: string; - toolData?: ToolData; -} | null { +type ParsedNotification = + | { type: "user" | "agent" | "agent_complete" | "thought"; content: string } + | { type: "tool" | "tool_update"; toolData: ToolData } + | { type: "plan"; entries: PlanEntry[] }; + +function parseSessionNotification( + notification: SessionNotification, +): ParsedNotification | null { const { update } = notification; if (!update?.sessionUpdate) { return null; @@ -70,7 +106,27 @@ function parseSessionNotification(notification: SessionNotification): { } return null; } + // `agent_message` is the aggregated final message emitted by the server + // once a response is complete. If we already received streaming chunks, + // this is a duplicate — replace pending text instead of appending. + case "agent_message": { + if (update.content?.type === "text") { + return { + type: "agent_complete" as const, + content: update.content.text, + }; + } + return null; + } + case "agent_thought_chunk": { + if (update.content?.type === "text") { + return { type: "thought", content: update.content.text }; + } + return null; + } case "tool_call": { + const meta = update._meta?.claudeCode; + const isAgent = meta?.toolName === "Agent" || meta?.toolName === "Task"; return { type: "tool", toolData: { @@ -78,10 +134,13 @@ function parseSessionNotification(notification: SessionNotification): { toolCallId: update.toolCallId ?? "", status: mapToolStatus(update.status), args: update.rawInput, + isAgent, + parentToolCallId: meta?.parentToolCallId, }, }; } case "tool_call_update": { + const meta = update._meta?.claudeCode; return { type: "tool_update", toolData: { @@ -90,31 +149,127 @@ function parseSessionNotification(notification: SessionNotification): { status: mapToolStatus(update.status), args: update.rawInput, result: update.rawOutput, + parentToolCallId: meta?.parentToolCallId, }, }; } + case "plan": { + if (Array.isArray(update.entries)) { + return { type: "plan", entries: update.entries }; + } + return null; + } default: return null; } } -function processEvents(events: SessionEvent[]): ParsedMessage[] { - const messages: ParsedMessage[] = []; - let pendingAgentText = ""; - let agentMessageCount = 0; - const toolMessages = new Map(); +interface ProcessedEvents { + messages: ParsedMessage[]; + plan: PlanEntry[] | null; +} + +function isQuestionTool(toolData?: ToolData): boolean { + if (!toolData) return false; + if (toolData.toolName.toLowerCase().includes("question")) return true; + if (Array.isArray(toolData.args?.questions)) return true; + return false; +} + +// Mutable processor state persisted across renders via useRef. +// Only new events (past processedIdx) are processed on each call. +interface EventProcessorState { + messages: ParsedMessage[]; + plan: PlanEntry[] | null; + pendingAgentText: string; + pendingAgentTs?: number; + pendingThoughtText: string; + lastAgentMsgIdx: number | null; + agentMessageCount: number; + thoughtMessageCount: number; + userMessageCount: number; + toolMessages: Map; + // Maps agent toolCallId → agent ParsedMessage for nesting children + agentTools: Map; + processedIdx: number; + // Snapshot tracking: only create a new array ref when messages grow. + // Mutations (tool_update, agent_complete replacing content) reuse the + // same snapshot so FlatList doesn't re-layout and reset scroll position. + lastSnapshot: ParsedMessage[]; + lastSnapshotLength: number; +} + +function createProcessorState(): EventProcessorState { + return { + messages: [], + plan: null, + pendingAgentText: "", + pendingThoughtText: "", + lastAgentMsgIdx: null, + agentMessageCount: 0, + thoughtMessageCount: 0, + userMessageCount: 0, + toolMessages: new Map(), + agentTools: new Map(), + processedIdx: 0, + lastSnapshot: [], + lastSnapshotLength: 0, + }; +} + +function processNewEvents( + state: EventProcessorState, + events: SessionEvent[], +): ProcessedEvents { + // If events shrank (e.g. session reset), start fresh + if (events.length < state.processedIdx) { + Object.assign(state, createProcessorState()); + } + + // Nothing new to process + if (events.length === state.processedIdx) { + return { messages: state.messages, plan: state.plan }; + } + + let hasItemMutation = false; const flushAgentText = () => { - if (!pendingAgentText) return; - messages.push({ - id: `agent-${agentMessageCount++}`, + if (!state.pendingAgentText) return; + const msg: ParsedMessage = { + id: `agent-${state.agentMessageCount++}`, type: "agent", - content: pendingAgentText, - }); - pendingAgentText = ""; + content: state.pendingAgentText, + ts: state.pendingAgentTs, + }; + state.messages.push(msg); + state.lastAgentMsgIdx = state.messages.length - 1; + state.pendingAgentText = ""; + state.pendingAgentTs = undefined; + }; + + const flushThoughtText = () => { + if (!state.pendingThoughtText) return; + // Merge consecutive thoughts into one message instead of many rows + const lastMsg = state.messages[state.messages.length - 1]; + if (lastMsg?.type === "thought") { + lastMsg.content += state.pendingThoughtText; + } else { + state.messages.push({ + id: `thought-${state.thoughtMessageCount++}`, + type: "thought", + content: state.pendingThoughtText, + }); + } + state.pendingThoughtText = ""; }; - for (const event of events) { + const flushPending = () => { + flushThoughtText(); + flushAgentText(); + }; + + for (let i = state.processedIdx; i < events.length; i++) { + const event = events[i]; if (event.type !== "session_update") continue; const parsed = parseSessionNotification(event.notification); @@ -122,103 +277,605 @@ function processEvents(events: SessionEvent[]): ParsedMessage[] { switch (parsed.type) { case "user": - flushAgentText(); - messages.push({ - id: `user-${event.ts}`, + flushPending(); + state.messages.push({ + id: `user-${state.userMessageCount++}`, type: "user", content: parsed.content ?? "", + ts: event.ts, }); + state.lastAgentMsgIdx = null; break; case "agent": - pendingAgentText += parsed.content ?? ""; + flushThoughtText(); + if (!state.pendingAgentTs) state.pendingAgentTs = event.ts; + state.pendingAgentText += parsed.content ?? ""; break; - case "tool": + case "agent_complete": + flushThoughtText(); + // If we already flushed an agent message from chunks, replace it + if ( + state.lastAgentMsgIdx !== null && + state.messages[state.lastAgentMsgIdx]?.type === "agent" + ) { + state.messages[state.lastAgentMsgIdx].content = parsed.content ?? ""; + if (!state.messages[state.lastAgentMsgIdx].ts) { + state.messages[state.lastAgentMsgIdx].ts = event.ts; + } + state.pendingAgentText = ""; + state.pendingAgentTs = undefined; + } else { + state.pendingAgentText = parsed.content ?? ""; + if (!state.pendingAgentTs) state.pendingAgentTs = event.ts; + } + break; + case "thought": flushAgentText(); + state.pendingThoughtText += parsed.content ?? ""; + break; + case "plan": + state.plan = parsed.entries; + break; + case "tool": + flushPending(); if (parsed.toolData) { - const msg: ParsedMessage = { - id: `tool-${parsed.toolData.toolCallId}`, - type: "tool", - content: "", - toolData: parsed.toolData, - }; - toolMessages.set(parsed.toolData.toolCallId, msg); - messages.push(msg); + const existing = state.toolMessages.get(parsed.toolData.toolCallId); + if (existing?.toolData) { + existing.toolData = { + ...existing.toolData, + ...parsed.toolData, + }; + } else { + const msg: ParsedMessage = { + id: `tool-${parsed.toolData.toolCallId}`, + type: "tool", + content: "", + toolData: parsed.toolData, + children: parsed.toolData.isAgent ? [] : undefined, + }; + state.toolMessages.set(parsed.toolData.toolCallId, msg); + + // Agent tools: register for child nesting + if (parsed.toolData.isAgent) { + state.agentTools.set(parsed.toolData.toolCallId, msg); + } + + // Child tools: nest under parent agent instead of top-level + const parentId = parsed.toolData.parentToolCallId; + const parent = parentId + ? state.agentTools.get(parentId) + : undefined; + if (parent?.children) { + parent.children.push(msg); + hasItemMutation = true; + } else { + state.messages.push(msg); + } + } } + state.lastAgentMsgIdx = null; break; case "tool_update": if (parsed.toolData) { - const existing = toolMessages.get(parsed.toolData.toolCallId); + const existing = state.toolMessages.get(parsed.toolData.toolCallId); if (existing?.toolData) { existing.toolData.status = parsed.toolData.status; existing.toolData.result = parsed.toolData.result; + if (parsed.toolData.args) { + existing.toolData.args = parsed.toolData.args; + } + hasItemMutation = true; } } break; } } - flushAgentText(); - return messages; + flushPending(); + state.processedIdx = events.length; + + // Create a new array reference when messages were added or when a tool + // received args for the first time (so the diff view can render). + // Pure status/text mutations reuse the prior snapshot to avoid jumps. + if (state.messages.length !== state.lastSnapshotLength || hasItemMutation) { + state.lastSnapshot = [...state.messages]; + state.lastSnapshotLength = state.messages.length; + } + + return { messages: state.lastSnapshot, plan: state.plan }; +} + +function CollapsedThought({ content }: { content: string }) { + const themeColors = useThemeColors(); + const [expanded, setExpanded] = useState(false); + + return ( + setExpanded(!expanded)} className="px-4 py-0.5"> + + + Thought + + {expanded && ( + + {content} + + )} + + ); +} + +// Detect objects like {"0":"E","1":"r","2":"r",...,"isError":true} — a string +// serialized as char-per-key (possibly with extra metadata keys mixed in). +function tryReassembleString(obj: Record): string | null { + const numericKeys = Object.keys(obj).filter((k) => /^\d+$/.test(k)); + if (numericKeys.length < 3) return null; + if ( + numericKeys.every( + (k) => typeof obj[k] === "string" && (obj[k] as string).length === 1, + ) + ) { + return numericKeys + .sort((a, b) => Number(a) - Number(b)) + .map((k) => obj[k]) + .join(""); + } + return null; +} + +function extractErrorText(result: unknown): string | null { + if (typeof result === "string") return result; + if (Array.isArray(result)) { + const texts = result.map(extractErrorText).filter(Boolean); + return texts.length > 0 ? texts.join("\n") : null; + } + if (!result || typeof result !== "object") return null; + const obj = result as Record; + + // Reassemble char-per-key strings: {"0":"E","1":"r",...} + const reassembled = tryReassembleString(obj); + if (reassembled) return reassembled; + + // Check simple string fields, recurse into nested objects + for (const key of [ + "error", + "message", + "stderr", + "output", + "text", + "content", + ]) { + if (typeof obj[key] === "string") return obj[key] as string; + if (obj[key] && typeof obj[key] === "object") { + const nested = extractErrorText(obj[key]); + if (nested) return nested; + } + } + + // Last resort: stringify the result so *something* shows + try { + const str = JSON.stringify(result, null, 2); + if (str && str !== "{}") return str; + } catch { + // ignore + } + + return null; +} + +function agentPromptSummary(args?: Record): string | null { + if (!args) return null; + const prompt = + typeof args.prompt === "string" + ? args.prompt + : typeof args.description === "string" + ? args.description + : null; + if (!prompt) return null; + // Take the first meaningful line, truncated + const firstLine = prompt + .split("\n") + .find((l) => l.trim()) + ?.trim(); + if (!firstLine) return null; + return firstLine.length > 120 ? `${firstLine.slice(0, 120)}…` : firstLine; +} + +function AgentToolCard({ + item, + onOpenTask, +}: { + item: ParsedMessage; + onOpenTask?: (taskId: string) => void; +}) { + const themeColors = useThemeColors(); + const [expanded, setExpanded] = useState(false); + const toolData = item.toolData; + const children = item.children ?? []; + if (!toolData) return null; + + const isLoading = + toolData.status === "pending" || toolData.status === "running"; + const isFailed = toolData.status === "error"; + const childCount = children.length; + const subtitle = agentPromptSummary(toolData.args); + const errorText = isFailed ? extractErrorText(toolData.result) : null; + + return ( + + {/* Header */} + setExpanded(!expanded)} className="px-3 py-2"> + + {isLoading ? ( + + ) : ( + + )} + + {toolData.toolName} + + {childCount > 0 && ( + + {childCount} {childCount === 1 ? "tool" : "tools"} + + )} + {isFailed && ( + + Failed + + )} + + + {subtitle && ( + + {subtitle} + + )} + + + {/* Error message + nested tool calls */} + {expanded && ( + + {errorText && ( + + + {errorText} + + + )} + {children.map((child) => { + if (!child.toolData) return null; + return ( + + ); + })} + + )} + + ); +} + +function formatElapsed(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return m > 0 ? `${m}m ${s}s` : `${s}s`; +} + +function useElapsedTimer() { + const [elapsed, setElapsed] = useState(0); + useEffect(() => { + setElapsed(0); + const interval = setInterval(() => { + setElapsed((e) => e + 1); + }, 1000); + return () => clearInterval(interval); + }, []); + return elapsed; +} + +function ThinkingIndicator() { + const themeColors = useThemeColors(); + const [dots, setDots] = useState(1); + const elapsed = useElapsedTimer(); + + useEffect(() => { + const interval = setInterval(() => { + setDots((d) => (d % 3) + 1); + }, 500); + return () => clearInterval(interval); + }, []); + + return ( + + + + + + Thinking{".".repeat(dots)} + + + + {formatElapsed(elapsed)} + + + + ); +} + +function ConnectingIndicator() { + const themeColors = useThemeColors(); + const [dots, setDots] = useState(1); + const elapsed = useElapsedTimer(); + + useEffect(() => { + const interval = setInterval(() => { + setDots((d) => (d % 3) + 1); + }, 500); + return () => clearInterval(interval); + }, []); + + return ( + + + + + + Connecting{".".repeat(dots)} + + + + {formatElapsed(elapsed)} + + + + ); } export function TaskSessionView({ events, - isPromptPending, + isConnecting, + isThinking, + terminalStatus, + lastError, + onRetry, onOpenTask, + onSendPermissionResponse, contentContainerStyle, }: TaskSessionViewProps) { - const messages = useMemo(() => processEvents(events), [events]); + const processorRef = useRef(createProcessorState()); + const prevEventsRef = useRef(events); + // Reset processor when events array shrinks or changes identity completely + // (e.g., navigating between tasks while Expo Router reuses the component). + if ( + events.length === 0 || + (events !== prevEventsRef.current && events[0] !== prevEventsRef.current[0]) + ) { + processorRef.current = createProcessorState(); + } + prevEventsRef.current = events; + const { messages, plan } = useMemo( + () => processNewEvents(processorRef.current, events), + [events], + ); + + // When the agent stops (cancel, completion, terminal), sweep any + // tools still stuck in pending/running to "completed" so their + // spinners stop. + const agentActive = isConnecting || isThinking; + const prevAgentActive = useRef(agentActive); + if (prevAgentActive.current && !agentActive) { + const state = processorRef.current; + let swept = false; + for (const msg of state.toolMessages.values()) { + if ( + msg.toolData && + (msg.toolData.status === "pending" || msg.toolData.status === "running") + ) { + msg.toolData.status = "completed"; + swept = true; + } + } + if (swept) { + state.lastSnapshot = [...state.messages]; + state.lastSnapshotLength = state.messages.length; + } + } + prevAgentActive.current = agentActive; + + // Inverted FlatList renders data[0] at the visual bottom. + // Reverse so newest messages are at index 0 = bottom. + const reversedMessages = useMemo(() => [...messages].reverse(), [messages]); const themeColors = useThemeColors(); + const flatListRef = useRef(null); + const buttonRef = useRef(null); + const isScrolledRef = useRef(false); + + const scrollToBottom = useCallback(() => { + flatListRef.current?.scrollToOffset({ offset: 0, animated: true }); + }, []); + + const handleScroll = useCallback( + (e: { nativeEvent: { contentOffset: { y: number } } }) => { + const scrolled = e.nativeEvent.contentOffset.y > 0; + if (scrolled !== isScrolledRef.current) { + isScrolledRef.current = scrolled; + buttonRef.current?.setNativeProps({ + style: { + opacity: scrolled ? 1 : 0, + pointerEvents: scrolled ? "auto" : "none", + }, + }); + } + }, + [], + ); const renderMessage = useCallback( ({ item }: { item: ParsedMessage }) => { switch (item.type) { case "user": - return ; + return ; case "agent": return ( - + ); + case "thought": + return ; case "tool": - return item.toolData ? ( + if (!item.toolData) return null; + if (isQuestionTool(item.toolData)) { + return ( + + ); + } + if (item.toolData.isAgent) { + return ; + } + return ( - ) : null; + ); default: return null; } }, - [onOpenTask], + [onOpenTask, onSendPermissionResponse], ); return ( - item.id} - inverted - contentContainerStyle={{ - flexDirection: "column-reverse", - ...contentContainerStyle, - }} - keyboardDismissMode="interactive" - keyboardShouldPersistTaps="handled" - showsVerticalScrollIndicator={false} - ListHeaderComponent={ - isPromptPending ? ( - - - - Thinking... - - - ) : null - } - /> + + + item.id} + inverted + contentContainerStyle={contentContainerStyle} + keyboardDismissMode="interactive" + keyboardShouldPersistTaps="handled" + showsVerticalScrollIndicator + onScroll={handleScroll} + scrollEventThrottle={100} + maxToRenderPerBatch={15} + windowSize={21} + initialNumToRender={30} + ListHeaderComponent={ + terminalStatus ? ( + + + {terminalStatus === "failed" ? "Run failed" : "Run completed"} + + {lastError && ( + {lastError} + )} + {onRetry && ( + + + {terminalStatus === "failed" ? "Retry" : "Continue"} + + + )} + + ) : null + } + /> + {/* Thinking/connecting indicators absolutely positioned above the Composer area. + Rendered outside FlatList to avoid inverted-list double-mount bugs. */} + {(isConnecting || isThinking) && ( + + {isConnecting ? ( + + ) : isThinking ? ( + + ) : null} + + )} + + + + + + ); } diff --git a/apps/mobile/src/features/tasks/hooks/useTasks.ts b/apps/mobile/src/features/tasks/hooks/useTasks.ts index c0bb1f812..340d83ef7 100644 --- a/apps/mobile/src/features/tasks/hooks/useTasks.ts +++ b/apps/mobile/src/features/tasks/hooks/useTasks.ts @@ -1,5 +1,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useAuthStore, useUserQuery } from "@/features/auth"; +import { logger } from "@/lib/logger"; import { createTask, deleteTask, @@ -11,6 +12,8 @@ import { import { filterAndSortTasks, useTaskStore } from "../stores/taskStore"; import type { CreateTaskOptions, Task } from "../types"; +const log = logger.scope("tasks-mutations"); + export const taskKeys = { all: ["tasks"] as const, lists: () => [...taskKeys.all, "list"] as const, @@ -64,26 +67,18 @@ export function useTask(taskId: string) { export function useCreateTask() { const queryClient = useQueryClient(); - const { data: currentUser } = useUserQuery(); - const invalidateTasks = (newTask?: Task) => { - if (newTask && currentUser?.id) { - // Update the correct cache entry with the user's filter - const queryKey = taskKeys.list({ createdBy: currentUser.id }); - queryClient.setQueryData(queryKey, (old) => - old ? [newTask, ...old] : [newTask], - ); - } + const invalidateTasks = () => { queryClient.invalidateQueries({ queryKey: taskKeys.lists() }); }; const mutation = useMutation({ mutationFn: (options: CreateTaskOptions) => createTask(options), - onSuccess: (newTask) => { - invalidateTasks(newTask); + onSuccess: () => { + invalidateTasks(); }, onError: (error) => { - console.error("Failed to create task:", error.message); + log.error("Failed to create task", error.message); }, }); @@ -107,7 +102,7 @@ export function useUpdateTask() { queryClient.invalidateQueries({ queryKey: taskKeys.lists() }); }, onError: (error) => { - console.error("Failed to update task:", error.message); + log.error("Failed to update task", error.message); }, }); } @@ -123,7 +118,7 @@ export function useDeleteTask() { queryClient.invalidateQueries({ queryKey: taskKeys.lists() }); }, onError: (error) => { - console.error("Failed to delete task:", error.message); + log.error("Failed to delete task", error.message); }, }); } @@ -138,7 +133,7 @@ export function useRunTask() { queryClient.invalidateQueries({ queryKey: taskKeys.lists() }); }, onError: (error) => { - console.error("Failed to run task:", error.message); + log.error("Failed to run task", error.message); }, }); } diff --git a/apps/mobile/src/features/tasks/stores/archivedTasksStore.ts b/apps/mobile/src/features/tasks/stores/archivedTasksStore.ts new file mode 100644 index 000000000..9a87f0072 --- /dev/null +++ b/apps/mobile/src/features/tasks/stores/archivedTasksStore.ts @@ -0,0 +1,55 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +// Cap on how many archived task IDs we persist. Beyond this, the oldest +// entries are evicted so AsyncStorage doesn't grow without bound. +const MAX_ARCHIVED_TASKS = 100; + +interface ArchivedTasksState { + // taskId → timestamp (ms) for eviction ordering + archivedTasks: Record; + archive: (taskId: string) => void; + unarchive: (taskId: string) => void; + isArchived: (taskId: string) => boolean; +} + +function withCap(entries: Record): Record { + const ids = Object.keys(entries); + if (ids.length <= MAX_ARCHIVED_TASKS) return entries; + const kept = ids + .sort((a, b) => entries[b] - entries[a]) + .slice(0, MAX_ARCHIVED_TASKS); + const trimmed: Record = {}; + for (const id of kept) trimmed[id] = entries[id]; + return trimmed; +} + +export const useArchivedTasksStore = create()( + persist( + (set, get) => ({ + archivedTasks: {}, + + archive: (taskId: string) => + set((state) => ({ + archivedTasks: withCap({ + ...state.archivedTasks, + [taskId]: Date.now(), + }), + })), + + unarchive: (taskId: string) => + set((state) => { + const { [taskId]: _, ...rest } = state.archivedTasks; + return { archivedTasks: rest }; + }), + + isArchived: (taskId: string) => taskId in get().archivedTasks, + }), + { + name: "archived-tasks", + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => ({ archivedTasks: state.archivedTasks }), + }, + ), +); diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts index a11ac1383..2d2be0f5b 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts @@ -1,6 +1,16 @@ +import * as Haptics from "expo-haptics"; +import { AppState } from "react-native"; import { create } from "zustand"; +import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; import { logger } from "@/lib/logger"; -import { appendTaskRunLog, fetchS3Logs, runTaskInCloud } from "../api"; +import { + CloudCommandError, + fetchS3Logs, + getTask, + getTaskRun, + runTaskInCloud, + sendCloudCommand, +} from "../api"; import type { SessionEvent, SessionNotification, @@ -11,6 +21,46 @@ import { convertRawEntriesToEvents, parseSessionLogs, } from "../utils/parseSessionLogs"; +import { playMeepSound } from "../utils/sounds"; + +// Infer whether the agent is actively working or idle (waiting for user input). +// Primary signal: _posthog/turn_complete or _posthog/task_complete in raw log +// entries. Fallback: session update notification heuristic for older logs. +function inferAgentIsIdle( + rawEntries: StoredLogEntry[], + notifications: SessionNotification[], +): boolean { + // Check raw entries for explicit turn/task completion signals + for (let i = rawEntries.length - 1; i >= 0; i--) { + const method = rawEntries[i].notification?.method; + if ( + method === "_posthog/turn_complete" || + method === "_posthog/task_complete" + ) { + return true; + } + // If we hit a client-direction entry (user message), the agent hasn't + // completed a turn since the last user input. + if (rawEntries[i].direction === "client") break; + } + + // Fallback: check session update notifications for agent responses + for (let i = notifications.length - 1; i >= 0; i--) { + const su = notifications[i].update?.sessionUpdate; + if (su === "agent_message" || su === "agent_message_chunk") { + return true; + } + if ( + su === "user_message_chunk" || + su === "tool_call" || + su === "tool_call_update" || + su === "agent_thought_chunk" + ) { + return false; + } + } + return false; +} const CLOUD_POLLING_INTERVAL_MS = 500; @@ -23,6 +73,23 @@ export interface TaskSession { logUrl: string; processedLineCount: number; processedHashes?: Set; + // Content of user prompts echoed locally (before the agent writes them to + // the log). Used by polling to dedup the canonical copy against the echo. + localUserEchoes?: Set; + // Terminal backend status for this run, populated by the status-check + // poller so the UI can surface "Run failed" / "Run completed". + terminalStatus?: "failed" | "completed"; + lastError?: string | null; + // True when the user initiated work (new task, sendPrompt, resume) and + // we should play a sound when control returns. False when reconnecting + // to an already-running task to avoid spurious pings. + awaitingPing?: boolean; + // True after a user prompt is sent, cleared when the first piece of + // agent output (tool call, message, etc.) arrives from polling. + awaitingAgentOutput?: boolean; + // Timestamp of the last new event received via polling. Used to detect + // stale local sessions (desktop stopped syncing). + lastEventAt?: number; } interface TaskSessionStore { @@ -31,16 +98,41 @@ interface TaskSessionStore { connectToTask: (task: Task) => Promise; disconnectFromTask: (taskId: string) => void; sendPrompt: (taskId: string, prompt: string) => Promise; + sendPermissionResponse: ( + taskId: string, + args: { + toolCallId: string; + optionId: string; + answers?: Record; + customInput?: string; + displayText: string; + }, + ) => Promise; cancelPrompt: (taskId: string) => Promise; getSessionForTask: (taskId: string) => TaskSession | undefined; - _handleEvent: (taskRunId: string, event: SessionEvent) => void; _startCloudPolling: (taskRunId: string, logUrl: string) => void; _stopCloudPolling: (taskRunId: string) => void; + _resumeCloudRun: ( + taskId: string, + previousRunId: string, + prompt: string, + ) => Promise; } const cloudPollers = new Map>(); const connectAttempts = new Set(); +// Guard against overlapping poll ticks — if a fetch takes >500ms, the next +// interval fires while the previous is still running, causing both to read +// the same processedLineCount and produce duplicate events. +const pollInFlight = new Set(); +// Timestamps for when each poll tick started — used to force-clear stuck ticks. +const pollInFlightSince = new Map(); +const POLL_IN_FLIGHT_TIMEOUT_MS = 30_000; +// Tick counts per task run used to throttle backend task-run status polling. +const pollTicks = new Map(); +// How many S3 polling ticks between each backend task-run status check. +const STATUS_CHECK_TICK_INTERVAL = 5; export const useTaskSessionStore = create((set, get) => ({ sessions: {}, @@ -49,7 +141,7 @@ export const useTaskSessionStore = create((set, get) => ({ const taskId = task.id; const latestRunId = task.latest_run?.id; const latestRunLogUrl = task.latest_run?.log_url; - const taskDescription = task.description; + const _taskDescription = task.description; if (connectAttempts.has(taskId)) { logger.debug("Connection already in progress", { taskId }); @@ -82,24 +174,13 @@ export const useTaskSessionStore = create((set, get) => ({ [newRunId]: { taskRunId: newRunId, taskId, - events: taskDescription - ? [ - { - type: "session_update" as const, - ts: Date.now(), - notification: { - update: { - sessionUpdate: "user_message_chunk", - content: { type: "text", text: taskDescription }, - }, - }, - }, - ] - : [], + events: [], status: "connected", - isPromptPending: true, // Agent is processing initial task + isPromptPending: true, logUrl: newLogUrl, processedLineCount: 0, + awaitingPing: true, + awaitingAgentOutput: true, }, }, })); @@ -121,29 +202,29 @@ export const useTaskSessionStore = create((set, get) => ({ logger.debug("Loaded cloud historical logs", { notifications: notifications.length, rawEntries: rawEntries.length, + backendStatus: task.latest_run?.status, }); const historicalEvents = convertRawEntriesToEvents( rawEntries, notifications, - taskDescription, ); - // Check if agent is still processing by looking at the last entry - // If the last non-client entry is a user message, agent is likely still working - const lastAgentEntry = [...rawEntries] - .reverse() - .find((e) => e.direction !== "client"); - // biome-ignore lint/suspicious/noExplicitAny: Entry structure varies - const lastUpdate = (lastAgentEntry?.notification as any)?.params?.update - ?.sessionUpdate; - const isAgentResponding = - lastUpdate === "agent_message_chunk" || - lastUpdate === "agent_thought_chunk" || - lastUpdate === "tool_call" || - lastUpdate === "tool_call_update"; - // If we have entries but the last one isn't an agent response, agent may still be processing - const isPromptPending = rawEntries.length > 0 && !isAgentResponding; + // Terminal runs (completed/failed) always clear isPromptPending. + // For non-terminal runs we infer idle vs working from the log shape + // because the backend has no "waiting_for_input" status. + const backendStatus = task.latest_run?.status; + const isTerminal = + backendStatus === "completed" || backendStatus === "failed"; + const terminalStatus: "completed" | "failed" | undefined = isTerminal + ? (backendStatus as "completed" | "failed") + : undefined; + const lastError = isTerminal + ? (task.latest_run?.error_message ?? null) + : null; + + const agentIsIdle = inferAgentIsIdle(rawEntries, notifications); + const isPromptPending = isTerminal ? false : !agentIsIdle; set((state) => ({ sessions: { @@ -156,12 +237,35 @@ export const useTaskSessionStore = create((set, get) => ({ isPromptPending, logUrl: latestRunLogUrl, processedLineCount: rawEntries.length, + terminalStatus, + lastError, + // Show "Connecting/Thinking" for active non-terminal runs + // that haven't produced visible agent output yet. + awaitingAgentOutput: + isPromptPending && + !historicalEvents.some((e) => { + if (e.type !== "session_update") return false; + const su = (e.notification as SessionNotification)?.update + ?.sessionUpdate; + return ( + su === "agent_message_chunk" || + su === "agent_message" || + su === "agent_thought_chunk" || + su === "tool_call" || + su === "tool_call_update" + ); + }), }, }, })); get()._startCloudPolling(latestRunId, latestRunLogUrl); - logger.debug("Connected to cloud session", { taskId, latestRunId }); + logger.debug("Connected to cloud session", { + taskId, + latestRunId, + backendStatus, + isTerminal, + }); } catch (error) { logger.error("Failed to connect to task", error); } finally { @@ -188,67 +292,209 @@ export const useTaskSessionStore = create((set, get) => ({ throw new Error("No active session for task"); } - const notification: StoredLogEntry = { - type: "notification", - timestamp: new Date().toISOString(), - direction: "client", + // Mobile is a dumb relay for local runs — always push the message to + // the backend and let the desktop decide whether/when to process it. + // No local gating, no client-side queueing. + + // Local echo for immediate UX feedback — polling will re-surface the + // canonical copy once the agent writes it to the log; any duplicate is + // removed by content-based dedup in the polling loop below. + const ts = Date.now(); + const userEvent: SessionEvent = { + type: "session_update", + ts, notification: { - method: "session/update", - params: { - update: { - sessionUpdate: "user_message_chunk", - content: { type: "text", text: prompt }, - }, + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: prompt }, }, }, }; - await appendTaskRunLog(taskId, session.taskRunId, [notification]); - logger.debug("Sent cloud message via S3", { - taskId, - runId: session.taskRunId, + set((state) => { + const current = state.sessions[session.taskRunId]; + const nextLocalEchoes = new Set(current.localUserEchoes ?? []); + nextLocalEchoes.add(prompt); + return { + sessions: { + ...state.sessions, + [session.taskRunId]: { + ...current, + events: [...current.events, userEvent], + localUserEchoes: nextLocalEchoes, + isPromptPending: true, + awaitingPing: true, + awaitingAgentOutput: true, + }, + }, + }; }); + try { + await sendCloudCommand(taskId, session.taskRunId, "user_message", { + content: prompt, + }); + logger.debug("Sent cloud command user_message", { + taskId, + runId: session.taskRunId, + }); + } catch (err) { + // Transient server errors (504 gateway timeout, etc.) — the sandbox + // may still be alive, just temporarily unreachable. Roll back so the + // user can retry but don't attempt a full resume. + if ( + err instanceof CloudCommandError && + (err.status === 504 || err.status === 502 || err.status === 503) + ) { + logger.warn("Transient server error sending prompt, rolling back", { + status: err.status, + taskId, + }); + set((state) => { + const current = state.sessions[session.taskRunId]; + if (!current) return state; + const nextLocalEchoes = new Set(current.localUserEchoes ?? []); + nextLocalEchoes.delete(prompt); + return { + sessions: { + ...state.sessions, + [session.taskRunId]: { + ...current, + events: current.events.filter((e) => e !== userEvent), + localUserEchoes: nextLocalEchoes, + isPromptPending: false, + }, + }, + }; + }); + throw err; + } + + // Sandbox for this run has shut down — create a resume run on the + // backend and swap the local session to the new run id. + let rollbackError: unknown = err; + if (err instanceof CloudCommandError && err.isSandboxInactive()) { + logger.info("Sandbox inactive, creating resume run", { + taskId, + previousRunId: session.taskRunId, + }); + try { + await get()._resumeCloudRun(taskId, session.taskRunId, prompt); + return; + } catch (resumeErr) { + logger.error("Failed to resume cloud run", resumeErr); + rollbackError = resumeErr; + } + } + + // Roll back the local echo + pending state so the user can retry. + set((state) => { + const current = state.sessions[session.taskRunId]; + if (!current) return state; + const nextLocalEchoes = new Set(current.localUserEchoes ?? []); + nextLocalEchoes.delete(prompt); + return { + sessions: { + ...state.sessions, + [session.taskRunId]: { + ...current, + events: current.events.filter((e) => e !== userEvent), + localUserEchoes: nextLocalEchoes, + isPromptPending: false, + }, + }, + }; + }); + throw rollbackError; + } + }, + + // Resolve an outstanding requestPermission on the desktop/agent side + // (e.g. AskUserQuestion). Unlike sendPrompt, this never queues — a + // permission reply only makes sense while the agent is paused inside + // requestPermission, and it completes an existing turn rather than + // starting a new one. + sendPermissionResponse: async (taskId, args) => { + const session = get().getSessionForTask(taskId); + if (!session) { + throw new Error("No active session for task"); + } + const ts = Date.now(); const userEvent: SessionEvent = { type: "session_update", ts, - notification: notification.notification?.params as SessionNotification, + notification: { + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: args.displayText }, + }, + }, }; - set((state) => ({ - sessions: { - ...state.sessions, - [session.taskRunId]: { - ...state.sessions[session.taskRunId], - events: [...state.sessions[session.taskRunId].events, userEvent], - processedLineCount: - (state.sessions[session.taskRunId].processedLineCount ?? 0) + 1, - isPromptPending: true, + set((state) => { + const current = state.sessions[session.taskRunId]; + if (!current) return state; + const nextLocalEchoes = new Set(current.localUserEchoes ?? []); + nextLocalEchoes.add(args.displayText); + return { + sessions: { + ...state.sessions, + [session.taskRunId]: { + ...current, + events: [...current.events, userEvent], + localUserEchoes: nextLocalEchoes, + isPromptPending: true, + awaitingPing: true, + awaitingAgentOutput: true, + }, }, - }, - })); + }; + }); + + try { + await sendCloudCommand(taskId, session.taskRunId, "permission_response", { + toolCallId: args.toolCallId, + optionId: args.optionId, + ...(args.answers ? { answers: args.answers } : {}), + ...(args.customInput ? { customInput: args.customInput } : {}), + }); + logger.debug("Sent permission_response", { + taskId, + runId: session.taskRunId, + toolCallId: args.toolCallId, + }); + } catch (err) { + logger.error("Failed to send permission_response", err); + // Roll back the optimistic state so the UI reflects reality. + set((state) => { + const current = state.sessions[session.taskRunId]; + if (!current) return state; + const nextLocalEchoes = new Set(current.localUserEchoes ?? []); + nextLocalEchoes.delete(args.displayText); + return { + sessions: { + ...state.sessions, + [session.taskRunId]: { + ...current, + events: current.events.filter((e) => e !== userEvent), + localUserEchoes: nextLocalEchoes, + isPromptPending: false, + }, + }, + }; + }); + throw err; + } }, cancelPrompt: async (taskId: string) => { const session = get().getSessionForTask(taskId); if (!session) return false; - const cancelNotification: StoredLogEntry = { - type: "notification", - timestamp: new Date().toISOString(), - direction: "client", - notification: { - method: "session/cancel", - params: { - sessionId: session.taskRunId, - }, - }, - }; - try { - await appendTaskRunLog(taskId, session.taskRunId, [cancelNotification]); - logger.debug("Sent cancel request via S3", { + await sendCloudCommand(taskId, session.taskRunId, "cancel"); + logger.debug("Sent cancel command", { taskId, runId: session.taskRunId, }); @@ -273,28 +519,22 @@ export const useTaskSessionStore = create((set, get) => ({ return Object.values(get().sessions).find((s) => s.taskId === taskId); }, - _handleEvent: (taskRunId: string, event: SessionEvent) => { - set((state) => { - const session = state.sessions[taskRunId]; - if (!session) return state; - - return { - sessions: { - ...state.sessions, - [taskRunId]: { - ...session, - events: [...session.events, event], - }, - }, - }; - }); - }, - _startCloudPolling: (taskRunId: string, logUrl: string) => { if (cloudPollers.has(taskRunId)) return; logger.debug("Starting cloud S3 polling", { taskRunId }); const pollS3 = async () => { + // Skip if previous tick is still in flight — but force-clear if stuck + if (pollInFlight.has(taskRunId)) { + const startedAt = pollInFlightSince.get(taskRunId) ?? 0; + if (Date.now() - startedAt < POLL_IN_FLIGHT_TIMEOUT_MS) return; + logger.warn("Force-clearing stuck pollInFlight", { taskRunId }); + pollInFlight.delete(taskRunId); + pollInFlightSince.delete(taskRunId); + } + pollInFlight.add(taskRunId); + pollInFlightSince.set(taskRunId, Date.now()); + try { const session = get().sessions[taskRunId]; if (!session) { @@ -302,6 +542,58 @@ export const useTaskSessionStore = create((set, get) => ({ return; } + // Check backend status periodically, or every tick while the agent + // is pending (so "Thinking..." clears promptly when the run finishes). + const tick = (pollTicks.get(taskRunId) ?? 0) + 1; + pollTicks.set(taskRunId, tick); + const shouldCheckStatus = + session.isPromptPending || tick % STATUS_CHECK_TICK_INTERVAL === 0; + if (shouldCheckStatus) { + try { + const run = await getTaskRun(session.taskId, taskRunId); + logger.debug("Status check", { + taskRunId, + status: run.status, + error: run.error_message, + }); + if (run.status === "failed" || run.status === "completed") { + logger.debug("Backend run reached terminal status", { + taskRunId, + status: run.status, + error: run.error_message, + }); + const shouldPing = + get().sessions[taskRunId]?.awaitingPing ?? false; + set((state) => { + const current = state.sessions[taskRunId]; + if (!current) return state; + return { + sessions: { + ...state.sessions, + [taskRunId]: { + ...current, + isPromptPending: false, + terminalStatus: run.status as "failed" | "completed", + lastError: run.error_message, + awaitingPing: false, + }, + }, + }; + }); + if (shouldPing && usePreferencesStore.getState().pingsEnabled) { + playMeepSound().catch(() => {}); + Haptics.notificationAsync( + Haptics.NotificationFeedbackType.Success, + ); + } + } + } catch (statusErr) { + logger.warn("Failed to fetch task run status", { + error: statusErr, + }); + } + } + const text = await fetchS3Logs(logUrl); if (!text) return; @@ -310,9 +602,20 @@ export const useTaskSessionStore = create((set, get) => ({ if (lines.length > processedCount) { const newLines = lines.slice(processedCount); + logger.debug("Poll picked up new log lines", { + taskRunId, + newLineCount: newLines.length, + totalLines: lines.length, + }); const currentHashes = new Set(session.processedHashes ?? []); - + const remainingLocalEchoes = new Set(session.localUserEchoes ?? []); + // Collect all new events in a batch, then do a single store + // update. This prevents N re-renders per poll tick. + const batchedEvents: SessionEvent[] = []; let receivedAgentMessage = false; + // Track when a user_message_chunk arrives that wasn't sent from + // this device — means someone prompted from the desktop app. + let receivedExternalUserMessage = false; for (const line of newLines) { try { @@ -321,45 +624,83 @@ export const useTaskSessionStore = create((set, get) => ({ ? new Date(entry.timestamp).getTime() : Date.now(); - const hash = `${entry.timestamp ?? ""}-${entry.notification?.method ?? ""}-${entry.direction ?? ""}`; + // Build a dedup hash specific enough to distinguish different + // events at the same timestamp. For session/update entries, + // include the update type, toolCallId, and status so that a + // tool_call and its tool_call_update don't collide. + const params = entry.notification?.params; + const suDetail = params?.update + ? `-${params.update.sessionUpdate ?? ""}-${params.update.toolCallId ?? ""}-${params.update.status ?? ""}` + : `-${entry.direction ?? ""}`; + const hash = `${entry.timestamp ?? ""}-${entry.notification?.method ?? ""}${suDetail}`; if (currentHashes.has(hash)) { continue; } currentHashes.add(hash); - const isClientMessage = entry.direction === "client"; - if (isClientMessage) { - continue; + // Check for local echo dedup BEFORE pushing any events for + // this entry — otherwise the acp_message duplicate gets in. + if ( + entry.type === "notification" && + entry.notification?.method === "session/update" && + entry.notification?.params + ) { + const params = entry.notification.params as SessionNotification; + const sessionUpdate = params?.update?.sessionUpdate; + + if (sessionUpdate === "user_message_chunk") { + const text = params?.update?.content?.text; + if (text && remainingLocalEchoes.has(text)) { + remainingLocalEchoes.delete(text); + continue; + } + // User message not from this device (e.g. desktop app) + receivedExternalUserMessage = true; + } } - const acpEvent: SessionEvent = { + batchedEvents.push({ type: "acp_message", direction: entry.direction ?? "agent", ts, message: entry.notification, - }; - get()._handleEvent(taskRunId, acpEvent); + }); + + if ( + entry.type === "notification" && + (entry.notification?.method === "_posthog/turn_complete" || + entry.notification?.method === "_posthog/task_complete" || + entry.notification?.method === "_posthog/error" || + // Agent explicitly blocked on a user reply (e.g. a question + // tool invoked via requestPermission). Treat this as a + // turn boundary so the input UI unblocks — otherwise the + // user's answer would be stuck in the "queue while busy" + // path in sendPrompt. + entry.notification?.method === "_posthog/awaiting_user_input") + ) { + receivedAgentMessage = true; + } if ( entry.type === "notification" && entry.notification?.method === "session/update" && entry.notification?.params ) { - const sessionUpdateEvent: SessionEvent = { + const params = entry.notification.params as SessionNotification; + const sessionUpdate = params?.update?.sessionUpdate; + + batchedEvents.push({ type: "session_update", ts, - notification: entry.notification - .params as SessionNotification, - }; - get()._handleEvent(taskRunId, sessionUpdateEvent); - - // Check if this is an agent message - means agent is responding - const sessionUpdate = - entry.notification?.params?.update?.sessionUpdate; - if ( - sessionUpdate === "agent_message_chunk" || - sessionUpdate === "agent_thought_chunk" - ) { + notification: params, + }); + + // agent_message (finalized, non-chunk) is a reasonable proxy + // for turn completion — it's emitted once the full response + // is assembled. Chunks and thoughts fire mid-turn and are NOT + // reliable. The proper signal is _posthog/turn_complete but + // it's not yet written to S3 logs by the server. + if (sessionUpdate === "agent_message") { receivedAgentMessage = true; } } @@ -368,23 +709,87 @@ export const useTaskSessionStore = create((set, get) => ({ } } - set((state) => ({ - sessions: { - ...state.sessions, - [taskRunId]: { - ...state.sessions[taskRunId], - processedLineCount: lines.length, - processedHashes: currentHashes, - // Clear pending state when we receive agent response - isPromptPending: receivedAgentMessage - ? false - : (state.sessions[taskRunId]?.isPromptPending ?? false), + // Determine if we should ping. If an external user message armed + // the ping in this same batch, honour it even though the store + // hasn't updated yet. + const wasAwaitingPing = + get().sessions[taskRunId]?.awaitingPing ?? false; + const shouldPingAfterBatch = + receivedAgentMessage && + (wasAwaitingPing || receivedExternalUserMessage); + set((state) => { + const current = state.sessions[taskRunId]; + if (!current) return state; + + // Determine isPromptPending: external user message starts work, + // turn/task completion ends it. + let nextIsPromptPending = current.isPromptPending; + if (receivedExternalUserMessage) nextIsPromptPending = true; + if (receivedAgentMessage) nextIsPromptPending = false; + + // awaitingPing: arm when work starts (even from another device), + // disarm when it completes and the ping fires. + let nextAwaitingPing = current.awaitingPing; + if (receivedExternalUserMessage && !current.awaitingPing) { + nextAwaitingPing = true; + } + if (receivedAgentMessage) nextAwaitingPing = false; + + // Clear awaitingAgentOutput once a visibly-rendered event arrives + // (agent message, thought, tool call) — not just any non-user event. + const visibleSessionUpdates = new Set([ + "agent_message_chunk", + "agent_message", + "agent_thought_chunk", + "tool_call", + "tool_call_update", + ]); + const hasVisibleAgentOutput = batchedEvents.some((e) => { + if (e.type !== "session_update") return false; + const su = (e.notification as SessionNotification)?.update + ?.sessionUpdate; + return su !== undefined && visibleSessionUpdates.has(su); + }); + const nextAwaitingAgentOutput = + current.awaitingAgentOutput && !hasVisibleAgentOutput; + + return { + sessions: { + ...state.sessions, + [taskRunId]: { + ...current, + events: + batchedEvents.length > 0 + ? [...current.events, ...batchedEvents] + : current.events, + processedLineCount: lines.length, + processedHashes: currentHashes, + localUserEchoes: + remainingLocalEchoes.size > 0 + ? remainingLocalEchoes + : undefined, + isPromptPending: nextIsPromptPending, + awaitingPing: nextAwaitingPing, + awaitingAgentOutput: nextAwaitingAgentOutput, + lastEventAt: + batchedEvents.length > 0 ? Date.now() : current.lastEventAt, + }, }, - }, - })); + }; + }); + if ( + shouldPingAfterBatch && + usePreferencesStore.getState().pingsEnabled + ) { + playMeepSound().catch(() => {}); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + } } } catch (err) { logger.warn("Cloud polling error", { error: err }); + } finally { + pollInFlight.delete(taskRunId); + pollInFlightSince.delete(taskRunId); } }; @@ -398,7 +803,89 @@ export const useTaskSessionStore = create((set, get) => ({ if (interval) { clearInterval(interval); cloudPollers.delete(taskRunId); + pollTicks.delete(taskRunId); logger.debug("Stopped cloud S3 polling", { taskRunId }); } }, + + _resumeCloudRun: async ( + taskId: string, + previousRunId: string, + prompt: string, + ) => { + // Fetch the latest task to pick up the branch the previous run was using — + // otherwise the backend would create a new branch and we'd lose working + // tree context. + const freshTask = await getTask(taskId); + const previousBranch = freshTask.latest_run?.branch ?? null; + + const updatedTask = await runTaskInCloud(taskId, { + branch: previousBranch, + resumeFromRunId: previousRunId, + pendingUserMessage: prompt, + }); + + const newRun = updatedTask.latest_run; + if (!newRun?.id || !newRun.log_url) { + throw new Error("Resume run was created but has no id or log_url"); + } + + // Stop polling the dead run and swap the session over to the new run id. + // Read the CURRENT session state to preserve the local echo that was + // just added in sendPrompt (the captured `session` variable in the + // caller is stale). + get()._stopCloudPolling(previousRunId); + + set((state) => { + const previousSession = state.sessions[previousRunId]; + if (!previousSession) return state; + const { [previousRunId]: _old, ...rest } = state.sessions; + return { + sessions: { + ...rest, + [newRun.id]: { + ...previousSession, + taskRunId: newRun.id, + logUrl: newRun.log_url, + status: "connected", + isPromptPending: true, + processedLineCount: 0, + processedHashes: new Set(), + awaitingPing: true, + awaitingAgentOutput: true, + }, + }, + }; + }); + + get()._startCloudPolling(newRun.id, newRun.log_url); + logger.debug("Swapped to resume run", { + taskId, + previousRunId, + newRunId: newRun.id, + }); + }, })); + +// When the app returns from background, iOS resumes JS execution but +// in-flight fetches may have been killed. Clear the pollInFlight guards +// and restart polling for all active sessions to catch up immediately. +AppState.addEventListener("change", (nextState) => { + if (nextState === "active") { + pollInFlight.clear(); + pollInFlightSince.clear(); + pollTicks.clear(); + for (const [taskRunId, interval] of cloudPollers) { + clearInterval(interval); + cloudPollers.delete(taskRunId); + } + const sessions = useTaskSessionStore.getState().sessions; + for (const session of Object.values(sessions)) { + if (session.status === "connected" && !session.terminalStatus) { + useTaskSessionStore + .getState() + ._startCloudPolling(session.taskRunId, session.logUrl); + } + } + } +}); diff --git a/apps/mobile/src/features/tasks/types.ts b/apps/mobile/src/features/tasks/types.ts index 4c1578a4b..4ee8bb75e 100644 --- a/apps/mobile/src/features/tasks/types.ts +++ b/apps/mobile/src/features/tasks/types.ts @@ -51,9 +51,22 @@ export interface SessionNotification { status?: "pending" | "in_progress" | "completed" | "failed" | null; rawInput?: Record; rawOutput?: unknown; + entries?: PlanEntry[]; + _meta?: { + claudeCode?: { + toolName?: string; + parentToolCallId?: string; + }; + }; }; } +export interface PlanEntry { + content: string; + status: "pending" | "in_progress" | "completed"; + priority: string; +} + export interface AcpMessage { type: "acp_message"; direction: "client" | "agent"; diff --git a/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts b/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts index fb405b1a6..307efca93 100644 --- a/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts +++ b/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts @@ -56,27 +56,10 @@ export function parseSessionLogs(content: string): ParsedSessionLogs { export function convertRawEntriesToEvents( rawEntries: StoredLogEntry[], notifications: SessionNotification[], - taskDescription?: string, ): SessionEvent[] { const events: SessionEvent[] = []; let notificationIdx = 0; - if (taskDescription) { - const startTs = rawEntries[0]?.timestamp - ? new Date(rawEntries[0].timestamp).getTime() - 1 - : Date.now(); - events.push({ - type: "session_update", - ts: startTs, - notification: { - update: { - sessionUpdate: "user_message_chunk", - content: { type: "text", text: taskDescription }, - }, - }, - }); - } - for (const entry of rawEntries) { const ts = entry.timestamp ? new Date(entry.timestamp).getTime() diff --git a/apps/mobile/src/features/tasks/utils/sounds.ts b/apps/mobile/src/features/tasks/utils/sounds.ts new file mode 100644 index 000000000..8460a0cb3 --- /dev/null +++ b/apps/mobile/src/features/tasks/utils/sounds.ts @@ -0,0 +1,23 @@ +import { Audio } from "expo-av"; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const meepAsset = require("../../../../assets/sounds/meep.mp3"); + +let audioModeConfigured = false; + +export async function playMeepSound(): Promise { + if (!audioModeConfigured) { + await Audio.setAudioModeAsync({ + playsInSilentModeIOS: true, + }); + audioModeConfigured = true; + } + const { sound } = await Audio.Sound.createAsync(meepAsset, { + shouldPlay: true, + }); + sound.setOnPlaybackStatusUpdate((status) => { + if (status.isLoaded && status.didJustFinish) { + sound.unloadAsync(); + } + }); +} diff --git a/apps/mobile/src/hooks/useNetworkStatus.ts b/apps/mobile/src/hooks/useNetworkStatus.ts new file mode 100644 index 000000000..fc183d7b1 --- /dev/null +++ b/apps/mobile/src/hooks/useNetworkStatus.ts @@ -0,0 +1,15 @@ +import NetInfo from "@react-native-community/netinfo"; +import { useEffect, useState } from "react"; + +export function useNetworkStatus() { + const [isConnected, setIsConnected] = useState(true); + + useEffect(() => { + const unsubscribe = NetInfo.addEventListener((state) => { + setIsConnected(state.isConnected ?? true); + }); + return unsubscribe; + }, []); + + return { isConnected }; +} diff --git a/apps/mobile/src/lib/format.ts b/apps/mobile/src/lib/format.ts new file mode 100644 index 000000000..10e2a0f15 --- /dev/null +++ b/apps/mobile/src/lib/format.ts @@ -0,0 +1,11 @@ +export function formatRelativeTime(ts: number): string { + const diffMs = Date.now() - ts; + const diffSec = Math.floor(diffMs / 1000); + if (diffSec < 60) return "just now"; + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDay = Math.floor(diffHr / 24); + return `${diffDay}d ago`; +} diff --git a/apps/mobile/src/lib/syntax-highlight.ts b/apps/mobile/src/lib/syntax-highlight.ts new file mode 100644 index 000000000..60f46902e --- /dev/null +++ b/apps/mobile/src/lib/syntax-highlight.ts @@ -0,0 +1,192 @@ +import hljs from "highlight.js/lib/core"; +import cpp from "highlight.js/lib/languages/cpp"; +import css from "highlight.js/lib/languages/css"; +import go from "highlight.js/lib/languages/go"; +import java from "highlight.js/lib/languages/java"; +import javascript from "highlight.js/lib/languages/javascript"; +import json from "highlight.js/lib/languages/json"; +import markdown from "highlight.js/lib/languages/markdown"; +import php from "highlight.js/lib/languages/php"; +import python from "highlight.js/lib/languages/python"; +import ruby from "highlight.js/lib/languages/ruby"; +import rust from "highlight.js/lib/languages/rust"; +import scss from "highlight.js/lib/languages/scss"; +import shell from "highlight.js/lib/languages/shell"; +import sql from "highlight.js/lib/languages/sql"; +import swift from "highlight.js/lib/languages/swift"; +import typescript from "highlight.js/lib/languages/typescript"; +import xml from "highlight.js/lib/languages/xml"; +import yaml from "highlight.js/lib/languages/yaml"; + +hljs.registerLanguage("cpp", cpp); +hljs.registerLanguage("c", cpp); +hljs.registerLanguage("css", css); +hljs.registerLanguage("go", go); +hljs.registerLanguage("golang", go); +hljs.registerLanguage("java", java); +hljs.registerLanguage("javascript", javascript); +hljs.registerLanguage("js", javascript); +hljs.registerLanguage("jsx", javascript); +hljs.registerLanguage("json", json); +hljs.registerLanguage("markdown", markdown); +hljs.registerLanguage("md", markdown); +hljs.registerLanguage("php", php); +hljs.registerLanguage("python", python); +hljs.registerLanguage("py", python); +hljs.registerLanguage("ruby", ruby); +hljs.registerLanguage("rb", ruby); +hljs.registerLanguage("rust", rust); +hljs.registerLanguage("rs", rust); +hljs.registerLanguage("scss", scss); +hljs.registerLanguage("sass", scss); +hljs.registerLanguage("shell", shell); +hljs.registerLanguage("bash", shell); +hljs.registerLanguage("sh", shell); +hljs.registerLanguage("zsh", shell); +hljs.registerLanguage("sql", sql); +hljs.registerLanguage("swift", swift); +hljs.registerLanguage("typescript", typescript); +hljs.registerLanguage("ts", typescript); +hljs.registerLanguage("tsx", typescript); +hljs.registerLanguage("xml", xml); +hljs.registerLanguage("html", xml); +hljs.registerLanguage("svg", xml); +hljs.registerLanguage("yaml", yaml); +hljs.registerLanguage("yml", yaml); + +export interface HighlightSegment { + text: string; + className?: string; +} + +// One Dark palette matching the desktop app +const ONE_DARK_COLORS: Record = { + "hljs-keyword": "#c678dd", + "hljs-built_in": "#e5c07b", + "hljs-type": "#e5c07b", + "hljs-literal": "#d19a66", + "hljs-number": "#d19a66", + "hljs-string": "#98c379", + "hljs-regexp": "#56b6c2", + "hljs-comment": "#8a8275", + "hljs-doctag": "#c678dd", + "hljs-function": "#61afef", + "hljs-title": "#61afef", + "hljs-title.function_": "#61afef", + "hljs-params": "#c4baa8", + "hljs-variable": "#e06c75", + "hljs-attr": "#d19a66", + "hljs-attribute": "#d19a66", + "hljs-name": "#e06c75", + "hljs-tag": "#e06c75", + "hljs-selector-tag": "#e06c75", + "hljs-selector-class": "#e5c07b", + "hljs-selector-id": "#61afef", + "hljs-property": "#e06c75", + "hljs-meta": "#56b6c2", + "hljs-operator": "#56b6c2", + "hljs-punctuation": "#c4baa8", + "hljs-subst": "#c4baa8", + "hljs-symbol": "#56b6c2", + "hljs-addition": "#98c379", + "hljs-deletion": "#e06c75", +}; + +function decodeEntities(text: string): string { + // Decode & last to avoid double-unescaping (e.g. &lt; → < → <) + return text + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/&/g, "&"); +} + +/** + * Parse highlight.js HTML output into segments. + * Input: `const x = 42;` + */ +function parseHljsHtml(html: string): HighlightSegment[] { + const segments: HighlightSegment[] = []; + const regex = /([\s\S]*?)<\/span>|([^<]+)/g; + + for (let match = regex.exec(html); match !== null; match = regex.exec(html)) { + if (match[3]) { + segments.push({ text: decodeEntities(match[3]) }); + } else if (match[1] && match[2] !== undefined) { + const className = match[1]; + const inner = match[2]; + if (inner.includes(" = { + ts: "typescript", + tsx: "typescript", + js: "javascript", + jsx: "javascript", + mjs: "javascript", + cjs: "javascript", + py: "python", + go: "go", + rs: "rust", + rb: "ruby", + java: "java", + c: "c", + cpp: "cpp", + h: "cpp", + hpp: "cpp", + css: "css", + scss: "scss", + html: "html", + xml: "xml", + svg: "xml", + json: "json", + yaml: "yaml", + yml: "yaml", + md: "markdown", + sql: "sql", + sh: "shell", + bash: "shell", + zsh: "shell", + swift: "swift", + php: "php", +}; + +export function languageFromPath(filePath: string): string | null { + const ext = filePath.split(".").pop()?.toLowerCase(); + if (!ext) return null; + return EXT_TO_LANGUAGE[ext] ?? null; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c16c23455..aee41a9b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -515,6 +515,9 @@ importers: '@react-native-async-storage/async-storage': specifier: ^2.2.0 version: 2.2.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0)) + '@react-native-community/netinfo': + specifier: ^12.0.1 + version: 12.0.1(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) '@tanstack/react-query': specifier: ^5.90.12 version: 5.90.20(react@19.1.0) @@ -533,6 +536,12 @@ importers: expo-av: specifier: ~16.0.8 version: 16.0.8(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) + expo-camera: + specifier: ^55.0.15 + version: 55.0.15(@types/emscripten@1.41.5)(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) + expo-clipboard: + specifier: ^55.0.13 + version: 55.0.13(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) expo-constants: specifier: ~18.0.11 version: 18.0.13(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0)) @@ -554,6 +563,9 @@ importers: expo-glass-effect: specifier: ~0.1.8 version: 0.1.8(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) + expo-haptics: + specifier: ^55.0.14 + version: 55.0.14(expo@54.0.33) expo-linear-gradient: specifier: ^15.0.8 version: 15.0.8(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) @@ -569,6 +581,9 @@ importers: expo-secure-store: specifier: ^15.0.8 version: 15.0.8(expo@54.0.33) + expo-speech-recognition: + specifier: ^3.1.2 + version: 3.1.2(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) expo-splash-screen: specifier: ~31.0.12 version: 31.0.13(expo@54.0.33) @@ -581,6 +596,9 @@ importers: expo-web-browser: specifier: ^15.0.10 version: 15.0.10(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0)) + highlight.js: + specifier: ^11.11.1 + version: 11.11.1 nativewind: specifier: ^4.2.1 version: 4.2.1(react-native-reanimated@4.1.6(@babel/core@7.29.0)(react-native-worklets@0.7.2(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0))(react-native-svg@15.15.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0)(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)) @@ -4404,6 +4422,12 @@ packages: peerDependencies: react-native: ^0.0.0-0 || >=0.65 <1.0 + '@react-native-community/netinfo@12.0.1': + resolution: {integrity: sha512-P/3caXIvfYSJG8AWJVefukg+ZGRPs+M4Lp3pNJtgcTYoJxCjWrKQGNnCkj/Cz//zWa/avGed0i/wzm0T8vV2IQ==} + peerDependencies: + react: '*' + react-native: '>=0.59' + '@react-native/assets-registry@0.81.5': resolution: {integrity: sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w==} engines: {node: '>= 20.19.4'} @@ -5173,6 +5197,9 @@ packages: '@types/earcut@3.0.0': resolution: {integrity: sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==} + '@types/emscripten@1.41.5': + resolution: {integrity: sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -5772,6 +5799,9 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + barcode-detector@3.1.2: + resolution: {integrity: sha512-Q5kjXpVH5I3ItykNzbWmfWnNryFN1ZTWp10k9/PKJuS0RnoKR7jTrHEJODR4fn04bRomq7TJwie/Dr9fj/GoGQ==} + base32-encode@1.2.0: resolution: {integrity: sha512-cHFU8XeRyx0GgmoWi5qHMCVRiqU6J3MHWxVgun7jggCBUpVzm1Ir7M9dYr2whjSNc3tFeXfQ/oZjQu/4u55h9A==} @@ -6901,6 +6931,24 @@ packages: react-native-web: optional: true + expo-camera@55.0.15: + resolution: {integrity: sha512-WRVsZf+2p7EsxudwyiUMYijJS8M98t/BVP6yG7N+08JSUotkGjmZcemom1gM36uy27P8QsSVP0hD+FravmQiBA==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + react-native-web: '*' + peerDependenciesMeta: + react-native-web: + optional: true + + expo-clipboard@55.0.13: + resolution: {integrity: sha512-PrOmmuVsGW4bAkNQmGKtxMXj3invsfN+jfIKmQxHwE/dn7ODqwFWviUTa+PMUjP3XZmYCDLyu/i0GLeu7HF9Ew==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + expo-constants@18.0.13: resolution: {integrity: sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==} peerDependencies: @@ -6957,6 +7005,11 @@ packages: react: '*' react-native: '*' + expo-haptics@55.0.14: + resolution: {integrity: sha512-KjDItBsA9mi1f5nRwf8g1wOdfEcLHwvEdt5Jl1sMCDETR/homcGOl+F3QIiPOl/PRlbGVieQsjTtF4DGtHOj6g==} + peerDependencies: + expo: '*' + expo-json-utils@0.15.0: resolution: {integrity: sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ==} @@ -7043,6 +7096,13 @@ packages: resolution: {integrity: sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA==} engines: {node: '>=20.16.0'} + expo-speech-recognition@3.1.2: + resolution: {integrity: sha512-yaXy+6w218Urdshits2KsfLjXNCnGNlXzUxEP4BVehKEbiIPAeUKBzuicCeELU5H2zTLwL9u+RjbFAUom4LiYQ==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + expo-splash-screen@31.0.13: resolution: {integrity: sha512-1epJLC1cDlwwj089R2h8cxaU5uk4ONVAC+vzGiTZH4YARQhL4Stlz1MbR6yAS173GMosvkE6CAeihR7oIbCkDA==} peerDependencies: @@ -7585,6 +7645,10 @@ packages: hermes-parser@0.32.0: resolution: {integrity: sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==} + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + hono@4.11.7: resolution: {integrity: sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==} engines: {node: '>=16.9.0'} @@ -11066,6 +11130,10 @@ packages: resolution: {integrity: sha512-AXSAQJu79WGc79/3e9/CR77I/KQgeY1AhNvcShIH4PTcGYyC4xv6H4R4AUOwkPS5799KlVDAu8zExeCrkGquiA==} engines: {node: '>=20'} + type-fest@5.6.0: + resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} + engines: {node: '>=20'} + type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} @@ -11753,6 +11821,11 @@ packages: zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + zxing-wasm@3.0.2: + resolution: {integrity: sha512-2YMAriaYHX9wrBY2k7H0epSo+dyCaCZg/vOtt+nEDXM9ul480gkXz/9SkwpOeHcD2H5qqDG8lWDSBwpTcZpa6w==} + peerDependencies: + '@types/emscripten': '>=1.39.6' + snapshots: '@0no-co/graphql.web@1.2.0(graphql@16.12.0)': @@ -16010,6 +16083,11 @@ snapshots: merge-options: 3.0.4 react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0) + '@react-native-community/netinfo@12.0.1(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0)': + dependencies: + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0) + '@react-native/assets-registry@0.81.5': {} '@react-native/babel-plugin-codegen@0.81.5(@babel/core@7.29.0)': @@ -16842,6 +16920,8 @@ snapshots: '@types/earcut@3.0.0': {} + '@types/emscripten@1.41.5': {} + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 @@ -17569,6 +17649,12 @@ snapshots: balanced-match@4.0.4: {} + barcode-detector@3.1.2(@types/emscripten@1.41.5): + dependencies: + zxing-wasm: 3.0.2(@types/emscripten@1.41.5) + transitivePeerDependencies: + - '@types/emscripten' + base32-encode@1.2.0: dependencies: to-data-view: 1.1.0 @@ -18702,6 +18788,21 @@ snapshots: react: 19.1.0 react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0) + expo-camera@55.0.15(@types/emscripten@1.41.5)(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0): + dependencies: + barcode-detector: 3.1.2(@types/emscripten@1.41.5) + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0) + transitivePeerDependencies: + - '@types/emscripten' + + expo-clipboard@55.0.13(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0): + dependencies: + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0) + expo-constants@18.0.13(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0)): dependencies: '@expo/config': 12.0.13 @@ -18768,6 +18869,10 @@ snapshots: react: 19.1.0 react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0) + expo-haptics@55.0.14(expo@54.0.33): + dependencies: + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) + expo-json-utils@0.15.0: {} expo-keep-awake@15.0.8(expo@54.0.33)(react@19.1.0): @@ -18866,6 +18971,12 @@ snapshots: expo-server@1.0.5: {} + expo-speech-recognition@3.1.2(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0): + dependencies: + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0) + expo-splash-screen@31.0.13(expo@54.0.33): dependencies: '@expo/prebuild-config': 54.0.8(expo@54.0.33) @@ -19559,6 +19670,8 @@ snapshots: dependencies: hermes-estree: 0.32.0 + highlight.js@11.11.1: {} + hono@4.11.7: {} hosted-git-info@2.8.9: {} @@ -23633,6 +23746,10 @@ snapshots: dependencies: tagged-tag: 1.0.0 + type-fest@5.6.0: + dependencies: + tagged-tag: 1.0.0 + type-is@2.0.1: dependencies: content-type: 1.0.5 @@ -24316,3 +24433,8 @@ snapshots: react: 19.1.0 zwitch@2.0.4: {} + + zxing-wasm@3.0.2(@types/emscripten@1.41.5): + dependencies: + '@types/emscripten': 1.41.5 + type-fest: 5.6.0