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