From 0b59b55c77ddb1107a18dd0b23d3eb65e2f558e5 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 13 May 2026 14:58:06 -0400 Subject: [PATCH 01/94] Added new nav bar --- .claude/settings.local.json | 7 + apps/mobile/package.json | 1 + apps/mobile/src/app/(tabs)/_layout.tsx | 87 ++------- apps/mobile/src/app/(tabs)/inbox.tsx | 37 ++++ apps/mobile/src/app/(tabs)/settings.tsx | 4 +- apps/mobile/src/app/(tabs)/tasks.tsx | 8 +- apps/mobile/src/app/index.tsx | 4 +- .../navigation/components/MenuButton.tsx | 25 +++ .../navigation/components/NavDrawer.tsx | 176 ++++++++++++++++++ .../navigation/stores/navDrawerStore.ts | 15 ++ pnpm-lock.yaml | 130 ++++++++++++- 11 files changed, 409 insertions(+), 85 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 apps/mobile/src/app/(tabs)/inbox.tsx create mode 100644 apps/mobile/src/features/navigation/components/MenuButton.tsx create mode 100644 apps/mobile/src/features/navigation/components/NavDrawer.tsx create mode 100644 apps/mobile/src/features/navigation/stores/navDrawerStore.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..40169fd73 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(pnpm exec *)" + ] + } +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 5484f36f3..84092fa2d 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -62,6 +62,7 @@ "react-native-safe-area-context": "~5.6.2", "react-native-screens": "~4.16.0", "react-native-svg": "^15.15.1", + "react-native-web": "^0.21.2", "react-native-webview": "^13.13.5", "zustand": "^4.5.7" }, diff --git a/apps/mobile/src/app/(tabs)/_layout.tsx b/apps/mobile/src/app/(tabs)/_layout.tsx index 1e4bc6033..3a6e67b8a 100644 --- a/apps/mobile/src/app/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(tabs)/_layout.tsx @@ -1,78 +1,25 @@ -import { Icon, Label, NativeTabs } from "expo-router/unstable-native-tabs"; -import { DynamicColorIOS, Platform } from "react-native"; -import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; +import { Stack } from "expo-router"; +import { View } from "react-native"; +import { NavDrawer } from "@/features/navigation/components/NavDrawer"; 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 = - Platform.OS === "ios" - ? DynamicColorIOS({ - dark: themeColors.gray[12], - light: themeColors.gray[12], - }) - : themeColors.gray[12]; - - const dynamicTintColor = - Platform.OS === "ios" - ? DynamicColorIOS({ - dark: themeColors.accent[9], - light: themeColors.accent[9], - }) - : themeColors.accent[9]; return ( - - {/* Conversations - Chats tab, hidden by default to focus on Code */} - {aiChatEnabled && ( - - - - - )} - - {/* Code tab (task list for PostHog Code) */} - - - - - - {/* Settings Tab */} - - - - - - {/* TODO: Fix this and use NativeTabs.Trigger for opening the chat */} - {/* Chat - Separate floating button (iOS search role style) */} - {/* - */} - + + + + + + + + + ); } diff --git a/apps/mobile/src/app/(tabs)/inbox.tsx b/apps/mobile/src/app/(tabs)/inbox.tsx new file mode 100644 index 000000000..e0be63718 --- /dev/null +++ b/apps/mobile/src/app/(tabs)/inbox.tsx @@ -0,0 +1,37 @@ +import { Text } from "@components/text"; +import { Tray } from "phosphor-react-native"; +import { View } from "react-native"; +import { MenuButton } from "@/features/navigation/components/MenuButton"; +import { useThemeColors } from "@/lib/theme"; + +export default function InboxScreen() { + const themeColors = useThemeColors(); + + return ( + + + + + + Inbox + + Signals and notifications + + + + + + + + + + + Inbox coming soon + + + Signals and notifications will show up here. + + + + ); +} diff --git a/apps/mobile/src/app/(tabs)/settings.tsx b/apps/mobile/src/app/(tabs)/settings.tsx index df35d9eb4..9fbb682a3 100644 --- a/apps/mobile/src/app/(tabs)/settings.tsx +++ b/apps/mobile/src/app/(tabs)/settings.tsx @@ -8,6 +8,7 @@ import { View, } from "react-native"; import { useAuthStore, useUserQuery } from "@/features/auth"; +import { MenuButton } from "@/features/navigation/components/MenuButton"; import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; export default function SettingsScreen() { @@ -33,7 +34,8 @@ export default function SettingsScreen() { {/* Header */} - + + Settings diff --git a/apps/mobile/src/app/(tabs)/tasks.tsx b/apps/mobile/src/app/(tabs)/tasks.tsx index 5e2b7226d..e713e5ac6 100644 --- a/apps/mobile/src/app/(tabs)/tasks.tsx +++ b/apps/mobile/src/app/(tabs)/tasks.tsx @@ -2,6 +2,7 @@ import { Text } from "@components/text"; import { useFocusEffect, useRouter } from "expo-router"; import { useCallback, useRef } from "react"; import { InteractionManager, Pressable, View } from "react-native"; +import { MenuButton } from "@/features/navigation/components/MenuButton"; import { TaskList } from "@/features/tasks"; export default function TasksScreen() { @@ -42,9 +43,10 @@ export default function TasksScreen() { return ( {/* Header */} - - - + + + + Code Your PostHog Code sessions diff --git a/apps/mobile/src/app/index.tsx b/apps/mobile/src/app/index.tsx index ba177f41f..ed718bd9d 100644 --- a/apps/mobile/src/app/index.tsx +++ b/apps/mobile/src/app/index.tsx @@ -4,9 +4,9 @@ import { useAuthStore } from "@/features/auth"; export default function Index() { const { isAuthenticated } = useAuthStore(); - // Redirect to tabs if authenticated, otherwise to login + // Redirect to tasks if authenticated, otherwise to login if (isAuthenticated) { - return ; + return ; } return ; diff --git a/apps/mobile/src/features/navigation/components/MenuButton.tsx b/apps/mobile/src/features/navigation/components/MenuButton.tsx new file mode 100644 index 000000000..a540f138e --- /dev/null +++ b/apps/mobile/src/features/navigation/components/MenuButton.tsx @@ -0,0 +1,25 @@ +import { List } from "phosphor-react-native"; +import { Pressable } from "react-native"; +import { useThemeColors } from "@/lib/theme"; +import { useNavDrawerStore } from "../stores/navDrawerStore"; + +interface MenuButtonProps { + className?: string; +} + +export function MenuButton({ className }: MenuButtonProps) { + const open = useNavDrawerStore((s) => s.open); + const themeColors = useThemeColors(); + + return ( + + + + ); +} diff --git a/apps/mobile/src/features/navigation/components/NavDrawer.tsx b/apps/mobile/src/features/navigation/components/NavDrawer.tsx new file mode 100644 index 000000000..8cc9ea76f --- /dev/null +++ b/apps/mobile/src/features/navigation/components/NavDrawer.tsx @@ -0,0 +1,176 @@ +import { Text } from "@components/text"; +import { useRouter } from "expo-router"; +import { GearSix, Plus, Tray } from "phosphor-react-native"; +import { useEffect, useRef } from "react"; +import { + Animated, + Dimensions, + Easing, + Modal, + Pressable, + ScrollView, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useTasks } from "@/features/tasks/hooks/useTasks"; +import { useThemeColors } from "@/lib/theme"; +import { useNavDrawerStore } from "../stores/navDrawerStore"; + +const { width: SCREEN_WIDTH } = Dimensions.get("window"); +const DRAWER_WIDTH = Math.min(320, Math.round(SCREEN_WIDTH * 0.85)); + +export function NavDrawer() { + const isOpen = useNavDrawerStore((s) => s.isOpen); + const close = useNavDrawerStore((s) => s.close); + const router = useRouter(); + const themeColors = useThemeColors(); + const insets = useSafeAreaInsets(); + const { tasks } = useTasks(); + + const translateX = useRef(new Animated.Value(-DRAWER_WIDTH)).current; + const backdropOpacity = useRef(new Animated.Value(0)).current; + + useEffect(() => { + Animated.parallel([ + Animated.timing(translateX, { + toValue: isOpen ? 0 : -DRAWER_WIDTH, + duration: isOpen ? 240 : 200, + easing: isOpen ? Easing.out(Easing.cubic) : Easing.in(Easing.cubic), + useNativeDriver: true, + }), + Animated.timing(backdropOpacity, { + toValue: isOpen ? 1 : 0, + duration: isOpen ? 240 : 200, + useNativeDriver: true, + }), + ]).start(); + }, [isOpen, translateX, backdropOpacity]); + + const handleNewTask = () => { + close(); + router.push("/task"); + }; + + const handleInbox = () => { + close(); + router.replace("/inbox"); + }; + + const handleSettings = () => { + close(); + router.replace("/settings"); + }; + + const handleTaskPress = (taskId: string) => { + close(); + router.push(`/task/${taskId}`); + }; + + return ( + + + + + + + + { + close(); + router.replace("/tasks"); + }} + className="px-4 pb-3 active:opacity-60" + > + PostHog + + + + + + + New task + + + + + + Inbox + + + + + + + + Tasks + + + + + {tasks.length === 0 ? ( + + No tasks yet + + ) : ( + tasks.map((task) => ( + handleTaskPress(task.id)} + className="px-4 py-3 active:bg-gray-3" + > + + {task.title} + + + )) + )} + + + + + + + + + Settings + + + + + + + ); +} diff --git a/apps/mobile/src/features/navigation/stores/navDrawerStore.ts b/apps/mobile/src/features/navigation/stores/navDrawerStore.ts new file mode 100644 index 000000000..adf285374 --- /dev/null +++ b/apps/mobile/src/features/navigation/stores/navDrawerStore.ts @@ -0,0 +1,15 @@ +import { create } from "zustand"; + +interface NavDrawerState { + isOpen: boolean; + open: () => void; + close: () => void; + toggle: () => void; +} + +export const useNavDrawerStore = create((set) => ({ + isOpen: false, + open: () => set({ isOpen: true }), + close: () => set({ isOpen: false }), + toggle: () => set((state) => ({ isOpen: !state.isOpen })), +})); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18830327f..bac4f3f8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -547,10 +547,10 @@ importers: version: 7.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))(react@19.1.0) 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) + version: 16.0.8(expo@54.0.33)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@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))(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) + version: 55.0.15(@types/emscripten@1.41.5)(expo@54.0.33)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@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))(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) @@ -589,7 +589,7 @@ importers: version: 17.0.8(expo@54.0.33)(react@19.1.0) expo-router: specifier: ~6.0.17 - version: 6.0.23(@expo/metro-runtime@6.1.2)(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(expo-constants@18.0.13)(expo-linking@8.0.11)(expo@54.0.33)(react-dom@19.1.0(react@19.1.0))(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-screens@4.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) + version: 6.0.23(@expo/metro-runtime@6.1.2)(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(expo-constants@18.0.13)(expo-linking@8.0.11)(expo@54.0.33)(react-dom@19.1.0(react@19.1.0))(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-screens@4.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-web@0.21.2(encoding@0.1.13)(react-dom@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))(react@19.1.0) expo-secure-store: specifier: ^15.0.8 version: 15.0.8(expo@54.0.33) @@ -604,7 +604,7 @@ importers: version: 3.0.9(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) expo-system-ui: specifier: ~6.0.9 - version: 6.0.9(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0)) + version: 6.0.9(expo@54.0.33)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@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-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)) @@ -641,6 +641,9 @@ importers: react-native-svg: specifier: ^15.15.1 version: 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-web: + specifier: ^0.21.2 + version: 0.21.2(encoding@0.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react-native-webview: specifier: ^13.13.5 version: 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) @@ -4496,6 +4499,9 @@ packages: resolution: {integrity: sha512-fB7M1CMOCIUudTRuj7kzxIBTVw2KXnsgbQ6+4cbqSxo8NmRRhA0Ul4ZUzZj3rFd3VznTL4Brmocv1oiN0bWZ8w==} engines: {node: '>= 20.19.4'} + '@react-native/normalize-colors@0.74.89': + resolution: {integrity: sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg==} + '@react-native/normalize-colors@0.81.5': resolution: {integrity: sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g==} @@ -6317,6 +6323,9 @@ packages: cross-dirname@0.1.0: resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==} + cross-fetch@3.2.0: + resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} + cross-spawn@6.0.6: resolution: {integrity: sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==} engines: {node: '>=4.8'} @@ -6333,6 +6342,9 @@ packages: resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} engines: {node: '>=8'} + css-in-js-utils@3.1.0: + resolution: {integrity: sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==} + css-select@5.2.2: resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} @@ -7228,6 +7240,12 @@ packages: fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + fbjs-css-vars@1.0.2: + resolution: {integrity: sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==} + + fbjs@3.0.5: + resolution: {integrity: sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==} + fd-package-json@2.0.0: resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} @@ -7761,6 +7779,9 @@ packages: resolution: {integrity: sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==} engines: {node: '>=10.18'} + hyphenate-style-name@1.1.0: + resolution: {integrity: sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==} + iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -7836,6 +7857,9 @@ packages: inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + inline-style-prefixer@7.0.1: + resolution: {integrity: sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==} + interpret@3.1.1: resolution: {integrity: sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==} engines: {node: '>=10.13.0'} @@ -8711,6 +8735,9 @@ packages: memoize-one@5.2.1: resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} + memoize-one@6.0.0: + resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} + merge-descriptors@2.0.0: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} @@ -9783,6 +9810,9 @@ packages: resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} engines: {node: '>=10'} + promise@7.3.1: + resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==} + promise@8.3.0: resolution: {integrity: sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==} @@ -10044,6 +10074,12 @@ packages: react: '*' react-native: '*' + react-native-web@0.21.2: + resolution: {integrity: sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + react-native-webview@13.16.0: resolution: {integrity: sha512-Nh13xKZWW35C0dbOskD7OX01nQQavOzHbCw9XoZmar4eXCo7AvrYJ0jlUfRVVIJzqINxHlpECYLdmAdFsl9xDA==} peerDependencies: @@ -10444,6 +10480,9 @@ packages: server-only@0.0.1: resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -10779,6 +10818,9 @@ packages: style-to-object@1.0.14: resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + styleq@0.1.3: + resolution: {integrity: sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==} + sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} @@ -11208,6 +11250,10 @@ packages: resolution: {integrity: sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==} hasBin: true + ua-parser-js@1.0.41: + resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==} + hasBin: true + uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} @@ -13743,7 +13789,7 @@ snapshots: wrap-ansi: 7.0.0 ws: 8.19.0 optionalDependencies: - expo-router: 6.0.23(@expo/metro-runtime@6.1.2)(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(expo-constants@18.0.13)(expo-linking@8.0.11)(expo@54.0.33)(react-dom@19.1.0(react@19.1.0))(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-screens@4.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-router: 6.0.23(@expo/metro-runtime@6.1.2)(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(expo-constants@18.0.13)(expo-linking@8.0.11)(expo@54.0.33)(react-dom@19.1.0(react@19.1.0))(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-screens@4.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-web@0.21.2(encoding@0.1.13)(react-dom@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))(react@19.1.0) react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0) transitivePeerDependencies: - bufferutil @@ -16250,6 +16296,8 @@ snapshots: '@react-native/js-polyfills@0.81.5': {} + '@react-native/normalize-colors@0.74.89': {} + '@react-native/normalize-colors@0.81.5': {} '@react-native/virtualized-lists@0.81.5(@types/react@19.2.11)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0)': @@ -18230,6 +18278,12 @@ snapshots: cross-dirname@0.1.0: {} + cross-fetch@3.2.0(encoding@0.1.13): + dependencies: + node-fetch: 2.7.0(encoding@0.1.13) + transitivePeerDependencies: + - encoding + cross-spawn@6.0.6: dependencies: nice-try: 1.0.5 @@ -18248,6 +18302,10 @@ snapshots: crypto-random-string@2.0.0: {} + css-in-js-utils@3.1.0: + dependencies: + hyphenate-style-name: 1.1.0 + css-select@5.2.2: dependencies: boolbase: 1.0.0 @@ -18842,18 +18900,22 @@ snapshots: - expo - supports-color - expo-av@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-av@16.0.8(expo@54.0.33)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@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))(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) + optionalDependencies: + react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.1.0(react@19.1.0))(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): + expo-camera@55.0.15(@types/emscripten@1.41.5)(expo@54.0.33)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@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))(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) + optionalDependencies: + react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) transitivePeerDependencies: - '@types/emscripten' @@ -18984,7 +19046,7 @@ 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-router@6.0.23(@expo/metro-runtime@6.1.2)(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(expo-constants@18.0.13)(expo-linking@8.0.11)(expo@54.0.33)(react-dom@19.1.0(react@19.1.0))(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-screens@4.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-router@6.0.23(@expo/metro-runtime@6.1.2)(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(expo-constants@18.0.13)(expo-linking@8.0.11)(expo@54.0.33)(react-dom@19.1.0(react@19.1.0))(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-screens@4.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-web@0.21.2(encoding@0.1.13)(react-dom@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))(react@19.1.0): dependencies: '@expo/metro-runtime': 6.1.2(expo@54.0.33)(react-dom@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/schema-utils': 0.1.8 @@ -19019,6 +19081,7 @@ snapshots: optionalDependencies: react-dom: 19.1.0(react@19.1.0) 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-web: 0.21.2(encoding@0.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) transitivePeerDependencies: - '@react-native-masked-view/masked-view' - '@types/react' @@ -19050,12 +19113,14 @@ snapshots: react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0) react-native-is-edge-to-edge: 1.2.1(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) - expo-system-ui@6.0.9(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0)): + expo-system-ui@6.0.9(expo@54.0.33)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@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)): dependencies: '@react-native/normalize-colors': 0.81.5 debug: 4.4.3 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-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0) + optionalDependencies: + react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) transitivePeerDependencies: - supports-color @@ -19186,6 +19251,20 @@ snapshots: dependencies: bser: 2.1.1 + fbjs-css-vars@1.0.2: {} + + fbjs@3.0.5(encoding@0.1.13): + dependencies: + cross-fetch: 3.2.0(encoding@0.1.13) + fbjs-css-vars: 1.0.2 + loose-envify: 1.4.0 + object-assign: 4.1.1 + promise: 7.3.1 + setimmediate: 1.0.5 + ua-parser-js: 1.0.41 + transitivePeerDependencies: + - encoding + fd-package-json@2.0.0: dependencies: walk-up-path: 4.0.0 @@ -19819,6 +19898,8 @@ snapshots: hyperdyperid@1.2.0: {} + hyphenate-style-name@1.1.0: {} + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -19877,6 +19958,10 @@ snapshots: inline-style-parser@0.2.7: {} + inline-style-prefixer@7.0.1: + dependencies: + css-in-js-utils: 3.1.0 + interpret@3.1.1: {} invariant@2.2.4: @@ -20879,6 +20964,8 @@ snapshots: memoize-one@5.2.1: {} + memoize-one@6.0.0: {} + merge-descriptors@2.0.0: {} merge-options@3.0.4: @@ -22153,6 +22240,10 @@ snapshots: err-code: 2.0.3 retry: 0.12.0 + promise@7.3.1: + dependencies: + asap: 2.0.6 + promise@8.3.0: dependencies: asap: 2.0.6 @@ -22549,6 +22640,21 @@ snapshots: react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0) warn-once: 0.1.1 + react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + '@babel/runtime': 7.28.6 + '@react-native/normalize-colors': 0.74.89 + fbjs: 3.0.5(encoding@0.1.13) + inline-style-prefixer: 7.0.1 + memoize-one: 6.0.0 + nullthrows: 1.1.1 + postcss-value-parser: 4.2.0 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + styleq: 0.1.3 + transitivePeerDependencies: + - encoding + 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): dependencies: escape-string-regexp: 4.0.0 @@ -23073,6 +23179,8 @@ snapshots: server-only@0.0.1: {} + setimmediate@1.0.5: {} + setprototypeof@1.2.0: {} sf-symbols-typescript@2.2.0: {} @@ -23450,6 +23558,8 @@ snapshots: dependencies: inline-style-parser: 0.2.7 + styleq@0.1.3: {} + sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -23867,6 +23977,8 @@ snapshots: ua-parser-js@0.7.41: {} + ua-parser-js@1.0.41: {} + uc.micro@2.1.0: {} ufo@1.6.3: {} From 68ed364e1eb919315f83e32161071f4accf3f233 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 13 May 2026 15:21:27 -0400 Subject: [PATCH 02/94] Nav changes --- apps/mobile/src/app/(tabs)/_layout.tsx | 26 +++++++++++++++++-- apps/mobile/src/app/(tabs)/inbox.tsx | 7 ++++- apps/mobile/src/app/(tabs)/settings.tsx | 4 ++- apps/mobile/src/app/(tabs)/tasks.tsx | 7 ++++- .../navigation/components/NavDrawer.tsx | 26 +++++++++---------- 5 files changed, 52 insertions(+), 18 deletions(-) diff --git a/apps/mobile/src/app/(tabs)/_layout.tsx b/apps/mobile/src/app/(tabs)/_layout.tsx index 3a6e67b8a..3058a01ee 100644 --- a/apps/mobile/src/app/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(tabs)/_layout.tsx @@ -1,10 +1,32 @@ -import { Stack } from "expo-router"; -import { View } from "react-native"; +import { Stack, usePathname, useRouter } from "expo-router"; +import { useEffect } from "react"; +import { BackHandler, View } from "react-native"; import { NavDrawer } from "@/features/navigation/components/NavDrawer"; +import { useNavDrawerStore } from "@/features/navigation/stores/navDrawerStore"; import { useThemeColors } from "@/lib/theme"; +const HOME_ROUTE = "/tasks"; + export default function TabsLayout() { const themeColors = useThemeColors(); + const router = useRouter(); + const pathname = usePathname(); + + // Android: each drawer destination replaces (no back stack between them), so + // hardware back from a non-home destination should go home instead of exiting. + useEffect(() => { + const subscription = BackHandler.addEventListener( + "hardwareBackPress", + () => { + // Let the drawer's Modal handle back when it's open. + if (useNavDrawerStore.getState().isOpen) return false; + if (pathname === HOME_ROUTE) return false; + router.replace(HOME_ROUTE); + return true; + }, + ); + return () => subscription.remove(); + }, [pathname, router]); return ( diff --git a/apps/mobile/src/app/(tabs)/inbox.tsx b/apps/mobile/src/app/(tabs)/inbox.tsx index e0be63718..277aa5807 100644 --- a/apps/mobile/src/app/(tabs)/inbox.tsx +++ b/apps/mobile/src/app/(tabs)/inbox.tsx @@ -1,15 +1,20 @@ import { Text } from "@components/text"; import { Tray } from "phosphor-react-native"; import { View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { MenuButton } from "@/features/navigation/components/MenuButton"; import { useThemeColors } from "@/lib/theme"; export default function InboxScreen() { const themeColors = useThemeColors(); + const insets = useSafeAreaInsets(); return ( - + diff --git a/apps/mobile/src/app/(tabs)/settings.tsx b/apps/mobile/src/app/(tabs)/settings.tsx index 9fbb682a3..0fbc67cd1 100644 --- a/apps/mobile/src/app/(tabs)/settings.tsx +++ b/apps/mobile/src/app/(tabs)/settings.tsx @@ -7,6 +7,7 @@ import { TouchableOpacity, View, } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useAuthStore, useUserQuery } from "@/features/auth"; import { MenuButton } from "@/features/navigation/components/MenuButton"; import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; @@ -14,6 +15,7 @@ import { usePreferencesStore } from "@/features/preferences/stores/preferencesSt export default function SettingsScreen() { const { logout, cloudRegion, getCloudUrlFromRegion } = useAuthStore(); const { data: userData } = useUserQuery(); + const insets = useSafeAreaInsets(); const aiChatEnabled = usePreferencesStore((s) => s.aiChatEnabled); const setAiChatEnabled = usePreferencesStore((s) => s.setAiChatEnabled); const pingsEnabled = usePreferencesStore((s) => s.pingsEnabled); @@ -32,7 +34,7 @@ export default function SettingsScreen() { return ( - + {/* Header */} diff --git a/apps/mobile/src/app/(tabs)/tasks.tsx b/apps/mobile/src/app/(tabs)/tasks.tsx index e713e5ac6..8d7912f61 100644 --- a/apps/mobile/src/app/(tabs)/tasks.tsx +++ b/apps/mobile/src/app/(tabs)/tasks.tsx @@ -2,11 +2,13 @@ import { Text } from "@components/text"; import { useFocusEffect, useRouter } from "expo-router"; import { useCallback, useRef } from "react"; import { InteractionManager, Pressable, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { MenuButton } from "@/features/navigation/components/MenuButton"; import { TaskList } from "@/features/tasks"; export default function TasksScreen() { const router = useRouter(); + const insets = useSafeAreaInsets(); const readyRef = useRef(true); // Block navigation while a modal dismiss animation is in progress. @@ -43,7 +45,10 @@ export default function TasksScreen() { return ( {/* Header */} - + diff --git a/apps/mobile/src/features/navigation/components/NavDrawer.tsx b/apps/mobile/src/features/navigation/components/NavDrawer.tsx index 8cc9ea76f..dfdf52f5e 100644 --- a/apps/mobile/src/features/navigation/components/NavDrawer.tsx +++ b/apps/mobile/src/features/navigation/components/NavDrawer.tsx @@ -1,5 +1,5 @@ import { Text } from "@components/text"; -import { useRouter } from "expo-router"; +import { usePathname, useRouter } from "expo-router"; import { GearSix, Plus, Tray } from "phosphor-react-native"; import { useEffect, useRef } from "react"; import { @@ -23,10 +23,17 @@ export function NavDrawer() { const isOpen = useNavDrawerStore((s) => s.isOpen); const close = useNavDrawerStore((s) => s.close); const router = useRouter(); + const pathname = usePathname(); const themeColors = useThemeColors(); const insets = useSafeAreaInsets(); const { tasks } = useTasks(); + const navigateTo = (target: string) => { + close(); + if (pathname === target) return; + router.replace(target); + }; + const translateX = useRef(new Animated.Value(-DRAWER_WIDTH)).current; const backdropOpacity = useRef(new Animated.Value(0)).current; @@ -51,15 +58,11 @@ export function NavDrawer() { router.push("/task"); }; - const handleInbox = () => { - close(); - router.replace("/inbox"); - }; + const handleInbox = () => navigateTo("/inbox"); - const handleSettings = () => { - close(); - router.replace("/settings"); - }; + const handleSettings = () => navigateTo("/settings"); + + const handleHome = () => navigateTo("/tasks"); const handleTaskPress = (taskId: string) => { close(); @@ -92,10 +95,7 @@ export function NavDrawer() { }} > { - close(); - router.replace("/tasks"); - }} + onPress={handleHome} className="px-4 pb-3 active:opacity-60" > PostHog From 72b5c15d5ca6128e79bb1833396384dbc5220371 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 13 May 2026 15:46:53 -0400 Subject: [PATCH 03/94] New styles --- .claude/settings.local.json | 19 ++- apps/mobile/app.json | 25 ++++ .../assets/fonts/OpenRunde/OpenRunde-Bold.ttf | Bin 0 -> 314960 bytes .../fonts/OpenRunde/OpenRunde-Medium.ttf | Bin 0 -> 323804 bytes .../fonts/OpenRunde/OpenRunde-Regular.ttf | Bin 0 -> 313048 bytes .../fonts/OpenRunde/OpenRunde-Semibold.ttf | Bin 0 -> 315984 bytes apps/mobile/src/app/(tabs)/inbox.tsx | 12 +- apps/mobile/src/app/(tabs)/index.tsx | 10 +- apps/mobile/src/app/(tabs)/settings.tsx | 2 +- apps/mobile/src/app/(tabs)/tasks.tsx | 8 +- apps/mobile/src/app/_layout.tsx | 1 + apps/mobile/src/app/auth.tsx | 2 +- apps/mobile/src/components/OfflineBanner.tsx | 2 +- apps/mobile/src/components/text.tsx | 2 +- .../navigation/components/NavDrawer.tsx | 139 +++++++++++------- apps/mobile/src/lib/textDefaults.ts | 22 +++ apps/mobile/src/lib/theme.ts | 112 +++++++------- apps/mobile/tailwind.config.js | 1 + 18 files changed, 234 insertions(+), 123 deletions(-) create mode 100644 apps/mobile/assets/fonts/OpenRunde/OpenRunde-Bold.ttf create mode 100644 apps/mobile/assets/fonts/OpenRunde/OpenRunde-Medium.ttf create mode 100644 apps/mobile/assets/fonts/OpenRunde/OpenRunde-Regular.ttf create mode 100644 apps/mobile/assets/fonts/OpenRunde/OpenRunde-Semibold.ttf create mode 100644 apps/mobile/src/lib/textDefaults.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 40169fd73..48e659ea6 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,24 @@ { "permissions": { "allow": [ - "Bash(pnpm exec *)" + "Bash(pnpm exec *)", + "Bash(xargs cat)", + "Bash(PYTHONPATH=/Users/tomowers/Library/Python/3.10/lib/python/site-packages python3 -c \"from fontTools.ttLib import TTFont; import brotli; print\\('ok'\\)\")", + "Bash(python3 -c \"import sys; print\\(sys.path\\)\")", + "Read(//opt/homebrew/lib/**)", + "Bash(python3 -m pip install --user --break-system-packages --quiet fonttools brotli)", + "Bash(python3 -c \"from fontTools.ttLib import TTFont; import brotli; print\\('ok', brotli.__version__\\)\")", + "Bash(mkdir -p /Users/tomowers/dev/posthog/code/apps/mobile/assets/fonts/OpenRunde)", + "Read(//private/tmp/**)", + "Bash(cat)", + "Bash(python3 /tmp/convert_woff.py)", + "Bash(ls -lh /Users/tomowers/dev/posthog/code/apps/mobile/assets/fonts/OpenRunde/ && rm /tmp/convert_woff.py)", + "Bash(python3 -c ' *)", + "Bash(python3 /tmp/normalize_fonts.py)", + "Bash(xargs grep -l \"^ Text,\\\\|^ Text$\\\\|, Text,\")" + ], + "additionalDirectories": [ + "/private/tmp" ] } } diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 6014e79f3..a55fc7d0d 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -62,6 +62,27 @@ { "android": { "fonts": [ + { + "fontFamily": "Open Runde", + "fontDefinitions": [ + { + "path": "./assets/fonts/OpenRunde/OpenRunde-Regular.ttf", + "weight": 400 + }, + { + "path": "./assets/fonts/OpenRunde/OpenRunde-Medium.ttf", + "weight": 500 + }, + { + "path": "./assets/fonts/OpenRunde/OpenRunde-Semibold.ttf", + "weight": 600 + }, + { + "path": "./assets/fonts/OpenRunde/OpenRunde-Bold.ttf", + "weight": 700 + } + ] + }, { "fontFamily": "JetBrains Mono", "fontDefinitions": [ @@ -143,6 +164,10 @@ }, "ios": { "fonts": [ + "./assets/fonts/OpenRunde/OpenRunde-Regular.ttf", + "./assets/fonts/OpenRunde/OpenRunde-Medium.ttf", + "./assets/fonts/OpenRunde/OpenRunde-Semibold.ttf", + "./assets/fonts/OpenRunde/OpenRunde-Bold.ttf", "./assets/fonts/JetBrainsMono-Thin.ttf", "./assets/fonts/JetBrainsMono-ThinItalic.ttf", "./assets/fonts/JetBrainsMono-ExtraLight.ttf", diff --git a/apps/mobile/assets/fonts/OpenRunde/OpenRunde-Bold.ttf b/apps/mobile/assets/fonts/OpenRunde/OpenRunde-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..323c57d5e611dcbef3d2466fc1126bf660341524 GIT binary patch literal 314960 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

amBf6K%c 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 XP4}LZ9QQSO!*rLMO+|Ui_6`3Bi9$2H literal 0 HcmV?d00001 diff --git a/apps/mobile/assets/fonts/OpenRunde/OpenRunde-Medium.ttf b/apps/mobile/assets/fonts/OpenRunde/OpenRunde-Medium.ttf new file mode 100644 index 0000000000000000000000000000000000000000..60327fe3b322669eb6885cc63f1bb620e5e16742 GIT binary patch literal 323804 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 h5bxrKb`ESi~V$~KS99MpKN@PPW76N50+ZJ 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≺OO-&M^9Rg?pThVWZioKb9@a)Pufv|q~+yF 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 ~CoB~pH8%Wi8AXT@4R8`EYis1*wg62r% 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

{Y?%^ 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

8dncl%(4Q8^M6uGPz$J#KOj}Afg7n>KR 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 literal 0 HcmV?d00001 diff --git a/apps/mobile/assets/fonts/OpenRunde/OpenRunde-Semibold.ttf b/apps/mobile/assets/fonts/OpenRunde/OpenRunde-Semibold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..b11dc30a3c0d336894bfc9003bbc38784210a663 GIT binary patch literal 315984 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%0D<+!y(7mEbob~! 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

@i4h;mDhSi!lWS{Th02{6%O`L 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 @QOTG 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�byP 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 - Inbox - + + Inbox + + Signals and notifications @@ -28,12 +30,12 @@ export default function InboxScreen() { - + - + Inbox coming soon - + Signals and notifications will show up here. diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx index f2dec54a9..14bbc93af 100644 --- a/apps/mobile/src/app/(tabs)/index.tsx +++ b/apps/mobile/src/app/(tabs)/index.tsx @@ -29,16 +29,18 @@ export default function ConversationsScreen() { - + Conversations - Your PostHog AI chats + + Your PostHog AI chats + - + New chat diff --git a/apps/mobile/src/app/(tabs)/settings.tsx b/apps/mobile/src/app/(tabs)/settings.tsx index 0fbc67cd1..b10f0fb6c 100644 --- a/apps/mobile/src/app/(tabs)/settings.tsx +++ b/apps/mobile/src/app/(tabs)/settings.tsx @@ -38,7 +38,7 @@ export default function SettingsScreen() { {/* Header */} - Settings + Settings {/* Organization */} diff --git a/apps/mobile/src/app/(tabs)/tasks.tsx b/apps/mobile/src/app/(tabs)/tasks.tsx index 8d7912f61..cf5342be0 100644 --- a/apps/mobile/src/app/(tabs)/tasks.tsx +++ b/apps/mobile/src/app/(tabs)/tasks.tsx @@ -52,16 +52,16 @@ export default function TasksScreen() { - Code - + Code + Your PostHog Code sessions - + New task diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 344e9b162..b678def6e 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -1,4 +1,5 @@ import "../../global.css"; +import "@/lib/textDefaults"; import { QueryClientProvider } from "@tanstack/react-query"; import { Stack } from "expo-router"; diff --git a/apps/mobile/src/app/auth.tsx b/apps/mobile/src/app/auth.tsx index 0992d2634..013b638e8 100644 --- a/apps/mobile/src/app/auth.tsx +++ b/apps/mobile/src/app/auth.tsx @@ -116,7 +116,7 @@ export default function AuthScreen() { > {/* Header */} - + PostHog Mobile diff --git a/apps/mobile/src/components/OfflineBanner.tsx b/apps/mobile/src/components/OfflineBanner.tsx index e70814fa0..0741f88e7 100644 --- a/apps/mobile/src/components/OfflineBanner.tsx +++ b/apps/mobile/src/components/OfflineBanner.tsx @@ -15,7 +15,7 @@ export function OfflineBanner() { style={{ top: insets.top }} > - + No internet connection diff --git a/apps/mobile/src/components/text.tsx b/apps/mobile/src/components/text.tsx index 72a280910..dfe3f03de 100644 --- a/apps/mobile/src/components/text.tsx +++ b/apps/mobile/src/components/text.tsx @@ -1,5 +1,5 @@ import { Text as RNText, type TextProps } from "react-native"; export function Text({ className, ...props }: TextProps) { - return ; + return ; } diff --git a/apps/mobile/src/features/navigation/components/NavDrawer.tsx b/apps/mobile/src/features/navigation/components/NavDrawer.tsx index dfdf52f5e..d3f101019 100644 --- a/apps/mobile/src/features/navigation/components/NavDrawer.tsx +++ b/apps/mobile/src/features/navigation/components/NavDrawer.tsx @@ -1,7 +1,7 @@ import { Text } from "@components/text"; import { usePathname, useRouter } from "expo-router"; import { GearSix, Plus, Tray } from "phosphor-react-native"; -import { useEffect, useRef } from "react"; +import { type ReactNode, useEffect, useRef } from "react"; import { Animated, Dimensions, @@ -19,6 +19,32 @@ import { useNavDrawerStore } from "../stores/navDrawerStore"; const { width: SCREEN_WIDTH } = Dimensions.get("window"); const DRAWER_WIDTH = Math.min(320, Math.round(SCREEN_WIDTH * 0.85)); +interface DrawerItemProps { + icon: ReactNode; + label: string; + active?: boolean; + onPress: () => void; +} + +function DrawerItem({ icon, label, active, onPress }: DrawerItemProps) { + return ( + + + {icon} + + + {label} + + + ); +} + export function NavDrawer() { const isOpen = useNavDrawerStore((s) => s.isOpen); const close = useNavDrawerStore((s) => s.close); @@ -59,9 +85,7 @@ export function NavDrawer() { }; const handleInbox = () => navigateTo("/inbox"); - const handleSettings = () => navigateTo("/settings"); - const handleHome = () => navigateTo("/tasks"); const handleTaskPress = (taskId: string) => { @@ -69,6 +93,11 @@ export function NavDrawer() { router.push(`/task/${taskId}`); }; + const iconColor = themeColors.gray[11]; + const iconColorActive = themeColors.gray[12]; + const isOnInbox = pathname === "/inbox"; + const isOnSettings = pathname === "/settings"; + return ( - PostHog + PostHog - - + } + label="New task" onPress={handleNewTask} - className="flex-row items-center gap-3 rounded-lg px-3 py-3 active:bg-gray-3" - > - - - New task - - - - + + } + label="Inbox" + active={isOnInbox} onPress={handleInbox} - className="flex-row items-center gap-3 rounded-lg px-3 py-3 active:bg-gray-3" - > - - Inbox - + /> - + - - + + Tasks {tasks.length === 0 ? ( - - No tasks yet + + No tasks yet ) : ( - tasks.map((task) => ( - handleTaskPress(task.id)} - className="px-4 py-3 active:bg-gray-3" - > - { + const taskHref = `/task/${task.id}`; + const active = pathname === taskHref; + return ( + handleTaskPress(task.id)} + className={`rounded-md px-2.5 py-2 ${active ? "bg-gray-3" : "active:bg-gray-2"}`} > - {task.title} - - - )) + + {task.title} + + + ); + }) )} - - + + } + label="Settings" + active={isOnSettings} onPress={handleSettings} - className="flex-row items-center gap-3 rounded-lg px-3 py-3 active:bg-gray-3" - > - - - Settings - - + /> diff --git a/apps/mobile/src/lib/textDefaults.ts b/apps/mobile/src/lib/textDefaults.ts new file mode 100644 index 000000000..02e449edf --- /dev/null +++ b/apps/mobile/src/lib/textDefaults.ts @@ -0,0 +1,22 @@ +import { cloneElement, type ReactElement } from "react"; +import { Text, type TextProps } from "react-native"; + +// Apply Open Runde as the default fontFamily for every , including those +// imported directly from react-native. User-provided styles (e.g. font-mono via +// NativeWind className) appear later in the style array and override the default. +type PatchableText = { + render: (...args: unknown[]) => ReactElement; + __posthogPatched?: boolean; +}; +const TextRef = Text as unknown as PatchableText; + +if (!TextRef.__posthogPatched) { + const baseRender = TextRef.render; + TextRef.render = function patchedRender(...args) { + const element = baseRender.apply(this, args); + return cloneElement(element, { + style: [{ fontFamily: "Open Runde" }, element.props.style], + }); + }; + TextRef.__posthogPatched = true; +} diff --git a/apps/mobile/src/lib/theme.ts b/apps/mobile/src/lib/theme.ts index 6395749f1..e459a4873 100644 --- a/apps/mobile/src/lib/theme.ts +++ b/apps/mobile/src/lib/theme.ts @@ -4,74 +4,76 @@ import { useColorScheme, vars } from "nativewind"; * Single source of truth for all theme colors. * Defined as hex for readability, converted to RGB for NativeWind vars(). */ +// Color palette mirrored from the desktop app (apps/code globals.css). +// Light: slate gray + orange accent. Dark: slate gray + yellow accent. const colors = { light: { gray: { - 1: "#eaeaea", - 2: "#e5e5e5", - 3: "#dbdbdb", - 4: "#d2d2d2", - 5: "#cacaca", - 6: "#c1c1c1", - 7: "#b5b5b5", - 8: "#a2a2a2", - 9: "#747474", - 10: "#6a6a6a", - 11: "#4e4e4e", - 12: "#1f1f1f", + 1: "#f2f3ee", + 2: "#eceee8", + 3: "#e4e5de", + 4: "#d8dbd1", + 5: "#cbd0c3", + 6: "#bcc1b4", + 7: "#a9af9f", + 8: "#93998a", + 9: "#6b7165", + 10: "#5a6054", + 11: "#3a4036", + 12: "#0d0d0d", }, accent: { - 1: "#ecebe9", - 2: "#f1e5d5", - 3: "#fcd9ac", - 4: "#ffcb81", - 5: "#ffbd57", - 6: "#f1b154", - 7: "#de9f41", - 8: "#ce8500", - 9: "#dc9300", - 10: "#d08800", - 11: "#8a5400", - 12: "#4d3616", + 1: "#fff5f0", + 2: "#ffe8dc", + 3: "#ffd0b8", + 4: "#ffb38a", + 5: "#ff8f56", + 6: "#f57030", + 7: "#e05a18", + 8: "#c94800", + 9: "#f54d00", + 10: "#e64600", + 11: "#a33300", + 12: "#4d1800", contrast: "#ffffff", }, status: { - success: "#22c55e", - error: "#ef4444", - warning: "#f59e0b", - info: "#3b82f6", + success: "#16a34a", + error: "#dc2626", + warning: "#d97706", + info: "#2563eb", }, - background: "#eeefe9", + background: "#f2f3ee", }, dark: { gray: { - 1: "#151515", - 2: "#1c1c1c", - 3: "#242424", - 4: "#2b2b28", - 5: "#323231", - 6: "#3b3b38", - 7: "#484846", - 8: "#60605c", - 9: "#6e6e6b", - 10: "#7b7b7b", - 11: "#b4b4b1", - 12: "#eeeeea", + 1: "#131316", + 2: "#18181f", + 3: "#1e1e28", + 4: "#24243e", + 5: "#2a2a37", + 6: "#2e2e3d", + 7: "#40405a", + 8: "#616180", + 9: "#7c7c9e", + 10: "#8d8daa", + 11: "#9898b6", + 12: "#e6e6e6", }, accent: { - 1: "#181410", - 2: "#1e1911", - 3: "#2e210e", - 4: "#3f2700", - 5: "#4c3101", - 6: "#5a3e13", - 7: "#6e5022", - 8: "#8d662d", - 9: "#f1a82c", - 10: "#e69d18", - 11: "#f9b858", - 12: "#fbe3c4", - contrast: "#2d1f0a", + 1: "#14120a", + 2: "#1a1608", + 3: "#261e07", + 4: "#362900", + 5: "#443300", + 6: "#524007", + 7: "#6b561a", + 8: "#8c7230", + 9: "#f8be2a", + 10: "#ebb520", + 11: "#fcc84e", + 12: "#fde8b8", + contrast: "#1a1200", }, status: { success: "#4ade80", @@ -79,7 +81,7 @@ const colors = { warning: "#fbbf24", info: "#60a5fa", }, - background: "#151515", + background: "#131316", }, } as const; diff --git a/apps/mobile/tailwind.config.js b/apps/mobile/tailwind.config.js index d09928ff2..eaa4178ee 100644 --- a/apps/mobile/tailwind.config.js +++ b/apps/mobile/tailwind.config.js @@ -43,6 +43,7 @@ module.exports = { background: "rgb(var(--background) / )", }, fontFamily: { + sans: ["Open Runde"], mono: ["JetBrains Mono"], }, }, From 2b7d48541f3a1a485043b4f4df17ec9372785dcb Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 13 May 2026 15:53:42 -0400 Subject: [PATCH 04/94] Added icons to the task list --- .claude/settings.local.json | 4 +- .../features/tasks/components/TaskItem.tsx | 98 +++++++------------ .../features/tasks/components/TaskList.tsx | 81 ++++++++++++--- .../tasks/components/TaskStatusIcon.tsx | 85 ++++++++++++++++ 4 files changed, 193 insertions(+), 75 deletions(-) create mode 100644 apps/mobile/src/features/tasks/components/TaskStatusIcon.tsx diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 48e659ea6..1990a1ac1 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -15,7 +15,9 @@ "Bash(ls -lh /Users/tomowers/dev/posthog/code/apps/mobile/assets/fonts/OpenRunde/ && rm /tmp/convert_woff.py)", "Bash(python3 -c ' *)", "Bash(python3 /tmp/normalize_fonts.py)", - "Bash(xargs grep -l \"^ Text,\\\\|^ Text$\\\\|, Text,\")" + "Bash(xargs grep -l \"^ Text,\\\\|^ Text$\\\\|, Text,\")", + "Bash(grep -E \"\\\\.\\(ts|tsx\\)$\")", + "Bash(node -e \"const p = require\\('phosphor-react-native'\\); console.log\\(Object.keys\\(p\\).filter\\(k => /[Cc]ircle/.test\\(k\\)\\).slice\\(0, 30\\).join\\(', '\\)\\)\")" ], "additionalDirectories": [ "/private/tmp" diff --git a/apps/mobile/src/features/tasks/components/TaskItem.tsx b/apps/mobile/src/features/tasks/components/TaskItem.tsx index e067abda7..5ed53a362 100644 --- a/apps/mobile/src/features/tasks/components/TaskItem.tsx +++ b/apps/mobile/src/features/tasks/components/TaskItem.tsx @@ -3,28 +3,13 @@ import { differenceInHours, format, formatDistanceToNow } from "date-fns"; import { memo } from "react"; import { Pressable, View } from "react-native"; import type { Task } from "../types"; +import { TaskStatusIcon } from "./TaskStatusIcon"; interface TaskItemProps { task: Task; onPress: (task: Task) => void; } -const statusColorMap: Record = { - completed: { bg: "bg-status-success/20", text: "text-status-success" }, - failed: { bg: "bg-status-error/20", text: "text-status-error" }, - in_progress: { bg: "bg-status-info/20", text: "text-status-info" }, - started: { bg: "bg-status-warning/20", text: "text-status-warning" }, - backlog: { bg: "bg-gray-5/20", text: "text-gray-9" }, -}; - -const statusDisplayMap: Record = { - completed: "Completed", - failed: "Failed", - in_progress: "In progress", - started: "Started", - backlog: "Backlog", -}; - function TaskItemComponent({ task, onPress }: TaskItemProps) { const createdAt = new Date(task.created_at); const hoursSinceCreated = differenceInHours(new Date(), createdAt); @@ -33,68 +18,55 @@ function TaskItemComponent({ task, onPress }: TaskItemProps) { ? formatDistanceToNow(createdAt, { addSuffix: true }) : format(createdAt, "MMM d"); - const prUrl = task.latest_run?.output?.pr_url as string | undefined; - const hasPR = !!prUrl; - const status = hasPR ? "completed" : task.latest_run?.status || "backlog"; const environment = task.latest_run?.environment; - const statusColors = statusColorMap[status] || statusColorMap.backlog; - return ( onPress(task)} - className="border-gray-6 border-b px-3 py-3 active:bg-gray-3" + className="flex-row items-start gap-3 border-gray-6 border-b px-3 py-3 active:bg-gray-3" > - - {/* Slug */} - {task.slug} + {/* Status icon column */} + + + - {/* Status Badge */} - - - {statusDisplayMap[status] || status} + {/* Content column */} + + + {task.slug} + {environment === "cloud" && ( + + Cloud + + )} + {environment === "local" && ( + + Local + + )} + + + {timeDisplay} - {/* Environment badge */} - {environment === "cloud" && ( - - Cloud - - )} - {environment === "local" && ( - - Local - - )} - - - {/* Title */} - - {task.title} - - - {/* Description preview */} - {task.description && ( - {task.description} + {task.title} - )} - {/* Bottom row: repo + time */} - - - {task.repository || "No repository"} - - {timeDisplay} + {task.description ? ( + + {task.description} + + ) : null} ); diff --git a/apps/mobile/src/features/tasks/components/TaskList.tsx b/apps/mobile/src/features/tasks/components/TaskList.tsx index 188714b6e..a54441d59 100644 --- a/apps/mobile/src/features/tasks/components/TaskList.tsx +++ b/apps/mobile/src/features/tasks/components/TaskList.tsx @@ -1,6 +1,6 @@ import { Text } from "@components/text"; import * as WebBrowser from "expo-web-browser"; -import { CaretRight } from "phosphor-react-native"; +import { CaretRight, GitBranch } from "phosphor-react-native"; import { useMemo, useState } from "react"; import { ActivityIndicator, @@ -113,8 +113,17 @@ function CreateTaskEmptyState({ onCreateTask }: CreateTaskEmptyStateProps) { type ListItem = | { type: "task"; task: Task; isArchived: boolean } + | { type: "repo-header"; repoLabel: string; count: number } | { type: "archived-header"; count: number; expanded: boolean }; +const NO_REPO_LABEL = "No repository"; + +function repoSortKey(task: Task): number { + // Most recent activity first within a group. + const ts = task.latest_run?.updated_at ?? task.updated_at ?? task.created_at; + return -new Date(ts).getTime(); +} + export function TaskList({ onTaskPress, onCreateTask }: TaskListProps) { const { tasks, isLoading, error, refetch } = useTasks(); const { hasGithubIntegration, refetch: refetchIntegrations } = @@ -149,11 +158,41 @@ export function TaskList({ onTaskPress, onCreateTask }: TaskListProps) { (a, b) => (archivedTasks[a.id] ?? 0) - (archivedTasks[b.id] ?? 0), ); - const items: ListItem[] = active.map((task) => ({ - type: "task", - task, - isArchived: false, - })); + // Group active tasks by repository. + const groups = new Map(); + for (const task of active) { + const key = task.repository?.trim() || NO_REPO_LABEL; + const bucket = groups.get(key); + if (bucket) { + bucket.push(task); + } else { + groups.set(key, [task]); + } + } + + // Sort each group's tasks by most-recent activity. + for (const tasksInRepo of groups.values()) { + tasksInRepo.sort((a, b) => repoSortKey(a) - repoSortKey(b)); + } + + // Order groups: most-recently-active repo first; "No repository" last. + const groupEntries = Array.from(groups.entries()).sort((a, b) => { + if (a[0] === NO_REPO_LABEL) return 1; + if (b[0] === NO_REPO_LABEL) return -1; + return repoSortKey(a[1][0]) - repoSortKey(b[1][0]); + }); + + const items: ListItem[] = []; + for (const [repoLabel, tasksInRepo] of groupEntries) { + items.push({ + type: "repo-header", + repoLabel, + count: tasksInRepo.length, + }); + for (const task of tasksInRepo) { + items.push({ type: "task", task, isArchived: false }); + } + } if (archived.length > 0) { items.push({ @@ -214,12 +253,32 @@ export function TaskList({ onTaskPress, onCreateTask }: TaskListProps) { - item.type === "archived-header" - ? "__archived_header__" - : `${item.task.id}-${item.isArchived ? "a" : "v"}` - } + keyExtractor={(item) => { + switch (item.type) { + case "archived-header": + return "__archived_header__"; + case "repo-header": + return `__repo__${item.repoLabel}`; + case "task": + return `${item.task.id}-${item.isArchived ? "a" : "v"}`; + } + }} renderItem={({ item }) => { + if (item.type === "repo-header") { + return ( + + + + {item.repoLabel} + + {item.count} + + ); + } + if (item.type === "archived-header") { return ( { + if (!isRunning) { + rotation.stopAnimation(); + rotation.setValue(0); + return; + } + const loop = Animated.loop( + Animated.timing(rotation, { + toValue: 1, + duration: 1000, + easing: Easing.linear, + useNativeDriver: true, + }), + ); + loop.start(); + return () => loop.stop(); + }, [isRunning, rotation]); + + // Priority: PR open > completed > failed > running > started > backlog + if (prUrl) { + return ( + + ); + } + + if (status === "completed") { + return ( + + ); + } + + if (status === "failed") { + return ; + } + + if (status === "in_progress") { + const spin = rotation.interpolate({ + inputRange: [0, 1], + outputRange: ["0deg", "360deg"], + }); + return ( + + + + ); + } + + if (status === "started") { + return ; + } + + // Backlog / no run yet + return ( + + + + ); +} + +export const TaskStatusIcon = memo(TaskStatusIconComponent); From 33083e051f1c8a79a9e482af442179c1ffc26906 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 13 May 2026 16:00:06 -0400 Subject: [PATCH 05/94] task list icons --- .../src/features/navigation/components/NavDrawer.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/navigation/components/NavDrawer.tsx b/apps/mobile/src/features/navigation/components/NavDrawer.tsx index d3f101019..87876a1db 100644 --- a/apps/mobile/src/features/navigation/components/NavDrawer.tsx +++ b/apps/mobile/src/features/navigation/components/NavDrawer.tsx @@ -12,6 +12,7 @@ import { View, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { TaskStatusIcon } from "@/features/tasks/components/TaskStatusIcon"; import { useTasks } from "@/features/tasks/hooks/useTasks"; import { useThemeColors } from "@/lib/theme"; import { useNavDrawerStore } from "../stores/navDrawerStore"; @@ -177,10 +178,13 @@ export function NavDrawer() { handleTaskPress(task.id)} - className={`rounded-md px-2.5 py-2 ${active ? "bg-gray-3" : "active:bg-gray-2"}`} + className={`flex-row items-center gap-2.5 rounded-md px-2.5 py-2 ${active ? "bg-gray-3" : "active:bg-gray-2"}`} > + + + From c0285fb5b8b1301a0f2f1a92e55d1bc629ca1bb3 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 13 May 2026 16:03:17 -0400 Subject: [PATCH 06/94] Added smooth nav --- .../navigation/components/NavDrawer.tsx | 43 +++++++++++++++---- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/features/navigation/components/NavDrawer.tsx b/apps/mobile/src/features/navigation/components/NavDrawer.tsx index 87876a1db..3f16a4027 100644 --- a/apps/mobile/src/features/navigation/components/NavDrawer.tsx +++ b/apps/mobile/src/features/navigation/components/NavDrawer.tsx @@ -1,7 +1,7 @@ import { Text } from "@components/text"; import { usePathname, useRouter } from "expo-router"; import { GearSix, Plus, Tray } from "phosphor-react-native"; -import { type ReactNode, useEffect, useRef } from "react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; import { Animated, Dimensions, @@ -63,21 +63,48 @@ export function NavDrawer() { const translateX = useRef(new Animated.Value(-DRAWER_WIDTH)).current; const backdropOpacity = useRef(new Animated.Value(0)).current; + const [isMounted, setIsMounted] = useState(false); useEffect(() => { + if (isOpen) { + // Mount before the animation kicks off so the drawer is on-screen to + // animate. The animation then slides it in from the offscreen position. + setIsMounted(true); + Animated.parallel([ + Animated.timing(translateX, { + toValue: 0, + duration: 280, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }), + Animated.timing(backdropOpacity, { + toValue: 1, + duration: 280, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }), + ]).start(); + return; + } + Animated.parallel([ Animated.timing(translateX, { - toValue: isOpen ? 0 : -DRAWER_WIDTH, - duration: isOpen ? 240 : 200, - easing: isOpen ? Easing.out(Easing.cubic) : Easing.in(Easing.cubic), + toValue: -DRAWER_WIDTH, + duration: 220, + easing: Easing.in(Easing.cubic), useNativeDriver: true, }), Animated.timing(backdropOpacity, { - toValue: isOpen ? 1 : 0, - duration: isOpen ? 240 : 200, + toValue: 0, + duration: 220, + easing: Easing.in(Easing.cubic), useNativeDriver: true, }), - ]).start(); + ]).start(({ finished }) => { + // Only unmount once the close animation actually settles, so a rapid + // re-open mid-close doesn't accidentally tear the drawer down. + if (finished) setIsMounted(false); + }); }, [isOpen, translateX, backdropOpacity]); const handleNewTask = () => { @@ -101,7 +128,7 @@ export function NavDrawer() { return ( Date: Wed, 13 May 2026 16:12:12 -0400 Subject: [PATCH 07/94] Added filtering --- apps/mobile/src/app/(tabs)/tasks.tsx | 9 + .../src/features/auth/hooks/useUserQuery.ts | 1 + .../tasks/components/TaskFilterMenu.tsx | 185 ++++++++++++++++++ .../features/tasks/components/TaskItem.tsx | 29 +-- .../features/tasks/components/TaskList.tsx | 143 ++++++++++---- .../src/features/tasks/hooks/useTasks.ts | 6 +- .../src/features/tasks/stores/taskStore.ts | 102 ++++++---- apps/mobile/src/features/tasks/types.ts | 1 + 8 files changed, 375 insertions(+), 101 deletions(-) create mode 100644 apps/mobile/src/features/tasks/components/TaskFilterMenu.tsx diff --git a/apps/mobile/src/app/(tabs)/tasks.tsx b/apps/mobile/src/app/(tabs)/tasks.tsx index cf5342be0..a78b0db5b 100644 --- a/apps/mobile/src/app/(tabs)/tasks.tsx +++ b/apps/mobile/src/app/(tabs)/tasks.tsx @@ -5,11 +5,17 @@ import { InteractionManager, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { MenuButton } from "@/features/navigation/components/MenuButton"; import { TaskList } from "@/features/tasks"; +import { + TaskFilterButton, + TaskFilterMenu, + useTaskFilterMenu, +} from "@/features/tasks/components/TaskFilterMenu"; export default function TasksScreen() { const router = useRouter(); const insets = useSafeAreaInsets(); const readyRef = useRef(true); + const filterMenu = useTaskFilterMenu(); // Block navigation while a modal dismiss animation is in progress. // When the screen loses focus (modal opens), readyRef is false. @@ -57,6 +63,7 @@ export default function TasksScreen() { Your PostHog Code sessions + + + ); } diff --git a/apps/mobile/src/features/auth/hooks/useUserQuery.ts b/apps/mobile/src/features/auth/hooks/useUserQuery.ts index 66b8a5795..564092854 100644 --- a/apps/mobile/src/features/auth/hooks/useUserQuery.ts +++ b/apps/mobile/src/features/auth/hooks/useUserQuery.ts @@ -7,6 +7,7 @@ export interface UserData { first_name: string; last_name?: string; email: string; + is_staff?: boolean; organization?: { id: string; name: string; diff --git a/apps/mobile/src/features/tasks/components/TaskFilterMenu.tsx b/apps/mobile/src/features/tasks/components/TaskFilterMenu.tsx new file mode 100644 index 000000000..bb603471c --- /dev/null +++ b/apps/mobile/src/features/tasks/components/TaskFilterMenu.tsx @@ -0,0 +1,185 @@ +import { Text } from "@components/text"; +import { CircleIcon, FunnelSimple } from "phosphor-react-native"; +import { useState } from "react"; +import { Modal, Pressable, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useUserQuery } from "@/features/auth"; +import { useThemeColors } from "@/lib/theme"; +import { + type OrganizeMode, + type SortMode, + useTaskStore, +} from "../stores/taskStore"; + +interface MenuSectionProps { + title: string; + children: React.ReactNode; +} + +function MenuSection({ title, children }: MenuSectionProps) { + return ( + + + {title} + + {children} + + ); +} + +interface RadioRowProps { + label: string; + selected: boolean; + onPress: () => void; +} + +function RadioRow({ label, selected, onPress }: RadioRowProps) { + const themeColors = useThemeColors(); + return ( + + + {selected ? ( + + + + ) : ( + + )} + + {label} + + ); +} + +interface TaskFilterMenuProps { + open: boolean; + onClose: () => void; +} + +export function TaskFilterMenu({ open, onClose }: TaskFilterMenuProps) { + const organizeMode = useTaskStore((s) => s.organizeMode); + const setOrganizeMode = useTaskStore((s) => s.setOrganizeMode); + const sortMode = useTaskStore((s) => s.sortMode); + const setSortMode = useTaskStore((s) => s.setSortMode); + const showInternal = useTaskStore((s) => s.showInternal); + const setShowInternal = useTaskStore((s) => s.setShowInternal); + const { data: userData } = useUserQuery(); + const isStaff = userData?.is_staff === true; + const insets = useSafeAreaInsets(); + + const pickOrganize = (mode: OrganizeMode) => { + setOrganizeMode(mode); + }; + const pickSort = (mode: SortMode) => { + setSortMode(mode); + }; + + return ( + + {/* Backdrop dismisses the menu */} + + {/* noop onPress so taps inside the menu don't bubble to the backdrop */} + {}} + className="absolute right-3 w-64 overflow-hidden rounded-xl border border-gray-6 bg-background" + style={{ + top: insets.top + 64, + shadowColor: "#000", + shadowOpacity: 0.12, + shadowRadius: 16, + shadowOffset: { width: 0, height: 4 }, + elevation: 8, + }} + > + + pickOrganize("by-project")} + /> + pickOrganize("chronological")} + /> + + + + + + pickSort("created")} + /> + pickSort("updated")} + /> + + + {isStaff ? ( + <> + + + setShowInternal(false)} + /> + setShowInternal(true)} + /> + + + ) : null} + + + + ); +} + +interface TaskFilterButtonProps { + onPress: () => void; +} + +export function TaskFilterButton({ onPress }: TaskFilterButtonProps) { + const themeColors = useThemeColors(); + return ( + + + + ); +} + +export function useTaskFilterMenu() { + const [open, setOpen] = useState(false); + return { + open, + show: () => setOpen(true), + hide: () => setOpen(false), + }; +} diff --git a/apps/mobile/src/features/tasks/components/TaskItem.tsx b/apps/mobile/src/features/tasks/components/TaskItem.tsx index 5ed53a362..3f9af52b2 100644 --- a/apps/mobile/src/features/tasks/components/TaskItem.tsx +++ b/apps/mobile/src/features/tasks/components/TaskItem.tsx @@ -18,8 +18,6 @@ function TaskItemComponent({ task, onPress }: TaskItemProps) { ? formatDistanceToNow(createdAt, { addSuffix: true }) : format(createdAt, "MMM d"); - const environment = task.latest_run?.environment; - return ( onPress(task)} @@ -33,31 +31,18 @@ function TaskItemComponent({ task, onPress }: TaskItemProps) { {/* Content column */} - {task.slug} - {environment === "cloud" && ( - - Cloud - - )} - {environment === "local" && ( - - Local - - )} - + + {task.title} + {timeDisplay} - - {task.title} - - {task.description ? ( s.organizeMode); + const sortMode = useTaskStore((s) => s.sortMode); const [archivedExpanded, setArchivedExpanded] = useState(false); const [scrollEnabled, setScrollEnabled] = useState(true); @@ -158,39 +179,76 @@ export function TaskList({ onTaskPress, onCreateTask }: TaskListProps) { (a, b) => (archivedTasks[a.id] ?? 0) - (archivedTasks[b.id] ?? 0), ); - // Group active tasks by repository. - const groups = new Map(); - for (const task of active) { - const key = task.repository?.trim() || NO_REPO_LABEL; - const bucket = groups.get(key); - if (bucket) { - bucket.push(task); - } else { - groups.set(key, [task]); - } - } + const items: ListItem[] = []; - // Sort each group's tasks by most-recent activity. - for (const tasksInRepo of groups.values()) { - tasksInRepo.sort((a, b) => repoSortKey(a) - repoSortKey(b)); - } + if (organizeMode === "by-project") { + // Group active tasks by repository. + const groups = new Map(); + for (const task of active) { + const key = task.repository?.trim() || NO_REPO_LABEL; + const bucket = groups.get(key); + if (bucket) { + bucket.push(task); + } else { + groups.set(key, [task]); + } + } - // Order groups: most-recently-active repo first; "No repository" last. - const groupEntries = Array.from(groups.entries()).sort((a, b) => { - if (a[0] === NO_REPO_LABEL) return 1; - if (b[0] === NO_REPO_LABEL) return -1; - return repoSortKey(a[1][0]) - repoSortKey(b[1][0]); - }); + // Sort each group's tasks by the configured sortMode (newest first). + for (const tasksInRepo of groups.values()) { + tasksInRepo.sort( + (a, b) => + taskActivityTimestamp(b, sortMode) - + taskActivityTimestamp(a, sortMode), + ); + } - const items: ListItem[] = []; - for (const [repoLabel, tasksInRepo] of groupEntries) { - items.push({ - type: "repo-header", - repoLabel, - count: tasksInRepo.length, + // Order groups: most-recently-active repo first; "No repository" last. + const groupEntries = Array.from(groups.entries()).sort((a, b) => { + if (a[0] === NO_REPO_LABEL) return 1; + if (b[0] === NO_REPO_LABEL) return -1; + return ( + taskActivityTimestamp(b[1][0], sortMode) - + taskActivityTimestamp(a[1][0], sortMode) + ); }); - for (const task of tasksInRepo) { - items.push({ type: "task", task, isArchived: false }); + + for (const [repoLabel, tasksInRepo] of groupEntries) { + items.push({ + type: "repo-header", + repoLabel, + count: tasksInRepo.length, + }); + for (const task of tasksInRepo) { + items.push({ type: "task", task, isArchived: false }); + } + } + } else { + // Chronological — flat list grouped by relative-date buckets. + const sorted = [...active].sort( + (a, b) => + taskActivityTimestamp(b, sortMode) - + taskActivityTimestamp(a, sortMode), + ); + + const buckets = new Map(); + for (const task of sorted) { + const label = relativeDateGroup(taskActivityTimestamp(task, sortMode)); + const bucket = buckets.get(label); + if (bucket) { + bucket.push(task); + } else { + buckets.set(label, [task]); + } + } + + for (const label of DATE_GROUP_ORDER) { + const bucket = buckets.get(label); + if (!bucket || bucket.length === 0) continue; + items.push({ type: "date-header", label, count: bucket.length }); + for (const task of bucket) { + items.push({ type: "task", task, isArchived: false }); + } } } @@ -209,7 +267,7 @@ export function TaskList({ onTaskPress, onCreateTask }: TaskListProps) { } return items; - }, [tasks, archivedTasks, archivedExpanded]); + }, [tasks, archivedTasks, archivedExpanded, organizeMode, sortMode]); if (error) { return ( @@ -259,6 +317,8 @@ export function TaskList({ onTaskPress, onCreateTask }: TaskListProps) { return "__archived_header__"; case "repo-header": return `__repo__${item.repoLabel}`; + case "date-header": + return `__date__${item.label}`; case "task": return `${item.task.id}-${item.isArchived ? "a" : "v"}`; } @@ -279,6 +339,21 @@ export function TaskList({ onTaskPress, onCreateTask }: TaskListProps) { ); } + if (item.type === "date-header") { + return ( + + + {item.label} + + {item.count} + + ); + } + if (item.type === "archived-header") { return ( void; - setOrderBy: (orderBy: OrderByField) => void; - setOrderDirection: (direction: OrderDirection) => void; + setOrganizeMode: (mode: OrganizeMode) => void; + setSortMode: (mode: SortMode) => void; + setShowInternal: (showInternal: boolean) => void; setFilter: (filter: string) => void; } -export const useTaskStore = create((set) => ({ - selectedTaskId: null, - orderBy: "created_at", - orderDirection: "desc", - filter: "", +export const useTaskStore = create()( + persist( + (set) => ({ + selectedTaskId: null, + organizeMode: "by-project", + sortMode: "updated", + showInternal: false, + filter: "", - selectTask: (selectedTaskId) => set({ selectedTaskId }), - setOrderBy: (orderBy) => set({ orderBy }), - setOrderDirection: (orderDirection) => set({ orderDirection }), - setFilter: (filter) => set({ filter }), -})); + selectTask: (selectedTaskId) => set({ selectedTaskId }), + setOrganizeMode: (organizeMode) => set({ organizeMode }), + setSortMode: (sortMode) => set({ sortMode }), + setShowInternal: (showInternal) => set({ showInternal }), + setFilter: (filter) => set({ filter }), + }), + { + name: "posthog-task-ui", + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => ({ + organizeMode: state.organizeMode, + sortMode: state.sortMode, + showInternal: state.showInternal, + }), + }, + ), +); + +export function taskActivityTimestamp(task: Task, sortMode: SortMode): number { + if (sortMode === "created") { + return new Date(task.created_at).getTime(); + } + // "updated" — take the most recent of task.updated_at and latest_run.updated_at. + const runUpdated = task.latest_run?.updated_at; + const taskUpdated = task.updated_at ?? task.created_at; + return Math.max( + runUpdated ? new Date(runUpdated).getTime() : 0, + new Date(taskUpdated).getTime(), + ); +} export function filterAndSortTasks( tasks: Task[], - orderBy: OrderByField, - orderDirection: OrderDirection, + sortMode: SortMode, + showInternal: boolean, filter: string, ): Task[] { let filtered = tasks; + // Visibility filter — mirrors desktop radio: External hides internal, Internal shows only internal. + filtered = filtered.filter((task) => + showInternal ? task.internal === true : task.internal !== true, + ); + if (filter) { const lowerFilter = filter.toLowerCase(); - filtered = tasks.filter( + filtered = filtered.filter( (task) => task.title.toLowerCase().includes(lowerFilter) || task.slug.toLowerCase().includes(lowerFilter) || @@ -46,27 +83,8 @@ export function filterAndSortTasks( ); } - return [...filtered].sort((a, b) => { - let comparison = 0; - - switch (orderBy) { - case "created_at": - comparison = - new Date(a.created_at).getTime() - new Date(b.created_at).getTime(); - break; - case "status": { - const statusOrder = ["failed", "in_progress", "started", "completed"]; - const aStatus = a.latest_run?.status || "backlog"; - const bStatus = b.latest_run?.status || "backlog"; - comparison = - statusOrder.indexOf(aStatus) - statusOrder.indexOf(bStatus); - break; - } - case "title": - comparison = a.title.localeCompare(b.title); - break; - } - - return orderDirection === "desc" ? -comparison : comparison; - }); + return [...filtered].sort( + (a, b) => + taskActivityTimestamp(b, sortMode) - taskActivityTimestamp(a, sortMode), + ); } diff --git a/apps/mobile/src/features/tasks/types.ts b/apps/mobile/src/features/tasks/types.ts index 4ee8bb75e..855dad397 100644 --- a/apps/mobile/src/features/tasks/types.ts +++ b/apps/mobile/src/features/tasks/types.ts @@ -9,6 +9,7 @@ export interface Task { origin_product: string; repository?: string | null; github_integration?: number | null; + internal?: boolean; latest_run?: TaskRun; } From d0d37c5076d286deb58a9b34858393c6413e4a88 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 13 May 2026 16:19:25 -0400 Subject: [PATCH 08/94] Added better nav presses --- apps/mobile/src/app/(tabs)/_layout.tsx | 9 +- .../navigation/components/MenuButton.tsx | 4 +- .../navigation/components/NavDrawer.tsx | 267 ++++++++---------- 3 files changed, 130 insertions(+), 150 deletions(-) diff --git a/apps/mobile/src/app/(tabs)/_layout.tsx b/apps/mobile/src/app/(tabs)/_layout.tsx index 3058a01ee..d17dd5e25 100644 --- a/apps/mobile/src/app/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(tabs)/_layout.tsx @@ -18,8 +18,13 @@ export default function TabsLayout() { const subscription = BackHandler.addEventListener( "hardwareBackPress", () => { - // Let the drawer's Modal handle back when it's open. - if (useNavDrawerStore.getState().isOpen) return false; + const store = useNavDrawerStore.getState(); + // Drawer always-mounted: close it explicitly here since there's no + // Modal onRequestClose to fall through to. + if (store.isOpen) { + store.close(); + return true; + } if (pathname === HOME_ROUTE) return false; router.replace(HOME_ROUTE); return true; diff --git a/apps/mobile/src/features/navigation/components/MenuButton.tsx b/apps/mobile/src/features/navigation/components/MenuButton.tsx index a540f138e..b123197d7 100644 --- a/apps/mobile/src/features/navigation/components/MenuButton.tsx +++ b/apps/mobile/src/features/navigation/components/MenuButton.tsx @@ -13,7 +13,9 @@ export function MenuButton({ className }: MenuButtonProps) { return ( { - if (isOpen) { - // Mount before the animation kicks off so the drawer is on-screen to - // animate. The animation then slides it in from the offscreen position. - setIsMounted(true); - Animated.parallel([ - Animated.timing(translateX, { - toValue: 0, - duration: 280, - easing: Easing.out(Easing.cubic), - useNativeDriver: true, - }), - Animated.timing(backdropOpacity, { - toValue: 1, - duration: 280, - easing: Easing.out(Easing.cubic), - useNativeDriver: true, - }), - ]).start(); - return; - } - + // Drawer is always mounted; only the animation values move. The native + // driver runs these off the JS thread, so a press triggers the slide + // instantly without re-rendering the (heavy) drawer subtree. Animated.parallel([ Animated.timing(translateX, { - toValue: -DRAWER_WIDTH, - duration: 220, - easing: Easing.in(Easing.cubic), + toValue: isOpen ? 0 : -DRAWER_WIDTH, + duration: isOpen ? 280 : 220, + easing: isOpen ? Easing.out(Easing.cubic) : Easing.in(Easing.cubic), useNativeDriver: true, }), Animated.timing(backdropOpacity, { - toValue: 0, - duration: 220, - easing: Easing.in(Easing.cubic), + toValue: isOpen ? 1 : 0, + duration: isOpen ? 280 : 220, + easing: isOpen ? Easing.out(Easing.cubic) : Easing.in(Easing.cubic), useNativeDriver: true, }), - ]).start(({ finished }) => { - // Only unmount once the close animation actually settles, so a rapid - // re-open mid-close doesn't accidentally tear the drawer down. - if (finished) setIsMounted(false); - }); + ]).start(); }, [isOpen, translateX, backdropOpacity]); const handleNewTask = () => { @@ -127,120 +104,116 @@ export function NavDrawer() { const isOnSettings = pathname === "/settings"; return ( - - - - - - - - - PostHog - - - - } - label="New task" - onPress={handleNewTask} - /> - - } - label="Inbox" - active={isOnInbox} - onPress={handleInbox} - /> - - - - - - - Tasks - - - - + {/* Touch-down close so the dismiss starts the moment the finger lands. */} + + + + + + PostHog + + + + } + label="New task" + onPress={handleNewTask} + /> + + } + label="Inbox" + active={isOnInbox} + onPress={handleInbox} + /> + + + + + + - {tasks.length === 0 ? ( - - No tasks yet - - ) : ( - tasks.map((task) => { - const taskHref = `/task/${task.id}`; - const active = pathname === taskHref; - return ( - handleTaskPress(task.id)} - className={`flex-row items-center gap-2.5 rounded-md px-2.5 py-2 ${active ? "bg-gray-3" : "active:bg-gray-2"}`} + Tasks + + + + + {tasks.length === 0 ? ( + + No tasks yet + + ) : ( + tasks.map((task) => { + const taskHref = `/task/${task.id}`; + const active = pathname === taskHref; + return ( + handleTaskPress(task.id)} + className={`flex-row items-center gap-2.5 rounded-md px-2.5 py-2 ${active ? "bg-gray-3" : "active:bg-gray-2"}`} + > + + + + - - - - - {task.title} - - - ); - }) - )} - - - - - - - } - label="Settings" - active={isOnSettings} - onPress={handleSettings} - /> - - - - + {task.title} + + + ); + }) + )} + + + + + + + } + label="Settings" + active={isOnSettings} + onPress={handleSettings} + /> + + + ); } From caef810813c30787fdd905d2d1d9ca0de21d594e Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 13 May 2026 17:04:47 -0400 Subject: [PATCH 09/94] new task UI --- .claude/settings.local.json | 4 +- apps/mobile/src/app/task/index.tsx | 469 +++++++++++++----- apps/mobile/src/features/tasks/api.ts | 24 +- .../features/tasks/composer/DotBackground.tsx | 28 ++ .../src/features/tasks/composer/Pill.tsx | 40 ++ .../tasks/composer/RepositoryPickerSheet.tsx | 143 ++++++ .../features/tasks/composer/SelectSheet.tsx | 114 +++++ .../src/features/tasks/composer/options.ts | 81 +++ apps/mobile/src/lib/theme.ts | 6 + apps/mobile/tailwind.config.js | 1 + 10 files changed, 788 insertions(+), 122 deletions(-) create mode 100644 apps/mobile/src/features/tasks/composer/DotBackground.tsx create mode 100644 apps/mobile/src/features/tasks/composer/Pill.tsx create mode 100644 apps/mobile/src/features/tasks/composer/RepositoryPickerSheet.tsx create mode 100644 apps/mobile/src/features/tasks/composer/SelectSheet.tsx create mode 100644 apps/mobile/src/features/tasks/composer/options.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 1990a1ac1..c0a10441c 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -17,7 +17,9 @@ "Bash(python3 /tmp/normalize_fonts.py)", "Bash(xargs grep -l \"^ Text,\\\\|^ Text$\\\\|, Text,\")", "Bash(grep -E \"\\\\.\\(ts|tsx\\)$\")", - "Bash(node -e \"const p = require\\('phosphor-react-native'\\); console.log\\(Object.keys\\(p\\).filter\\(k => /[Cc]ircle/.test\\(k\\)\\).slice\\(0, 30\\).join\\(', '\\)\\)\")" + "Bash(node -e \"const p = require\\('phosphor-react-native'\\); console.log\\(Object.keys\\(p\\).filter\\(k => /[Cc]ircle/.test\\(k\\)\\).slice\\(0, 30\\).join\\(', '\\)\\)\")", + "Bash(grep -E \"\\\\.\\(tsx?|jsx?\\)$\")", + "Bash(grep -E \"\\\\.\\(tsx?\\)$\")" ], "additionalDirectories": [ "/private/tmp" diff --git a/apps/mobile/src/app/task/index.tsx b/apps/mobile/src/app/task/index.tsx index 38023a5ae..e4c053c9d 100644 --- a/apps/mobile/src/app/task/index.tsx +++ b/apps/mobile/src/app/task/index.tsx @@ -1,17 +1,31 @@ import { Text } from "@components/text"; import { Stack, useRouter } from "expo-router"; import * as WebBrowser from "expo-web-browser"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { + ArrowUp, + BrainIcon, + CaretDown, + GithubLogo, + PaperclipIcon, + PauseIcon, + PencilIcon, + Robot, + ShieldCheck, +} from "phosphor-react-native"; +import { useCallback, useEffect, useState } from "react"; import { ActivityIndicator, - Keyboard, - KeyboardAvoidingView, - Platform, Pressable, ScrollView, TextInput, View, } from "react-native"; +import { + useKeyboardHandler, + useReanimatedKeyboardAnimation, +} from "react-native-keyboard-controller"; +import Animated, { runOnJS, useAnimatedStyle } from "react-native-reanimated"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useAuthStore } from "@/features/auth"; import { createTask, @@ -20,11 +34,37 @@ import { type Integration, runTaskInCloud, } from "@/features/tasks"; +import { DotBackground } from "@/features/tasks/composer/DotBackground"; +import { + DEFAULT_EXECUTION_MODE, + DEFAULT_MODEL, + DEFAULT_REASONING, + EXECUTION_MODES, + type ExecutionMode, + MODELS, + modeLabel, + modelLabel, + modelSupportsReasoning, + REASONING_LEVELS, + type ReasoningEffort, + reasoningLabel, +} from "@/features/tasks/composer/options"; +import { Pill } from "@/features/tasks/composer/Pill"; +import { RepositoryPickerSheet } from "@/features/tasks/composer/RepositoryPickerSheet"; +import { SelectSheet } from "@/features/tasks/composer/SelectSheet"; import { logger } from "@/lib/logger"; import { useThemeColors } from "@/lib/theme"; const log = logger.scope("task-create"); +// Pre-canned starter prompts shown above the composer when the input is empty. +// Mirrors Claude Code's "Suggestions" affordance for an empty new-task screen. +const SUGGESTIONS = [ + "Create or update my CLAUDE.md file", + "Search for a TODO comment and fix it", + "Recommend areas to improve our tests", +] as const; + interface ConnectGitHubPromptProps { onConnected?: () => void; } @@ -38,13 +78,11 @@ function ConnectGitHubPrompt({ onConnected }: ConnectGitHubPromptProps) { const baseUrl = getCloudUrlFromRegion(cloudRegion); const authorizeUrl = `${baseUrl}/api/environments/${projectId}/integrations/authorize/?kind=github`; - // Open in-app browser - will auto-detect when user returns const result = await WebBrowser.openAuthSessionAsync( authorizeUrl, "posthog://github/callback", ); - // When browser session ends, refresh integrations if ( result.type === "dismiss" || result.type === "cancel" || @@ -55,23 +93,22 @@ function ConnectGitHubPrompt({ onConnected }: ConnectGitHubPromptProps) { }; return ( - - - 🔗 - + + + + Connect GitHub to continue - + You need to connect your GitHub account before creating tasks. This allows PostHog to work on your repositories. - + Connect GitHub @@ -79,22 +116,78 @@ function ConnectGitHubPrompt({ onConnected }: ConnectGitHubPromptProps) { ); } +function modeIcon(mode: ExecutionMode, color: string, size = 14) { + switch (mode) { + case "plan": + return ; + case "default": + return ; + case "acceptEdits": + return ; + } +} + export default function NewTaskScreen() { const router = useRouter(); const themeColors = useThemeColors(); + const insets = useSafeAreaInsets(); + const keyboard = useReanimatedKeyboardAnimation(); + const restingBottom = insets.bottom + 12; + + // Compress the layout from the bottom as the keyboard rises: paddingBottom + // interpolates from `restingBottom` (clear the home indicator) to the + // keyboard's height (flush against the keyboard top, no gap). Smooth because + // we blend the two via the keyboard's progress value. + const containerStyle = useAnimatedStyle(() => { + const kbHeight = -keyboard.height.value; + const progress = keyboard.progress.value; + return { + paddingBottom: kbHeight + restingBottom * (1 - progress), + }; + }); + + // Fade suggestions out as the keyboard appears so they don't clip the top of + // the now-smaller middle area. + const suggestionsStyle = useAnimatedStyle(() => ({ + opacity: 1 - keyboard.progress.value, + })); + + // Track whether the keyboard is opening so we can disable touches on the + // (now-fading) suggestions. `useKeyboardHandler.onStart` fires at the start + // of the keyboard animation — synchronously with the opacity fade — so there + // is no window where invisible cards are still tappable. + const [keyboardActive, setKeyboardActive] = useState(false); + useKeyboardHandler( + { + onStart: (e) => { + "worklet"; + runOnJS(setKeyboardActive)(e.height > 0); + }, + }, + [], + ); + + // Data const [integrations, setIntegrations] = useState([]); const [repositories, setRepositories] = useState([]); - const [selectedRepo, setSelectedRepo] = useState(null); - const [repoSearch, setRepoSearch] = useState(""); + const [loadingRepos, setLoadingRepos] = useState(true); + + // Composer state const [prompt, setPrompt] = useState(""); + const [selectedRepo, setSelectedRepo] = useState(null); + const [mode, setMode] = useState(DEFAULT_EXECUTION_MODE); + const [model, setModel] = useState(DEFAULT_MODEL); + const [reasoning, setReasoning] = + useState(DEFAULT_REASONING); + + // Submit state 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]); + // Sheet visibility + const [repoSheetOpen, setRepoSheetOpen] = useState(false); + const [modeSheetOpen, setModeSheetOpen] = useState(false); + const [modelSheetOpen, setModelSheetOpen] = useState(false); + const [reasoningSheetOpen, setReasoningSheetOpen] = useState(false); const loadIntegrations = useCallback(async () => { try { @@ -123,13 +216,13 @@ export default function NewTaskScreen() { }, [loadIntegrations]); const handleCreateTask = useCallback(async () => { - if (!prompt.trim() || !selectedRepo) return; + if (!prompt.trim() || !selectedRepo || creating) return; setCreating(true); try { const githubIntegration = integrations.find((i) => i.kind === "github"); - const trimmedPrompt = prompt.trim(); + const task = await createTask({ description: trimmedPrompt, title: trimmedPrompt.slice(0, 100), @@ -137,25 +230,57 @@ export default function NewTaskScreen() { github_integration: githubIntegration?.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...". + const supportsReasoning = modelSupportsReasoning(model); + await runTaskInCloud(task.id, { pendingUserMessage: trimmedPrompt, + runtimeAdapter: "claude", + model, + reasoningEffort: supportsReasoning ? reasoning : undefined, + initialPermissionMode: mode, }); - // Navigate to task detail (replaces current modal) router.replace(`/task/${task.id}`); } catch (error) { log.error("Failed to create task", error); } finally { setCreating(false); } - }, [prompt, selectedRepo, integrations, router]); + }, [ + prompt, + selectedRepo, + integrations, + model, + reasoning, + mode, + router, + creating, + ]); const hasGithubIntegration = integrations.length > 0; - const canSubmit = prompt.trim() && selectedRepo && !creating; + const canSubmit = !!prompt.trim() && !!selectedRepo && !creating; + const showReasoningPill = modelSupportsReasoning(model); + + // Render the connect-github state at the top of an otherwise simple + // screen — composer is hidden until a GitHub integration exists. + if (!loadingRepos && !hasGithubIntegration) { + return ( + <> + + + + + + ); + } return ( <> @@ -168,113 +293,217 @@ export default function NewTaskScreen() { presentation: "modal", }} /> - - - - {loadingRepos ? ( - - - - Loading repositories... + + + + + {/* Suggestions — vertically centered above the composer. Fades out + as the keyboard rises so they don't clip the top of the + now-smaller middle area. */} + + {prompt.trim().length === 0 ? ( + + + Suggestions - - ) : !hasGithubIntegration ? ( - - ) : ( - <> - Repository - + {SUGGESTIONS.map((suggestion) => ( + setPrompt(suggestion)} + className="rounded-2xl border border-gray-6 bg-card px-4 py-3 active:bg-gray-2" + > + + {suggestion} + + + ))} + + + ) : null} + + + {/* Bottom composer block — repo pill above, composer card below. + No own paddingBottom; the parent Animated.View provides it so + the composer sits flush against the keyboard top when open. */} + + + setRepoSheetOpen(true)} + className="flex-row items-center gap-2 rounded-full border border-gray-6 bg-card py-1.5 pr-2.5 pl-2 active:bg-gray-2" + > + + + {selectedRepo ?? "Select repository…"} + + + + + + + + + + { + /* attachments — coming soon */ + }} + className="h-9 w-9 items-center justify-center" + > + + + - {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} - - - )) - )} - + setModeSheetOpen(true)} + /> - - Task description - - + } + label={modelLabel(model)} + onPress={() => setModelSheetOpen(true)} + /> + + {showReasoningPill ? ( + + } + label={reasoningLabel(reasoning)} + onPress={() => setReasoningSheetOpen(true)} + /> + ) : null} + {creating ? ( ) : ( - - Create task - + )} - - )} - - - + + + + + + + {/* Sheets */} + setRepoSheetOpen(false)} + /> + + setMode(v as ExecutionMode)} + onClose={() => setModeSheetOpen(false)} + options={EXECUTION_MODES.map((m) => ({ + value: m.value, + label: m.label, + description: m.description, + icon: modeIcon( + m.value, + m.value === "plan" ? themeColors.accent[11] : themeColors.gray[11], + 16, + ), + }))} + /> + + { + setModel(v); + // If the new model doesn't support reasoning, drop the level so the + // payload stays consistent. Re-pick last value when switching back. + if (!modelSupportsReasoning(v)) setReasoning(DEFAULT_REASONING); + }} + onClose={() => setModelSheetOpen(false)} + options={MODELS.map((m) => ({ + value: m.value, + label: m.label, + description: m.description, + icon: , + }))} + /> + + setReasoning(v as ReasoningEffort)} + onClose={() => setReasoningSheetOpen(false)} + options={REASONING_LEVELS.map((r) => ({ + value: r.value, + label: r.label, + icon: , + }))} + /> ); } diff --git a/apps/mobile/src/features/tasks/api.ts b/apps/mobile/src/features/tasks/api.ts index f88901ec4..e91ed0d92 100644 --- a/apps/mobile/src/features/tasks/api.ts +++ b/apps/mobile/src/features/tasks/api.ts @@ -202,6 +202,14 @@ export interface RunTaskInCloudOptions { resumeFromRunId?: string; pendingUserMessage?: string; mode?: "interactive" | "background"; + /** Adapter to use on the cloud runner. Currently only "claude" on mobile. */ + runtimeAdapter?: "claude" | "codex"; + /** Gateway model ID, e.g. "claude-opus-4-7". */ + model?: string; + /** Reasoning effort: "low" | "medium" | "high" (model-dependent). */ + reasoningEffort?: string; + /** Permission mode: "default" | "acceptEdits" | "plan". */ + initialPermissionMode?: string; } export async function runTaskInCloud( @@ -220,7 +228,11 @@ export async function runTaskInCloud( (options.branch !== undefined || options.resumeFromRunId !== undefined || options.pendingUserMessage !== undefined || - options.mode !== undefined); + options.mode !== undefined || + options.runtimeAdapter !== undefined || + options.model !== undefined || + options.reasoningEffort !== undefined || + options.initialPermissionMode !== undefined); let body: string | undefined; if (hasOptions) { @@ -234,6 +246,16 @@ export async function runTaskInCloud( if (options?.pendingUserMessage) { payload.pending_user_message = options.pendingUserMessage; } + if (options?.runtimeAdapter) { + payload.runtime_adapter = options.runtimeAdapter; + if (options?.model) payload.model = options.model; + if (options?.reasoningEffort) { + payload.reasoning_effort = options.reasoningEffort; + } + } + if (options?.initialPermissionMode) { + payload.initial_permission_mode = options.initialPermissionMode; + } body = JSON.stringify(payload); } diff --git a/apps/mobile/src/features/tasks/composer/DotBackground.tsx b/apps/mobile/src/features/tasks/composer/DotBackground.tsx new file mode 100644 index 000000000..f68672049 --- /dev/null +++ b/apps/mobile/src/features/tasks/composer/DotBackground.tsx @@ -0,0 +1,28 @@ +import { StyleSheet, View } from "react-native"; +import Svg, { Circle, Defs, Pattern, Rect } from "react-native-svg"; +import { useThemeColors } from "@/lib/theme"; + +/** + * Subtle tileable dot grid background, matching the desktop new-task screen. + * Renders absolute-fill behind the composer. + */ +export function DotBackground() { + const colors = useThemeColors(); + return ( + + + + + + + + + + + ); +} diff --git a/apps/mobile/src/features/tasks/composer/Pill.tsx b/apps/mobile/src/features/tasks/composer/Pill.tsx new file mode 100644 index 000000000..5860c05d0 --- /dev/null +++ b/apps/mobile/src/features/tasks/composer/Pill.tsx @@ -0,0 +1,40 @@ +import { Text } from "@components/text"; +import { CaretDown } from "phosphor-react-native"; +import type { ReactNode } from "react"; +import { Pressable, View } from "react-native"; +import { useThemeColors } from "@/lib/theme"; + +interface PillProps { + icon?: ReactNode; + label: string; + /** Optional secondary muted label, e.g. placeholder text "Select…". */ + placeholder?: boolean; + /** Tone the label in accent (used for Plan Mode in the desktop). */ + accent?: boolean; + onPress: () => void; +} + +export function Pill({ icon, label, placeholder, accent, onPress }: PillProps) { + const themeColors = useThemeColors(); + return ( + + {icon ? {icon} : null} + + {label} + + + + ); +} diff --git a/apps/mobile/src/features/tasks/composer/RepositoryPickerSheet.tsx b/apps/mobile/src/features/tasks/composer/RepositoryPickerSheet.tsx new file mode 100644 index 000000000..de356ddb4 --- /dev/null +++ b/apps/mobile/src/features/tasks/composer/RepositoryPickerSheet.tsx @@ -0,0 +1,143 @@ +import { Text } from "@components/text"; +import { Check, MagnifyingGlass } from "phosphor-react-native"; +import { useMemo, useState } from "react"; +import { + ActivityIndicator, + Modal, + Pressable, + ScrollView, + TextInput, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useThemeColors } from "@/lib/theme"; + +interface RepositoryPickerSheetProps { + open: boolean; + repositories: string[]; + selected: string | null; + loading?: boolean; + onChange: (repo: string) => void; + onClose: () => void; +} + +export function RepositoryPickerSheet({ + open, + repositories, + selected, + loading, + onChange, + onClose, +}: RepositoryPickerSheetProps) { + const themeColors = useThemeColors(); + const insets = useSafeAreaInsets(); + const [search, setSearch] = useState(""); + + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return repositories; + return repositories.filter((r) => r.toLowerCase().includes(q)); + }, [repositories, search]); + + return ( + + + {}} + className="mt-auto h-3/4 rounded-t-2xl border-gray-6 border-t bg-background" + style={{ + paddingBottom: insets.bottom + 12, + shadowColor: "#000", + shadowOpacity: 0.15, + shadowRadius: 20, + shadowOffset: { width: 0, height: -4 }, + elevation: 12, + }} + > + {/* Drag handle */} + + + + + + + Select repository + + + + + + + + {loading ? ( + + + + Loading repositories… + + + ) : ( + + {filtered.length === 0 ? ( + + + {search + ? `No repositories match “${search}”` + : "No repositories available"} + + + ) : ( + filtered.map((repo) => { + const isSelected = repo === selected; + return ( + { + onChange(repo); + onClose(); + }} + className="flex-row items-center gap-2 px-4 py-3 active:bg-gray-2" + > + + {repo} + + {isSelected ? ( + + ) : null} + + ); + }) + )} + + )} + + + + ); +} diff --git a/apps/mobile/src/features/tasks/composer/SelectSheet.tsx b/apps/mobile/src/features/tasks/composer/SelectSheet.tsx new file mode 100644 index 000000000..a1932f7a6 --- /dev/null +++ b/apps/mobile/src/features/tasks/composer/SelectSheet.tsx @@ -0,0 +1,114 @@ +import { Text } from "@components/text"; +import { Check } from "phosphor-react-native"; +import type { ReactNode } from "react"; +import { Modal, Pressable, ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useThemeColors } from "@/lib/theme"; + +export interface SelectOption { + value: T; + label: string; + description?: string; + icon?: ReactNode; + disabled?: boolean; +} + +interface SelectSheetProps { + open: boolean; + title: string; + options: SelectOption[]; + value: T; + onChange: (value: T) => void; + onClose: () => void; +} + +export function SelectSheet({ + open, + title, + options, + value, + onChange, + onClose, +}: SelectSheetProps) { + const themeColors = useThemeColors(); + const insets = useSafeAreaInsets(); + + return ( + + + {}} + className="mt-auto rounded-t-2xl border-gray-6 border-t bg-background" + style={{ + paddingBottom: insets.bottom + 12, + shadowColor: "#000", + shadowOpacity: 0.15, + shadowRadius: 20, + shadowOffset: { width: 0, height: -4 }, + elevation: 12, + }} + > + {/* Drag handle */} + + + + + + + {title} + + + + + {options.map((option) => { + const selected = option.value === value; + return ( + { + if (option.disabled) return; + onChange(option.value); + onClose(); + }} + disabled={option.disabled} + className={`flex-row items-center gap-3 px-4 py-3 ${ + option.disabled ? "opacity-40" : "active:bg-gray-2" + }`} + > + {option.icon ? ( + + {option.icon} + + ) : null} + + + {option.label} + + {option.description ? ( + + {option.description} + + ) : null} + + {selected ? ( + + ) : null} + + ); + })} + + + + + ); +} diff --git a/apps/mobile/src/features/tasks/composer/options.ts b/apps/mobile/src/features/tasks/composer/options.ts new file mode 100644 index 000000000..9cd01bc75 --- /dev/null +++ b/apps/mobile/src/features/tasks/composer/options.ts @@ -0,0 +1,81 @@ +export type ExecutionMode = "default" | "acceptEdits" | "plan"; +export type ReasoningEffort = "low" | "medium" | "high"; + +export const EXECUTION_MODES: { + value: ExecutionMode; + label: string; + description: string; +}[] = [ + { + value: "plan", + label: "Plan Mode", + description: "Plan first, no tool execution", + }, + { + value: "default", + label: "Default", + description: "Standard behaviour, prompts for dangerous operations", + }, + { + value: "acceptEdits", + label: "Accept Edits", + description: "Auto-accept file edit operations", + }, +]; + +export interface ModelOption { + value: string; + label: string; + description?: string; + supportsReasoning: boolean; +} + +export const MODELS: ModelOption[] = [ + { + value: "claude-opus-4-7", + label: "Claude Opus 4.7", + description: "Most capable, slower", + supportsReasoning: true, + }, + { + value: "claude-sonnet-4-6", + label: "Claude Sonnet 4.6", + description: "Balanced", + supportsReasoning: true, + }, + { + value: "claude-haiku-4-5", + label: "Claude Haiku 4.5", + description: "Fastest", + supportsReasoning: false, + }, +]; + +export const REASONING_LEVELS: { + value: ReasoningEffort; + label: string; +}[] = [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, +]; + +export const DEFAULT_EXECUTION_MODE: ExecutionMode = "plan"; +export const DEFAULT_MODEL = "claude-opus-4-7"; +export const DEFAULT_REASONING: ReasoningEffort = "high"; + +export function modelLabel(value: string): string { + return MODELS.find((m) => m.value === value)?.label ?? value; +} + +export function modeLabel(value: ExecutionMode): string { + return EXECUTION_MODES.find((m) => m.value === value)?.label ?? value; +} + +export function reasoningLabel(value: ReasoningEffort): string { + return REASONING_LEVELS.find((r) => r.value === value)?.label ?? value; +} + +export function modelSupportsReasoning(value: string): boolean { + return MODELS.find((m) => m.value === value)?.supportsReasoning ?? false; +} diff --git a/apps/mobile/src/lib/theme.ts b/apps/mobile/src/lib/theme.ts index e459a4873..0fa4d0e81 100644 --- a/apps/mobile/src/lib/theme.ts +++ b/apps/mobile/src/lib/theme.ts @@ -44,6 +44,10 @@ const colors = { info: "#2563eb", }, background: "#f2f3ee", + // "Card" surface — used for raised UI like buttons, composer card, pills. + // Pure white in light mode for max contrast against the cream background; + // gray-3 in dark mode so cards lift slightly off the bg. + card: "#ffffff", }, dark: { gray: { @@ -82,6 +86,7 @@ const colors = { info: "#60a5fa", }, background: "#131316", + card: "#1e1e28", }, } as const; @@ -135,6 +140,7 @@ function createThemeVars(theme: (typeof colors)["light" | "dark"]) { "--status-warning": hexToRgb(theme.status.warning), "--status-info": hexToRgb(theme.status.info), "--background": hexToRgb(theme.background), + "--card": hexToRgb(theme.card), }); } diff --git a/apps/mobile/tailwind.config.js b/apps/mobile/tailwind.config.js index eaa4178ee..63ee5fdbb 100644 --- a/apps/mobile/tailwind.config.js +++ b/apps/mobile/tailwind.config.js @@ -41,6 +41,7 @@ module.exports = { info: "rgb(var(--status-info) / )", }, background: "rgb(var(--background) / )", + card: "rgb(var(--card) / )", }, fontFamily: { sans: ["Open Runde"], From b9f8cbfc23d5d96fd0db415580af4aff33efc38b Mon Sep 17 00:00:00 2001 From: Annika <14750837+annikaschmid@users.noreply.github.com> Date: Wed, 13 May 2026 17:17:10 -0400 Subject: [PATCH 10/94] Add mobile task automations UI --- apps/mobile/package.json | 10 +- apps/mobile/src/app/(tabs)/_layout.tsx | 1 + apps/mobile/src/app/(tabs)/automations.tsx | 69 ++++ apps/mobile/src/app/(tabs)/tasks.tsx | 4 +- apps/mobile/src/app/_layout.tsx | 41 ++- apps/mobile/src/app/automation/[id].tsx | 215 +++++++++++ apps/mobile/src/app/automation/index.tsx | 90 +++++ apps/mobile/src/app/task/[id].tsx | 44 ++- apps/mobile/src/app/task/index.tsx | 225 ++++-------- apps/mobile/src/components/OfflineBanner.tsx | 12 +- .../components/ConversationList.tsx | 11 +- .../navigation/components/NavDrawer.tsx | 16 +- .../features/tasks/api.automations.test.ts | 199 +++++++++++ apps/mobile/src/features/tasks/api.ts | 212 ++++++++++- .../tasks/components/AutomationDetail.tsx | 117 ++++++ .../tasks/components/AutomationForm.tsx | 334 ++++++++++++++++++ .../tasks/components/AutomationItem.tsx | 62 ++++ .../tasks/components/AutomationList.tsx | 137 +++++++ .../components/AutomationStatusBadge.tsx | 60 ++++ .../components/GitHubConnectionPrompt.tsx | 87 +++++ .../tasks/components/GitHubLoadNotice.tsx | 33 ++ .../tasks/components/RepositorySelector.tsx | 95 +++++ .../tasks/components/ScheduleEditor.tsx | 185 ++++++++++ .../features/tasks/components/TaskList.tsx | 105 ++---- .../tasks/hooks/useAutomations.test.ts | 227 ++++++++++++ .../features/tasks/hooks/useAutomations.ts | 139 ++++++++ .../tasks/hooks/useIntegrations.test.ts | 177 ++++++++++ .../features/tasks/hooks/useIntegrations.ts | 60 +++- .../src/features/tasks/hooks/useTasks.ts | 12 +- apps/mobile/src/features/tasks/types.ts | 52 +++ .../tasks/utils/automationSchedule.test.ts | 101 ++++++ .../tasks/utils/automationSchedule.ts | 213 +++++++++++ .../tasks/utils/repositorySelection.test.ts | 89 +++++ .../tasks/utils/repositorySelection.ts | 62 ++++ apps/mobile/src/hooks/useNetworkStatus.ts | 103 +++++- apps/mobile/src/test/setup.ts | 27 ++ apps/mobile/vitest.config.ts | 19 + pnpm-lock.yaml | 312 +++++++++++++++- 38 files changed, 3669 insertions(+), 288 deletions(-) create mode 100644 apps/mobile/src/app/(tabs)/automations.tsx create mode 100644 apps/mobile/src/app/automation/[id].tsx create mode 100644 apps/mobile/src/app/automation/index.tsx create mode 100644 apps/mobile/src/features/tasks/api.automations.test.ts create mode 100644 apps/mobile/src/features/tasks/components/AutomationDetail.tsx create mode 100644 apps/mobile/src/features/tasks/components/AutomationForm.tsx create mode 100644 apps/mobile/src/features/tasks/components/AutomationItem.tsx create mode 100644 apps/mobile/src/features/tasks/components/AutomationList.tsx create mode 100644 apps/mobile/src/features/tasks/components/AutomationStatusBadge.tsx create mode 100644 apps/mobile/src/features/tasks/components/GitHubConnectionPrompt.tsx create mode 100644 apps/mobile/src/features/tasks/components/GitHubLoadNotice.tsx create mode 100644 apps/mobile/src/features/tasks/components/RepositorySelector.tsx create mode 100644 apps/mobile/src/features/tasks/components/ScheduleEditor.tsx create mode 100644 apps/mobile/src/features/tasks/hooks/useAutomations.test.ts create mode 100644 apps/mobile/src/features/tasks/hooks/useAutomations.ts create mode 100644 apps/mobile/src/features/tasks/hooks/useIntegrations.test.ts create mode 100644 apps/mobile/src/features/tasks/utils/automationSchedule.test.ts create mode 100644 apps/mobile/src/features/tasks/utils/automationSchedule.ts create mode 100644 apps/mobile/src/features/tasks/utils/repositorySelection.test.ts create mode 100644 apps/mobile/src/features/tasks/utils/repositorySelection.ts create mode 100644 apps/mobile/src/test/setup.ts create mode 100644 apps/mobile/vitest.config.ts diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 84092fa2d..27b5a50cb 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -9,6 +9,8 @@ "ios": "expo run:ios", "ios:device": "expo run:ios --device", "web": "expo start --web", + "test": "vitest run", + "test:watch": "vitest", "prebuild": "expo prebuild", "prebuild:clean": "expo prebuild --clean", "build:dev": "eas build --profile development --platform ios", @@ -67,9 +69,15 @@ "zustand": "^4.5.7" }, "devDependencies": { + "@testing-library/react-native": "^13.3.3", "@types/react": "^19.1.0", + "@types/react-test-renderer": "^19.1.0", + "@vitejs/plugin-react": "^4.7.0", + "react-test-renderer": "^19.1.0", "tailwindcss": "^3.4.18", - "typescript": "~5.9.2" + "typescript": "~5.9.2", + "vite": "^6.4.1", + "vitest": "^4.1.6" }, "private": true } diff --git a/apps/mobile/src/app/(tabs)/_layout.tsx b/apps/mobile/src/app/(tabs)/_layout.tsx index d17dd5e25..4a5b7b4e2 100644 --- a/apps/mobile/src/app/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(tabs)/_layout.tsx @@ -43,6 +43,7 @@ export default function TabsLayout() { > + diff --git a/apps/mobile/src/app/(tabs)/automations.tsx b/apps/mobile/src/app/(tabs)/automations.tsx new file mode 100644 index 000000000..718de7d80 --- /dev/null +++ b/apps/mobile/src/app/(tabs)/automations.tsx @@ -0,0 +1,69 @@ +import { Text } from "@components/text"; +import { useFocusEffect, useRouter } from "expo-router"; +import { useCallback, useRef } from "react"; +import { InteractionManager, Pressable, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { MenuButton } from "@/features/navigation/components/MenuButton"; +import { AutomationList } from "@/features/tasks/components/AutomationList"; + +export default function AutomationsScreen() { + const router = useRouter(); + const insets = useSafeAreaInsets(); + const readyRef = useRef(true); + + useFocusEffect( + useCallback(() => { + const handle = InteractionManager.runAfterInteractions(() => { + readyRef.current = true; + }); + return () => { + readyRef.current = false; + handle.cancel(); + }; + }, []), + ); + + const handleCreateAutomation = useCallback(() => { + if (!readyRef.current) return; + readyRef.current = false; + router.push("/automation"); + }, [router]); + + const handleAutomationPress = useCallback( + (automationId: string) => { + if (!readyRef.current) return; + readyRef.current = false; + router.push(`/automation/${automationId}`); + }, + [router], + ); + + return ( + + + + + + Automations + + + + New automation + + + + + + + + ); +} diff --git a/apps/mobile/src/app/(tabs)/tasks.tsx b/apps/mobile/src/app/(tabs)/tasks.tsx index a78b0db5b..4b10532e4 100644 --- a/apps/mobile/src/app/(tabs)/tasks.tsx +++ b/apps/mobile/src/app/(tabs)/tasks.tsx @@ -4,12 +4,12 @@ import { useCallback, useRef } from "react"; import { InteractionManager, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { MenuButton } from "@/features/navigation/components/MenuButton"; -import { TaskList } from "@/features/tasks"; import { TaskFilterButton, TaskFilterMenu, useTaskFilterMenu, } from "@/features/tasks/components/TaskFilterMenu"; +import { TaskList } from "@/features/tasks/components/TaskList"; export default function TasksScreen() { const router = useRouter(); @@ -50,7 +50,6 @@ export default function TasksScreen() { return ( - {/* Header */} - {/* Task List */} diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index b678def6e..f51f9dfd0 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -10,9 +10,13 @@ import { useEffect } from "react"; import { ActivityIndicator, View } from "react-native"; import { KeyboardProvider } from "react-native-keyboard-controller"; import { SafeAreaProvider } from "react-native-safe-area-context"; -import { OfflineBanner } from "@/components/OfflineBanner"; +import { + OFFLINE_BANNER_HEIGHT, + OfflineBanner, +} from "@/components/OfflineBanner"; import { useAuthStore } from "@/features/auth"; import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; +import { useNetworkStatus } from "@/hooks/useNetworkStatus"; import { POSTHOG_API_KEY, POSTHOG_OPTIONS, @@ -21,7 +25,11 @@ import { import { queryClient } from "@/lib/queryClient"; import { darkTheme, lightTheme, useThemeColors } from "@/lib/theme"; -function RootLayoutNav() { +interface RootLayoutNavProps { + isConnected: boolean; +} + +function RootLayoutNav({ isConnected }: RootLayoutNavProps) { const { isLoading, initializeAuth } = useAuthStore(); const aiChatEnabled = usePreferencesStore((s) => s.aiChatEnabled); const themeColors = useThemeColors(); @@ -44,7 +52,10 @@ function RootLayoutNav() { @@ -95,6 +106,25 @@ function RootLayoutNav() { headerTintColor: themeColors.gray[12], }} /> + + ); } @@ -102,6 +132,7 @@ function RootLayoutNav() { export default function RootLayout() { const { colorScheme } = useColorScheme(); const themeVars = colorScheme === "dark" ? darkTheme : lightTheme; + const { isConnected } = useNetworkStatus(); return ( @@ -116,8 +147,8 @@ export default function RootLayout() { > - - + + diff --git a/apps/mobile/src/app/automation/[id].tsx b/apps/mobile/src/app/automation/[id].tsx new file mode 100644 index 000000000..c52cb1f2e --- /dev/null +++ b/apps/mobile/src/app/automation/[id].tsx @@ -0,0 +1,215 @@ +import { Text } from "@components/text"; +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; +import { useState } from "react"; +import { + ActivityIndicator, + Alert, + KeyboardAvoidingView, + Platform, + Pressable, + ScrollView, + View, +} from "react-native"; +import { TaskAutomationValidationError } from "@/features/tasks/api"; +import { AutomationDetail } from "@/features/tasks/components/AutomationDetail"; +import { AutomationForm } from "@/features/tasks/components/AutomationForm"; +import { + useAutomation, + useDeleteTaskAutomation, + useRunTaskAutomation, + useUpdateTaskAutomation, +} from "@/features/tasks/hooks/useAutomations"; +import { useThemeColors } from "@/lib/theme"; + +export default function AutomationDetailScreen() { + const { id } = useLocalSearchParams<{ id: string }>(); + const router = useRouter(); + const themeColors = useThemeColors(); + const { data: automation, isLoading, error } = useAutomation(id); + const updateAutomation = useUpdateTaskAutomation(); + const deleteAutomation = useDeleteTaskAutomation(); + const runAutomation = useRunTaskAutomation(); + const [isEditing, setIsEditing] = useState(false); + const [fieldError, setFieldError] = useState<{ + attr: string | null; + message: string | null; + } | null>(null); + const [generalError, setGeneralError] = useState(null); + + if (error || (!automation && !isLoading)) { + return ( + <> + + + + {error?.message ?? "Automation not found"} + + router.back()} + className="rounded-lg bg-gray-3 px-4 py-2" + > + Go back + + + + ); + } + + return ( + <> + + + + {isLoading || !automation ? ( + + + Loading automation... + + ) : isEditing ? ( + { + setFieldError(null); + setGeneralError(null); + setIsEditing(false); + }} + onSubmit={async (values) => { + setFieldError(null); + setGeneralError(null); + + try { + await updateAutomation.mutateAsync({ + automationId: automation.id, + updates: values, + }); + setIsEditing(false); + } catch (error) { + if (error instanceof TaskAutomationValidationError) { + setFieldError({ + attr: error.attr, + message: error.message, + }); + return; + } + + setGeneralError("Could not save automation changes."); + } + }} + /> + ) : ( + setIsEditing(true)} + onToggleEnabled={async () => { + setFieldError(null); + setGeneralError(null); + try { + await updateAutomation.mutateAsync({ + automationId: automation.id, + updates: { + enabled: !automation.enabled, + }, + }); + } catch { + setGeneralError("Could not update automation state."); + } + }} + onRunNow={async () => { + setFieldError(null); + setGeneralError(null); + try { + const updatedAutomation = await runAutomation.mutateAsync( + automation.id, + ); + if (updatedAutomation.last_task_id) { + router.push({ + pathname: "/task/[id]", + params: { + id: updatedAutomation.last_task_id, + fromAutomation: "1", + automationName: updatedAutomation.name, + }, + }); + } + } catch { + setGeneralError("Could not start the automation run."); + } + }} + onDelete={() => { + Alert.alert( + "Delete automation?", + "This will remove the schedule and stop future runs.", + [ + { + text: "Cancel", + style: "cancel", + }, + { + text: "Delete", + style: "destructive", + onPress: async () => { + try { + await deleteAutomation.mutateAsync(automation.id); + router.back(); + } catch { + setGeneralError("Could not delete automation."); + } + }, + }, + ], + ); + }} + /> + )} + + {generalError && !isEditing && ( + + {generalError} + + )} + + + + ); +} diff --git a/apps/mobile/src/app/automation/index.tsx b/apps/mobile/src/app/automation/index.tsx new file mode 100644 index 000000000..2f9606f47 --- /dev/null +++ b/apps/mobile/src/app/automation/index.tsx @@ -0,0 +1,90 @@ +import { getCalendars } from "expo-localization"; +import { Stack, useRouter } from "expo-router"; +import { useMemo, useState } from "react"; +import { KeyboardAvoidingView, Platform, ScrollView, View } from "react-native"; +import { Text } from "@/components/text"; +import { TaskAutomationValidationError } from "@/features/tasks/api"; +import { AutomationForm } from "@/features/tasks/components/AutomationForm"; +import { useCreateTaskAutomation } from "@/features/tasks/hooks/useAutomations"; +import { useThemeColors } from "@/lib/theme"; + +export default function NewAutomationScreen() { + const router = useRouter(); + const themeColors = useThemeColors(); + const createAutomation = useCreateTaskAutomation(); + const defaultTimezone = useMemo( + () => getCalendars()[0]?.timeZone ?? "UTC", + [], + ); + const [fieldError, setFieldError] = useState<{ + attr: string | null; + message: string | null; + } | null>(null); + const [generalError, setGeneralError] = useState(null); + + return ( + <> + + + + + + + New automation + + + + { + setFieldError(null); + setGeneralError(null); + + try { + const automation = await createAutomation.mutateAsync(values); + router.replace(`/automation/${automation.id}`); + } catch (error) { + if (error instanceof TaskAutomationValidationError) { + setFieldError({ + attr: error.attr, + message: error.message, + }); + return; + } + + setGeneralError( + "Could not create automation. Please try again.", + ); + } + }} + onCancel={() => router.back()} + /> + + + + + ); +} diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 85d2f0ed2..1335f9364 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -14,21 +14,26 @@ 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 { getTask, runTaskInCloud } from "@/features/tasks/api"; +import { TaskSessionView } from "@/features/tasks/components/TaskSessionView"; +import { taskKeys } from "@/features/tasks/hooks/useTasks"; +import { useTaskSessionStore } from "@/features/tasks/stores/taskSessionStore"; +import type { Task } from "@/features/tasks/types"; 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 { + id: taskId, + fromAutomation, + automationName, + } = useLocalSearchParams<{ + id: string; + fromAutomation?: string; + automationName?: string; + }>(); const router = useRouter(); const queryClient = useQueryClient(); const insets = useSafeAreaInsets(); @@ -266,6 +271,13 @@ export default function TaskDetailScreen() { const isConnecting = retrying || (!!session?.awaitingAgentOutput && !hasAnyAgentOutput); const isThinking = !!session?.awaitingAgentOutput && hasAnyAgentOutput; + const showAutomationContext = + fromAutomation === "1" || task?.origin_product === "automation"; + const automationContextLabel = + automationName ?? + (task?.origin_product === "automation" + ? "This run was started from a task automation." + : null); // Haptic pulse when connecting/thinking indicators dismiss const prevWaiting = useRef(false); @@ -357,6 +369,16 @@ export default function TaskDetailScreen() { }} /> + {showAutomationContext && automationContextLabel && ( + + + {automationName + ? `Started from automation: ${automationName}` + : automationContextLabel} + + + )} + {/* 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. */} @@ -373,7 +395,9 @@ export default function TaskDetailScreen() { onSendPermissionResponse={handleSendPermissionResponse} contentContainerStyle={{ paddingTop: - session?.terminalStatus && !retrying ? 16 : 80 + insets.bottom, + session?.terminalStatus && !retrying + ? 16 + (showAutomationContext ? 44 : 0) + : 80 + insets.bottom + (showAutomationContext ? 44 : 0), paddingBottom: 16, }} /> diff --git a/apps/mobile/src/app/task/index.tsx b/apps/mobile/src/app/task/index.tsx index 38023a5ae..3cf9dffd3 100644 --- a/apps/mobile/src/app/task/index.tsx +++ b/apps/mobile/src/app/task/index.tsx @@ -1,7 +1,6 @@ import { Text } from "@components/text"; import { Stack, useRouter } from "expo-router"; -import * as WebBrowser from "expo-web-browser"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useState } from "react"; import { ActivityIndicator, Keyboard, @@ -12,150 +11,72 @@ import { TextInput, View, } from "react-native"; -import { useAuthStore } from "@/features/auth"; -import { - createTask, - getGithubRepositories, - getIntegrations, - type Integration, - runTaskInCloud, -} from "@/features/tasks"; +import { createTask, runTaskInCloud } from "@/features/tasks/api"; +import { GitHubConnectionPrompt } from "@/features/tasks/components/GitHubConnectionPrompt"; +import { GitHubLoadNotice } from "@/features/tasks/components/GitHubLoadNotice"; +import { RepositorySelector } from "@/features/tasks/components/RepositorySelector"; +import { useIntegrations } from "@/features/tasks/hooks/useIntegrations"; import { logger } from "@/lib/logger"; import { useThemeColors } from "@/lib/theme"; const log = logger.scope("task-create"); -interface ConnectGitHubPromptProps { - onConnected?: () => void; -} - -function ConnectGitHubPrompt({ onConnected }: ConnectGitHubPromptProps) { - const { cloudRegion, projectId, getCloudUrlFromRegion } = useAuthStore(); - const themeColors = useThemeColors(); - - const handleConnectGitHub = async () => { - if (!cloudRegion || !projectId) return; - const baseUrl = getCloudUrlFromRegion(cloudRegion); - const authorizeUrl = `${baseUrl}/api/environments/${projectId}/integrations/authorize/?kind=github`; - - // Open in-app browser - will auto-detect when user returns - const result = await WebBrowser.openAuthSessionAsync( - authorizeUrl, - "posthog://github/callback", - ); - - // When browser session ends, refresh integrations - if ( - result.type === "dismiss" || - result.type === "cancel" || - result.type === "success" - ) { - onConnected?.(); - } - }; - - return ( - - - 🔗 - - Connect GitHub to continue - - - - You need to connect your GitHub account before creating tasks. This - allows PostHog to work on your repositories. - - - - Connect GitHub - - - - ); -} - export default function NewTaskScreen() { const router = useRouter(); const themeColors = useThemeColors(); - const [integrations, setIntegrations] = useState([]); - const [repositories, setRepositories] = useState([]); - const [selectedRepo, setSelectedRepo] = useState(null); - const [repoSearch, setRepoSearch] = useState(""); + const { + error, + hasGithubIntegration, + repositoryOptions, + repositoryWarning, + isLoading, + refetch, + } = useIntegrations(); + const [selection, setSelection] = useState<{ + integrationId: number | null; + repository: string | null; + }>({ + integrationId: null, + repository: null, + }); 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); - const data = await getIntegrations(); - const githubIntegrations = data.filter((i) => i.kind === "github"); - setIntegrations(githubIntegrations); - - if (githubIntegrations.length > 0) { - const allRepos: string[] = []; - for (const integration of githubIntegrations) { - const repos = await getGithubRepositories(integration.id); - allRepos.push(...repos); - } - setRepositories(allRepos.sort()); - } - } catch (error) { - log.error("Failed to fetch integrations", error); - } finally { - setLoadingRepos(false); - } - }, []); - - useEffect(() => { - loadIntegrations(); - }, [loadIntegrations]); const handleCreateTask = useCallback(async () => { - if (!prompt.trim() || !selectedRepo) return; + if (!prompt.trim() || !selection.integrationId || !selection.repository) { + return; + } setCreating(true); - try { - const githubIntegration = integrations.find((i) => i.kind === "github"); + try { const trimmedPrompt = prompt.trim(); const task = await createTask({ description: trimmedPrompt, title: trimmedPrompt.slice(0, 100), - repository: selectedRepo, - github_integration: githubIntegration?.id, + repository: selection.repository, + github_integration: selection.integrationId, }); - // 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) { log.error("Failed to create task", error); } finally { setCreating(false); } - }, [prompt, selectedRepo, integrations, router]); + }, [prompt, router, selection]); - const hasGithubIntegration = integrations.length > 0; - const canSubmit = prompt.trim() && selectedRepo && !creating; + const canSubmit = + !!prompt.trim() && + !!selection.integrationId && + !!selection.repository && + !creating; + const repositoryLoadBlocked = + !!repositoryWarning && repositoryOptions.length === 0; return ( <> @@ -179,65 +100,45 @@ export default function NewTaskScreen() { contentContainerStyle={{ paddingBottom: 40 }} > - {loadingRepos ? ( + {isLoading && hasGithubIntegration === null ? ( Loading repositories... - ) : !hasGithubIntegration ? ( - + ) : error || repositoryLoadBlocked ? ( + + ) : hasGithubIntegration === false ? ( + ) : ( <> + {repositoryWarning && ( + + )} 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 diff --git a/apps/mobile/src/features/conversations/components/ConversationList.tsx b/apps/mobile/src/features/conversations/components/ConversationList.tsx index eb49a0934..62435da6e 100644 --- a/apps/mobile/src/features/conversations/components/ConversationList.tsx +++ b/apps/mobile/src/features/conversations/components/ConversationList.tsx @@ -25,11 +25,18 @@ export function ConversationList({ onConversationPress?.(conversation); }; + const handleRetry = () => { + void refetch(); + }; + if (error) { return ( {error} - + Retry @@ -69,7 +76,7 @@ export function ConversationList({ refreshControl={ } diff --git a/apps/mobile/src/features/navigation/components/NavDrawer.tsx b/apps/mobile/src/features/navigation/components/NavDrawer.tsx index e449ad19f..ac877ae33 100644 --- a/apps/mobile/src/features/navigation/components/NavDrawer.tsx +++ b/apps/mobile/src/features/navigation/components/NavDrawer.tsx @@ -1,6 +1,6 @@ import { Text } from "@components/text"; import { usePathname, useRouter } from "expo-router"; -import { GearSix, Plus, Tray } from "phosphor-react-native"; +import { Clock, GearSix, Plus, Tray } from "phosphor-react-native"; import { type ReactNode, useEffect, useRef } from "react"; import { Animated, @@ -90,6 +90,7 @@ export function NavDrawer() { }; const handleInbox = () => navigateTo("/inbox"); + const handleAutomations = () => navigateTo("/automations"); const handleSettings = () => navigateTo("/settings"); const handleHome = () => navigateTo("/tasks"); @@ -101,6 +102,7 @@ export function NavDrawer() { const iconColor = themeColors.gray[11]; const iconColorActive = themeColors.gray[12]; const isOnInbox = pathname === "/inbox"; + const isOnAutomations = pathname === "/automations"; const isOnSettings = pathname === "/settings"; return ( @@ -150,6 +152,18 @@ export function NavDrawer() { active={isOnInbox} onPress={handleInbox} /> + + } + label="Automations" + active={isOnAutomations} + onPress={handleAutomations} + /> diff --git a/apps/mobile/src/features/tasks/api.automations.test.ts b/apps/mobile/src/features/tasks/api.automations.test.ts new file mode 100644 index 000000000..e34fd50bd --- /dev/null +++ b/apps/mobile/src/features/tasks/api.automations.test.ts @@ -0,0 +1,199 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockFetch } = vi.hoisted(() => ({ + mockFetch: vi.fn(), +})); + +vi.mock("expo/fetch", () => ({ + fetch: mockFetch, +})); + +vi.mock("@/lib/api", () => ({ + getBaseUrl: () => "https://app.posthog.test", + getHeaders: () => ({ + Authorization: "Bearer token", + "Content-Type": "application/json", + }), + getProjectId: () => 42, +})); + +import { + createTaskAutomation, + deleteTaskAutomation, + getTaskAutomation, + getTaskAutomations, + runTaskAutomation, + TaskAutomationValidationError, + updateTaskAutomation, +} from "./api"; + +const automationPayload = { + id: "automation-1", + name: "Daily PRs", + prompt: "Check PRs", + repository: "posthog/posthog", + github_integration: 7, + cron_expression: "0 9 * * *", + timezone: "Europe/London", + enabled: true, + last_run_at: null, + last_run_status: null, + last_task_id: "task-1", + last_task_run_id: null, + last_error: null, + created_at: "2026-05-13T00:00:00Z", + updated_at: "2026-05-13T00:00:00Z", +}; + +describe("task automation api", () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + + it("lists task automations from the existing backend endpoint", async () => { + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + results: [automationPayload], + }), + { status: 200 }, + ), + ); + + const automations = await getTaskAutomations(); + + expect(automations).toHaveLength(1); + expect(automations[0]?.id).toBe("automation-1"); + expect(mockFetch).toHaveBeenCalledWith( + "https://app.posthog.test/api/projects/42/task_automations/?limit=500", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer token", + }), + }), + ); + }); + + it("serializes automation creation payloads with the existing backend contract", async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify(automationPayload), { status: 200 }), + ); + + await createTaskAutomation({ + name: "Daily PRs", + prompt: "Check PRs", + repository: "posthog/posthog", + github_integration: 7, + cron_expression: "0 9 * * *", + timezone: "Europe/London", + enabled: true, + }); + + expect(mockFetch).toHaveBeenCalledWith( + "https://app.posthog.test/api/projects/42/task_automations/", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + name: "Daily PRs", + prompt: "Check PRs", + repository: "posthog/posthog", + github_integration: 7, + cron_expression: "0 9 * * *", + timezone: "Europe/London", + enabled: true, + }), + }), + ); + }); + + it("retains backend field attribution for validation errors", async () => { + mockFetch.mockImplementation(() => + Promise.resolve( + new Response( + JSON.stringify({ + type: "validation_error", + code: "invalid_input", + detail: + "Only standard 5-field cron expressions are supported (minute hour day month weekday). Example: '0 9 * * 1-5'.", + attr: "cron_expression", + }), + { status: 400, statusText: "Bad Request" }, + ), + ), + ); + + await expect( + createTaskAutomation({ + name: "Daily PRs", + prompt: "Check PRs", + repository: "posthog/posthog", + cron_expression: "not a cron", + timezone: "Europe/London", + }), + ).rejects.toBeInstanceOf(TaskAutomationValidationError); + + await expect( + createTaskAutomation({ + name: "Daily PRs", + prompt: "Check PRs", + repository: "posthog/posthog", + cron_expression: "not a cron", + timezone: "Europe/London", + }), + ).rejects.toMatchObject({ + attr: "cron_expression", + code: "invalid_input", + }); + }); + + it("supports retrieve, update, delete, and run-now automation flows", async () => { + mockFetch + .mockResolvedValueOnce( + new Response(JSON.stringify(automationPayload), { status: 200 }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify(automationPayload), { status: 200 }), + ) + .mockResolvedValueOnce(new Response(null, { status: 204 })) + .mockResolvedValueOnce( + new Response(JSON.stringify(automationPayload), { status: 200 }), + ); + + const retrieved = await getTaskAutomation("automation-1"); + const updated = await updateTaskAutomation("automation-1", { + enabled: false, + cron_expression: "30 14 * * *", + }); + await deleteTaskAutomation("automation-1"); + const ran = await runTaskAutomation("automation-1"); + + expect(retrieved.id).toBe("automation-1"); + expect(updated.id).toBe("automation-1"); + expect(ran.id).toBe("automation-1"); + expect(mockFetch).toHaveBeenNthCalledWith( + 2, + "https://app.posthog.test/api/projects/42/task_automations/automation-1/", + expect.objectContaining({ + method: "PATCH", + body: JSON.stringify({ + enabled: false, + cron_expression: "30 14 * * *", + }), + }), + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 3, + "https://app.posthog.test/api/projects/42/task_automations/automation-1/", + expect.objectContaining({ + method: "DELETE", + }), + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 4, + "https://app.posthog.test/api/projects/42/task_automations/automation-1/run/", + expect.objectContaining({ + method: "POST", + }), + ); + }); +}); diff --git a/apps/mobile/src/features/tasks/api.ts b/apps/mobile/src/features/tasks/api.ts index f88901ec4..388752b5b 100644 --- a/apps/mobile/src/features/tasks/api.ts +++ b/apps/mobile/src/features/tasks/api.ts @@ -2,11 +2,14 @@ import { fetch } from "expo/fetch"; import { getBaseUrl, getHeaders, getProjectId } from "@/lib/api"; import { logger } from "@/lib/logger"; import type { + CreateTaskAutomationOptions, CreateTaskOptions, Integration, StoredLogEntry, Task, + TaskAutomation, TaskRun, + UpdateTaskAutomationOptions, } from "./types"; const log = logger.scope("tasks-api"); @@ -21,6 +24,50 @@ export class HttpError extends Error { } } +export class TaskAutomationValidationError extends Error { + readonly code: string; + readonly attr: string | null; + + constructor(message: string, code: string, attr: string | null) { + super(message); + this.name = "TaskAutomationValidationError"; + this.code = code; + this.attr = attr; + } +} + +async function parseJsonResponse(response: Response): Promise { + return (await response.json()) as T; +} + +async function parseTaskAutomationError(response: Response): Promise { + let payload: { + code?: string; + detail?: string; + attr?: string; + } | null = null; + + try { + payload = await response.json(); + } catch { + payload = null; + } + + if (response.status === 400 && payload?.detail) { + throw new TaskAutomationValidationError( + payload.detail, + payload.code ?? "invalid_input", + payload.attr ?? null, + ); + } + + throw new HttpError( + response.status, + response.statusText, + "Task automation request failed", + ); +} + async function withRetry( fn: () => Promise, options: { @@ -69,6 +116,7 @@ function isRetryableError(error: unknown): boolean { export async function getTasks(filters?: { repository?: string; createdBy?: number; + originProduct?: string; }): Promise { const baseUrl = getBaseUrl(); const projectId = getProjectId(); @@ -81,6 +129,9 @@ export async function getTasks(filters?: { if (filters?.createdBy) { params.set("created_by", String(filters.createdBy)); } + if (filters?.originProduct) { + params.set("origin_product", filters.originProduct); + } const response = await fetch( `${baseUrl}/api/projects/${projectId}/tasks/?${params}`, @@ -95,7 +146,7 @@ export async function getTasks(filters?: { ); } - const data = await response.json(); + const data = await parseJsonResponse<{ results?: Task[] }>(response); return data.results ?? []; } @@ -117,7 +168,151 @@ export async function getTask(taskId: string): Promise { ); } - return await response.json(); + return await parseJsonResponse(response); +} + +export async function getTaskAutomations(): Promise { + const baseUrl = getBaseUrl(); + const projectId = getProjectId(); + const headers = getHeaders(); + + const response = await fetch( + `${baseUrl}/api/projects/${projectId}/task_automations/?limit=500`, + { headers }, + ); + + if (!response.ok) { + throw new HttpError( + response.status, + response.statusText, + "Failed to fetch task automations", + ); + } + + const data = await parseJsonResponse<{ results?: TaskAutomation[] }>( + response, + ); + return data.results ?? []; +} + +export async function getTaskAutomation( + automationId: string, +): Promise { + const baseUrl = getBaseUrl(); + const projectId = getProjectId(); + const headers = getHeaders(); + + const response = await fetch( + `${baseUrl}/api/projects/${projectId}/task_automations/${automationId}/`, + { headers }, + ); + + if (!response.ok) { + throw new HttpError( + response.status, + response.statusText, + "Failed to fetch task automation", + ); + } + + return await parseJsonResponse(response); +} + +export async function createTaskAutomation( + options: CreateTaskAutomationOptions, +): Promise { + const baseUrl = getBaseUrl(); + const projectId = getProjectId(); + const headers = getHeaders(); + + const response = await fetch( + `${baseUrl}/api/projects/${projectId}/task_automations/`, + { + method: "POST", + headers, + body: JSON.stringify(options), + }, + ); + + if (!response.ok) { + await parseTaskAutomationError(response); + } + + return await parseJsonResponse(response); +} + +export async function updateTaskAutomation( + automationId: string, + updates: UpdateTaskAutomationOptions, +): Promise { + const baseUrl = getBaseUrl(); + const projectId = getProjectId(); + const headers = getHeaders(); + + const response = await fetch( + `${baseUrl}/api/projects/${projectId}/task_automations/${automationId}/`, + { + method: "PATCH", + headers, + body: JSON.stringify(updates), + }, + ); + + if (!response.ok) { + await parseTaskAutomationError(response); + } + + return await parseJsonResponse(response); +} + +export async function deleteTaskAutomation( + automationId: string, +): Promise { + const baseUrl = getBaseUrl(); + const projectId = getProjectId(); + const headers = getHeaders(); + + const response = await fetch( + `${baseUrl}/api/projects/${projectId}/task_automations/${automationId}/`, + { + method: "DELETE", + headers, + }, + ); + + if (!response.ok) { + throw new HttpError( + response.status, + response.statusText, + "Failed to delete task automation", + ); + } +} + +export async function runTaskAutomation( + automationId: string, +): Promise { + const baseUrl = getBaseUrl(); + const projectId = getProjectId(); + const headers = getHeaders(); + + const response = await fetch( + `${baseUrl}/api/projects/${projectId}/task_automations/${automationId}/run/`, + { + method: "POST", + headers, + }, + ); + + if (!response.ok) { + throw new HttpError( + response.status, + response.statusText, + "Failed to run task automation", + ); + } + + return await parseJsonResponse(response); } export async function createTask(options: CreateTaskOptions): Promise { @@ -144,7 +339,7 @@ export async function createTask(options: CreateTaskOptions): Promise { ); } - return await response.json(); + return await parseJsonResponse(response); } export async function updateTask( @@ -172,7 +367,7 @@ export async function updateTask( ); } - return await response.json(); + return await parseJsonResponse(response); } export async function deleteTask(taskId: string): Promise { @@ -456,8 +651,13 @@ export async function getIntegrations(): Promise { ); } - const data = await response.json(); - return data.results ?? data ?? []; + const data = await parseJsonResponse< + | { + results?: Integration[]; + } + | Integration[] + >(response); + return Array.isArray(data) ? data : (data.results ?? []); } const GITHUB_REPOS_PAGE_SIZE = 500; diff --git a/apps/mobile/src/features/tasks/components/AutomationDetail.tsx b/apps/mobile/src/features/tasks/components/AutomationDetail.tsx new file mode 100644 index 000000000..de4ed628c --- /dev/null +++ b/apps/mobile/src/features/tasks/components/AutomationDetail.tsx @@ -0,0 +1,117 @@ +import { Text } from "@components/text"; +import { ActivityIndicator, Pressable, View } from "react-native"; +import type { TaskAutomation } from "../types"; +import { formatAutomationScheduleSummary } from "../utils/automationSchedule"; +import { AutomationStatusBadge } from "./AutomationStatusBadge"; + +interface AutomationDetailProps { + automation: TaskAutomation; + isWorking?: boolean; + onRunNow: () => void; + onToggleEnabled: () => void; + onEdit: () => void; + onDelete: () => void; +} + +export function AutomationDetail({ + automation, + isWorking = false, + onRunNow, + onToggleEnabled, + onEdit, + onDelete, +}: AutomationDetailProps) { + return ( + + + + {automation.name} + + + + + + + + Repository + + {automation.repository} + + + + Schedule + + {formatAutomationScheduleSummary(automation)} + + + + Prompt + + {automation.prompt} + + + + Last task + + {automation.last_task_id ?? "No runs yet"} + + + + + {automation.last_error && ( + + Last error + + {automation.last_error} + + + )} + + + + + {isWorking ? ( + + ) : ( + + Run now + + )} + + + + + Edit + + + + {automation.enabled ? "Pause" : "Resume"} + + + + + + + Delete automation + + + + + ); +} diff --git a/apps/mobile/src/features/tasks/components/AutomationForm.tsx b/apps/mobile/src/features/tasks/components/AutomationForm.tsx new file mode 100644 index 000000000..9026fdf2b --- /dev/null +++ b/apps/mobile/src/features/tasks/components/AutomationForm.tsx @@ -0,0 +1,334 @@ +import { Text } from "@components/text"; +import { useEffect, useMemo, useState } from "react"; +import { + ActivityIndicator, + Pressable, + Switch, + TextInput, + View, +} from "react-native"; +import { useThemeColors } from "@/lib/theme"; +import { useIntegrations } from "../hooks/useIntegrations"; +import type { + CreateTaskAutomationOptions, + RepositorySelection, +} from "../types"; +import { + type AutomationScheduleDraft, + buildCronExpression, + createDefaultScheduleDraft, + deriveAutomationName, + parseCronExpression, +} from "../utils/automationSchedule"; +import { isRepositorySelectionComplete } from "../utils/repositorySelection"; +import { GitHubConnectionPrompt } from "./GitHubConnectionPrompt"; +import { GitHubLoadNotice } from "./GitHubLoadNotice"; +import { RepositorySelector } from "./RepositorySelector"; +import { ScheduleEditor } from "./ScheduleEditor"; + +interface AutomationFormProps { + initialValues?: { + name?: string; + prompt?: string; + repositorySelection?: RepositorySelection; + cronExpression?: string; + timezone?: string; + enabled?: boolean; + }; + isSubmitting: boolean; + submitLabel: string; + fieldError?: { + attr: string | null; + message: string | null; + } | null; + generalError?: string | null; + onSubmit: (values: CreateTaskAutomationOptions) => Promise | void; + onCancel?: () => void; +} + +export function AutomationForm({ + initialValues, + isSubmitting, + submitLabel, + fieldError, + generalError, + onSubmit, + onCancel, +}: AutomationFormProps) { + const themeColors = useThemeColors(); + const { + error, + hasGithubIntegration, + repositoryOptions, + repositoryWarning, + isLoading, + refetch, + } = useIntegrations(); + + const [name, setName] = useState(initialValues?.name ?? ""); + const [prompt, setPrompt] = useState(initialValues?.prompt ?? ""); + const [timezone, setTimezone] = useState(initialValues?.timezone ?? "UTC"); + const [enabled, setEnabled] = useState(initialValues?.enabled ?? true); + const [repositorySelection, setRepositorySelection] = + useState( + initialValues?.repositorySelection ?? { + integrationId: null, + repository: null, + }, + ); + const [scheduleDraft, setScheduleDraft] = useState( + initialValues?.cronExpression + ? parseCronExpression(initialValues.cronExpression) + : createDefaultScheduleDraft(), + ); + const [hasEditedName, setHasEditedName] = useState( + !!initialValues?.name?.trim(), + ); + const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false); + + useEffect(() => { + if (hasEditedName) { + return; + } + + setName(deriveAutomationName(prompt)); + }, [prompt, hasEditedName]); + + const validationErrors = useMemo( + () => ({ + name: + fieldError?.attr === "name" + ? fieldError.message + : hasAttemptedSubmit && !name.trim() + ? "Name is required." + : null, + prompt: + fieldError?.attr === "prompt" + ? fieldError.message + : hasAttemptedSubmit && !prompt.trim() + ? "Prompt is required." + : null, + repository: + fieldError?.attr === "repository" + ? fieldError.message + : hasAttemptedSubmit && + !isRepositorySelectionComplete(repositorySelection) + ? "Repository selection is required." + : null, + cronExpression: + fieldError?.attr === "cron_expression" ? fieldError.message : null, + timezone: + fieldError?.attr === "timezone" + ? fieldError.message + : hasAttemptedSubmit && !timezone.trim() + ? "Timezone is required." + : null, + }), + [ + fieldError, + hasAttemptedSubmit, + name, + prompt, + repositorySelection, + timezone, + ], + ); + + const canSubmit = + !!name.trim() && + !!prompt.trim() && + !!timezone.trim() && + isRepositorySelectionComplete(repositorySelection) && + !isSubmitting; + const repositoryLoadBlocked = + !!repositoryWarning && repositoryOptions.length === 0; + + const handleSubmit = async () => { + setHasAttemptedSubmit(true); + if (!canSubmit) { + return; + } + + await onSubmit({ + name: name.trim(), + prompt: prompt.trim(), + repository: repositorySelection.repository ?? "", + github_integration: repositorySelection.integrationId, + cron_expression: buildCronExpression(scheduleDraft), + timezone: timezone.trim(), + enabled, + }); + }; + + if (isLoading && hasGithubIntegration === null) { + return ( + + + + Loading repositories... + + + ); + } + + if (error || repositoryLoadBlocked) { + return ( + + ); + } + + if (hasGithubIntegration === false) { + return ( + + ); + } + + return ( + + + + Name + + { + setHasEditedName(true); + setName(nextName); + }} + /> + {validationErrors.name && ( + + {validationErrors.name} + + )} + + + Prompt + + + {validationErrors.prompt && ( + + {validationErrors.prompt} + + )} + + + + {repositoryWarning && ( + + )} + + Repository + + + {validationErrors.repository && ( + + {validationErrors.repository} + + )} + + + + + {(validationErrors.cronExpression || validationErrors.timezone) && ( + + {validationErrors.cronExpression || validationErrors.timezone} + + )} + + + + + + Enabled + + + Turn this off to pause scheduled runs without deleting it. + + + + + + {generalError && ( + + {generalError} + + )} + + + {onCancel && ( + + Cancel + + )} + + {isSubmitting ? ( + + ) : ( + + {submitLabel} + + )} + + + + ); +} diff --git a/apps/mobile/src/features/tasks/components/AutomationItem.tsx b/apps/mobile/src/features/tasks/components/AutomationItem.tsx new file mode 100644 index 000000000..1f1d67983 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/AutomationItem.tsx @@ -0,0 +1,62 @@ +import { Text } from "@components/text"; +import { format, formatDistanceToNow } from "date-fns"; +import { memo } from "react"; +import { Pressable, View } from "react-native"; +import type { TaskAutomation } from "../types"; +import { formatAutomationScheduleSummary } from "../utils/automationSchedule"; +import { AutomationStatusBadge } from "./AutomationStatusBadge"; + +interface AutomationItemProps { + automation: TaskAutomation; + onPress: (automation: TaskAutomation) => void; +} + +function AutomationItemComponent({ automation, onPress }: AutomationItemProps) { + const lastRunDisplay = automation.last_run_at + ? new Date(automation.last_run_at).getTime() > + Date.now() - 24 * 60 * 60 * 1000 + ? formatDistanceToNow(new Date(automation.last_run_at), { + addSuffix: true, + }) + : format(new Date(automation.last_run_at), "MMM d") + : "No runs yet"; + + return ( + onPress(automation)} + className="border-gray-6 border-b px-3 py-3 active:bg-gray-3" + > + + + {automation.name} + + {lastRunDisplay} + + + + + + + + {automation.repository} + + + {formatAutomationScheduleSummary(automation)} + + + {automation.last_error && ( + + {automation.last_error} + + )} + + ); +} + +export const AutomationItem = memo(AutomationItemComponent); diff --git a/apps/mobile/src/features/tasks/components/AutomationList.tsx b/apps/mobile/src/features/tasks/components/AutomationList.tsx new file mode 100644 index 000000000..7be5073c7 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/AutomationList.tsx @@ -0,0 +1,137 @@ +import { Text } from "@components/text"; +import { + ActivityIndicator, + FlatList, + Pressable, + RefreshControl, + View, +} from "react-native"; +import { useThemeColors } from "@/lib/theme"; +import { useAutomations } from "../hooks/useAutomations"; +import { useIntegrations } from "../hooks/useIntegrations"; +import type { TaskAutomation } from "../types"; +import { AutomationItem } from "./AutomationItem"; +import { GitHubConnectionPrompt } from "./GitHubConnectionPrompt"; + +interface AutomationListProps { + onAutomationPress?: (automationId: string) => void; + onCreateAutomation?: () => void; +} + +function EmptyAutomationState({ + onCreateAutomation, +}: Pick) { + const themeColors = useThemeColors(); + + return ( + + + No automations yet + + + Schedule recurring tasks + + {onCreateAutomation && ( + + + Create automation + + + )} + + ); +} + +export function AutomationList({ + onAutomationPress, + onCreateAutomation, +}: AutomationListProps) { + const { automations, isLoading, error, refetch } = useAutomations(); + const { + error: integrationsError, + hasGithubIntegration, + refetch: refetchIntegrations, + } = useIntegrations(); + const themeColors = useThemeColors(); + + const handleRefresh = async () => { + await Promise.all([refetch(), refetchIntegrations()]); + }; + + const handleAutomationPress = (automation: TaskAutomation) => { + onAutomationPress?.(automation.id); + }; + + const isInitialLoading = + (isLoading && automations.length === 0) || + (automations.length === 0 && hasGithubIntegration === null); + + if (error) { + return ( + + {error} + + Retry + + + ); + } + + if (integrationsError && automations.length === 0) { + return ( + + + {integrationsError} + + + Retry + + + ); + } + + if (isInitialLoading) { + return ( + + + Loading automations... + + ); + } + + if (hasGithubIntegration === false && automations.length === 0) { + return ; + } + + if (automations.length === 0) { + return ; + } + + return ( + item.id} + renderItem={({ item }) => ( + + )} + refreshControl={ + + } + contentContainerStyle={{ paddingBottom: 100 }} + /> + ); +} diff --git a/apps/mobile/src/features/tasks/components/AutomationStatusBadge.tsx b/apps/mobile/src/features/tasks/components/AutomationStatusBadge.tsx new file mode 100644 index 000000000..889018d2b --- /dev/null +++ b/apps/mobile/src/features/tasks/components/AutomationStatusBadge.tsx @@ -0,0 +1,60 @@ +import { Text } from "@components/text"; +import { View } from "react-native"; + +interface AutomationStatusBadgeProps { + enabled: boolean; + lastRunStatus: string | null; +} + +function renderRunStatus(lastRunStatus: string | null) { + switch (lastRunStatus) { + case "running": + return { + label: "Running", + className: "bg-status-info/20 text-status-info", + }; + case "success": + return { + label: "Success", + className: "bg-status-success/20 text-status-success", + }; + case "failed": + return { + label: "Failed", + className: "bg-status-error/20 text-status-error", + }; + default: + return { + label: "Never run", + className: "bg-gray-4 text-gray-11", + }; + } +} + +export function AutomationStatusBadge({ + enabled, + lastRunStatus, +}: AutomationStatusBadgeProps) { + const runStatus = renderRunStatus(lastRunStatus); + + return ( + + + + {enabled ? "Enabled" : "Paused"} + + + + + {runStatus.label} + + + + ); +} diff --git a/apps/mobile/src/features/tasks/components/GitHubConnectionPrompt.tsx b/apps/mobile/src/features/tasks/components/GitHubConnectionPrompt.tsx new file mode 100644 index 000000000..81d1ccc09 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/GitHubConnectionPrompt.tsx @@ -0,0 +1,87 @@ +import { Text } from "@components/text"; +import * as WebBrowser from "expo-web-browser"; +import { Pressable, View } from "react-native"; +import { useAuthStore } from "@/features/auth"; +import { useThemeColors } from "@/lib/theme"; + +interface GitHubConnectionPromptProps { + onConnected?: () => void; + mode?: "card" | "empty"; + title?: string; + description?: string; +} + +export function GitHubConnectionPrompt({ + onConnected, + mode = "card", + title = "Connect GitHub to continue", + description = "You need to connect your GitHub account before using this workflow.", +}: GitHubConnectionPromptProps) { + const { cloudRegion, projectId, getCloudUrlFromRegion } = useAuthStore(); + const themeColors = useThemeColors(); + + const handleConnectGitHub = async () => { + if (!cloudRegion || !projectId) { + return; + } + + const baseUrl = getCloudUrlFromRegion(cloudRegion); + const authorizeUrl = `${baseUrl}/api/environments/${projectId}/integrations/authorize/?kind=github`; + const result = await WebBrowser.openAuthSessionAsync( + authorizeUrl, + "posthog://github/callback", + ); + + if ( + result.type === "dismiss" || + result.type === "cancel" || + result.type === "success" + ) { + onConnected?.(); + } + }; + + if (mode === "empty") { + return ( + + + 🔗 + + + Connect GitHub + + + Let PostHog work on your repositories. + + + + Connect GitHub + + + + ); + } + + return ( + + + 🔗 + {title} + + {description} + + + Connect GitHub + + + + ); +} diff --git a/apps/mobile/src/features/tasks/components/GitHubLoadNotice.tsx b/apps/mobile/src/features/tasks/components/GitHubLoadNotice.tsx new file mode 100644 index 000000000..435ef7ad6 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/GitHubLoadNotice.tsx @@ -0,0 +1,33 @@ +import { Text } from "@components/text"; +import { Pressable, View } from "react-native"; + +interface GitHubLoadNoticeProps { + message: string; + onRetry: () => void; + tone?: "error" | "warning"; +} + +export function GitHubLoadNotice({ + message, + onRetry, + tone = "error", +}: GitHubLoadNoticeProps) { + const containerClassName = + tone === "warning" + ? "mb-4 rounded-lg border border-status-warning/30 bg-status-warning/10 p-3" + : "mb-4 rounded-lg border border-status-error bg-status-error/10 p-3"; + const messageClassName = + tone === "warning" ? "text-status-warning" : "text-status-error"; + + return ( + + {message} + + Retry + + + ); +} diff --git a/apps/mobile/src/features/tasks/components/RepositorySelector.tsx b/apps/mobile/src/features/tasks/components/RepositorySelector.tsx new file mode 100644 index 000000000..6c5ff4428 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/RepositorySelector.tsx @@ -0,0 +1,95 @@ +import { Text } from "@components/text"; +import { useMemo, useState } from "react"; +import { Pressable, ScrollView, TextInput, View } from "react-native"; +import { useThemeColors } from "@/lib/theme"; +import type { RepositoryOption, RepositorySelection } from "../types"; + +interface RepositorySelectorProps { + options: RepositoryOption[]; + value: RepositorySelection; + onChange: (selection: RepositorySelection) => void; +} + +export function RepositorySelector({ + options, + value, + onChange, +}: RepositorySelectorProps) { + const themeColors = useThemeColors(); + const [search, setSearch] = useState(""); + + const filteredOptions = useMemo(() => { + const query = search.trim().toLowerCase(); + if (!query) { + return options; + } + + return options.filter( + (option) => + option.repository.toLowerCase().includes(query) || + option.integrationLabel.toLowerCase().includes(query), + ); + }, [options, search]); + + return ( + <> + + + {filteredOptions.length === 0 ? ( + + + {search + ? `No repositories match "${search}"` + : "No repositories available"} + + + ) : ( + filteredOptions.map((option) => { + const isSelected = + value.integrationId === option.integrationId && + value.repository === option.repository; + + return ( + + onChange({ + integrationId: option.integrationId, + repository: option.repository, + }) + } + className={`border-gray-5 border-b px-3.5 py-3 ${ + isSelected ? "bg-accent-3" : "" + }`} + > + + {option.repository} + + + {option.integrationLabel} + + + ); + }) + )} + + + ); +} diff --git a/apps/mobile/src/features/tasks/components/ScheduleEditor.tsx b/apps/mobile/src/features/tasks/components/ScheduleEditor.tsx new file mode 100644 index 000000000..102e607e8 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/ScheduleEditor.tsx @@ -0,0 +1,185 @@ +import { Text } from "@components/text"; +import { Pressable, TextInput, View } from "react-native"; +import { + type AutomationScheduleDraft, + type AutomationScheduleMode, + buildCronExpression, + sanitizeHour, + sanitizeMinute, + WEEKDAY_OPTIONS, +} from "../utils/automationSchedule"; + +interface ScheduleEditorProps { + value: AutomationScheduleDraft; + timezone: string; + onChange: (value: AutomationScheduleDraft) => void; + onTimezoneChange: (timezone: string) => void; +} + +const MODE_OPTIONS: Array<{ + value: AutomationScheduleMode; + label: string; +}> = [ + { value: "hourly", label: "Hourly" }, + { value: "daily", label: "Daily" }, + { value: "weekdays", label: "Weekdays" }, + { value: "weekly", label: "Weekly" }, + { value: "custom", label: "Custom" }, +]; + +export function ScheduleEditor({ + value, + timezone, + onChange, + onTimezoneChange, +}: ScheduleEditorProps) { + const updateDraft = (updates: Partial) => { + const nextDraft = { + ...value, + ...updates, + }; + + onChange({ + ...nextDraft, + rawCron: + nextDraft.mode === "custom" + ? nextDraft.rawCron + : buildCronExpression(nextDraft), + }); + }; + + return ( + + + Schedule + + + + {MODE_OPTIONS.map((option) => { + const isSelected = value.mode === option.value; + return ( + updateDraft({ mode: option.value })} + className={`rounded-xl border px-3 py-2 ${ + isSelected + ? "border-accent-8 bg-accent-3" + : "border-gray-5 bg-background" + }`} + > + + {option.label} + + + ); + })} + + + {value.mode === "custom" ? ( + onChange({ ...value, rawCron })} + autoCapitalize="none" + autoCorrect={false} + /> + ) : ( + <> + {value.mode === "hourly" ? ( + + + Minute past the hour + + + updateDraft({ minute: sanitizeMinute(minute) }) + } + keyboardType="number-pad" + /> + + ) : ( + + + Hour + + updateDraft({ hour: sanitizeHour(hour) }) + } + keyboardType="number-pad" + /> + + + Minute + + updateDraft({ minute: sanitizeMinute(minute) }) + } + keyboardType="number-pad" + /> + + + )} + + {value.mode === "weekly" && ( + + Day + + {WEEKDAY_OPTIONS.map((option) => { + const isSelected = value.weekday === option.value; + return ( + updateDraft({ weekday: option.value })} + className={`rounded-xl border px-3 py-2 ${ + isSelected + ? "border-accent-8 bg-accent-3" + : "border-gray-5 bg-background" + }`} + > + + {option.label} + + + ); + })} + + + )} + + )} + + + Timezone + + + + ); +} diff --git a/apps/mobile/src/features/tasks/components/TaskList.tsx b/apps/mobile/src/features/tasks/components/TaskList.tsx index f7bde20e4..9390134f7 100644 --- a/apps/mobile/src/features/tasks/components/TaskList.tsx +++ b/apps/mobile/src/features/tasks/components/TaskList.tsx @@ -1,5 +1,4 @@ import { Text } from "@components/text"; -import * as WebBrowser from "expo-web-browser"; import { CaretRight, GitBranch } from "phosphor-react-native"; import { useMemo, useState } from "react"; import { @@ -9,13 +8,14 @@ import { RefreshControl, View, } from "react-native"; -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 { taskActivityTimestamp, useTaskStore } from "../stores/taskStore"; import type { Task } from "../types"; +import { GitHubConnectionPrompt } from "./GitHubConnectionPrompt"; +import { GitHubLoadNotice } from "./GitHubLoadNotice"; import { SwipeableTaskItem } from "./SwipeableTaskItem"; interface TaskListProps { @@ -23,62 +23,6 @@ interface TaskListProps { onCreateTask?: () => void; } -interface ConnectGitHubEmptyStateProps { - onConnected?: () => void; -} - -function ConnectGitHubEmptyState({ - onConnected, -}: ConnectGitHubEmptyStateProps) { - const { cloudRegion, projectId, getCloudUrlFromRegion } = useAuthStore(); - const themeColors = useThemeColors(); - - const handleConnectGitHub = async () => { - if (!cloudRegion || !projectId) return; - const baseUrl = getCloudUrlFromRegion(cloudRegion); - // Use the authorize endpoint which redirects to GitHub App installation - const authorizeUrl = `${baseUrl}/api/environments/${projectId}/integrations/authorize/?kind=github`; - - // Open in-app browser - will auto-detect when user returns - const result = await WebBrowser.openAuthSessionAsync( - authorizeUrl, - "posthog://github/callback", - ); - - // When browser session ends (dismiss, cancel, or redirect), refresh integrations - if ( - result.type === "dismiss" || - result.type === "cancel" || - result.type === "success" - ) { - onConnected?.(); - } - }; - - return ( - - - 🔗 - - - Connect GitHub - - - Let PostHog work on your repositories. - - - - Connect GitHub - - - - ); -} - interface CreateTaskEmptyStateProps { onCreateTask?: () => void; } @@ -144,9 +88,14 @@ const DATE_GROUP_ORDER = [ ]; export function TaskList({ onTaskPress, onCreateTask }: TaskListProps) { - const { tasks, isLoading, error, refetch } = useTasks(); - const { hasGithubIntegration, refetch: refetchIntegrations } = - useIntegrations(); + const { tasks, isLoading, error, refetch } = useTasks({ + originProduct: "user_created", + }); + const { + error: integrationsError, + hasGithubIntegration, + refetch: refetchIntegrations, + } = useIntegrations(); const themeColors = useThemeColors(); const { archivedTasks, archive, unarchive } = useArchivedTasksStore(); const organizeMode = useTaskStore((s) => s.organizeMode); @@ -174,7 +123,6 @@ export function TaskList({ onTaskPress, onCreateTask }: TaskListProps) { } } - // Sort archived by FIFO (earliest archived first) archived.sort( (a, b) => (archivedTasks[a.id] ?? 0) - (archivedTasks[b.id] ?? 0), ); @@ -182,7 +130,6 @@ export function TaskList({ onTaskPress, onCreateTask }: TaskListProps) { const items: ListItem[] = []; if (organizeMode === "by-project") { - // Group active tasks by repository. const groups = new Map(); for (const task of active) { const key = task.repository?.trim() || NO_REPO_LABEL; @@ -194,7 +141,6 @@ export function TaskList({ onTaskPress, onCreateTask }: TaskListProps) { } } - // Sort each group's tasks by the configured sortMode (newest first). for (const tasksInRepo of groups.values()) { tasksInRepo.sort( (a, b) => @@ -203,7 +149,6 @@ export function TaskList({ onTaskPress, onCreateTask }: TaskListProps) { ); } - // Order groups: most-recently-active repo first; "No repository" last. const groupEntries = Array.from(groups.entries()).sort((a, b) => { if (a[0] === NO_REPO_LABEL) return 1; if (b[0] === NO_REPO_LABEL) return -1; @@ -224,7 +169,6 @@ export function TaskList({ onTaskPress, onCreateTask }: TaskListProps) { } } } else { - // Chronological — flat list grouped by relative-date buckets. const sorted = [...active].sort( (a, b) => taskActivityTimestamp(b, sortMode) - @@ -283,7 +227,22 @@ export function TaskList({ onTaskPress, onCreateTask }: TaskListProps) { ); } - // Show loading while tasks are loading OR while we haven't checked integrations yet (when no tasks) + if (integrationsError && tasks.length === 0) { + return ( + + + {integrationsError} + + + Retry + + + ); + } + const isInitialLoading = (isLoading && tasks.length === 0) || (tasks.length === 0 && hasGithubIntegration === null); @@ -297,12 +256,10 @@ export function TaskList({ onTaskPress, onCreateTask }: TaskListProps) { ); } - // No GitHub connection and no tasks - prompt to connect GitHub if (hasGithubIntegration === false && tasks.length === 0) { - return ; + return ; } - // Has GitHub connection but no tasks - prompt to create first task if (tasks.length === 0) { return ; } @@ -323,6 +280,14 @@ export function TaskList({ onTaskPress, onCreateTask }: TaskListProps) { return `${item.task.id}-${item.isArchived ? "a" : "v"}`; } }} + ListHeaderComponent={ + integrationsError ? ( + + ) : null + } renderItem={({ item }) => { if (item.type === "repo-header") { return ( diff --git a/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts b/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts new file mode 100644 index 000000000..e7e3d6c32 --- /dev/null +++ b/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts @@ -0,0 +1,227 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createElement, type PropsWithChildren } from "react"; +import { act, create } from "react-test-renderer"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + mockUseAuthStore, + mockGetTaskAutomations, + mockCreateTaskAutomation, + mockUpdateTaskAutomation, +} = vi.hoisted(() => ({ + mockUseAuthStore: vi.fn(), + mockGetTaskAutomations: vi.fn(), + mockCreateTaskAutomation: vi.fn(), + mockUpdateTaskAutomation: vi.fn(), +})); + +vi.mock("@/features/auth", () => ({ + useAuthStore: mockUseAuthStore, +})); + +vi.mock("../api", () => ({ + getTaskAutomations: mockGetTaskAutomations, + getTaskAutomation: vi.fn(), + createTaskAutomation: mockCreateTaskAutomation, + updateTaskAutomation: mockUpdateTaskAutomation, + deleteTaskAutomation: vi.fn(), + runTaskAutomation: vi.fn(), +})); + +import { + automationKeys, + useAutomations, + useCreateTaskAutomation, + useUpdateTaskAutomation, +} from "./useAutomations"; +import { taskKeys } from "./useTasks"; + +function createWrapper(queryClient: QueryClient) { + return function Wrapper({ children }: PropsWithChildren) { + return createElement( + QueryClientProvider, + { client: queryClient }, + children, + ); + }; +} + +function renderTestHook( + useHook: () => Result, + wrapper: + | ((props: PropsWithChildren) => ReturnType) + | undefined, +) { + let currentResult: Result; + + function HookProbe() { + currentResult = useHook(); + return null; + } + + function TestTree() { + if (!wrapper) { + return createElement(HookProbe); + } + + const Wrapper = wrapper; + return createElement(Wrapper, null, createElement(HookProbe)); + } + + let renderer: ReturnType; + act(() => { + renderer = create(createElement(TestTree)); + }); + + return { + result: { + get current() { + return currentResult; + }, + }, + unmount() { + act(() => { + renderer.unmount(); + }); + }, + }; +} + +async function waitForAssertion(assertion: () => void): Promise { + const timeoutAt = Date.now() + 2_000; + + while (Date.now() < timeoutAt) { + try { + assertion(); + return; + } catch (error) { + await new Promise((resolve) => setTimeout(resolve, 10)); + if (Date.now() >= timeoutAt) { + throw error; + } + } + } +} + +const automationPayload = { + id: "automation-1", + name: "Daily PRs", + prompt: "Check PRs", + repository: "posthog/posthog", + github_integration: 7, + cron_expression: "0 9 * * *", + timezone: "Europe/London", + enabled: true, + last_run_at: null, + last_run_status: null, + last_task_id: "task-1", + last_task_run_id: null, + last_error: null, + created_at: "2026-05-13T00:00:00Z", + updated_at: "2026-05-13T00:00:00Z", +}; + +describe("useAutomations", () => { + beforeEach(() => { + mockUseAuthStore.mockImplementation((selector) => + selector + ? selector({ + projectId: 42, + oauthAccessToken: "token", + }) + : { + projectId: 42, + oauthAccessToken: "token", + }, + ); + mockGetTaskAutomations.mockReset(); + mockCreateTaskAutomation.mockReset(); + mockUpdateTaskAutomation.mockReset(); + }); + + it("loads automation lists through the dedicated query key", async () => { + mockGetTaskAutomations.mockResolvedValueOnce([automationPayload]); + + const queryClient = new QueryClient(); + const { result, unmount } = renderTestHook( + () => useAutomations(), + createWrapper(queryClient), + ); + + await waitForAssertion(() => { + expect(result.current.automations).toHaveLength(1); + }); + + expect(mockGetTaskAutomations).toHaveBeenCalledOnce(); + expect(queryClient.getQueryData(automationKeys.list())).toEqual([ + automationPayload, + ]); + unmount(); + }); + + it("invalidates automation and task lists after create", async () => { + const queryClient = new QueryClient(); + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); + mockCreateTaskAutomation.mockResolvedValueOnce(automationPayload); + + const { result, unmount } = renderTestHook( + () => useCreateTaskAutomation(), + createWrapper(queryClient), + ); + + await act(async () => { + await result.current.mutateAsync({ + name: "Daily PRs", + prompt: "Check PRs", + repository: "posthog/posthog", + github_integration: 7, + cron_expression: "0 9 * * *", + timezone: "Europe/London", + }); + }); + + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: automationKeys.lists(), + }); + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: taskKeys.lists(), + }); + unmount(); + }); + + it("updates the detail cache immediately after automation edits", async () => { + const queryClient = new QueryClient(); + queryClient.setQueryData( + automationKeys.detail("automation-1"), + automationPayload, + ); + mockUpdateTaskAutomation.mockResolvedValueOnce({ + ...automationPayload, + enabled: false, + cron_expression: "30 14 * * *", + }); + + const { result, unmount } = renderTestHook( + () => useUpdateTaskAutomation(), + createWrapper(queryClient), + ); + + await act(async () => { + await result.current.mutateAsync({ + automationId: "automation-1", + updates: { + enabled: false, + cron_expression: "30 14 * * *", + }, + }); + }); + + expect( + queryClient.getQueryData(automationKeys.detail("automation-1")), + ).toMatchObject({ + enabled: false, + cron_expression: "30 14 * * *", + }); + unmount(); + }); +}); diff --git a/apps/mobile/src/features/tasks/hooks/useAutomations.ts b/apps/mobile/src/features/tasks/hooks/useAutomations.ts new file mode 100644 index 000000000..347707092 --- /dev/null +++ b/apps/mobile/src/features/tasks/hooks/useAutomations.ts @@ -0,0 +1,139 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useAuthStore } from "@/features/auth"; +import { logger } from "@/lib/logger"; +import { + createTaskAutomation, + deleteTaskAutomation, + getTaskAutomation, + getTaskAutomations, + runTaskAutomation, + updateTaskAutomation, +} from "../api"; +import type { + CreateTaskAutomationOptions, + TaskAutomation, + UpdateTaskAutomationOptions, +} from "../types"; +import { taskKeys } from "./useTasks"; + +const log = logger.scope("automations-mutations"); + +export const automationKeys = { + all: ["task-automations"] as const, + lists: () => [...automationKeys.all, "list"] as const, + list: () => [...automationKeys.lists(), "all"] as const, + details: () => [...automationKeys.all, "detail"] as const, + detail: (id: string) => [...automationKeys.details(), id] as const, +}; + +function invalidateAutomationAndTaskLists( + queryClient: ReturnType, +) { + queryClient.invalidateQueries({ queryKey: automationKeys.lists() }); + queryClient.invalidateQueries({ queryKey: taskKeys.lists() }); +} + +export function useAutomations() { + const { projectId, oauthAccessToken } = useAuthStore(); + + const query = useQuery({ + queryKey: automationKeys.list(), + queryFn: getTaskAutomations, + enabled: !!projectId && !!oauthAccessToken, + }); + + return { + automations: query.data ?? [], + isLoading: query.isLoading, + error: query.error?.message ?? null, + refetch: query.refetch, + }; +} + +export function useAutomation(automationId: string) { + const { projectId, oauthAccessToken } = useAuthStore(); + + return useQuery({ + queryKey: automationKeys.detail(automationId), + queryFn: () => getTaskAutomation(automationId), + enabled: !!projectId && !!oauthAccessToken && !!automationId, + }); +} + +export function useCreateTaskAutomation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (options: CreateTaskAutomationOptions) => + createTaskAutomation(options), + onSuccess: (automation) => { + queryClient.setQueryData( + automationKeys.detail(automation.id), + automation, + ); + invalidateAutomationAndTaskLists(queryClient); + }, + onError: (error) => { + log.error("Failed to create automation", error.message); + }, + }); +} + +export function useUpdateTaskAutomation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + automationId, + updates, + }: { + automationId: string; + updates: UpdateTaskAutomationOptions; + }) => updateTaskAutomation(automationId, updates), + onSuccess: (automation, { automationId }) => { + queryClient.setQueryData( + automationKeys.detail(automationId), + automation, + ); + invalidateAutomationAndTaskLists(queryClient); + }, + onError: (error) => { + log.error("Failed to update automation", error.message); + }, + }); +} + +export function useDeleteTaskAutomation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (automationId: string) => deleteTaskAutomation(automationId), + onSuccess: (_, automationId) => { + queryClient.removeQueries({ + queryKey: automationKeys.detail(automationId), + }); + invalidateAutomationAndTaskLists(queryClient); + }, + onError: (error) => { + log.error("Failed to delete automation", error.message); + }, + }); +} + +export function useRunTaskAutomation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (automationId: string) => runTaskAutomation(automationId), + onSuccess: (automation, automationId) => { + queryClient.setQueryData( + automationKeys.detail(automationId), + automation, + ); + invalidateAutomationAndTaskLists(queryClient); + }, + onError: (error) => { + log.error("Failed to run automation", error.message); + }, + }); +} diff --git a/apps/mobile/src/features/tasks/hooks/useIntegrations.test.ts b/apps/mobile/src/features/tasks/hooks/useIntegrations.test.ts new file mode 100644 index 000000000..caf1bcade --- /dev/null +++ b/apps/mobile/src/features/tasks/hooks/useIntegrations.test.ts @@ -0,0 +1,177 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createElement, type PropsWithChildren } from "react"; +import { act, create } from "react-test-renderer"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockUseAuthStore, mockGetGithubRepositories, mockGetIntegrations } = + vi.hoisted(() => ({ + mockUseAuthStore: vi.fn(), + mockGetGithubRepositories: vi.fn(), + mockGetIntegrations: vi.fn(), + })); + +vi.mock("@/features/auth", () => ({ + useAuthStore: mockUseAuthStore, +})); + +vi.mock("../api", () => ({ + getGithubRepositories: mockGetGithubRepositories, + getIntegrations: mockGetIntegrations, +})); + +import { useIntegrations } from "./useIntegrations"; + +function createWrapper(queryClient: QueryClient) { + return function Wrapper({ children }: PropsWithChildren) { + return createElement( + QueryClientProvider, + { client: queryClient }, + children, + ); + }; +} + +function renderTestHook( + useHook: () => Result, + wrapper: + | ((props: PropsWithChildren) => ReturnType) + | undefined, +) { + let currentResult: Result; + + function HookProbe() { + currentResult = useHook(); + return null; + } + + function TestTree() { + if (!wrapper) { + return createElement(HookProbe); + } + + const Wrapper = wrapper; + return createElement(Wrapper, null, createElement(HookProbe)); + } + + let renderer: ReturnType; + act(() => { + renderer = create(createElement(TestTree)); + }); + + return { + result: { + get current() { + return currentResult; + }, + }, + unmount() { + act(() => { + renderer.unmount(); + }); + }, + }; +} + +async function waitForAssertion(assertion: () => void): Promise { + const timeoutAt = Date.now() + 2_000; + + while (Date.now() < timeoutAt) { + try { + assertion(); + return; + } catch (error) { + await new Promise((resolve) => setTimeout(resolve, 10)); + if (Date.now() >= timeoutAt) { + throw error; + } + } + } +} + +describe("useIntegrations", () => { + beforeEach(() => { + mockUseAuthStore.mockImplementation((selector) => + selector + ? selector({ + projectId: 42, + oauthAccessToken: "token", + }) + : { + projectId: 42, + oauthAccessToken: "token", + }, + ); + mockGetIntegrations.mockReset(); + mockGetGithubRepositories.mockReset(); + }); + + it("keeps repositories from healthy integrations when one repository fetch fails", async () => { + mockGetIntegrations.mockResolvedValueOnce([ + { + id: 7, + kind: "github", + display_name: "Personal GitHub", + }, + { + id: 11, + kind: "github", + display_name: "PostHog", + }, + ]); + mockGetGithubRepositories + .mockResolvedValueOnce(["annika/mobile-app"]) + .mockRejectedValueOnce(new Error("GitHub repos failed")); + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + const { result, unmount } = renderTestHook( + () => useIntegrations(), + createWrapper(queryClient), + ); + + await waitForAssertion(() => { + expect(result.current.isLoading).toBe(false); + expect(result.current.repositoryOptions).toEqual([ + { + integrationId: 7, + integrationLabel: "Personal GitHub", + repository: "annika/mobile-app", + }, + ]); + }); + + expect(result.current.repositoryWarning).toBe( + "Some GitHub repositories could not be loaded. Pull to retry.", + ); + expect(result.current.error).toBeNull(); + unmount(); + }); + + it("surfaces integration fetch failures as blocking errors", async () => { + mockGetIntegrations.mockRejectedValueOnce( + new Error("Failed to fetch integrations"), + ); + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + const { result, unmount } = renderTestHook( + () => useIntegrations(), + createWrapper(queryClient), + ); + + await waitForAssertion(() => { + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe("Failed to fetch integrations"); + }); + + expect(result.current.repositoryOptions).toEqual([]); + expect(result.current.repositoryWarning).toBeNull(); + unmount(); + }); +}); diff --git a/apps/mobile/src/features/tasks/hooks/useIntegrations.ts b/apps/mobile/src/features/tasks/hooks/useIntegrations.ts index 50e988299..54c2fdb03 100644 --- a/apps/mobile/src/features/tasks/hooks/useIntegrations.ts +++ b/apps/mobile/src/features/tasks/hooks/useIntegrations.ts @@ -1,6 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { useAuthStore } from "@/features/auth"; import { getGithubRepositories, getIntegrations } from "../api"; +import { buildRepositoryOptions } from "../utils/repositorySelection"; export const integrationKeys = { all: ["integrations"] as const, @@ -10,6 +11,11 @@ export const integrationKeys = { [...integrationKeys.all, "repos", integrationId] as const, }; +interface RepositoryLoadResult { + repositoriesByIntegration: Record; + partialError: string | null; +} + export function useIntegrations() { const { projectId, oauthAccessToken } = useAuthStore(); @@ -30,17 +36,49 @@ export function useIntegrations() { "repos", githubIntegrations.map((i) => i.id), ], - queryFn: async () => { - const allRepos: string[] = []; - for (const integration of githubIntegrations) { - const repos = await getGithubRepositories(integration.id); - allRepos.push(...repos); + queryFn: async (): Promise => { + const repositoriesByIntegration: Record = {}; + const results = await Promise.allSettled( + githubIntegrations.map(async (integration) => ({ + integrationId: integration.id, + repositories: await getGithubRepositories(integration.id), + })), + ); + + let failedCount = 0; + + for (const result of results) { + if (result.status === "fulfilled") { + repositoriesByIntegration[result.value.integrationId] = + result.value.repositories; + continue; + } + + failedCount += 1; } - return allRepos.sort(); + + return { + repositoriesByIntegration, + partialError: + failedCount === 0 + ? null + : failedCount === githubIntegrations.length + ? "Could not load GitHub repositories. Pull to retry." + : "Some GitHub repositories could not be loaded. Pull to retry.", + }; }, enabled: githubIntegrations.length > 0, }); + const repositoriesByIntegration = + repositoriesQuery.data?.repositoriesByIntegration ?? {}; + const repositories = Object.values(repositoriesByIntegration).flat().sort(); + const repositoryOptions = buildRepositoryOptions( + githubIntegrations, + repositoriesByIntegration, + ); + const repositoryWarning = repositoriesQuery.data?.partialError ?? null; + const refetch = async () => { await integrationsQuery.refetch(); await repositoriesQuery.refetch(); @@ -51,12 +89,12 @@ export function useIntegrations() { ? githubIntegrations.length > 0 : null, githubIntegrations, - repositories: repositoriesQuery.data ?? [], + repositories, + repositoriesByIntegration, + repositoryOptions, isLoading: integrationsQuery.isLoading || repositoriesQuery.isLoading, - error: - integrationsQuery.error?.message ?? - repositoriesQuery.error?.message ?? - null, + error: integrationsQuery.error?.message ?? null, + repositoryWarning, refetch, }; } diff --git a/apps/mobile/src/features/tasks/hooks/useTasks.ts b/apps/mobile/src/features/tasks/hooks/useTasks.ts index db0abc243..86c8e4f74 100644 --- a/apps/mobile/src/features/tasks/hooks/useTasks.ts +++ b/apps/mobile/src/features/tasks/hooks/useTasks.ts @@ -17,13 +17,19 @@ const log = logger.scope("tasks-mutations"); export const taskKeys = { all: ["tasks"] as const, lists: () => [...taskKeys.all, "list"] as const, - list: (filters?: { repository?: string; createdBy?: number }) => - [...taskKeys.lists(), filters] as const, + list: (filters?: { + repository?: string; + createdBy?: number; + originProduct?: string; + }) => [...taskKeys.lists(), filters] as const, details: () => [...taskKeys.all, "detail"] as const, detail: (id: string) => [...taskKeys.details(), id] as const, }; -export function useTasks(filters?: { repository?: string }) { +export function useTasks(filters?: { + repository?: string; + originProduct?: string; +}) { const { projectId, oauthAccessToken } = useAuthStore(); const { data: currentUser } = useUserQuery(); const { sortMode, showInternal, filter } = useTaskStore(); diff --git a/apps/mobile/src/features/tasks/types.ts b/apps/mobile/src/features/tasks/types.ts index 855dad397..c863a59f6 100644 --- a/apps/mobile/src/features/tasks/types.ts +++ b/apps/mobile/src/features/tasks/types.ts @@ -13,6 +13,25 @@ export interface Task { latest_run?: TaskRun; } +export interface TaskAutomation { + id: string; + name: string; + prompt: string; + repository: string; + github_integration?: number | null; + cron_expression: string; + timezone?: string | null; + template_id?: string | null; + enabled: boolean; + last_run_at: string | null; + last_run_status: string | null; + last_task_id: string | null; + last_task_run_id: string | null; + last_error: string | null; + created_at: string; + updated_at: string; +} + export interface TaskRun { id: string; task: string; @@ -94,9 +113,42 @@ export interface Integration { }; } +export interface RepositoryOption { + integrationId: number; + integrationLabel: string; + repository: string; +} + +export interface RepositorySelection { + integrationId: number | null; + repository: string | null; +} + export interface CreateTaskOptions { description: string; title?: string; repository?: string; github_integration?: number; } + +export interface CreateTaskAutomationOptions { + name: string; + prompt: string; + repository: string; + github_integration?: number | null; + cron_expression: string; + timezone: string; + enabled?: boolean; + template_id?: string | null; +} + +export interface UpdateTaskAutomationOptions { + name?: string; + prompt?: string; + repository?: string; + github_integration?: number | null; + cron_expression?: string; + timezone?: string; + enabled?: boolean; + template_id?: string | null; +} diff --git a/apps/mobile/src/features/tasks/utils/automationSchedule.test.ts b/apps/mobile/src/features/tasks/utils/automationSchedule.test.ts new file mode 100644 index 000000000..af54fb016 --- /dev/null +++ b/apps/mobile/src/features/tasks/utils/automationSchedule.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { + buildCronExpression, + createDefaultScheduleDraft, + deriveAutomationName, + formatScheduleSummary, + parseCronExpression, +} from "./automationSchedule"; + +describe("automationSchedule", () => { + it("builds cron expressions for common schedule presets", () => { + expect( + buildCronExpression({ + ...createDefaultScheduleDraft(), + mode: "hourly", + minute: "15", + }), + ).toBe("15 * * * *"); + + expect( + buildCronExpression({ + ...createDefaultScheduleDraft(), + mode: "daily", + hour: "09", + minute: "15", + }), + ).toBe("15 9 * * *"); + + expect( + buildCronExpression({ + ...createDefaultScheduleDraft(), + mode: "weekdays", + hour: "10", + minute: "00", + }), + ).toBe("0 10 * * 1-5"); + + expect( + buildCronExpression({ + ...createDefaultScheduleDraft(), + mode: "weekly", + hour: "11", + minute: "30", + weekday: "4", + }), + ).toBe("30 11 * * 4"); + }); + + it("parses common cron expressions back into schedule drafts", () => { + expect(parseCronExpression("15 * * * *")).toMatchObject({ + mode: "hourly", + minute: "15", + }); + + expect(parseCronExpression("0 9 * * *")).toMatchObject({ + mode: "daily", + hour: "09", + minute: "00", + }); + + expect(parseCronExpression("0 9 * * 1-5")).toMatchObject({ + mode: "weekdays", + hour: "09", + minute: "00", + }); + + expect(parseCronExpression("30 14 * * 2")).toMatchObject({ + mode: "weekly", + weekday: "2", + hour: "14", + minute: "30", + }); + }); + + it("keeps custom cron expressions in custom mode", () => { + expect(parseCronExpression("*/15 * * * *")).toMatchObject({ + mode: "custom", + rawCron: "*/15 * * * *", + }); + }); + + it("derives a readable automation name from the prompt", () => { + expect( + deriveAutomationName( + "\n Review every open PostHog PR for stale comments \n", + ), + ).toBe("Review every open PostHog PR for stale comments"); + }); + + it("formats schedule summaries with timezone context", () => { + expect(formatScheduleSummary("15 * * * *", "Europe/London")).toBe( + "Every hour at :15 · Europe/London", + ); + expect(formatScheduleSummary("0 9 * * 1-5", "Europe/London")).toBe( + "Weekdays at 09:00 · Europe/London", + ); + expect(formatScheduleSummary("*/15 * * * *", "UTC")).toBe( + "Custom schedule · UTC", + ); + }); +}); diff --git a/apps/mobile/src/features/tasks/utils/automationSchedule.ts b/apps/mobile/src/features/tasks/utils/automationSchedule.ts new file mode 100644 index 000000000..06cec5faf --- /dev/null +++ b/apps/mobile/src/features/tasks/utils/automationSchedule.ts @@ -0,0 +1,213 @@ +import type { TaskAutomation } from "../types"; + +export type AutomationScheduleMode = + | "hourly" + | "daily" + | "weekdays" + | "weekly" + | "custom"; + +export interface AutomationScheduleDraft { + mode: AutomationScheduleMode; + hour: string; + minute: string; + weekday: string; + rawCron: string; +} + +export const WEEKDAY_OPTIONS = [ + { value: "1", label: "Mon" }, + { value: "2", label: "Tue" }, + { value: "3", label: "Wed" }, + { value: "4", label: "Thu" }, + { value: "5", label: "Fri" }, + { value: "6", label: "Sat" }, + { value: "0", label: "Sun" }, +] as const; + +export function createDefaultScheduleDraft(): AutomationScheduleDraft { + return { + mode: "daily", + hour: "09", + minute: "00", + weekday: "1", + rawCron: "0 9 * * *", + }; +} + +function padTimePart(value: string): string { + return value.padStart(2, "0"); +} + +export function sanitizeHour(value: string): string { + const digitsOnly = value.replace(/\D/g, "").slice(0, 2); + if (!digitsOnly) { + return ""; + } + + return String(Math.min(23, Number(digitsOnly))).padStart(2, "0"); +} + +export function sanitizeMinute(value: string): string { + const digitsOnly = value.replace(/\D/g, "").slice(0, 2); + if (!digitsOnly) { + return ""; + } + + return String(Math.min(59, Number(digitsOnly))).padStart(2, "0"); +} + +export function buildCronExpression(draft: AutomationScheduleDraft): string { + if (draft.mode === "custom") { + return draft.rawCron.trim(); + } + + const minute = draft.minute ? String(Number(draft.minute)) : "0"; + const hour = draft.hour ? String(Number(draft.hour)) : "9"; + + switch (draft.mode) { + case "hourly": + return `${minute} * * * *`; + case "weekdays": + return `${minute} ${hour} * * 1-5`; + case "weekly": + return `${minute} ${hour} * * ${draft.weekday || "1"}`; + default: + return `${minute} ${hour} * * *`; + } +} + +export function parseCronExpression( + cronExpression: string, +): AutomationScheduleDraft { + const normalized = cronExpression.trim(); + const parts = normalized.split(/\s+/); + + if (parts.length !== 5) { + return { + ...createDefaultScheduleDraft(), + mode: "custom", + rawCron: normalized, + }; + } + + const [minute, hour, dayOfMonth, month, dayOfWeek] = parts; + const isNumericMinute = /^\d{1,2}$/.test(minute); + const isNumericHour = /^\d{1,2}$/.test(hour); + const draftBase = { + hour: padTimePart(hour), + minute: padTimePart(minute), + weekday: dayOfWeek, + rawCron: normalized, + }; + + if ( + isNumericMinute && + hour === "*" && + dayOfMonth === "*" && + month === "*" && + dayOfWeek === "*" + ) { + return { + ...draftBase, + mode: "hourly", + hour: "09", + }; + } + + if ( + isNumericMinute && + isNumericHour && + dayOfMonth === "*" && + month === "*" && + dayOfWeek === "*" + ) { + return { + ...draftBase, + mode: "daily", + }; + } + + if ( + isNumericMinute && + isNumericHour && + dayOfMonth === "*" && + month === "*" && + dayOfWeek === "1-5" + ) { + return { + ...draftBase, + mode: "weekdays", + weekday: "1", + }; + } + + if ( + isNumericMinute && + isNumericHour && + dayOfMonth === "*" && + month === "*" && + /^\d$/.test(dayOfWeek) + ) { + return { + ...draftBase, + mode: "weekly", + }; + } + + return { + ...draftBase, + mode: "custom", + }; +} + +export function deriveAutomationName(prompt: string): string { + const normalized = prompt + .split("\n") + .map((line) => line.trim()) + .find(Boolean); + + if (!normalized) { + return ""; + } + + return normalized.replace(/\s+/g, " ").slice(0, 80); +} + +function formatTime(hour: string, minute: string): string { + return `${padTimePart(hour)}:${padTimePart(minute)}`; +} + +export function formatScheduleSummary( + cronExpression: string, + timezone: string | null | undefined, +): string { + const draft = parseCronExpression(cronExpression); + const suffix = timezone ? ` · ${timezone}` : ""; + + switch (draft.mode) { + case "hourly": + return `Every hour at :${padTimePart(draft.minute)}${suffix}`; + case "weekdays": + return `Weekdays at ${formatTime(draft.hour, draft.minute)}${suffix}`; + case "weekly": { + const label = + WEEKDAY_OPTIONS.find((option) => option.value === draft.weekday) + ?.label ?? "Weekly"; + return `${label} at ${formatTime(draft.hour, draft.minute)}${suffix}`; + } + case "custom": + return `Custom schedule${suffix}`; + default: + return `Daily at ${formatTime(draft.hour, draft.minute)}${suffix}`; + } +} + +export function formatAutomationScheduleSummary( + automation: Pick, +): string { + return formatScheduleSummary( + automation.cron_expression, + automation.timezone ?? null, + ); +} diff --git a/apps/mobile/src/features/tasks/utils/repositorySelection.test.ts b/apps/mobile/src/features/tasks/utils/repositorySelection.test.ts new file mode 100644 index 000000000..820ef73ca --- /dev/null +++ b/apps/mobile/src/features/tasks/utils/repositorySelection.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { + buildRepositoryOptions, + findRepositoryOption, + isRepositorySelectionComplete, + toRepositorySelection, +} from "./repositorySelection"; + +describe("repositorySelection", () => { + const integrations = [ + { + id: 7, + kind: "github", + display_name: "Personal GitHub", + }, + { + id: 11, + kind: "github", + config: { + account: { + login: "posthog", + }, + }, + }, + ]; + + it("preserves integration identity for each repository option", () => { + const options = buildRepositoryOptions(integrations, { + 7: ["annika/mobile-app"], + 11: ["posthog/posthog", "posthog/code"], + }); + + expect(options).toEqual([ + { + integrationId: 7, + integrationLabel: "Personal GitHub", + repository: "annika/mobile-app", + }, + { + integrationId: 11, + integrationLabel: "posthog", + repository: "posthog/code", + }, + { + integrationId: 11, + integrationLabel: "posthog", + repository: "posthog/posthog", + }, + ]); + }); + + it("finds the exact repository option when multiple integrations expose the same repository", () => { + const options = buildRepositoryOptions(integrations, { + 7: ["posthog/posthog"], + 11: ["posthog/posthog"], + }); + + const selected = findRepositoryOption(options, { + integrationId: 11, + repository: "posthog/posthog", + }); + + expect(selected).toEqual({ + integrationId: 11, + integrationLabel: "posthog", + repository: "posthog/posthog", + }); + }); + + it("converts an option into a reusable repository selection payload", () => { + const options = buildRepositoryOptions(integrations, { + 11: ["posthog/code"], + }); + + const selection = toRepositorySelection(options[0] ?? null); + + expect(selection).toEqual({ + integrationId: 11, + repository: "posthog/code", + }); + expect(isRepositorySelectionComplete(selection)).toBe(true); + expect( + isRepositorySelectionComplete({ + integrationId: null, + repository: "posthog/code", + }), + ).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/tasks/utils/repositorySelection.ts b/apps/mobile/src/features/tasks/utils/repositorySelection.ts new file mode 100644 index 000000000..9e7b2cc8a --- /dev/null +++ b/apps/mobile/src/features/tasks/utils/repositorySelection.ts @@ -0,0 +1,62 @@ +import type { + Integration, + RepositoryOption, + RepositorySelection, +} from "../types"; + +function getIntegrationLabel(integration: Integration): string { + return ( + integration.display_name ?? + integration.config?.account?.login ?? + `GitHub ${integration.id}` + ); +} + +export function buildRepositoryOptions( + integrations: Integration[], + repositoriesByIntegration: Record, +): RepositoryOption[] { + return integrations + .flatMap((integration) => { + const repositories = repositoriesByIntegration[integration.id] ?? []; + + return repositories.map((repository) => ({ + integrationId: integration.id, + integrationLabel: getIntegrationLabel(integration), + repository, + })); + }) + .sort((left, right) => left.repository.localeCompare(right.repository)); +} + +export function findRepositoryOption( + options: RepositoryOption[], + selection: RepositorySelection, +): RepositoryOption | null { + if (!selection.integrationId || !selection.repository) { + return null; + } + + return ( + options.find( + (option) => + option.integrationId === selection.integrationId && + option.repository === selection.repository, + ) ?? null + ); +} + +export function toRepositorySelection( + option: RepositoryOption | null, +): RepositorySelection { + return { + integrationId: option?.integrationId ?? null, + repository: option?.repository ?? null, + }; +} + +export function isRepositorySelectionComplete( + selection: RepositorySelection, +): boolean { + return !!selection.integrationId && !!selection.repository; +} diff --git a/apps/mobile/src/hooks/useNetworkStatus.ts b/apps/mobile/src/hooks/useNetworkStatus.ts index fc183d7b1..87eaca610 100644 --- a/apps/mobile/src/hooks/useNetworkStatus.ts +++ b/apps/mobile/src/hooks/useNetworkStatus.ts @@ -1,14 +1,111 @@ import NetInfo from "@react-native-community/netinfo"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { AppState } from "react-native"; + +const OFFLINE_RECOVERY_POLL_INTERVAL_MS = 5_000; + +type NetworkSnapshot = { + isConnected: boolean | null; + isInternetReachable?: boolean | null; +}; + +export function hasInternetConnection(state: NetworkSnapshot): boolean { + if (state.isConnected === false) { + return false; + } + + if (state.isInternetReachable === false) { + return false; + } + + return true; +} export function useNetworkStatus() { const [isConnected, setIsConnected] = useState(true); + const isConnectedRef = useRef(isConnected); useEffect(() => { + isConnectedRef.current = isConnected; + }, [isConnected]); + + useEffect(() => { + let isMounted = true; + let recoveryPoller: ReturnType | null = null; + + const stopRecoveryPoller = () => { + if (!recoveryPoller) { + return; + } + + clearInterval(recoveryPoller); + recoveryPoller = null; + }; + + const startRecoveryPoller = () => { + if (recoveryPoller) { + return; + } + + recoveryPoller = setInterval(() => { + if (isConnectedRef.current) { + stopRecoveryPoller(); + return; + } + + void refreshStatus(); + }, OFFLINE_RECOVERY_POLL_INTERVAL_MS); + }; + + const applyStatus = (state: NetworkSnapshot) => { + if (!isMounted) { + return; + } + + const nextIsConnected = hasInternetConnection(state); + isConnectedRef.current = nextIsConnected; + setIsConnected(nextIsConnected); + + if (nextIsConnected) { + stopRecoveryPoller(); + } else { + startRecoveryPoller(); + } + }; + + const refreshStatus = async () => { + try { + const state = await NetInfo.fetch(); + applyStatus(state); + } catch { + applyStatus({ + isConnected: false, + isInternetReachable: false, + }); + } + }; + const unsubscribe = NetInfo.addEventListener((state) => { - setIsConnected(state.isConnected ?? true); + applyStatus(state); }); - return unsubscribe; + + const appStateSubscription = AppState.addEventListener( + "change", + (nextState) => { + if (nextState === "active") { + void refreshStatus(); + } + }, + ); + + void refreshStatus(); + + return () => { + isMounted = false; + unsubscribe(); + appStateSubscription.remove(); + stopRecoveryPoller(); + }; }, []); return { isConnected }; diff --git a/apps/mobile/src/test/setup.ts b/apps/mobile/src/test/setup.ts new file mode 100644 index 000000000..88c78122e --- /dev/null +++ b/apps/mobile/src/test/setup.ts @@ -0,0 +1,27 @@ +import { afterEach, vi } from "vitest"; + +vi.mock("expo-constants", () => ({ + default: { + expoConfig: { + version: "0.0.0-test", + }, + }, +})); + +vi.mock("@/lib/logger", () => { + const mockLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + scope: () => mockLogger, + }; + + return { + logger: mockLogger, + }; +}); + +afterEach(() => { + vi.clearAllMocks(); +}); diff --git a/apps/mobile/vitest.config.ts b/apps/mobile/vitest.config.ts new file mode 100644 index 000000000..c31342dec --- /dev/null +++ b/apps/mobile/vitest.config.ts @@ -0,0 +1,19 @@ +import path from "node:path"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [react()], + test: { + globals: true, + environment: "node", + setupFiles: ["./src/test/setup.ts"], + exclude: ["**/node_modules/**", "**/dist/**"], + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + "@components": path.resolve(__dirname, "./src/components"), + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bac4f3f8b..896e9907e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -589,7 +589,7 @@ importers: version: 17.0.8(expo@54.0.33)(react@19.1.0) expo-router: specifier: ~6.0.17 - version: 6.0.23(@expo/metro-runtime@6.1.2)(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(expo-constants@18.0.13)(expo-linking@8.0.11)(expo@54.0.33)(react-dom@19.1.0(react@19.1.0))(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-screens@4.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-web@0.21.2(encoding@0.1.13)(react-dom@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))(react@19.1.0) + version: 6.0.23(@expo/metro-runtime@6.1.2)(@testing-library/react-native@13.3.3(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0))(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(expo-constants@18.0.13)(expo-linking@8.0.11)(expo@54.0.33)(react-dom@19.1.0(react@19.1.0))(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-screens@4.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-secure-store: specifier: ^15.0.8 version: 15.0.8(expo@54.0.33) @@ -651,15 +651,33 @@ importers: specifier: ^4.5.7 version: 4.5.7(@types/react@19.2.11)(immer@11.1.3)(react@19.1.0) devDependencies: + '@testing-library/react-native': + specifier: ^13.3.3 + version: 13.3.3(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0) '@types/react': specifier: ^19.1.0 version: 19.2.11 + '@types/react-test-renderer': + specifier: ^19.1.0 + version: 19.1.0 + '@vitejs/plugin-react': + specifier: ^4.7.0 + version: 4.7.0(vite@6.4.1(@types/node@25.2.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + react-test-renderer: + specifier: ^19.1.0 + version: 19.1.0(react@19.1.0) tailwindcss: specifier: ^3.4.18 version: 3.4.19(tsx@4.21.0)(yaml@2.8.2) typescript: specifier: ~5.9.2 version: 5.9.3 + vite: + specifier: ^6.4.1 + version: 6.4.1(@types/node@25.2.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vitest: + specifier: ^4.1.6 + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.2.0)(jsdom@26.1.0)(msw@2.12.8(@types/node@25.2.0)(typescript@5.9.3))(vite@6.4.1(@types/node@25.2.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) packages/agent: dependencies: @@ -2814,6 +2832,10 @@ packages: resolution: {integrity: sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/diff-sequences@30.4.0': + resolution: {integrity: sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/environment@29.7.0': resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -2822,10 +2844,18 @@ packages: resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/get-type@30.1.0': + resolution: {integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/schemas@29.6.3': resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/schemas@30.4.1': + resolution: {integrity: sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/transform@29.7.0': resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -4749,6 +4779,9 @@ packages: '@sinclair/typebox@0.33.22': resolution: {integrity: sha512-auUj4k+f4pyrIVf4GW5UKquSZFHJWri06QgARy9C0t9ZTjJLIuNIrr1yl9bWcJWJ1Gz1vOvYN1D+QPaIlNMVkQ==} + '@sinclair/typebox@0.34.49': + resolution: {integrity: sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==} + '@sindresorhus/is@4.6.0': resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} @@ -4949,6 +4982,18 @@ packages: resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + '@testing-library/react-native@13.3.3': + resolution: {integrity: sha512-k6Mjsd9dbZgvY4Bl7P1NIpePQNi+dfYtlJ5voi9KQlynxSyQkfOgJmYGCYmw/aSgH/rUcFvG8u5gd4npzgRDyg==} + engines: {node: '>=18'} + peerDependencies: + jest: '>=29.0.0' + react: '>=18.2.0' + react-native: '>=0.71' + react-test-renderer: '>=18.2.0' + peerDependenciesMeta: + jest: + optional: true + '@testing-library/react@16.3.2': resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} engines: {node: '>=18'} @@ -5310,6 +5355,9 @@ packages: peerDependencies: '@types/react': ^19.2.0 + '@types/react-test-renderer@19.1.0': + resolution: {integrity: sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ==} + '@types/react@19.2.11': resolution: {integrity: sha512-tORuanb01iEzWvMGVGv2ZDhYZVeRMrw453DCSAIn/5yvcSVnMoUMTyf33nQJLahYEnv9xqrTNbgz4qY5EfSh0g==} @@ -5390,6 +5438,9 @@ packages: '@vitest/expect@4.0.18': resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + '@vitest/expect@4.1.6': + resolution: {integrity: sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==} + '@vitest/mocker@2.1.9': resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} peerDependencies: @@ -5423,6 +5474,17 @@ packages: vite: optional: true + '@vitest/mocker@4.1.6': + resolution: {integrity: sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/pretty-format@2.1.9': resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} @@ -5432,18 +5494,27 @@ packages: '@vitest/pretty-format@4.0.18': resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + '@vitest/pretty-format@4.1.6': + resolution: {integrity: sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==} + '@vitest/runner@2.1.9': resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} '@vitest/runner@4.0.18': resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + '@vitest/runner@4.1.6': + resolution: {integrity: sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==} + '@vitest/snapshot@2.1.9': resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} '@vitest/snapshot@4.0.18': resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + '@vitest/snapshot@4.1.6': + resolution: {integrity: sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==} + '@vitest/spy@2.1.9': resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} @@ -5453,6 +5524,9 @@ packages: '@vitest/spy@4.0.18': resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + '@vitest/spy@4.1.6': + resolution: {integrity: sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==} + '@vitest/ui@4.0.18': resolution: {integrity: sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==} peerDependencies: @@ -5467,6 +5541,9 @@ packages: '@vitest/utils@4.0.18': resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@vitest/utils@4.1.6': + resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==} + '@vscode/sudo-prompt@9.3.2': resolution: {integrity: sha512-gcXoCN00METUNFeQOFJ+C9xUI0DKB+0EGMVg7wbVYRHBw2Eq3fKisDZOkRdOz3kqXRKOENMfShPOmypw1/8nOw==} @@ -8076,6 +8153,10 @@ packages: resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==} engines: {node: 20 || >=22} + jest-diff@30.4.1: + resolution: {integrity: sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-environment-node@29.7.0: resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -8088,6 +8169,10 @@ packages: resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-matcher-utils@30.4.1: + resolution: {integrity: sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-message-util@29.7.0: resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -9774,6 +9859,10 @@ packages: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + pretty-format@30.4.1: + resolution: {integrity: sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + pretty-ms@9.3.0: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} @@ -10010,8 +10099,8 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - react-is@19.2.4: - resolution: {integrity: sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==} + react-is@19.2.6: + resolution: {integrity: sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==} react-markdown@10.1.0: resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} @@ -10161,6 +10250,11 @@ packages: '@types/react': optional: true + react-test-renderer@19.1.0: + resolution: {integrity: sha512-jXkSl3CpvPYEF+p/eGDLB4sPoDX8pKkYvRl9+rR8HxLY0X04vW7hCm1/0zHoUSjPZ3bDa+wXWNTDVIw/R8aDVw==} + peerDependencies: + react: ^19.1.0 + react@19.1.0: resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} engines: {node: '>=0.10.0'} @@ -10691,6 +10785,9 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + stdin-discarder@0.2.2: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} engines: {node: '>=18'} @@ -10997,6 +11094,10 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + tinyspy@3.0.2: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} @@ -11618,6 +11719,47 @@ packages: jsdom: optional: true + vitest@4.1.6: + resolution: {integrity: sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.6 + '@vitest/browser-preview': 4.1.6 + '@vitest/browser-webdriverio': 4.1.6 + '@vitest/coverage-istanbul': 4.1.6 + '@vitest/coverage-v8': 4.1.6 + '@vitest/ui': 4.1.6 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vlq@1.0.1: resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==} @@ -13789,7 +13931,7 @@ snapshots: wrap-ansi: 7.0.0 ws: 8.19.0 optionalDependencies: - expo-router: 6.0.23(@expo/metro-runtime@6.1.2)(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(expo-constants@18.0.13)(expo-linking@8.0.11)(expo@54.0.33)(react-dom@19.1.0(react@19.1.0))(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-screens@4.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-web@0.21.2(encoding@0.1.13)(react-dom@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))(react@19.1.0) + expo-router: 6.0.23(@expo/metro-runtime@6.1.2)(@testing-library/react-native@13.3.3(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0))(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(expo-constants@18.0.13)(expo-linking@8.0.11)(expo@54.0.33)(react-dom@19.1.0(react@19.1.0))(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-screens@4.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-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0) transitivePeerDependencies: - bufferutil @@ -14345,6 +14487,8 @@ snapshots: dependencies: '@jest/types': 29.6.3 + '@jest/diff-sequences@30.4.0': {} + '@jest/environment@29.7.0': dependencies: '@jest/fake-timers': 29.7.0 @@ -14361,10 +14505,16 @@ snapshots: jest-mock: 29.7.0 jest-util: 29.7.0 + '@jest/get-type@30.1.0': {} + '@jest/schemas@29.6.3': dependencies: '@sinclair/typebox': 0.27.10 + '@jest/schemas@30.4.1': + dependencies: + '@sinclair/typebox': 0.34.49 + '@jest/transform@29.7.0': dependencies: '@babel/core': 7.29.0 @@ -16330,7 +16480,7 @@ snapshots: nanoid: 3.3.11 query-string: 7.1.3 react: 19.1.0 - react-is: 19.2.4 + react-is: 19.2.6 use-latest-callback: 0.2.6(react@19.1.0) use-sync-external-store: 1.6.0(react@19.1.0) @@ -16509,6 +16659,8 @@ snapshots: '@sinclair/typebox@0.33.22': {} + '@sinclair/typebox@0.34.49': {} + '@sindresorhus/is@4.6.0': {} '@sindresorhus/merge-streams@4.0.0': {} @@ -16717,6 +16869,16 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 + '@testing-library/react-native@13.3.3(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + jest-matcher-utils: 30.4.1 + picocolors: 1.1.1 + pretty-format: 30.4.1 + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0) + react-test-renderer: 19.1.0(react@19.1.0) + redent: 3.0.0 + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@babel/runtime': 7.28.6 @@ -17116,6 +17278,10 @@ snapshots: dependencies: '@types/react': 19.2.11 + '@types/react-test-renderer@19.1.0': + dependencies: + '@types/react': 19.2.11 + '@types/react@19.2.11': dependencies: csstype: 3.2.3 @@ -17187,6 +17353,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@25.2.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.4.1(@types/node@25.2.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - supports-color + '@vitest/coverage-v8@0.34.6(vitest@2.1.9(@types/node@24.12.0)(jsdom@26.1.0)(lightningcss@1.32.0)(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(terser@5.46.0))': dependencies: '@ampproject/remapping': 2.3.0 @@ -17228,6 +17406,15 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 + '@vitest/expect@4.1.6': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + chai: 6.2.2 + tinyrainbow: 3.1.0 + '@vitest/mocker@2.1.9(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(vite@5.4.21(@types/node@24.12.0)(lightningcss@1.32.0)(terser@5.46.0))': dependencies: '@vitest/spy': 2.1.9 @@ -17264,6 +17451,15 @@ snapshots: msw: 2.12.8(@types/node@24.12.0)(typescript@5.9.3) vite: 6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/mocker@4.1.6(msw@2.12.8(@types/node@25.2.0)(typescript@5.9.3))(vite@6.4.1(@types/node@25.2.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 4.1.6 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.12.8(@types/node@25.2.0)(typescript@5.9.3) + vite: 6.4.1(@types/node@25.2.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/pretty-format@2.1.9': dependencies: tinyrainbow: 1.2.0 @@ -17276,6 +17472,10 @@ snapshots: dependencies: tinyrainbow: 3.0.3 + '@vitest/pretty-format@4.1.6': + dependencies: + tinyrainbow: 3.1.0 + '@vitest/runner@2.1.9': dependencies: '@vitest/utils': 2.1.9 @@ -17286,6 +17486,11 @@ snapshots: '@vitest/utils': 4.0.18 pathe: 2.0.3 + '@vitest/runner@4.1.6': + dependencies: + '@vitest/utils': 4.1.6 + pathe: 2.0.3 + '@vitest/snapshot@2.1.9': dependencies: '@vitest/pretty-format': 2.1.9 @@ -17298,6 +17503,13 @@ snapshots: magic-string: 0.30.21 pathe: 2.0.3 + '@vitest/snapshot@4.1.6': + dependencies: + '@vitest/pretty-format': 4.1.6 + '@vitest/utils': 4.1.6 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/spy@2.1.9': dependencies: tinyspy: 3.0.2 @@ -17308,6 +17520,8 @@ snapshots: '@vitest/spy@4.0.18': {} + '@vitest/spy@4.1.6': {} + '@vitest/ui@4.0.18(vitest@4.0.18)': dependencies: '@vitest/utils': 4.0.18 @@ -17336,6 +17550,12 @@ snapshots: '@vitest/pretty-format': 4.0.18 tinyrainbow: 3.0.3 + '@vitest/utils@4.1.6': + dependencies: + '@vitest/pretty-format': 4.1.6 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@vscode/sudo-prompt@9.3.2': {} '@webassemblyjs/ast@1.14.1': @@ -19046,7 +19266,7 @@ 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-router@6.0.23(@expo/metro-runtime@6.1.2)(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(expo-constants@18.0.13)(expo-linking@8.0.11)(expo@54.0.33)(react-dom@19.1.0(react@19.1.0))(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-screens@4.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-web@0.21.2(encoding@0.1.13)(react-dom@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))(react@19.1.0): + expo-router@6.0.23(@expo/metro-runtime@6.1.2)(@testing-library/react-native@13.3.3(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0))(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(expo-constants@18.0.13)(expo-linking@8.0.11)(expo@54.0.33)(react-dom@19.1.0(react@19.1.0))(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-screens@4.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): dependencies: '@expo/metro-runtime': 6.1.2(expo@54.0.33)(react-dom@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/schema-utils': 0.1.8 @@ -19079,6 +19299,7 @@ snapshots: use-latest-callback: 0.2.6(react@19.1.0) vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) optionalDependencies: + '@testing-library/react-native': 13.3.3(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0) react-dom: 19.1.0(react@19.1.0) 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-web: 0.21.2(encoding@0.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -20141,6 +20362,13 @@ snapshots: dependencies: '@isaacs/cliui': 8.0.2 + jest-diff@30.4.1: + dependencies: + '@jest/diff-sequences': 30.4.0 + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + pretty-format: 30.4.1 + jest-environment-node@29.7.0: dependencies: '@jest/environment': 29.7.0 @@ -20168,6 +20396,13 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + jest-matcher-utils@30.4.1: + dependencies: + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + jest-diff: 30.4.1 + pretty-format: 30.4.1 + jest-message-util@29.7.0: dependencies: '@babel/code-frame': 7.29.0 @@ -22219,6 +22454,13 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 + pretty-format@30.4.1: + dependencies: + '@jest/schemas': 30.4.1 + ansi-styles: 5.2.0 + react-is-18: react-is@18.3.1 + react-is-19: react-is@19.2.6 + pretty-ms@9.3.0: dependencies: parse-ms: 4.0.0 @@ -22560,7 +22802,7 @@ snapshots: react-is@18.3.1: {} - react-is@19.2.4: {} + react-is@19.2.6: {} react-markdown@10.1.0(@types/react@19.2.11)(react@19.1.0): dependencies: @@ -22776,6 +23018,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.11 + react-test-renderer@19.1.0(react@19.1.0): + dependencies: + react: 19.1.0 + react-is: 19.2.6 + scheduler: 0.26.0 + react@19.1.0: {} read-binary-file-arch@1.0.6: @@ -23431,6 +23679,8 @@ snapshots: std-env@3.10.0: {} + std-env@4.1.0: {} + stdin-discarder@0.2.2: {} storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): @@ -23753,6 +24003,8 @@ snapshots: tinyrainbow@3.0.3: {} + tinyrainbow@3.1.0: {} + tinyspy@3.0.2: {} tinyspy@4.0.4: {} @@ -24257,6 +24509,23 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 + vite@6.4.1(@types/node@25.2.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.57.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 25.2.0 + fsevents: 2.3.3 + jiti: 1.21.7 + lightningcss: 1.32.0 + terser: 5.46.0 + tsx: 4.21.0 + yaml: 2.8.2 + vitest@2.1.9(@types/node@24.12.0)(jsdom@26.1.0)(lightningcss@1.32.0)(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(terser@5.46.0): dependencies: '@vitest/expect': 2.1.9 @@ -24369,6 +24638,35 @@ snapshots: - tsx - yaml + vitest@4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.2.0)(jsdom@26.1.0)(msw@2.12.8(@types/node@25.2.0)(typescript@5.9.3))(vite@6.4.1(@types/node@25.2.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): + dependencies: + '@vitest/expect': 4.1.6 + '@vitest/mocker': 4.1.6(msw@2.12.8(@types/node@25.2.0)(typescript@5.9.3))(vite@6.4.1(@types/node@25.2.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 4.1.6 + '@vitest/runner': 4.1.6 + '@vitest/snapshot': 4.1.6 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 6.4.1(@types/node@25.2.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 25.2.0 + jsdom: 26.1.0 + transitivePeerDependencies: + - msw + vlq@1.0.1: {} vscode-icons-js@11.6.1: From 32cd2ba534336e2c3f7298dba3bef2d024065787 Mon Sep 17 00:00:00 2001 From: dylan Date: Wed, 13 May 2026 17:17:13 -0400 Subject: [PATCH 11/94] i think we have notifications now but i haven't read the code --- apps/mobile/app.json | 7 + apps/mobile/package.json | 1 + apps/mobile/src/app/(tabs)/settings.tsx | 41 +++++ apps/mobile/src/app/_layout.tsx | 9 +- apps/mobile/src/app/task/[id].tsx | 7 + .../src/features/auth/stores/authStore.ts | 19 +++ .../notifications/lib/notifications.ts | 148 ++++++++++++++++++ .../notifications/stores/pushTokenStore.ts | 101 ++++++++++++ .../preferences/stores/preferencesStore.ts | 6 + .../features/tasks/stores/taskSessionStore.ts | 71 +++++++++ apps/mobile/src/lib/api.ts | 52 ++++++ 11 files changed, 461 insertions(+), 1 deletion(-) create mode 100644 apps/mobile/src/features/notifications/lib/notifications.ts create mode 100644 apps/mobile/src/features/notifications/stores/pushTokenStore.ts diff --git a/apps/mobile/app.json b/apps/mobile/app.json index a55fc7d0d..05e16ed8a 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -189,6 +189,13 @@ } ], "expo-localization", + [ + "expo-notifications", + { + "icon": "./assets/posthog.icon", + "color": "#0f0f0f" + } + ], [ "expo-speech-recognition", { diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 84092fa2d..99719e720 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -44,6 +44,7 @@ "expo-linear-gradient": "^15.0.8", "expo-linking": "~8.0.10", "expo-localization": "~17.0.8", + "expo-notifications": "~0.32.12", "expo-router": "~6.0.17", "expo-secure-store": "^15.0.8", "expo-speech-recognition": "^3.1.2", diff --git a/apps/mobile/src/app/(tabs)/settings.tsx b/apps/mobile/src/app/(tabs)/settings.tsx index b10f0fb6c..25488c285 100644 --- a/apps/mobile/src/app/(tabs)/settings.tsx +++ b/apps/mobile/src/app/(tabs)/settings.tsx @@ -10,7 +10,9 @@ import { import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useAuthStore, useUserQuery } from "@/features/auth"; import { MenuButton } from "@/features/navigation/components/MenuButton"; +import { usePushTokenStore } from "@/features/notifications/stores/pushTokenStore"; import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; +import { logger } from "@/lib/logger"; export default function SettingsScreen() { const { logout, cloudRegion, getCloudUrlFromRegion } = useAuthStore(); @@ -20,6 +22,31 @@ export default function SettingsScreen() { const setAiChatEnabled = usePreferencesStore((s) => s.setAiChatEnabled); const pingsEnabled = usePreferencesStore((s) => s.pingsEnabled); const setPingsEnabled = usePreferencesStore((s) => s.setPingsEnabled); + const pushNotificationsEnabled = usePreferencesStore( + (s) => s.pushNotificationsEnabled, + ); + const setPushNotificationsEnabled = usePreferencesStore( + (s) => s.setPushNotificationsEnabled, + ); + + const handleTogglePushNotifications = (enabled: boolean) => { + setPushNotificationsEnabled(enabled); + if (enabled) { + usePushTokenStore + .getState() + .registerAndUpload() + .catch((error) => { + logger.warn("Push token registration failed", error); + }); + } else { + usePushTokenStore + .getState() + .clear() + .catch((error) => { + logger.warn("Push token clear failed", error); + }); + } + }; const handleLogout = async () => { await logout(); @@ -126,6 +153,20 @@ export default function SettingsScreen() { + + + + Push notifications + + + Get notified when a task finishes or needs your input + + + + {/* All Settings Button */} diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index b678def6e..73de099fd 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -2,7 +2,7 @@ import "../../global.css"; import "@/lib/textDefaults"; import { QueryClientProvider } from "@tanstack/react-query"; -import { Stack } from "expo-router"; +import { router, Stack } from "expo-router"; import { StatusBar } from "expo-status-bar"; import { useColorScheme } from "nativewind"; import { PostHogProvider } from "posthog-react-native"; @@ -12,6 +12,7 @@ import { KeyboardProvider } from "react-native-keyboard-controller"; import { SafeAreaProvider } from "react-native-safe-area-context"; import { OfflineBanner } from "@/components/OfflineBanner"; import { useAuthStore } from "@/features/auth"; +import { setupNotificationResponseListener } from "@/features/notifications/lib/notifications"; import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; import { POSTHOG_API_KEY, @@ -32,6 +33,12 @@ function RootLayoutNav() { initializeAuth(); }, [initializeAuth]); + useEffect(() => { + return setupNotificationResponseListener(({ taskId }) => { + router.push(`/task/${taskId}`); + }); + }, []); + if (isLoading) { return ( diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 85d2f0ed2..b4dddfae1 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -45,8 +45,15 @@ export default function TaskDetailScreen() { cancelPrompt, sendPermissionResponse, getSessionForTask, + setFocusedTaskId, } = useTaskSessionStore(); + useEffect(() => { + if (!taskId) return; + setFocusedTaskId(taskId); + return () => setFocusedTaskId(null); + }, [taskId, setFocusedTaskId]); + const session = taskId ? getSessionForTask(taskId) : undefined; const { height } = useReanimatedKeyboardAnimation(); diff --git a/apps/mobile/src/features/auth/stores/authStore.ts b/apps/mobile/src/features/auth/stores/authStore.ts index 79a029415..2162ab1fc 100644 --- a/apps/mobile/src/features/auth/stores/authStore.ts +++ b/apps/mobile/src/features/auth/stores/authStore.ts @@ -1,6 +1,8 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; +import { usePushTokenStore } from "@/features/notifications/stores/pushTokenStore"; +import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; import { logger } from "@/lib/logger"; import { queryClient } from "@/lib/queryClient"; import { @@ -43,6 +45,16 @@ interface AuthState { let refreshTimeoutId: ReturnType | null = null; +function maybeRegisterPushToken(): void { + if (!usePreferencesStore.getState().pushNotificationsEnabled) return; + usePushTokenStore + .getState() + .registerAndUpload() + .catch((error) => { + logger.warn("Push token registration failed", error); + }); +} + export const useAuthStore = create()( persist( (set, get) => ({ @@ -99,6 +111,7 @@ export const useAuthStore = create()( }); get().scheduleTokenRefresh(); + maybeRegisterPushToken(); }, loginWithPersonalApiKey: async ({ token, projectId, region }) => { @@ -133,6 +146,8 @@ export const useAuthStore = create()( projectId, isAuthenticated: true, }); + + maybeRegisterPushToken(); }, refreshAccessToken: async () => { @@ -239,6 +254,7 @@ export const useAuthStore = create()( set({ isLoading: false, isAuthenticated: true }); get().scheduleTokenRefresh(); + maybeRegisterPushToken(); return true; } catch (error) { logger.error("Failed to initialize auth:", error); @@ -253,6 +269,9 @@ export const useAuthStore = create()( refreshTimeoutId = null; } + // Delete push token from the backend before we drop credentials. + await usePushTokenStore.getState().clear(); + await deleteTokens(); // Clear React Query cache to prevent data leakage between sessions diff --git a/apps/mobile/src/features/notifications/lib/notifications.ts b/apps/mobile/src/features/notifications/lib/notifications.ts new file mode 100644 index 000000000..d8b49d610 --- /dev/null +++ b/apps/mobile/src/features/notifications/lib/notifications.ts @@ -0,0 +1,148 @@ +import Constants from "expo-constants"; +import * as Device from "expo-device"; +import * as Notifications from "expo-notifications"; +import { Platform } from "react-native"; +import { logger } from "@/lib/logger"; + +const log = logger.scope("notifications"); + +export interface NotificationData { + taskId: string; + taskRunId: string; +} + +export type NotificationResponseHandler = (data: NotificationData) => void; + +let handlerConfigured = false; + +function configureHandler(): void { + if (handlerConfigured) return; + handlerConfigured = true; + Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowBanner: true, + shouldShowList: true, + shouldPlaySound: true, + shouldSetBadge: false, + }), + }); +} + +/** + * Requests permission and returns an Expo push token for this device, or null + * if permission is denied / not supported (e.g. iOS Simulator). + */ +export async function registerForPushNotificationsAsync(): Promise< + string | null +> { + configureHandler(); + + if (Platform.OS === "android") { + await Notifications.setNotificationChannelAsync("default", { + name: "default", + importance: Notifications.AndroidImportance.DEFAULT, + vibrationPattern: [0, 250, 250, 250], + lightColor: "#FF231F7C", + }); + } + + if (!Device.isDevice) { + log.debug("Skipping push token retrieval: not a physical device"); + return null; + } + + const { status: existingStatus } = await Notifications.getPermissionsAsync(); + let finalStatus = existingStatus; + if (existingStatus !== "granted") { + const { status } = await Notifications.requestPermissionsAsync(); + finalStatus = status; + } + if (finalStatus !== "granted") { + log.debug("Push notification permission not granted", { finalStatus }); + return null; + } + + const projectId = Constants.expoConfig?.extra?.eas?.projectId; + if (!projectId) { + log.warn("Missing EAS projectId in app config; cannot fetch push token"); + return null; + } + + try { + const tokenResponse = await Notifications.getExpoPushTokenAsync({ + projectId, + }); + log.debug("Retrieved Expo push token"); + return tokenResponse.data; + } catch (err) { + log.warn("Failed to retrieve Expo push token", { error: err }); + return null; + } +} + +export async function presentLocalNotification(args: { + title: string; + body: string; + data: NotificationData; +}): Promise { + configureHandler(); + try { + await Notifications.scheduleNotificationAsync({ + content: { + title: args.title, + body: args.body, + data: args.data as unknown as Record, + sound: "default", + }, + trigger: null, + }); + } catch (err) { + log.warn("Failed to present local notification", { error: err }); + } +} + +function extractNotificationData( + response: Notifications.NotificationResponse, +): NotificationData | null { + const data = response.notification.request.content.data as + | Partial + | undefined; + if ( + !data || + typeof data.taskId !== "string" || + typeof data.taskRunId !== "string" + ) { + return null; + } + return { taskId: data.taskId, taskRunId: data.taskRunId }; +} + +/** + * Wires a listener that fires when the user taps a notification. Returns an + * unsubscribe function. Also checks for a cold-start notification (the app + * was launched by tapping a notification) and invokes the handler once. + */ +export function setupNotificationResponseListener( + onTap: NotificationResponseHandler, +): () => void { + configureHandler(); + + Notifications.getLastNotificationResponseAsync() + .then((response) => { + if (!response) return; + const data = extractNotificationData(response); + if (data) onTap(data); + }) + .catch((err) => { + log.warn("Failed to read last notification response", { error: err }); + }); + + const subscription = Notifications.addNotificationResponseReceivedListener( + (response) => { + const data = extractNotificationData(response); + if (data) onTap(data); + }, + ); + + return () => subscription.remove(); +} diff --git a/apps/mobile/src/features/notifications/stores/pushTokenStore.ts b/apps/mobile/src/features/notifications/stores/pushTokenStore.ts new file mode 100644 index 000000000..98df10b67 --- /dev/null +++ b/apps/mobile/src/features/notifications/stores/pushTokenStore.ts @@ -0,0 +1,101 @@ +import * as SecureStore from "expo-secure-store"; +import { Platform } from "react-native"; +import { create } from "zustand"; +import { deletePushToken, registerPushToken } from "@/lib/api"; +import { logger } from "@/lib/logger"; +import { registerForPushNotificationsAsync } from "../lib/notifications"; + +const log = logger.scope("push-token-store"); + +const TOKEN_KEY = "posthog_expo_push_token"; +const LAST_UPLOADED_KEY = "posthog_expo_push_token_uploaded"; + +interface PushTokenState { + expoPushToken: string | null; + lastUploadedToken: string | null; + isHydrated: boolean; + + hydrate: () => Promise; + registerAndUpload: () => Promise; + clear: () => Promise; +} + +async function readSecure(key: string): Promise { + try { + return await SecureStore.getItemAsync(key); + } catch (err) { + log.warn("SecureStore read failed", { key, error: err }); + return null; + } +} + +async function writeSecure(key: string, value: string | null): Promise { + try { + if (value === null) { + await SecureStore.deleteItemAsync(key); + } else { + await SecureStore.setItemAsync(key, value); + } + } catch (err) { + log.warn("SecureStore write failed", { key, error: err }); + } +} + +export const usePushTokenStore = create((set, get) => ({ + expoPushToken: null, + lastUploadedToken: null, + isHydrated: false, + + hydrate: async () => { + if (get().isHydrated) return; + const [expoPushToken, lastUploadedToken] = await Promise.all([ + readSecure(TOKEN_KEY), + readSecure(LAST_UPLOADED_KEY), + ]); + set({ expoPushToken, lastUploadedToken, isHydrated: true }); + }, + + registerAndUpload: async () => { + await get().hydrate(); + + const token = await registerForPushNotificationsAsync(); + if (!token) return; + + if (token !== get().expoPushToken) { + await writeSecure(TOKEN_KEY, token); + set({ expoPushToken: token }); + } + + if (token === get().lastUploadedToken) { + log.debug("Push token unchanged, skipping upload"); + return; + } + + try { + await registerPushToken({ token, platform: Platform.OS }); + await writeSecure(LAST_UPLOADED_KEY, token); + set({ lastUploadedToken: token }); + log.debug("Uploaded push token to backend"); + } catch (err) { + log.debug("Push token upload failed (endpoint may not exist yet)", { + error: err, + }); + } + }, + + clear: async () => { + const { expoPushToken } = get(); + if (expoPushToken) { + try { + await deletePushToken({ token: expoPushToken }); + } catch (err) { + log.debug("Push token delete failed", { error: err }); + } + } + await Promise.all([ + writeSecure(TOKEN_KEY, null), + writeSecure(LAST_UPLOADED_KEY, null), + ]); + set({ expoPushToken: null, lastUploadedToken: null }); + }, +})); diff --git a/apps/mobile/src/features/preferences/stores/preferencesStore.ts b/apps/mobile/src/features/preferences/stores/preferencesStore.ts index 1e44c1cce..2f045e8f0 100644 --- a/apps/mobile/src/features/preferences/stores/preferencesStore.ts +++ b/apps/mobile/src/features/preferences/stores/preferencesStore.ts @@ -7,6 +7,8 @@ interface PreferencesState { setAiChatEnabled: (enabled: boolean) => void; pingsEnabled: boolean; setPingsEnabled: (enabled: boolean) => void; + pushNotificationsEnabled: boolean; + setPushNotificationsEnabled: (enabled: boolean) => void; } export const usePreferencesStore = create()( @@ -16,6 +18,9 @@ export const usePreferencesStore = create()( setAiChatEnabled: (enabled) => set({ aiChatEnabled: enabled }), pingsEnabled: true, setPingsEnabled: (enabled) => set({ pingsEnabled: enabled }), + pushNotificationsEnabled: true, + setPushNotificationsEnabled: (enabled) => + set({ pushNotificationsEnabled: enabled }), }), { name: "posthog-preferences", @@ -23,6 +28,7 @@ export const usePreferencesStore = create()( partialize: (state) => ({ aiChatEnabled: state.aiChatEnabled, pingsEnabled: state.pingsEnabled, + pushNotificationsEnabled: state.pushNotificationsEnabled, }), }, ), diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts index 2d2be0f5b..6c2c21b05 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts @@ -1,6 +1,7 @@ import * as Haptics from "expo-haptics"; import { AppState } from "react-native"; import { create } from "zustand"; +import { presentLocalNotification } from "@/features/notifications/lib/notifications"; import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; import { logger } from "@/lib/logger"; import { @@ -64,9 +65,50 @@ function inferAgentIsIdle( const CLOUD_POLLING_INTERVAL_MS = 500; +type LocalNotificationKind = + | "turn_complete" + | "awaiting_user_input" + | "task_failed"; + +function maybePresentLocalNotification(args: { + taskRunId: string; + kind: LocalNotificationKind; +}): void { + if (!usePreferencesStore.getState().pushNotificationsEnabled) return; + + const storeState = useTaskSessionStore.getState(); + const session = storeState.sessions[args.taskRunId]; + if (!session) return; + + // Skip when the user is actively viewing this task — the UI already + // surfaces what changed; an OS banner would be redundant noise. + if (storeState.focusedTaskId === session.taskId) return; + + const title = session.taskTitle ?? "PostHog Code"; + let body: string; + switch (args.kind) { + case "awaiting_user_input": + body = `"${title}" needs your input`; + break; + case "task_failed": + body = `"${title}" failed`; + break; + default: + body = `"${title}" finished`; + break; + } + + presentLocalNotification({ + title: "PostHog Code", + body, + data: { taskId: session.taskId, taskRunId: session.taskRunId }, + }).catch(() => {}); +} + export interface TaskSession { taskRunId: string; taskId: string; + taskTitle?: string; events: SessionEvent[]; status: "connecting" | "connected" | "disconnected" | "error"; isPromptPending: boolean; @@ -94,6 +136,9 @@ export interface TaskSession { interface TaskSessionStore { sessions: Record; + focusedTaskId: string | null; + + setFocusedTaskId: (taskId: string | null) => void; connectToTask: (task: Task) => Promise; disconnectFromTask: (taskId: string) => void; @@ -136,6 +181,9 @@ const STATUS_CHECK_TICK_INTERVAL = 5; export const useTaskSessionStore = create((set, get) => ({ sessions: {}, + focusedTaskId: null, + + setFocusedTaskId: (taskId) => set({ focusedTaskId: taskId }), connectToTask: async (task: Task) => { const taskId = task.id; @@ -174,6 +222,7 @@ export const useTaskSessionStore = create((set, get) => ({ [newRunId]: { taskRunId: newRunId, taskId, + taskTitle: task.title, events: [], status: "connected", isPromptPending: true, @@ -232,6 +281,7 @@ export const useTaskSessionStore = create((set, get) => ({ [latestRunId]: { taskRunId: latestRunId, taskId, + taskTitle: task.title, events: historicalEvents, status: "connected", isPromptPending, @@ -586,6 +636,13 @@ export const useTaskSessionStore = create((set, get) => ({ Haptics.NotificationFeedbackType.Success, ); } + if (shouldPing) { + maybePresentLocalNotification({ + taskRunId, + kind: + run.status === "failed" ? "task_failed" : "turn_complete", + }); + } } } catch (statusErr) { logger.warn("Failed to fetch task run status", { @@ -613,6 +670,7 @@ export const useTaskSessionStore = create((set, get) => ({ // update. This prevents N re-renders per poll tick. const batchedEvents: SessionEvent[] = []; let receivedAgentMessage = false; + let receivedAwaitingUserInput = 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; @@ -679,6 +737,11 @@ export const useTaskSessionStore = create((set, get) => ({ entry.notification?.method === "_posthog/awaiting_user_input") ) { receivedAgentMessage = true; + if ( + entry.notification?.method === "_posthog/awaiting_user_input" + ) { + receivedAwaitingUserInput = true; + } } if ( @@ -784,6 +847,14 @@ export const useTaskSessionStore = create((set, get) => ({ playMeepSound().catch(() => {}); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); } + if (shouldPingAfterBatch) { + maybePresentLocalNotification({ + taskRunId, + kind: receivedAwaitingUserInput + ? "awaiting_user_input" + : "turn_complete", + }); + } } } catch (err) { logger.warn("Cloud polling error", { error: err }); diff --git a/apps/mobile/src/lib/api.ts b/apps/mobile/src/lib/api.ts index 0a5ac354e..74fc9eaa6 100644 --- a/apps/mobile/src/lib/api.ts +++ b/apps/mobile/src/lib/api.ts @@ -1,5 +1,9 @@ +import { fetch } from "expo/fetch"; import Constants from "expo-constants"; import { useAuthStore } from "@/features/auth"; +import { logger } from "@/lib/logger"; + +const log = logger.scope("api"); const USER_AGENT = `posthog/mobile.hog.dev; version: ${Constants.expoConfig?.version ?? "unknown"}`; @@ -30,3 +34,51 @@ export function getProjectId(): number { } return projectId; } + +export async function registerPushToken(args: { + token: string; + platform: string; +}): Promise { + const baseUrl = getBaseUrl(); + const projectId = getProjectId(); + const headers = getHeaders(); + + const response = await fetch( + `${baseUrl}/api/projects/${projectId}/users/@me/push_tokens/`, + { + method: "POST", + headers, + body: JSON.stringify(args), + }, + ); + + if (!response.ok) { + // Endpoint may not exist yet (backend rollout in posthog/posthog is a + // separate PR). Log at debug so we can verify the call without spamming. + log.debug("registerPushToken non-OK response", { + status: response.status, + }); + return; + } +} + +export async function deletePushToken(args: { token: string }): Promise { + const baseUrl = getBaseUrl(); + const projectId = getProjectId(); + const headers = getHeaders(); + + const response = await fetch( + `${baseUrl}/api/projects/${projectId}/users/@me/push_tokens/`, + { + method: "DELETE", + headers, + body: JSON.stringify(args), + }, + ); + + if (!response.ok) { + log.debug("deletePushToken non-OK response", { + status: response.status, + }); + } +} From ede87642b4dbe862e438150c514f752bba36f543 Mon Sep 17 00:00:00 2001 From: dylan Date: Wed, 13 May 2026 17:17:45 -0400 Subject: [PATCH 12/94] binch --- pnpm-lock.yaml | 216 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 211 insertions(+), 5 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bac4f3f8b..4a71b0a75 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -587,6 +587,9 @@ importers: expo-localization: specifier: ~17.0.8 version: 17.0.8(expo@54.0.33)(react@19.1.0) + expo-notifications: + specifier: ~0.32.12 + version: 0.32.17(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-router: specifier: ~6.0.17 version: 6.0.23(@expo/metro-runtime@6.1.2)(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(expo-constants@18.0.13)(expo-linking@8.0.11)(expo@54.0.33)(react-dom@19.1.0(react@19.1.0))(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-screens@4.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-web@0.21.2(encoding@0.1.13)(react-dom@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))(react@19.1.0) @@ -2570,6 +2573,9 @@ packages: peerDependencies: hono: ^4 + '@ide/backoff@1.0.0': + resolution: {integrity: sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g==} + '@img/sharp-darwin-arm64@0.34.5': resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -5717,6 +5723,9 @@ packages: asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + assert@2.1.0: + resolution: {integrity: sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -5745,6 +5754,10 @@ packages: resolution: {integrity: sha512-KbWgR8wOYRAPekEmMXrYYdc7BRyhn2Ftk7KWfMUnQ43hFdojWEFRxhhRUm3/OFEdPa1r0KAvTTg9YQK57xTe0g==} engines: {node: '>=0.8'} + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + await-to-js@3.0.0: resolution: {integrity: sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g==} engines: {node: '>=6.0.0'} @@ -5823,6 +5836,9 @@ packages: peerDependencies: '@babel/core': ^7.0.0 + badgin@1.2.3: + resolution: {integrity: sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==} + bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -5983,6 +5999,10 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + call-bound@1.0.4: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} @@ -7093,6 +7113,13 @@ packages: react: '*' react-native: '*' + expo-notifications@0.32.17: + resolution: {integrity: sha512-lwwzn7tImuzTzn9PAglZlS2VfZEvsfFGJTK9Eb8I4cqkGh2DI23YJFJH+WPEIu4QhDvk5JeBjklenJ8IZbmA4A==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + expo-router@6.0.23: resolution: {integrity: sha512-qCxVAiCrCyu0npky6azEZ6dJDMt77OmCzEbpF6RbUTlfkaCA417LvY14SBkk0xyGruSxy/7pvJOI6tuThaUVCA==} peerDependencies: @@ -7350,6 +7377,10 @@ packages: fontfaceobserver@2.3.0: resolution: {integrity: sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==} + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -7472,6 +7503,10 @@ packages: generate-object-property@1.2.0: resolution: {integrity: sha512-TuOwZWgJ2VAMEGJvAyPWvpqxSANF0LDpmyHauMjFYzaACvn+QTT/AZomvPCzVBV7yDN3OmwHQ5OvHaeLKre3JQ==} + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -7884,6 +7919,10 @@ packages: is-alphanumerical@2.0.1: resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + is-arguments@1.2.0: + resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} + engines: {node: '>= 0.4'} + is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} @@ -7894,6 +7933,10 @@ packages: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + is-core-module@2.16.1: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} @@ -7927,6 +7970,10 @@ packages: resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} engines: {node: '>=18'} + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -7960,6 +8007,10 @@ packages: is-my-json-valid@2.20.6: resolution: {integrity: sha512-1JQwulVNjx8UqkPE/bqDaxtH4PXCe/2VRh/y3p99heOV87HG4Id5/VfDswd+YiAfHcRTfDlWgISycnHuhZq1aw==} + is-nan@1.3.2: + resolution: {integrity: sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==} + engines: {node: '>= 0.4'} + is-node-process@1.2.0: resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} @@ -7988,6 +8039,10 @@ packages: is-property@1.0.2: resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + is-regexp@3.1.0: resolution: {integrity: sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==} engines: {node: '>=12'} @@ -8011,6 +8066,10 @@ packages: resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} engines: {node: '>=18'} + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + is-unicode-supported@0.1.0: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} @@ -9252,6 +9311,10 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + object-is@1.1.6: + resolution: {integrity: sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==} + engines: {node: '>= 0.4'} + object-keys@1.1.1: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} @@ -9260,6 +9323,10 @@ packages: resolution: {integrity: sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==} engines: {node: '>= 10'} + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} @@ -9628,6 +9695,10 @@ packages: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + postcss-import@15.1.0: resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} engines: {node: '>=14.0.0'} @@ -10406,6 +10477,10 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -10480,6 +10555,10 @@ packages: server-only@0.0.1: resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + setimmediate@1.0.5: resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} @@ -11404,6 +11483,9 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + util@0.12.5: + resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} + utils-merge@1.0.1: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} @@ -11715,6 +11797,10 @@ packages: when-exit@2.1.5: resolution: {integrity: sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==} + which-typed-array@1.1.20: + resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} + engines: {node: '>= 0.4'} + which@1.3.1: resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} hasBin: true @@ -14070,6 +14156,8 @@ snapshots: dependencies: hono: 4.11.7 + '@ide/backoff@1.0.0': {} + '@img/sharp-darwin-arm64@0.34.5': optionalDependencies: '@img/sharp-libvips-darwin-arm64': 1.2.4 @@ -17586,6 +17674,14 @@ snapshots: asap@2.0.6: {} + assert@2.1.0: + dependencies: + call-bind: 1.0.9 + is-nan: 1.3.2 + object-is: 1.1.6 + object.assign: 4.1.7 + util: 0.12.5 + assertion-error@2.0.1: {} ast-types@0.16.1: @@ -17608,6 +17704,10 @@ snapshots: author-regex@1.0.0: {} + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + await-to-js@3.0.0: {} axe-core@4.11.1: {} @@ -17751,6 +17851,8 @@ snapshots: babel-plugin-jest-hoist: 29.6.3 babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) + badgin@1.2.3: {} + bail@2.0.2: {} balanced-match@1.0.2: {} @@ -17956,6 +18058,13 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + call-bound@1.0.4: dependencies: call-bind-apply-helpers: 1.0.2 @@ -18395,7 +18504,6 @@ snapshots: es-define-property: 1.0.1 es-errors: 1.3.0 gopd: 1.2.0 - optional: true define-lazy-prop@2.0.0: {} @@ -18406,7 +18514,6 @@ snapshots: define-data-property: 1.1.4 has-property-descriptors: 1.0.2 object-keys: 1.1.1 - optional: true delayed-stream@1.0.0: {} @@ -19046,6 +19153,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-notifications@0.32.17(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/image-utils': 0.8.8 + '@ide/backoff': 1.0.0 + abort-controller: 3.0.0 + assert: 2.1.0 + badgin: 1.2.3 + 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-application: 7.0.8(expo@54.0.33) + 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)) + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0) + transitivePeerDependencies: + - supports-color + expo-router@6.0.23(@expo/metro-runtime@6.1.2)(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(expo-constants@18.0.13)(expo-linking@8.0.11)(expo@54.0.33)(react-dom@19.1.0(react@19.1.0))(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-screens@4.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-web@0.21.2(encoding@0.1.13)(react-dom@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))(react@19.1.0): dependencies: '@expo/metro-runtime': 6.1.2(expo@54.0.33)(react-dom@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) @@ -19379,6 +19501,10 @@ snapshots: fontfaceobserver@2.3.0: {} + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -19501,6 +19627,8 @@ snapshots: is-property: 1.0.2 optional: true + generator-function@2.0.1: {} + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -19699,7 +19827,6 @@ snapshots: has-property-descriptors@1.0.2: dependencies: es-define-property: 1.0.1 - optional: true has-symbols@1.1.0: {} @@ -19987,6 +20114,11 @@ snapshots: is-alphabetical: 2.0.1 is-decimal: 2.0.1 + is-arguments@1.2.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + is-arrayish@0.2.1: {} is-arrayish@0.3.4: {} @@ -19995,6 +20127,8 @@ snapshots: dependencies: binary-extensions: 2.3.0 + is-callable@1.2.7: {} + is-core-module@2.16.1: dependencies: hasown: 2.0.2 @@ -20015,6 +20149,14 @@ snapshots: dependencies: get-east-asian-width: 1.4.0 + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -20045,6 +20187,11 @@ snapshots: xtend: 4.0.2 optional: true + is-nan@1.3.2: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + is-node-process@1.2.0: {} is-number@7.0.0: {} @@ -20062,6 +20209,13 @@ snapshots: is-property@1.0.2: optional: true + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + is-regexp@3.1.0: {} is-ssh@1.4.1: @@ -20076,6 +20230,10 @@ snapshots: is-stream@4.0.1: {} + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.20 + is-unicode-supported@0.1.0: {} is-unicode-supported@1.3.0: {} @@ -21704,11 +21862,24 @@ snapshots: object-inspect@1.13.4: {} - object-keys@1.1.1: - optional: true + object-is@1.1.6: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + + object-keys@1.1.1: {} object-treeify@1.1.33: {} + object.assign@4.1.7: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + has-symbols: 1.1.0 + object-keys: 1.1.1 + obug@2.1.1: {} omggif@1.0.10: {} @@ -22084,6 +22255,8 @@ snapshots: pngjs@7.0.0: {} + possible-typed-array-names@1.1.0: {} + postcss-import@15.1.0(postcss@8.5.6): dependencies: postcss: 8.5.6 @@ -23084,6 +23257,12 @@ snapshots: safe-buffer@5.2.1: {} + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + safer-buffer@2.1.2: {} sax@1.4.4: {} @@ -23179,6 +23358,15 @@ snapshots: server-only@0.0.1: {} + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + setimmediate@1.0.5: {} setprototypeof@1.2.0: {} @@ -24117,6 +24305,14 @@ snapshots: util-deprecate@1.0.2: {} + util@0.12.5: + dependencies: + inherits: 2.0.4 + is-arguments: 1.2.0 + is-generator-function: 1.1.2 + is-typed-array: 1.1.15 + which-typed-array: 1.1.20 + utils-merge@1.0.1: {} uuid@12.0.0: {} @@ -24474,6 +24670,16 @@ snapshots: when-exit@2.1.5: {} + which-typed-array@1.1.20: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + which@1.3.1: dependencies: isexe: 2.0.0 From 1a0821ebe32bfcab93d5455013a73d2dad636440 Mon Sep 17 00:00:00 2001 From: dylan Date: Wed, 13 May 2026 17:38:30 -0400 Subject: [PATCH 13/94] poosh --- .../src/features/notifications/lib/notifications.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/features/notifications/lib/notifications.ts b/apps/mobile/src/features/notifications/lib/notifications.ts index d8b49d610..bb390133a 100644 --- a/apps/mobile/src/features/notifications/lib/notifications.ts +++ b/apps/mobile/src/features/notifications/lib/notifications.ts @@ -46,11 +46,6 @@ export async function registerForPushNotificationsAsync(): Promise< }); } - if (!Device.isDevice) { - log.debug("Skipping push token retrieval: not a physical device"); - return null; - } - const { status: existingStatus } = await Notifications.getPermissionsAsync(); let finalStatus = existingStatus; if (existingStatus !== "granted") { @@ -62,6 +57,11 @@ export async function registerForPushNotificationsAsync(): Promise< return null; } + if (!Device.isDevice) { + log.debug("Skipping push token retrieval: not a physical device"); + return null; + } + const projectId = Constants.expoConfig?.extra?.eas?.projectId; if (!projectId) { log.warn("Missing EAS projectId in app config; cannot fetch push token"); From 9eb94abab1997080f345416dba67f788da9d163b Mon Sep 17 00:00:00 2001 From: Oliver Browne Date: Wed, 13 May 2026 17:58:49 -0400 Subject: [PATCH 14/94] sickos yes --- apps/mobile/src/app/(tabs)/inbox.tsx | 83 ++++- apps/mobile/src/app/_layout.tsx | 11 + apps/mobile/src/app/report/[id].tsx | 317 ++++++++++++++++++ apps/mobile/src/app/task/index.tsx | 43 ++- apps/mobile/src/features/inbox/api.ts | 226 +++++++++++++ .../features/inbox/components/FilterSheet.tsx | 205 +++++++++++ .../src/features/inbox/components/LiveDot.tsx | 71 ++++ .../features/inbox/components/ReportList.tsx | 83 +++++ .../inbox/components/ReportListRow.tsx | 122 +++++++ .../inbox/components/ReviewerFilterSheet.tsx | 199 +++++++++++ apps/mobile/src/features/inbox/constants.ts | 6 + .../features/inbox/hooks/useInboxReports.ts | 105 ++++++ .../features/inbox/stores/inboxFilterStore.ts | 110 ++++++ .../src/features/inbox/stores/inboxStore.ts | 48 +++ apps/mobile/src/features/inbox/types.ts | 79 +++++ apps/mobile/src/features/inbox/utils.ts | 89 +++++ apps/mobile/src/features/tasks/types.ts | 2 + 17 files changed, 1775 insertions(+), 24 deletions(-) create mode 100644 apps/mobile/src/app/report/[id].tsx create mode 100644 apps/mobile/src/features/inbox/api.ts create mode 100644 apps/mobile/src/features/inbox/components/FilterSheet.tsx create mode 100644 apps/mobile/src/features/inbox/components/LiveDot.tsx create mode 100644 apps/mobile/src/features/inbox/components/ReportList.tsx create mode 100644 apps/mobile/src/features/inbox/components/ReportListRow.tsx create mode 100644 apps/mobile/src/features/inbox/components/ReviewerFilterSheet.tsx create mode 100644 apps/mobile/src/features/inbox/constants.ts create mode 100644 apps/mobile/src/features/inbox/hooks/useInboxReports.ts create mode 100644 apps/mobile/src/features/inbox/stores/inboxFilterStore.ts create mode 100644 apps/mobile/src/features/inbox/stores/inboxStore.ts create mode 100644 apps/mobile/src/features/inbox/types.ts create mode 100644 apps/mobile/src/features/inbox/utils.ts diff --git a/apps/mobile/src/app/(tabs)/inbox.tsx b/apps/mobile/src/app/(tabs)/inbox.tsx index b4c22bafe..6e09e42ef 100644 --- a/apps/mobile/src/app/(tabs)/inbox.tsx +++ b/apps/mobile/src/app/(tabs)/inbox.tsx @@ -1,13 +1,36 @@ import { Text } from "@components/text"; -import { Tray } from "phosphor-react-native"; -import { View } from "react-native"; +import { useRouter } from "expo-router"; +import { FunnelSimple, UsersThree } from "phosphor-react-native"; +import { useCallback, useState } from "react"; +import { Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { FilterSheet } from "@/features/inbox/components/FilterSheet"; +import { LiveDot } from "@/features/inbox/components/LiveDot"; +import { ReportList } from "@/features/inbox/components/ReportList"; +import { ReviewerFilterSheet } from "@/features/inbox/components/ReviewerFilterSheet"; +import { useInboxReports } from "@/features/inbox/hooks/useInboxReports"; +import { useInboxFilterStore } from "@/features/inbox/stores/inboxFilterStore"; +import type { SignalReport } from "@/features/inbox/types"; import { MenuButton } from "@/features/navigation/components/MenuButton"; import { useThemeColors } from "@/lib/theme"; export default function InboxScreen() { - const themeColors = useThemeColors(); const insets = useSafeAreaInsets(); + const router = useRouter(); + const themeColors = useThemeColors(); + const { isFetching, error } = useInboxReports(); + const [filterOpen, setFilterOpen] = useState(false); + const [reviewerOpen, setReviewerOpen] = useState(false); + const reviewerFilterCount = useInboxFilterStore( + (s) => s.suggestedReviewerFilter.length, + ); + + const handleReportPress = useCallback( + (report: SignalReport) => { + router.push(`/report/${report.id}`); + }, + [router], + ); return ( @@ -18,27 +41,51 @@ export default function InboxScreen() { - - Inbox - + + + Inbox + + + - Signals and notifications + Signals and reports + setReviewerOpen(true)} + className={`h-9 flex-row items-center justify-center gap-1 rounded-md px-2 active:bg-gray-3 ${ + reviewerFilterCount > 0 ? "bg-gray-3" : "" + }`} + > + 0 + ? themeColors.gray[12] + : themeColors.gray[11] + } + /> + {reviewerFilterCount > 0 && ( + + {reviewerFilterCount} + + )} + + setFilterOpen(true)} + className="h-9 w-9 items-center justify-center rounded-md active:bg-gray-3" + > + + - - - - - - Inbox coming soon - - - Signals and notifications will show up here. - - + + setFilterOpen(false)} /> + setReviewerOpen(false)} + /> ); } diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 727eec270..8db37e48d 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -93,6 +93,17 @@ function RootLayoutNav({ isConnected }: RootLayoutNavProps) { )} + {/* Report detail - modal presentation */} + + {/* Task routes - modal presentation */} = { + ready: { bg: "bg-status-success/20", text: "text-status-success" }, + pending_input: { bg: "bg-accent-3", text: "text-accent-11" }, + in_progress: { bg: "bg-status-warning/20", text: "text-status-warning" }, + candidate: { bg: "bg-status-info/20", text: "text-status-info" }, + potential: { bg: "bg-gray-5/20", text: "text-gray-9" }, + failed: { bg: "bg-status-error/20", text: "text-status-error" }, + suppressed: { bg: "bg-gray-5/20", text: "text-gray-9" }, + deleted: { bg: "bg-gray-5/20", text: "text-gray-9" }, +}; + +const priorityColorMap: Record = { + P0: { bg: "bg-status-error/20", text: "text-status-error" }, + P1: { bg: "bg-status-warning/20", text: "text-status-warning" }, + P2: { bg: "bg-status-warning/20", text: "text-status-warning" }, + P3: { bg: "bg-gray-5/20", text: "text-gray-9" }, + P4: { bg: "bg-gray-5/20", text: "text-gray-9" }, +}; + +function StatusBadge({ status }: { status: SignalReportStatus }) { + const colors = statusColorMap[status] ?? statusColorMap.potential; + return ( + + + {inboxStatusLabel(status)} + + + ); +} + +function PriorityBadge({ priority }: { priority: SignalReportPriority }) { + const colors = priorityColorMap[priority] ?? priorityColorMap.P3; + return ( + + + {priority} + + + ); +} + +function SectionHeader({ title }: { title: string }) { + return ( + + {title} + + ); +} + +export default function ReportDetailScreen() { + const { id: reportId } = useLocalSearchParams<{ id: string }>(); + const router = useRouter(); + const themeColors = useThemeColors(); + const insets = useSafeAreaInsets(); + const { data: report, isLoading, error } = useInboxReport(reportId ?? null); + const [reportRepo, setReportRepo] = useState(null); + + useEffect(() => { + if (!reportId) return; + let cancelled = false; + getReportRepository(reportId) + .then((repo) => { + if (!cancelled) setReportRepo(repo); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [reportId]); + + const handleStartTask = useCallback(() => { + if (!report) return; + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + const prompt = `Act on this signal report. Investigate the root cause, implement the fix, and open a PR if appropriate.\n\n${report.summary ?? ""}`; + router.push({ + pathname: "/task", + params: { + prompt, + ...(reportRepo ? { repo: reportRepo } : {}), + signalReport: report.id, + }, + }); + }, [report, router, reportRepo]); + + if (error) { + return ( + <> + + + + Failed to load report + + router.back()} + className="rounded-lg bg-gray-3 px-4 py-2" + > + Go back + + + + ); + } + + if (isLoading || !report) { + return ( + <> + + + + + + ); + } + + const updatedAt = new Date(report.updated_at); + const hoursSince = differenceInHours(new Date(), updatedAt); + const timeDisplay = + hoursSince < 24 + ? formatDistanceToNow(updatedAt, { addSuffix: true }) + : format(updatedAt, "MMM d, yyyy"); + + const isReady = report.status === "ready"; + + const isAwaitingInput = + report.status === "pending_input" || + (report.status === "ready" && + report.actionability === "requires_human_input"); + + const canStartTask = + isAwaitingInput || + (report.status === "ready" && + report.actionability === "immediately_actionable" && + report.already_addressed !== true); + + return ( + <> + + + {/* Badges row */} + + + {report.priority && } + {report.is_suggested_reviewer && ( + + + For you + + + )} + {report.already_addressed && ( + + + May be addressed + + + )} + + + {/* Title */} + + {report.title ?? "Untitled signal"} + + + {/* Meta row */} + + + + + {report.signal_count} signal{report.signal_count !== 1 ? "s" : ""} + + + Updated {timeDisplay} + + + {/* Failed warning */} + {report.status === "failed" && ( + + + + + Report processing failed + + + There was an issue processing this report. It may be retried + automatically. + + + + )} + + {/* Summary */} + {report.summary && ( + + + + {report.summary} + + + )} + + {/* Actionability info */} + {report.actionability && ( + + + + + {report.actionability === "immediately_actionable" + ? "This report is immediately actionable — a task can be created directly." + : report.actionability === "requires_human_input" + ? "This report needs human input before it can be acted on." + : "This report is not directly actionable at this time."} + + + + )} + + {/* PR link */} + {report.implementation_pr_url && ( + + + + + {report.implementation_pr_url} + + + + )} + + + {/* Floating "Start task" button */} + {canStartTask && ( + + + ({ opacity: pressed ? 0.8 : 1 })} + > + + + + Start task + + + + + + )} + + ); +} diff --git a/apps/mobile/src/app/task/index.tsx b/apps/mobile/src/app/task/index.tsx index 8632c2223..238484627 100644 --- a/apps/mobile/src/app/task/index.tsx +++ b/apps/mobile/src/app/task/index.tsx @@ -1,5 +1,5 @@ import { Text } from "@components/text"; -import { Stack, useRouter } from "expo-router"; +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { ArrowUp, BrainIcon, @@ -76,6 +76,15 @@ function modeIcon(mode: ExecutionMode, color: string, size = 14) { } export default function NewTaskScreen() { + const { + prompt: initialPrompt, + repo: initialRepo, + signalReport, + } = useLocalSearchParams<{ + prompt?: string; + repo?: string; + signalReport?: string; + }>(); const router = useRouter(); const themeColors = useThemeColors(); const insets = useSafeAreaInsets(); @@ -113,10 +122,17 @@ export default function NewTaskScreen() { [], ); - const [prompt, setPrompt] = useState(""); - const [selection, setSelection] = useState({ - integrationId: null, - repository: null, + const [prompt, setPrompt] = useState(initialPrompt ?? ""); + const [selection, setSelection] = useState(() => { + if (initialRepo) { + const match = repositoryOptions.find( + (o) => o.repository.toLowerCase() === initialRepo.toLowerCase(), + ); + if (match) return toRepositorySelection(match); + // Repo known but integration not yet loaded — set repo, integrationId will resolve later + return { integrationId: null, repository: initialRepo }; + } + return { integrationId: null, repository: null }; }); const [mode, setMode] = useState(DEFAULT_EXECUTION_MODE); const [model, setModel] = useState(DEFAULT_MODEL); @@ -161,6 +177,12 @@ export default function NewTaskScreen() { title: trimmedPrompt.slice(0, 100), repository: selection.repository ?? undefined, github_integration: selection.integrationId ?? undefined, + ...(signalReport + ? { + signal_report: signalReport, + signal_report_task_relationship: "implementation", + } + : {}), }); const supportsReasoning = modelSupportsReasoning(model); @@ -179,7 +201,16 @@ export default function NewTaskScreen() { } finally { setCreating(false); } - }, [creating, mode, model, prompt, reasoning, router, selection]); + }, [ + creating, + mode, + model, + prompt, + reasoning, + router, + selection, + signalReport, + ]); const canSubmit = !!prompt.trim() && isRepositorySelectionComplete(selection) && !creating; diff --git a/apps/mobile/src/features/inbox/api.ts b/apps/mobile/src/features/inbox/api.ts new file mode 100644 index 000000000..3342171dd --- /dev/null +++ b/apps/mobile/src/features/inbox/api.ts @@ -0,0 +1,226 @@ +import { fetch } from "expo/fetch"; +import { HttpError } from "@/features/tasks/api"; +import { getBaseUrl, getHeaders, getProjectId } from "@/lib/api"; +import { logger } from "@/lib/logger"; + +const log = logger.scope("inbox-api"); + +import type { + AvailableSuggestedReviewer, + AvailableSuggestedReviewersResponse, + SignalProcessingStateResponse, + SignalReport, + SignalReportsQueryParams, + SignalReportsResponse, + SignalReportTask, +} from "./types"; + +export async function getSignalReports( + params?: SignalReportsQueryParams, +): Promise { + const baseUrl = getBaseUrl(); + const projectId = getProjectId(); + const headers = getHeaders(); + + const url = new URL(`${baseUrl}/api/projects/${projectId}/signals/reports/`); + + if (params?.limit != null) { + url.searchParams.set("limit", String(params.limit)); + } + if (params?.offset != null) { + url.searchParams.set("offset", String(params.offset)); + } + if (params?.status) { + url.searchParams.set("status", params.status); + } + if (params?.ordering) { + url.searchParams.set("ordering", params.ordering); + } + if (params?.source_product) { + url.searchParams.set("source_product", params.source_product); + } + if (params?.suggested_reviewers) { + url.searchParams.set("suggested_reviewers", params.suggested_reviewers); + } + + const response = await fetch(url.toString(), { headers }); + + if (!response.ok) { + throw new HttpError( + response.status, + response.statusText, + "Failed to fetch signal reports", + ); + } + + const data = await response.json(); + return { + results: data.results ?? [], + count: data.count ?? data.results?.length ?? 0, + }; +} + +export async function getSignalReport( + reportId: string, +): Promise { + const baseUrl = getBaseUrl(); + const projectId = getProjectId(); + const headers = getHeaders(); + + const response = await fetch( + `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/`, + { headers }, + ); + + if (response.status === 404 || response.status === 403) { + return null; + } + + if (!response.ok) { + throw new HttpError( + response.status, + response.statusText, + "Failed to fetch signal report", + ); + } + + return await response.json(); +} + +export async function getSignalProcessingState(): Promise { + const baseUrl = getBaseUrl(); + const projectId = getProjectId(); + const headers = getHeaders(); + + const response = await fetch( + `${baseUrl}/api/projects/${projectId}/signals/processing_state/`, + { headers }, + ); + + if (!response.ok) { + throw new HttpError( + response.status, + response.statusText, + "Failed to fetch signal processing state", + ); + } + + return await response.json(); +} + +export async function getAvailableSuggestedReviewers( + query?: string, +): Promise { + const baseUrl = getBaseUrl(); + const projectId = getProjectId(); + const headers = getHeaders(); + + const url = new URL( + `${baseUrl}/api/projects/${projectId}/signals/reports/available_reviewers/`, + ); + + if (query?.trim()) { + url.searchParams.set("query", query.trim()); + } + + const response = await fetch(url.toString(), { headers }); + + if (!response.ok) { + throw new HttpError( + response.status, + response.statusText, + "Failed to fetch available suggested reviewers", + ); + } + + // API returns a dict keyed by UUID: { "uuid": { name, email, github_login } } + const data = await response.json(); + const results = Object.entries(data) + .map(([uuid, value]) => { + if (typeof value !== "object" || value === null) return null; + const v = value as Record; + return { + uuid, + name: typeof v.name === "string" ? v.name : "", + email: typeof v.email === "string" ? v.email : "", + github_login: typeof v.github_login === "string" ? v.github_login : "", + }; + }) + .filter((r): r is AvailableSuggestedReviewer => r !== null); + + return { results, count: results.length }; +} + +export async function getSignalReportTasks( + reportId: string, +): Promise { + const baseUrl = getBaseUrl(); + const projectId = getProjectId(); + const headers = getHeaders(); + + const response = await fetch( + `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/tasks/`, + { headers }, + ); + + if (!response.ok) { + throw new HttpError( + response.status, + response.statusText, + "Failed to fetch signal report tasks", + ); + } + + const data = await response.json(); + return data.results ?? []; +} + +/** Resolve the repository associated with a signal report via its repo_selection artefact. */ +export async function getReportRepository( + reportId: string, +): Promise { + const baseUrl = getBaseUrl(); + const projectId = getProjectId(); + const headers = getHeaders(); + + const response = await fetch( + `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/artefacts/`, + { headers }, + ); + + if (!response.ok) { + const body = await response.text().catch(() => ""); + log.warn("Failed to fetch report artefacts", { + reportId, + status: response.status, + body: body.slice(0, 500), + }); + return null; + } + + const data = await response.json(); + const artefacts: { type: string; content: unknown }[] = data.results ?? []; + const repoArtefact = artefacts.find((a) => a.type === "repo_selection"); + + if (!repoArtefact) return null; + + // content may be a JSON string or an already-parsed object + let parsed: unknown = repoArtefact.content; + if (typeof parsed === "string") { + try { + parsed = JSON.parse(parsed); + } catch { + // Plain string like "org/repo" + return (parsed as string).toLowerCase(); + } + } + + if (typeof parsed === "object" && parsed !== null) { + const repo = + (parsed as Record).repository ?? + (parsed as Record).repo; + if (typeof repo === "string") return repo.toLowerCase(); + } + + return null; +} diff --git a/apps/mobile/src/features/inbox/components/FilterSheet.tsx b/apps/mobile/src/features/inbox/components/FilterSheet.tsx new file mode 100644 index 000000000..4fe683d40 --- /dev/null +++ b/apps/mobile/src/features/inbox/components/FilterSheet.tsx @@ -0,0 +1,205 @@ +import { Text } from "@components/text"; +import { Check } from "phosphor-react-native"; +import { Modal, Pressable, ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useThemeColors } from "@/lib/theme"; +import { + type SourceProduct, + useInboxFilterStore, +} from "../stores/inboxFilterStore"; +import type { SignalReportStatus } from "../types"; +import { inboxStatusLabel } from "../utils"; + +interface FilterSheetProps { + visible: boolean; + onClose: () => void; +} + +type SortOption = { + label: string; + field: "priority" | "created_at" | "total_weight"; + direction: "asc" | "desc"; +}; + +const SORT_OPTIONS: SortOption[] = [ + { label: "Priority", field: "priority", direction: "asc" }, + { label: "Strongest signal", field: "total_weight", direction: "desc" }, + { label: "Newest first", field: "created_at", direction: "desc" }, + { label: "Oldest first", field: "created_at", direction: "asc" }, +]; + +const FILTERABLE_STATUSES: SignalReportStatus[] = [ + "ready", + "pending_input", + "in_progress", + "failed", + "candidate", + "potential", +]; + +function useStatusDotColors(): Record { + const themeColors = useThemeColors(); + return { + ready: themeColors.status.success, + pending_input: themeColors.accent[9], + in_progress: themeColors.status.warning, + candidate: themeColors.status.info, + potential: themeColors.gray[9], + failed: themeColors.status.error, + }; +} + +const SOURCE_PRODUCT_OPTIONS: { value: SourceProduct; label: string }[] = [ + { value: "session_replay", label: "Session replay" }, + { value: "error_tracking", label: "Error tracking" }, + { value: "llm_analytics", label: "LLM analytics" }, + { value: "github", label: "GitHub" }, + { value: "linear", label: "Linear" }, + { value: "zendesk", label: "Zendesk" }, + { value: "conversations", label: "Conversations" }, +]; + +function SectionHeader({ title }: { title: string }) { + return ( + + {title} + + ); +} + +function OptionRow({ + label, + selected, + onPress, + left, +}: { + label: string; + selected: boolean; + onPress: () => void; + left?: React.ReactNode; +}) { + const themeColors = useThemeColors(); + return ( + + + {left} + {label} + + {selected && } + + ); +} + +export function FilterSheet({ visible, onClose }: FilterSheetProps) { + const insets = useSafeAreaInsets(); + const themeColors = useThemeColors(); + const statusDotColors = useStatusDotColors(); + + const sortField = useInboxFilterStore((s) => s.sortField); + const sortDirection = useInboxFilterStore((s) => s.sortDirection); + const setSort = useInboxFilterStore((s) => s.setSort); + const statusFilter = useInboxFilterStore((s) => s.statusFilter); + const toggleStatus = useInboxFilterStore((s) => s.toggleStatus); + const sourceProductFilter = useInboxFilterStore((s) => s.sourceProductFilter); + const toggleSourceProduct = useInboxFilterStore((s) => s.toggleSourceProduct); + const resetFilters = useInboxFilterStore((s) => s.resetFilters); + + const hasActiveFilters = + sourceProductFilter.length > 0 || + statusFilter.length < FILTERABLE_STATUSES.length; + + return ( + + + {/* Header */} + + + Filter & Sort + + + {hasActiveFilters && ( + + Reset + + )} + + + Done + + + + + + + {/* Sort */} + + + {SORT_OPTIONS.map((option) => ( + setSort(option.field, option.direction)} + /> + ))} + + + {/* Status */} + + + {FILTERABLE_STATUSES.map((status) => ( + toggleStatus(status)} + left={ + + } + /> + ))} + + + {/* Source */} + + + {SOURCE_PRODUCT_OPTIONS.map((option) => ( + toggleSourceProduct(option.value)} + /> + ))} + + + + + ); +} diff --git a/apps/mobile/src/features/inbox/components/LiveDot.tsx b/apps/mobile/src/features/inbox/components/LiveDot.tsx new file mode 100644 index 000000000..114baa238 --- /dev/null +++ b/apps/mobile/src/features/inbox/components/LiveDot.tsx @@ -0,0 +1,71 @@ +import { useEffect } from "react"; +import Animated, { + useAnimatedStyle, + useSharedValue, + withRepeat, + withSequence, + withTiming, +} from "react-native-reanimated"; + +interface LiveDotProps { + /** True while a background fetch is in flight */ + active: boolean; + /** True when there's a network/fetch error */ + hasError?: boolean; +} + +/** + * Strobing red dot that indicates the inbox is live-polling. + * Pulses brighter/larger when actively fetching, dims when idle. + */ +const COLOR_LIVE = "#e5484d"; +const COLOR_ERROR = "#f5a623"; + +export function LiveDot({ active, hasError }: LiveDotProps) { + const color = hasError ? COLOR_ERROR : COLOR_LIVE; + const scale = useSharedValue(0.92); + const opacity = useSharedValue(0.5); + + useEffect(() => { + if (active) { + scale.value = withRepeat( + withSequence( + withTiming(1.15, { duration: 400 }), + withTiming(0.92, { duration: 400 }), + ), + -1, + true, + ); + opacity.value = withRepeat( + withSequence( + withTiming(1, { duration: 400 }), + withTiming(0.5, { duration: 400 }), + ), + -1, + true, + ); + } else { + scale.value = withTiming(0.92, { duration: 600 }); + opacity.value = withTiming(0.5, { duration: 600 }); + } + }, [active, scale, opacity]); + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ scale: scale.value }], + opacity: opacity.value, + })); + + return ( + + ); +} diff --git a/apps/mobile/src/features/inbox/components/ReportList.tsx b/apps/mobile/src/features/inbox/components/ReportList.tsx new file mode 100644 index 000000000..cd52f064d --- /dev/null +++ b/apps/mobile/src/features/inbox/components/ReportList.tsx @@ -0,0 +1,83 @@ +import { Text } from "@components/text"; +import { Tray } from "phosphor-react-native"; +import { + ActivityIndicator, + FlatList, + Pressable, + RefreshControl, + View, +} from "react-native"; +import { useThemeColors } from "@/lib/theme"; +import { useInboxReports } from "../hooks/useInboxReports"; +import type { SignalReport } from "../types"; +import { ReportListRow } from "./ReportListRow"; + +interface ReportListProps { + onReportPress?: (report: SignalReport) => void; +} + +export function ReportList({ onReportPress }: ReportListProps) { + const { reports, totalCount, isLoading, error, refetch } = useInboxReports(); + const themeColors = useThemeColors(); + + const handlePress = (report: SignalReport) => { + onReportPress?.(report); + }; + + if (error) { + return ( + + {error} + refetch()} + className="rounded-lg bg-gray-3 px-4 py-2" + > + Retry + + + ); + } + + if (isLoading && reports.length === 0) { + return ( + + + Loading reports... + + ); + } + + if (reports.length === 0) { + return ( + + + + + + Inbox is empty + + + Reports will appear here as signals come in. + + + ); + } + + return ( + item.id} + renderItem={({ item }) => ( + + )} + refreshControl={ + refetch()} + tintColor={themeColors.accent[9]} + /> + } + contentContainerStyle={{ paddingBottom: 100 }} + /> + ); +} diff --git a/apps/mobile/src/features/inbox/components/ReportListRow.tsx b/apps/mobile/src/features/inbox/components/ReportListRow.tsx new file mode 100644 index 000000000..3d7fe1873 --- /dev/null +++ b/apps/mobile/src/features/inbox/components/ReportListRow.tsx @@ -0,0 +1,122 @@ +import { Text } from "@components/text"; +import { differenceInHours, format, formatDistanceToNow } from "date-fns"; +import { Eye, Lightning } from "phosphor-react-native"; +import { memo } from "react"; +import { Pressable, View } from "react-native"; +import { useThemeColors } from "@/lib/theme"; +import type { SignalReport, SignalReportActionability } from "../types"; +import { inboxStatusLabel } from "../utils"; + +interface ReportListRowProps { + report: SignalReport; + onPress: (report: SignalReport) => void; +} + +const statusColorMap: Record = { + ready: { bg: "bg-status-success/20", text: "text-status-success" }, + pending_input: { bg: "bg-accent-3", text: "text-accent-11" }, + in_progress: { bg: "bg-status-warning/20", text: "text-status-warning" }, + candidate: { bg: "bg-status-info/20", text: "text-status-info" }, + potential: { bg: "bg-gray-5/20", text: "text-gray-9" }, + failed: { bg: "bg-status-error/20", text: "text-status-error" }, + suppressed: { bg: "bg-gray-5/20", text: "text-gray-9" }, + deleted: { bg: "bg-gray-5/20", text: "text-gray-9" }, +}; + +const priorityColorMap: Record = { + P0: { bg: "bg-status-error/20", text: "text-status-error" }, + P1: { bg: "bg-status-warning/20", text: "text-status-warning" }, + P2: { bg: "bg-status-warning/20", text: "text-status-warning" }, + P3: { bg: "bg-gray-5/20", text: "text-gray-9" }, + P4: { bg: "bg-gray-5/20", text: "text-gray-9" }, +}; + +const actionabilityMap: Record< + SignalReportActionability, + { bg: string; text: string; label: string } +> = { + immediately_actionable: { + bg: "bg-status-success/20", + text: "text-status-success", + label: "Actionable", + }, + requires_human_input: { + bg: "bg-status-warning/20", + text: "text-status-warning", + label: "Needs input", + }, + not_actionable: { + bg: "bg-gray-5/20", + text: "text-gray-9", + label: "Not actionable", + }, +}; + +function ReportListRowComponent({ report, onPress }: ReportListRowProps) { + const themeColors = useThemeColors(); + const updatedAt = new Date(report.updated_at); + const hoursSince = differenceInHours(new Date(), updatedAt); + const timeDisplay = + hoursSince < 24 + ? formatDistanceToNow(updatedAt, { addSuffix: true }) + : format(updatedAt, "MMM d"); + + const statusColors = + statusColorMap[report.status] ?? statusColorMap.potential; + + return ( + onPress(report)} + className="border-gray-6 border-b px-3 py-3 active:bg-gray-3" + > + {/* Title — full wrap, no truncation */} + + {report.title ?? "Untitled signal"} + + + {/* Badges + time */} + + + + {inboxStatusLabel(report.status)} + + + {report.priority && ( + + + {report.priority} + + + )} + {report.actionability && ( + + + {actionabilityMap[report.actionability].label} + + + )} + {report.is_suggested_reviewer && ( + + + + )} + + + + {report.signal_count} + + {timeDisplay} + + + ); +} + +export const ReportListRow = memo(ReportListRowComponent); diff --git a/apps/mobile/src/features/inbox/components/ReviewerFilterSheet.tsx b/apps/mobile/src/features/inbox/components/ReviewerFilterSheet.tsx new file mode 100644 index 000000000..927546241 --- /dev/null +++ b/apps/mobile/src/features/inbox/components/ReviewerFilterSheet.tsx @@ -0,0 +1,199 @@ +import { Text } from "@components/text"; +import { Check } from "phosphor-react-native"; +import { useMemo } from "react"; +import { + ActivityIndicator, + Image, + Modal, + Pressable, + ScrollView, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useUserQuery } from "@/features/auth"; +import { useThemeColors } from "@/lib/theme"; +import { useAvailableSuggestedReviewers } from "../hooks/useInboxReports"; +import { useInboxFilterStore } from "../stores/inboxFilterStore"; +import type { AvailableSuggestedReviewer } from "../types"; + +interface ReviewerFilterSheetProps { + visible: boolean; + onClose: () => void; +} + +interface ReviewerOption { + uuid: string; + name: string; + email: string; + github_login: string; + isMe: boolean; +} + +function buildReviewerOptions( + reviewers: AvailableSuggestedReviewer[], + currentUserUuid: string | undefined, +): ReviewerOption[] { + const seen = new Set(); + const options: ReviewerOption[] = []; + + for (const r of reviewers) { + if (!r.uuid || seen.has(r.uuid)) continue; + seen.add(r.uuid); + options.push({ + uuid: r.uuid, + name: r.name?.trim() || "", + email: r.email?.trim() || "", + github_login: r.github_login?.trim() || "", + isMe: r.uuid === currentUserUuid, + }); + } + + // Sort: "Me" first, then alphabetical by name + options.sort((a, b) => { + if (a.isMe && !b.isMe) return -1; + if (!a.isMe && b.isMe) return 1; + return (a.name || a.email).localeCompare(b.name || b.email); + }); + + return options; +} + +function displayName(r: ReviewerOption): string { + const base = r.name || r.email || "Unknown user"; + return r.isMe ? `${base} (Me)` : base; +} + +export function ReviewerFilterSheet({ + visible, + onClose, +}: ReviewerFilterSheetProps) { + const insets = useSafeAreaInsets(); + const themeColors = useThemeColors(); + const { data: currentUser } = useUserQuery(); + const { data: available, isLoading } = useAvailableSuggestedReviewers(); + + const suggestedReviewerFilter = useInboxFilterStore( + (s) => s.suggestedReviewerFilter, + ); + const toggleSuggestedReviewer = useInboxFilterStore( + (s) => s.toggleSuggestedReviewer, + ); + const setSuggestedReviewerFilter = useInboxFilterStore( + (s) => s.setSuggestedReviewerFilter, + ); + + const options = useMemo( + () => buildReviewerOptions(available?.results ?? [], currentUser?.uuid), + [available?.results, currentUser?.uuid], + ); + + const hasSelection = suggestedReviewerFilter.length > 0; + + return ( + + + {/* Header */} + + + Suggested Reviewer + + + {hasSelection && ( + setSuggestedReviewerFilter([])}> + Clear + + )} + + + Done + + + + + + {isLoading && options.length === 0 ? ( + + + + ) : options.length === 0 ? ( + + No reviewers found + + ) : ( + + {options.map((reviewer, index) => { + const isSelected = suggestedReviewerFilter.includes( + reviewer.uuid, + ); + const showDivider = reviewer.isMe && index < options.length - 1; + + return ( + + toggleSuggestedReviewer(reviewer.uuid)} + className="flex-row items-center justify-between rounded-md px-2 py-2.5 active:bg-gray-3" + > + + {reviewer.github_login ? ( + + ) : ( + + + {(reviewer.name || + reviewer.email || + "?")[0].toUpperCase()} + + + )} + + + {displayName(reviewer)} + + {reviewer.email && ( + + {reviewer.email} + + )} + + + {isSelected && ( + + )} + + {showDivider && ( + + )} + + ); + })} + + )} + + + ); +} diff --git a/apps/mobile/src/features/inbox/constants.ts b/apps/mobile/src/features/inbox/constants.ts new file mode 100644 index 000000000..be3f886b4 --- /dev/null +++ b/apps/mobile/src/features/inbox/constants.ts @@ -0,0 +1,6 @@ +/** Comma-separated statuses for the inbox pipeline (excludes terminal/deleted). */ +export const INBOX_PIPELINE_STATUS_FILTER = + "potential,candidate,in_progress,ready,pending_input"; + +/** Polling interval for inbox queries (ms). */ +export const INBOX_REFETCH_INTERVAL_MS = 5_000; diff --git a/apps/mobile/src/features/inbox/hooks/useInboxReports.ts b/apps/mobile/src/features/inbox/hooks/useInboxReports.ts new file mode 100644 index 000000000..973ee659f --- /dev/null +++ b/apps/mobile/src/features/inbox/hooks/useInboxReports.ts @@ -0,0 +1,105 @@ +import { useQuery } from "@tanstack/react-query"; +import { useAuthStore } from "@/features/auth"; +import { + getAvailableSuggestedReviewers, + getSignalProcessingState, + getSignalReport, + getSignalReports, +} from "../api"; +import { INBOX_REFETCH_INTERVAL_MS } from "../constants"; +import { useInboxFilterStore } from "../stores/inboxFilterStore"; +import type { + AvailableSuggestedReviewersResponse, + SignalProcessingStateResponse, + SignalReport, + SignalReportsQueryParams, + SignalReportsResponse, +} from "../types"; +import { + buildSignalReportListOrdering, + buildStatusFilterParam, + buildSuggestedReviewerFilterParam, +} from "../utils"; + +export const inboxKeys = { + all: ["inbox", "signal-reports"] as const, + list: (params?: SignalReportsQueryParams) => + [...inboxKeys.all, "list", params ?? {}] as const, + detail: (reportId: string) => [...inboxKeys.all, reportId, "detail"] as const, + processingState: ["inbox", "signal-processing-state"] as const, +}; + +export function useInboxReports(options?: { enabled?: boolean }) { + const { projectId, oauthAccessToken } = useAuthStore(); + const sortField = useInboxFilterStore((s) => s.sortField); + const sortDirection = useInboxFilterStore((s) => s.sortDirection); + const statusFilter = useInboxFilterStore((s) => s.statusFilter); + const sourceProductFilter = useInboxFilterStore((s) => s.sourceProductFilter); + const suggestedReviewerFilter = useInboxFilterStore( + (s) => s.suggestedReviewerFilter, + ); + + const params: SignalReportsQueryParams = { + status: buildStatusFilterParam(statusFilter), + ordering: buildSignalReportListOrdering(sortField, sortDirection), + source_product: + sourceProductFilter.length > 0 + ? sourceProductFilter.join(",") + : undefined, + suggested_reviewers: + suggestedReviewerFilter.length > 0 + ? buildSuggestedReviewerFilterParam(suggestedReviewerFilter) + : undefined, + }; + + const query = useQuery({ + queryKey: inboxKeys.list(params), + queryFn: () => getSignalReports(params), + enabled: !!projectId && !!oauthAccessToken && (options?.enabled ?? true), + refetchInterval: INBOX_REFETCH_INTERVAL_MS, + }); + + return { + reports: query.data?.results ?? [], + totalCount: query.data?.count ?? 0, + isLoading: query.isLoading, + isFetching: query.isFetching, + error: query.error?.message ?? null, + refetch: query.refetch, + }; +} + +export function useInboxReport(reportId: string | null) { + const { projectId, oauthAccessToken } = useAuthStore(); + + return useQuery({ + queryKey: inboxKeys.detail(reportId ?? ""), + queryFn: () => getSignalReport(reportId!), + enabled: !!projectId && !!oauthAccessToken && !!reportId, + }); +} + +export function useSignalProcessingState(options?: { enabled?: boolean }) { + const { projectId, oauthAccessToken } = useAuthStore(); + + return useQuery({ + queryKey: inboxKeys.processingState, + queryFn: () => getSignalProcessingState(), + enabled: !!projectId && !!oauthAccessToken && (options?.enabled ?? true), + refetchInterval: INBOX_REFETCH_INTERVAL_MS, + }); +} + +export function useAvailableSuggestedReviewers(options?: { + enabled?: boolean; +}) { + const { projectId, oauthAccessToken } = useAuthStore(); + + return useQuery({ + queryKey: [...inboxKeys.all, "available-reviewers"] as const, + queryFn: () => getAvailableSuggestedReviewers(), + enabled: !!projectId && !!oauthAccessToken && (options?.enabled ?? true), + staleTime: 5 * 60 * 1000, + refetchInterval: 60_000, + }); +} diff --git a/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts b/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts new file mode 100644 index 000000000..f8efc7baf --- /dev/null +++ b/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts @@ -0,0 +1,110 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; +import type { SignalReportOrderingField, SignalReportStatus } from "../types"; + +type SortField = Extract< + SignalReportOrderingField, + "priority" | "created_at" | "total_weight" +>; + +type SortDirection = "asc" | "desc"; + +export type SourceProduct = + | "session_replay" + | "error_tracking" + | "llm_analytics" + | "github" + | "linear" + | "zendesk" + | "conversations"; + +const DEFAULT_STATUS_FILTER: SignalReportStatus[] = [ + "ready", + "pending_input", + "in_progress", + "failed", + "candidate", + "potential", +]; + +interface InboxFilterState { + sortField: SortField; + sortDirection: SortDirection; + statusFilter: SignalReportStatus[]; + sourceProductFilter: SourceProduct[]; + suggestedReviewerFilter: string[]; +} + +interface InboxFilterActions { + setSort: (field: SortField, direction: SortDirection) => void; + setStatusFilter: (statuses: SignalReportStatus[]) => void; + toggleStatus: (status: SignalReportStatus) => void; + toggleSourceProduct: (source: SourceProduct) => void; + toggleSuggestedReviewer: (reviewerUuid: string) => void; + setSuggestedReviewerFilter: (reviewerUuids: string[]) => void; + resetFilters: () => void; +} + +type InboxFilterStore = InboxFilterState & InboxFilterActions; + +export const useInboxFilterStore = create()( + persist( + (set) => ({ + sortField: "priority", + sortDirection: "asc", + statusFilter: DEFAULT_STATUS_FILTER, + sourceProductFilter: [], + suggestedReviewerFilter: [], + + setSort: (sortField, sortDirection) => set({ sortField, sortDirection }), + setStatusFilter: (statusFilter) => set({ statusFilter }), + toggleStatus: (status) => + set((state) => { + const current = state.statusFilter; + const next = current.includes(status) + ? current.filter((s) => s !== status) + : [...current, status]; + // Don't allow empty — keep at least one + return { statusFilter: next.length > 0 ? next : current }; + }), + toggleSourceProduct: (source) => + set((state) => { + const current = state.sourceProductFilter; + const next = current.includes(source) + ? current.filter((s) => s !== source) + : [...current, source]; + return { sourceProductFilter: next }; + }), + toggleSuggestedReviewer: (reviewerUuid) => + set((state) => { + const current = state.suggestedReviewerFilter; + const next = current.includes(reviewerUuid) + ? current.filter((uuid) => uuid !== reviewerUuid) + : [...current, reviewerUuid]; + return { suggestedReviewerFilter: next }; + }), + setSuggestedReviewerFilter: (reviewerUuids) => + set({ + suggestedReviewerFilter: Array.from(new Set(reviewerUuids)), + }), + resetFilters: () => + set({ + statusFilter: DEFAULT_STATUS_FILTER, + sourceProductFilter: [], + suggestedReviewerFilter: [], + }), + }), + { + name: "inbox-filter-storage", + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => ({ + sortField: state.sortField, + sortDirection: state.sortDirection, + statusFilter: state.statusFilter, + sourceProductFilter: state.sourceProductFilter, + suggestedReviewerFilter: state.suggestedReviewerFilter, + }), + }, + ), +); diff --git a/apps/mobile/src/features/inbox/stores/inboxStore.ts b/apps/mobile/src/features/inbox/stores/inboxStore.ts new file mode 100644 index 000000000..76dded4d9 --- /dev/null +++ b/apps/mobile/src/features/inbox/stores/inboxStore.ts @@ -0,0 +1,48 @@ +import { create } from "zustand"; +import type { SignalReportOrderingField } from "../types"; + +type OrderDirection = "asc" | "desc"; + +/** Set of report IDs the user has swiped past (skipped) this session. */ +type SkippedSet = Set; + +interface InboxStoreState { + /** Field used for API ordering */ + orderByField: SignalReportOrderingField; + /** Sort direction */ + orderDirection: OrderDirection; + /** Report IDs skipped (swiped left) during this session */ + skippedIds: SkippedSet; + /** Index of the currently visible card in the deck */ + currentIndex: number; +} + +interface InboxStoreActions { + setOrderByField: (field: SignalReportOrderingField) => void; + setOrderDirection: (direction: OrderDirection) => void; + skipReport: (reportId: string) => void; + resetSkipped: () => void; + setCurrentIndex: (index: number) => void; + advanceCard: () => void; +} + +type InboxStore = InboxStoreState & InboxStoreActions; + +export const useInboxStore = create((set) => ({ + orderByField: "priority", + orderDirection: "desc", + skippedIds: new Set(), + currentIndex: 0, + + setOrderByField: (orderByField) => set({ orderByField }), + setOrderDirection: (orderDirection) => set({ orderDirection }), + skipReport: (reportId) => + set((state) => { + const next = new Set(state.skippedIds); + next.add(reportId); + return { skippedIds: next }; + }), + resetSkipped: () => set({ skippedIds: new Set(), currentIndex: 0 }), + setCurrentIndex: (currentIndex) => set({ currentIndex }), + advanceCard: () => set((state) => ({ currentIndex: state.currentIndex + 1 })), +})); diff --git a/apps/mobile/src/features/inbox/types.ts b/apps/mobile/src/features/inbox/types.ts new file mode 100644 index 000000000..9ecbbf485 --- /dev/null +++ b/apps/mobile/src/features/inbox/types.ts @@ -0,0 +1,79 @@ +export type SignalReportStatus = + | "potential" + | "candidate" + | "in_progress" + | "ready" + | "failed" + | "pending_input" + | "suppressed" + | "deleted"; + +export type SignalReportPriority = "P0" | "P1" | "P2" | "P3" | "P4"; + +export type SignalReportActionability = + | "immediately_actionable" + | "requires_human_input" + | "not_actionable"; + +export interface SignalReport { + id: string; + title: string | null; + summary: string | null; + status: SignalReportStatus; + total_weight: number; + signal_count: number; + signals_at_run?: number; + created_at: string; + updated_at: string; + artefact_count: number; + priority?: SignalReportPriority | null; + actionability?: SignalReportActionability | null; + already_addressed?: boolean | null; + is_suggested_reviewer?: boolean; + source_products?: string[]; + implementation_pr_url?: string | null; +} + +export interface SignalReportsResponse { + results: SignalReport[]; + count: number; +} + +export type SignalReportOrderingField = + | "priority" + | "signal_count" + | "total_weight" + | "created_at" + | "updated_at"; + +export interface SignalReportsQueryParams { + limit?: number; + offset?: number; + status?: string; + ordering?: string; + source_product?: string; + suggested_reviewers?: string; +} + +export interface SignalProcessingStateResponse { + paused_until: string | null; +} + +export interface AvailableSuggestedReviewer { + uuid: string; + name: string; + email: string; + github_login: string; +} + +export interface AvailableSuggestedReviewersResponse { + results: AvailableSuggestedReviewer[]; + count: number; +} + +export interface SignalReportTask { + id: string; + relationship: string; + task_id: string; + created_at: string; +} diff --git a/apps/mobile/src/features/inbox/utils.ts b/apps/mobile/src/features/inbox/utils.ts new file mode 100644 index 000000000..588978b49 --- /dev/null +++ b/apps/mobile/src/features/inbox/utils.ts @@ -0,0 +1,89 @@ +import type { + SignalReport, + SignalReportOrderingField, + SignalReportStatus, +} from "./types"; + +export function inboxStatusLabel(status: SignalReportStatus): string { + switch (status) { + case "ready": + return "Ready"; + case "pending_input": + return "Needs input"; + case "in_progress": + return "Researching"; + case "candidate": + return "Queued"; + case "potential": + return "Gathering"; + case "failed": + return "Failed"; + case "suppressed": + return "Suppressed"; + case "deleted": + return "Deleted"; + default: + return status; + } +} + +/** + * Build comma-separated `ordering` param for the API: + * 1. Status rank (ready first) + * 2. Suggested reviewer (current user first) + * 3. User-selected field + */ +export function buildSignalReportListOrdering( + field: SignalReportOrderingField, + direction: "asc" | "desc", +): string { + const fieldKey = direction === "desc" ? `-${field}` : field; + return `status,-is_suggested_reviewer,${fieldKey}`; +} + +/** + * Build a comma-separated status filter string for the API. + */ +export function buildStatusFilterParam(statuses: SignalReportStatus[]): string { + return statuses.join(","); +} + +/** + * Build a comma-separated suggested reviewer filter for the API. + */ +export function buildSuggestedReviewerFilterParam( + reviewerIds: string[], +): string | undefined { + const normalized = reviewerIds.map((id) => id.trim()).filter(Boolean); + if (normalized.length === 0) return undefined; + return Array.from(new Set(normalized)).join(","); +} + +export function filterReportsBySearch( + reports: SignalReport[], + query: string, +): SignalReport[] { + const trimmed = query.trim(); + if (!trimmed) return reports; + + const lower = trimmed.toLowerCase(); + return reports.filter( + (report) => + report.title?.toLowerCase().includes(lower) || + report.summary?.toLowerCase().includes(lower) || + report.id.toLowerCase().includes(lower), + ); +} + +/** + * Returns only reports that are actionable for the tinder-like card deck: + * ready, immediately actionable, not already addressed. + */ +export function getActionableReports(reports: SignalReport[]): SignalReport[] { + return reports.filter( + (r) => + r.status === "ready" && + r.actionability === "immediately_actionable" && + !r.already_addressed, + ); +} diff --git a/apps/mobile/src/features/tasks/types.ts b/apps/mobile/src/features/tasks/types.ts index c863a59f6..1ecf906d4 100644 --- a/apps/mobile/src/features/tasks/types.ts +++ b/apps/mobile/src/features/tasks/types.ts @@ -129,6 +129,8 @@ export interface CreateTaskOptions { title?: string; repository?: string; github_integration?: number; + signal_report?: string; + signal_report_task_relationship?: string; } export interface CreateTaskAutomationOptions { From a2a6ea480e40596a674b2cb799e88940a16edf19 Mon Sep 17 00:00:00 2001 From: dylan Date: Wed, 13 May 2026 18:11:17 -0400 Subject: [PATCH 15/94] fix icon --- apps/mobile/app.json | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 05e16ed8a..fbdc22320 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -189,13 +189,7 @@ } ], "expo-localization", - [ - "expo-notifications", - { - "icon": "./assets/posthog.icon", - "color": "#0f0f0f" - } - ], + "expo-notifications", [ "expo-speech-recognition", { From d677d33a47ea82d25225dca1793d99886465cdc6 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 13 May 2026 17:13:28 -0400 Subject: [PATCH 16/94] Store last used repo --- .claude/settings.local.json | 5 ++++- apps/mobile/src/app/task/index.tsx | 17 +++++++++++++++-- .../src/features/tasks/stores/taskStore.ts | 14 +++++++++++++- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c0a10441c..920230720 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -19,7 +19,10 @@ "Bash(grep -E \"\\\\.\\(ts|tsx\\)$\")", "Bash(node -e \"const p = require\\('phosphor-react-native'\\); console.log\\(Object.keys\\(p\\).filter\\(k => /[Cc]ircle/.test\\(k\\)\\).slice\\(0, 30\\).join\\(', '\\)\\)\")", "Bash(grep -E \"\\\\.\\(tsx?|jsx?\\)$\")", - "Bash(grep -E \"\\\\.\\(tsx?\\)$\")" + "Bash(grep -E \"\\\\.\\(tsx?\\)$\")", + "Bash(git -C /Users/tomowers/dev/posthog/code status)", + "Bash(npx tsc *)", + "Bash(git -C /Users/tomowers/dev/posthog/code add apps/mobile/src/app/task/index.tsx)" ], "additionalDirectories": [ "/private/tmp" diff --git a/apps/mobile/src/app/task/index.tsx b/apps/mobile/src/app/task/index.tsx index 238484627..31f7e1236 100644 --- a/apps/mobile/src/app/task/index.tsx +++ b/apps/mobile/src/app/task/index.tsx @@ -47,6 +47,7 @@ import { Pill } from "@/features/tasks/composer/Pill"; import { RepositoryPickerSheet } from "@/features/tasks/composer/RepositoryPickerSheet"; import { SelectSheet } from "@/features/tasks/composer/SelectSheet"; import { useIntegrations } from "@/features/tasks/hooks/useIntegrations"; +import { useTaskStore } from "@/features/tasks/stores/taskStore"; import type { RepositorySelection } from "@/features/tasks/types"; import { findRepositoryOption, @@ -122,8 +123,13 @@ export default function NewTaskScreen() { [], ); + // Default the repo to the URL param (deep-link from a signal report etc.), + // falling back to the most recently used repo so the user doesn't have to + // re-pick the same one for every new task. + const lastRepository = useTaskStore((s) => s.lastRepository); + const setLastRepository = useTaskStore((s) => s.setLastRepository); const [prompt, setPrompt] = useState(initialPrompt ?? ""); - const [selection, setSelection] = useState(() => { + const [selection, setSelectionState] = useState(() => { if (initialRepo) { const match = repositoryOptions.find( (o) => o.repository.toLowerCase() === initialRepo.toLowerCase(), @@ -132,8 +138,15 @@ export default function NewTaskScreen() { // Repo known but integration not yet loaded — set repo, integrationId will resolve later return { integrationId: null, repository: initialRepo }; } - return { integrationId: null, repository: null }; + return lastRepository; }); + const setSelection = useCallback( + (next: RepositorySelection) => { + setSelectionState(next); + setLastRepository(next); + }, + [setLastRepository], + ); const [mode, setMode] = useState(DEFAULT_EXECUTION_MODE); const [model, setModel] = useState(DEFAULT_MODEL); const [reasoning, setReasoning] = diff --git a/apps/mobile/src/features/tasks/stores/taskStore.ts b/apps/mobile/src/features/tasks/stores/taskStore.ts index 5085da270..dd29c07be 100644 --- a/apps/mobile/src/features/tasks/stores/taskStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskStore.ts @@ -1,23 +1,32 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; -import type { Task } from "../types"; +import type { RepositorySelection, Task } from "../types"; export type OrganizeMode = "by-project" | "chronological"; export type SortMode = "created" | "updated"; +const EMPTY_REPOSITORY_SELECTION: RepositorySelection = { + integrationId: null, + repository: null, +}; + interface TaskUIState { selectedTaskId: string | null; organizeMode: OrganizeMode; sortMode: SortMode; showInternal: boolean; filter: string; + /** Most-recently-used repository for the new-task composer. Pre-fills the + * repo pill so users don't have to re-pick the same repo every time. */ + lastRepository: RepositorySelection; selectTask: (taskId: string | null) => void; setOrganizeMode: (mode: OrganizeMode) => void; setSortMode: (mode: SortMode) => void; setShowInternal: (showInternal: boolean) => void; setFilter: (filter: string) => void; + setLastRepository: (selection: RepositorySelection) => void; } export const useTaskStore = create()( @@ -28,12 +37,14 @@ export const useTaskStore = create()( sortMode: "updated", showInternal: false, filter: "", + lastRepository: EMPTY_REPOSITORY_SELECTION, selectTask: (selectedTaskId) => set({ selectedTaskId }), setOrganizeMode: (organizeMode) => set({ organizeMode }), setSortMode: (sortMode) => set({ sortMode }), setShowInternal: (showInternal) => set({ showInternal }), setFilter: (filter) => set({ filter }), + setLastRepository: (lastRepository) => set({ lastRepository }), }), { name: "posthog-task-ui", @@ -42,6 +53,7 @@ export const useTaskStore = create()( organizeMode: state.organizeMode, sortMode: state.sortMode, showInternal: state.showInternal, + lastRepository: state.lastRepository, }), }, ), From 96b551d3354f0b41561030fd6484f2b92012dd01 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 13 May 2026 17:29:18 -0400 Subject: [PATCH 17/94] Added a text input for the chat view --- .claude/settings.local.json | 6 +- apps/mobile/src/app/task/[id].tsx | 298 ++++++++++----- .../tasks/composer/TaskChatComposer.tsx | 353 ++++++++++++++++++ .../features/tasks/stores/taskSessionStore.ts | 34 ++ .../src/features/tasks/stores/taskStore.ts | 26 ++ 5 files changed, 621 insertions(+), 96 deletions(-) create mode 100644 apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 920230720..241edefdd 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -22,7 +22,11 @@ "Bash(grep -E \"\\\\.\\(tsx?\\)$\")", "Bash(git -C /Users/tomowers/dev/posthog/code status)", "Bash(npx tsc *)", - "Bash(git -C /Users/tomowers/dev/posthog/code add apps/mobile/src/app/task/index.tsx)" + "Bash(git -C /Users/tomowers/dev/posthog/code add apps/mobile/src/app/task/index.tsx)", + "Bash(git -C /Users/tomowers/dev/posthog/code diff apps/mobile/src/features/tasks/stores/taskStore.ts apps/mobile/src/app/task/index.tsx)", + "Bash(git -C /Users/tomowers/dev/posthog/code status -s)", + "Bash(git -C /Users/tomowers/dev/posthog/code diff --stat HEAD apps/mobile)", + "Bash(npx biome *)" ], "additionalDirectories": [ "/private/tmp" diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 0aca8468c..1dfa09b7f 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -13,11 +13,21 @@ import { 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 } from "@/features/tasks/api"; import { TaskSessionView } from "@/features/tasks/components/TaskSessionView"; +import { DotBackground } from "@/features/tasks/composer/DotBackground"; +import { + DEFAULT_EXECUTION_MODE, + DEFAULT_MODEL, + DEFAULT_REASONING, + type ExecutionMode, + modelSupportsReasoning, + type ReasoningEffort, +} from "@/features/tasks/composer/options"; +import { TaskChatComposer } from "@/features/tasks/composer/TaskChatComposer"; import { taskKeys } from "@/features/tasks/hooks/useTasks"; import { useTaskSessionStore } from "@/features/tasks/stores/taskSessionStore"; +import { useTaskStore } from "@/features/tasks/stores/taskStore"; import type { Task } from "@/features/tasks/types"; import { logger } from "@/lib/logger"; import { useThemeColors } from "@/lib/theme"; @@ -49,6 +59,7 @@ export default function TaskDetailScreen() { sendPrompt, cancelPrompt, sendPermissionResponse, + setConfigOption, getSessionForTask, setFocusedTaskId, } = useTaskSessionStore(); @@ -61,6 +72,19 @@ export default function TaskDetailScreen() { const session = taskId ? getSessionForTask(taskId) : undefined; + // Per-task composer pill values. Persisted in taskStore so reopening the + // task keeps the user's choices; defaults fall back to the same constants + // the new-task composer uses. + const composerConfig = useTaskStore((s) => + taskId ? s.composerConfigByTaskId[taskId] : undefined, + ); + const setComposerConfig = useTaskStore((s) => s.setComposerConfig); + const composerMode: ExecutionMode = + composerConfig?.mode ?? DEFAULT_EXECUTION_MODE; + const composerModel = composerConfig?.model ?? DEFAULT_MODEL; + const composerReasoning: ReasoningEffort = + composerConfig?.reasoning ?? DEFAULT_REASONING; + const { height } = useReanimatedKeyboardAnimation(); // useReanimatedKeyboardAnimation returns negative height values @@ -72,8 +96,11 @@ export default function TaskDetailScreen() { }, []); const inputContainerStyle = useAnimatedStyle(() => { + // contentPosition already translates the whole content up by the keyboard + // height, so the composer sits at the keyboard top — no extra gap needed + // when open. Closed state keeps a comfortable bottom inset. return { - marginBottom: height.value < 0 ? 26 : Math.max(insets.bottom, 50), + marginBottom: height.value < 0 ? 0 : Math.max(insets.bottom, 50), }; }, [insets.bottom]); @@ -133,10 +160,72 @@ export default function TaskDetailScreen() { }; }, [taskId, task, loading, session, connectToTask, retrying]); + 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], + ); + + // Resume a terminal (completed/failed) run with a new user prompt. Mirrors + // the desktop "send on a finished task continues the conversation" UX — + // creates a fresh run that resumes from the previous one and queues the + // message as pending_user_message. + const handleSendAfterTerminal = useCallback( + async (text: string) => { + if (!taskId || !task) return; + try { + setRetrying(true); + disconnectFromTask(taskId); + + const supportsReasoning = modelSupportsReasoning(composerModel); + const updatedTask = await runTaskInCloud(taskId, { + resumeFromRunId: task.latest_run?.id, + pendingUserMessage: text, + runtimeAdapter: "claude", + model: composerModel, + reasoningEffort: supportsReasoning ? composerReasoning : undefined, + initialPermissionMode: composerMode, + }); + setTask(updatedTask); + await connectToTask(updatedTask); + updateTaskInCache(updatedTask); + } catch (err) { + log.error("Failed to send after terminal", err); + setRetrying(false); + Alert.alert( + "Failed to send", + "Could not continue this task. Please try again.", + ); + } + }, + [ + taskId, + task, + disconnectFromTask, + connectToTask, + updateTaskInCache, + composerMode, + composerModel, + composerReasoning, + ], + ); + const handleSendPrompt = useCallback( (text: string) => { if (!taskId) return; Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + + if (session?.terminalStatus) { + handleSendAfterTerminal(text); + return; + } + sendPrompt(taskId, text).catch((err) => { log.error("Failed to send prompt", err); Alert.alert( @@ -145,7 +234,37 @@ export default function TaskDetailScreen() { ); }); }, - [taskId, sendPrompt], + [taskId, sendPrompt, session?.terminalStatus, handleSendAfterTerminal], + ); + + const handleModeChange = useCallback( + (value: ExecutionMode) => { + if (!taskId) return; + setComposerConfig(taskId, { mode: value }); + // Push to the live cloud session so the next turn uses the new mode. + // Silently ignore failures — value is already persisted locally and + // will be replayed if the user resumes from a terminal state. + setConfigOption(taskId, "mode", value).catch(() => {}); + }, + [taskId, setComposerConfig, setConfigOption], + ); + + const handleModelChange = useCallback( + (value: string) => { + if (!taskId) return; + setComposerConfig(taskId, { model: value }); + setConfigOption(taskId, "model", value).catch(() => {}); + }, + [taskId, setComposerConfig, setConfigOption], + ); + + const handleReasoningChange = useCallback( + (value: ReasoningEffort) => { + if (!taskId) return; + setComposerConfig(taskId, { reasoning: value }); + setConfigOption(taskId, "effort", value).catch(() => {}); + }, + [taskId, setComposerConfig, setConfigOption], ); const handleStop = useCallback(() => { @@ -155,18 +274,6 @@ export default function TaskDetailScreen() { 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 { @@ -258,8 +365,6 @@ export default function TaskDetailScreen() { } }, [taskId, task, disconnectFromTask, connectToTask, updateTaskInCache]); - const environment = task?.latest_run?.environment; - const visibleAgentTypes = [ "agent_message_chunk", "agent_message", @@ -337,103 +442,106 @@ export default function TaskDetailScreen() { fontWeight: "600", }, presentation: "modal", - headerRight: environment + headerRight: isLocal ? () => ( - 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 + onPress={() => + 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(); + }, + ) } - className={`rounded-full px-3 py-1 ${ - environment === "cloud" ? "bg-accent-3" : "bg-gray-4" - }`} + className="rounded-full bg-gray-4 px-3 py-1" > - - {environment === "cloud" ? "Cloud" : "Local"} + + Local ) : undefined, }} /> - - {showAutomationContext && automationContextLabel && ( - - - {automationName - ? `Started from automation: ${automationName}` - : automationContextLabel} - - - )} - - {/* 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. */} - - - {/* 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 && ( + + {/* Subtle dotted background — matches the new-task screen. Sits below + the translated content so it stays put when the keyboard opens. */} + + + {showAutomationContext && automationContextLabel && ( + + + {automationName + ? `Started from automation: ${automationName}` + : automationContextLabel} + + + )} + + {/* 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. */} + + + {/* Loading overlay — covers the list while it does initial layout */} + {loading && ( + + + + {task?.latest_run ? "Connecting..." : "Loading task..."} + + + )} + + {/* Fixed composer at bottom — stays visible even on terminal runs so + the user can send a follow-up message that resumes the task. */} - - )} - + + ); } diff --git a/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx b/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx new file mode 100644 index 000000000..10ae32f30 --- /dev/null +++ b/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx @@ -0,0 +1,353 @@ +import * as Haptics from "expo-haptics"; +import { + ArrowUp, + BrainIcon, + Microphone, + PaperclipIcon, + PauseIcon, + PencilIcon, + Robot, + ShieldCheck, + Stop, +} from "phosphor-react-native"; +import { type ReactNode, useEffect, useRef, useState } from "react"; +import { + ActivityIndicator, + Animated, + Easing, + Keyboard, + Pressable, + ScrollView, + TextInput, + View, +} from "react-native"; +import { useVoiceRecording } from "@/features/chat"; +import { useThemeColors } from "@/lib/theme"; +import { + DEFAULT_EXECUTION_MODE, + DEFAULT_MODEL, + DEFAULT_REASONING, + EXECUTION_MODES, + type ExecutionMode, + MODELS, + modeLabel, + modelLabel, + modelSupportsReasoning, + REASONING_LEVELS, + type ReasoningEffort, + reasoningLabel, +} from "./options"; +import { Pill } from "./Pill"; +import { SelectSheet } from "./SelectSheet"; + +interface TaskChatComposerProps { + onSend: (message: string) => void; + onStop?: () => void; + disabled?: boolean; + placeholder?: string; + isUserTurn?: boolean; + /** Current pill values (persisted per-task by the caller). */ + mode: ExecutionMode; + model: string; + reasoning: ReasoningEffort; + onModeChange: (mode: ExecutionMode) => void; + onModelChange: (model: string) => void; + onReasoningChange: (reasoning: ReasoningEffort) => void; +} + +function modeIcon(mode: ExecutionMode, color: string, size = 14): ReactNode { + switch (mode) { + case "plan": + return ; + case "default": + return ; + case "acceptEdits": + return ; + } +} + +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 TaskChatComposer({ + onSend, + onStop, + disabled = false, + placeholder = "Ask a question", + isUserTurn = false, + mode, + model, + reasoning, + onModeChange, + onModelChange, + onReasoningChange, +}: TaskChatComposerProps) { + const themeColors = useThemeColors(); + const [message, setMessage] = useState(""); + const { status, startRecording, stopRecording, cancelRecording } = + useVoiceRecording(); + + const isRecording = status === "recording"; + const isTranscribing = status === "transcribing"; + + const [modeSheetOpen, setModeSheetOpen] = useState(false); + const [modelSheetOpen, setModelSheetOpen] = useState(false); + const [reasoningSheetOpen, setReasoningSheetOpen] = useState(false); + + const showReasoningPill = modelSupportsReasoning(model); + + const canSend = message.trim().length > 0 && !disabled && !isRecording; + const showStop = + !isUserTurn && !canSend && !isRecording && !isTranscribing && !!onStop; + + const handleSend = () => { + const trimmed = message.trim(); + if (!trimmed || disabled) return; + setMessage(""); + Keyboard.dismiss(); + onSend(trimmed); + }; + + const handleMicPress = async () => { + if (isRecording) { + const transcript = await stopRecording(); + if (transcript) { + setMessage((prev) => (prev ? `${prev} ${transcript}` : transcript)); + } + } else if (!isTranscribing) { + await startRecording(); + } + }; + + const handleMicLongPress = async () => { + if (isRecording) { + await cancelRecording(); + } + }; + + const handleStop = () => { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + onStop?.(); + }; + + return ( + <> + + + + + + + + { + /* attachments — coming soon */ + }} + className="h-9 w-9 items-center justify-center" + > + + + + + setModeSheetOpen(true)} + /> + + } + label={modelLabel(model)} + onPress={() => setModelSheetOpen(true)} + /> + + {showReasoningPill ? ( + } + label={reasoningLabel(reasoning)} + onPress={() => setReasoningSheetOpen(true)} + /> + ) : null} + + + + {isTranscribing ? ( + + ) : canSend ? ( + + ) : isRecording || showStop ? ( + + ) : ( + + )} + + + + + + + onModeChange(v as ExecutionMode)} + onClose={() => setModeSheetOpen(false)} + options={EXECUTION_MODES.map((m) => ({ + value: m.value, + label: m.label, + description: m.description, + icon: modeIcon( + m.value, + m.value === "plan" ? themeColors.accent[11] : themeColors.gray[11], + 16, + ), + }))} + /> + + { + onModelChange(v); + // If the new model doesn't support reasoning, drop the level so the + // payload stays consistent. Default reasoning re-applies when + // switching back to a reasoning-capable model. + if (!modelSupportsReasoning(v)) { + onReasoningChange(DEFAULT_REASONING); + } + }} + onClose={() => setModelSheetOpen(false)} + options={MODELS.map((m) => ({ + value: m.value, + label: m.label, + description: m.description, + icon: , + }))} + /> + + onReasoningChange(v as ReasoningEffort)} + onClose={() => setReasoningSheetOpen(false)} + options={REASONING_LEVELS.map((r) => ({ + value: r.value, + label: r.label, + icon: , + }))} + /> + + ); +} + +export const TASK_CHAT_DEFAULTS = { + mode: DEFAULT_EXECUTION_MODE, + model: DEFAULT_MODEL, + reasoning: DEFAULT_REASONING, +} as const; diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts index 6c2c21b05..fe9574670 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts @@ -154,6 +154,11 @@ interface TaskSessionStore { }, ) => Promise; cancelPrompt: (taskId: string) => Promise; + setConfigOption: ( + taskId: string, + configId: string, + value: string, + ) => Promise; getSessionForTask: (taskId: string) => TaskSession | undefined; _startCloudPolling: (taskRunId: string, logUrl: string) => void; @@ -538,6 +543,35 @@ export const useTaskSessionStore = create((set, get) => ({ } }, + // Update an agent-side config option on the running cloud session + // (e.g. mode, model, effort). No-op when there is no live session — the + // caller is expected to persist the value locally so it can be replayed + // on the next resume run. + setConfigOption: async (taskId, configId, value) => { + const session = get().getSessionForTask(taskId); + if (!session || session.terminalStatus) return; + + try { + await sendCloudCommand(taskId, session.taskRunId, "set_config_option", { + configId, + value, + }); + logger.debug("Sent set_config_option", { + taskId, + runId: session.taskRunId, + configId, + value, + }); + } catch (err) { + logger.warn("Failed to send set_config_option", { + taskId, + configId, + error: err, + }); + throw err; + } + }, + cancelPrompt: async (taskId: string) => { const session = get().getSessionForTask(taskId); if (!session) return false; diff --git a/apps/mobile/src/features/tasks/stores/taskStore.ts b/apps/mobile/src/features/tasks/stores/taskStore.ts index dd29c07be..9ce1dd8c6 100644 --- a/apps/mobile/src/features/tasks/stores/taskStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskStore.ts @@ -1,6 +1,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; +import type { ExecutionMode, ReasoningEffort } from "../composer/options"; import type { RepositorySelection, Task } from "../types"; export type OrganizeMode = "by-project" | "chronological"; @@ -11,6 +12,14 @@ const EMPTY_REPOSITORY_SELECTION: RepositorySelection = { repository: null, }; +/** Per-task chat composer pill values. Persisted so reopening a task keeps + * the mode/model/reasoning the user last selected for it. */ +export interface TaskComposerConfig { + mode?: ExecutionMode; + model?: string; + reasoning?: ReasoningEffort; +} + interface TaskUIState { selectedTaskId: string | null; organizeMode: OrganizeMode; @@ -20,6 +29,7 @@ interface TaskUIState { /** Most-recently-used repository for the new-task composer. Pre-fills the * repo pill so users don't have to re-pick the same repo every time. */ lastRepository: RepositorySelection; + composerConfigByTaskId: Record; selectTask: (taskId: string | null) => void; setOrganizeMode: (mode: OrganizeMode) => void; @@ -27,6 +37,10 @@ interface TaskUIState { setShowInternal: (showInternal: boolean) => void; setFilter: (filter: string) => void; setLastRepository: (selection: RepositorySelection) => void; + setComposerConfig: ( + taskId: string, + config: Partial, + ) => void; } export const useTaskStore = create()( @@ -38,6 +52,7 @@ export const useTaskStore = create()( showInternal: false, filter: "", lastRepository: EMPTY_REPOSITORY_SELECTION, + composerConfigByTaskId: {}, selectTask: (selectedTaskId) => set({ selectedTaskId }), setOrganizeMode: (organizeMode) => set({ organizeMode }), @@ -45,6 +60,16 @@ export const useTaskStore = create()( setShowInternal: (showInternal) => set({ showInternal }), setFilter: (filter) => set({ filter }), setLastRepository: (lastRepository) => set({ lastRepository }), + setComposerConfig: (taskId, config) => + set((state) => ({ + composerConfigByTaskId: { + ...state.composerConfigByTaskId, + [taskId]: { + ...state.composerConfigByTaskId[taskId], + ...config, + }, + }, + })), }), { name: "posthog-task-ui", @@ -54,6 +79,7 @@ export const useTaskStore = create()( sortMode: state.sortMode, showInternal: state.showInternal, lastRepository: state.lastRepository, + composerConfigByTaskId: state.composerConfigByTaskId, }), }, ), From a0869b27073c56bbb410a3ad06ffe0a708e70008 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 13 May 2026 17:52:13 -0400 Subject: [PATCH 18/94] Remove local settings from source control --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index d603ab8a5..3d9297a76 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,5 @@ plugins/posthog/local-skills/ # Symlinked copies of posthog, to make developing against those APIs easier posthog-sym + +.claude/settings.local.json \ No newline at end of file From a9aead168866ce76a7b30a6040089430ae957a31 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 13 May 2026 17:52:59 -0400 Subject: [PATCH 19/94] Updated settings file --- .claude/settings.local.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 241edefdd..233f90736 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -26,7 +26,8 @@ "Bash(git -C /Users/tomowers/dev/posthog/code diff apps/mobile/src/features/tasks/stores/taskStore.ts apps/mobile/src/app/task/index.tsx)", "Bash(git -C /Users/tomowers/dev/posthog/code status -s)", "Bash(git -C /Users/tomowers/dev/posthog/code diff --stat HEAD apps/mobile)", - "Bash(npx biome *)" + "Bash(npx biome *)", + "Bash(npx tsc *)" ], "additionalDirectories": [ "/private/tmp" From 6ea5aad3609c31dc2c98af10967ec9e31e63b13a Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 13 May 2026 17:53:48 -0400 Subject: [PATCH 20/94] Updated claude.md with mobile stuff --- CLAUDE.md | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index cac4d3f89..f70911967 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -127,6 +127,13 @@ See [ARCHITECTURE.md](./apps/code/ARCHITECTURE.md) for detailed patterns (DI, se - Saga pattern for atomic multi-step operations with automatic rollback - Built with tsup, outputs ESM +### Mobile App (apps/mobile) + +- React Native + Expo (SDK 54), expo-router for file-based routing +- NativeWind v4 for styling (Tailwind classes compiled to RN styles) +- React Query for server state, Zustand for client state +- See [Mobile App](#mobile-app-appsmobile-1) section below for UI rules and patterns — Electron patterns in `Code Patterns` do NOT apply on mobile + ## Agent Integration Guidelines - **No rawInput**: Don't use Claude Code SDK's `rawInput` - only use Zod validated meta fields. This keeps us agent agnostic and gives us a maintainable, extensible format for logs. @@ -364,6 +371,82 @@ export const useNavigationStore = create()( ); ``` +## Mobile App (apps/mobile) + +When working in `apps/mobile/`, the patterns in `Code Patterns` above are for the **Electron renderer** (web DOM, Radix, web Tailwind v4). They do NOT apply here. Mobile is React Native: no `

`, no `window`/`document`/`localStorage`, no `:hover`/`cursor-*`/`focus-visible:`, no CSS `position: fixed`, no `overflow-y-auto`. If a feature only exists in CSS, it doesn't exist on mobile — design for touch and native primitives. + +See [apps/mobile/README.md](./apps/mobile/README.md) for setup, build profiles, and full command list. + +### Mobile UI Principles + +Every screen must be designed for a phone: portrait-first, touch-driven, dark + light mode, safe areas honoured, keyboard-aware. Treat tablet/landscape as a stretch goal, not a baseline — but never let layouts hard-break on them. + +- **Touch targets are 44pt minimum.** Use `hitSlop` to widen the hit area when the visual element is smaller. Never assume a pointer. +- **Provide press feedback.** `active:opacity-*` or `active:bg-*` on every `Pressable`. There is no hover state — feedback only happens on press. +- **Honour safe areas.** Use `useSafeAreaInsets()` from `react-native-safe-area-context` for top/bottom padding. Never hardcode status-bar height. Edge-to-edge screens (no native header) MUST account for the notch and home indicator. +- **Keyboard handling is mandatory for any input.** Use `react-native-keyboard-controller`'s `KeyboardAvoidingView` / `KeyboardAwareScrollView`. Set `keyboardShouldPersistTaps="handled"` on scroll containers that contain inputs. Verify the composer/input remains visible with the keyboard up. +- **Dark mode is not optional.** Every new screen must work in both light and dark. Pick from theme tokens, never raw hex. +- **One-handed reachability.** Primary actions belong in the bottom half of the screen where the thumb actually lives. Avoid forcing reach to the top corners for frequent actions — that's what `FloatingBackButton` / floating CTAs are for. +- **Respect platform conventions.** iOS swipe-back gestures, Android hardware back, sheet/modal idioms. Don't reinvent navigation. + +### Primitives + +- **Layout & containers:** `View`, `ScrollView`, `FlatList`. Never reach for HTML elements; they don't exist. +- **Long lists:** Always `FlatList` (or `SectionList`) with a stable `keyExtractor`. Plain `ScrollView` is for short, bounded content only. +- **Text:** Import from `@components/text` — it applies the project's default font stack. Direct `react-native` `Text` is monkey-patched in [textDefaults.ts](apps/mobile/src/lib/textDefaults.ts) but the wrapper is preferred for consistency. +- **Buttons / tappables:** `Pressable`. Always set `hitSlop` and an `active:*` class. +- **Icons:** `phosphor-react-native`. Pass color via `useThemeColors()` (e.g. `color={themeColors.gray[12]}`), never a hex literal. +- **Animations:** `react-native-reanimated` v4. Do not use the legacy `Animated` API. +- **Haptics:** `expo-haptics` for confirmation / destructive actions. Pair with visual feedback — haptics alone are not a signal. + +### Styling: NativeWind + Theme Tokens + +Mobile uses NativeWind v3 with the token system defined in [theme.ts](apps/mobile/src/lib/theme.ts) and exposed via [tailwind.config.js](apps/mobile/tailwind.config.js). + +- **Use named token classes**, not hex: `bg-gray-1`, `bg-gray-2`, `text-gray-12`, `border-gray-6`, `bg-accent-9`, `text-accent-11`, `bg-background`, `bg-card`, `text-status-error`. These automatically switch between light and dark. +- **Arbitrary values** (`text-[15px]`, `pl-[18px]`) are fine when the design token doesn't match. Pair body text with `leading-snug`, titles with `leading-tight`. +- **For native props that take a color directly** — `ActivityIndicator`, `RefreshControl`, `StatusBar`, gradient stops, icon `color={...}` — call `useThemeColors()` and pass the hex. Don't hardcode. +- **For transparent variants** (gradients, overlays), use `toRgba(themeColors.background, 0.92)` rather than guessing rgba values. + +Inline `style={{}}` on mobile is acceptable ONLY for: + +1. **Runtime-computed values:** `style={{ paddingTop: insets.top + 8 }}`, `style={{ height: fadeHeight }}`, `transform: [{ translateY }]` driven by Reanimated/measurement. +2. **Library configuration objects** that aren't React props (e.g. `LinearGradient`'s absolute fill, gesture handler configs). +3. **Theme tokens consumed by native components** that don't accept className (passed to `contentStyle`, `headerStyle`, etc.). + +Do NOT use inline `style` for static color, spacing, layout, border, radius, opacity, position, or z-index — those are all NativeWind classes. If a conditional looks like `style={{ color: isActive ? a : b }}`, rewrite as ``className={`base ${isActive ? "text-accent-9" : "text-gray-10"}`}``. + +When writing custom components, accept `className?: string` and merge it into the inner element so call sites can override styling without inline `style`. + +### Navigation & Screen Patterns + +- **expo-router**, file-based. Routes live in [src/app/](apps/mobile/src/app/). `(group)/` is a layout group, `[id].tsx` is a dynamic param. +- **Modals:** Configure on the Stack screen with `presentation: "modal"` — see [_layout.tsx](apps/mobile/src/app/_layout.tsx). Don't roll a custom modal component when a stack modal will do. +- **Headers:** Prefer the existing floating header pattern ([FloatingBackButton](apps/mobile/src/components/FloatingBackButton.tsx), [FloatingTaskHeader](apps/mobile/src/features/tasks/components/FloatingTaskHeader.tsx)) over the native stack header. It lets content fill the full screen (incl. behind the status bar) and looks correct in both light/dark. +- **Don't go back blindly.** Always guard with `if (router.canGoBack()) router.back()`. + +### Storage & Side Effects + +- **Persistent key/value:** `@react-native-async-storage/async-storage` — NOT `localStorage` (doesn't exist on RN). +- **Secrets / tokens:** `expo-secure-store`. +- **Logger:** Use `@/lib/logger`. Never `console.*` in source. +- **Path alias:** `@/*` → `apps/mobile/src/*`. Don't use deep relative imports. + +### Platform Differences + +- Split iOS/Android behavior with `Platform.OS === "ios"`. Don't ship iOS-only APIs (`expo-glass-effect`, certain haptics, modal `presentation: "formSheet"`) without an Android fallback. +- iOS swipe-back is on by default — don't disable it without a strong reason. On Android, ensure hardware back behaves the same. + +### Verifying Mobile UI Work + +You cannot fully validate mobile UI from a typecheck. Before claiming a mobile UI task is done: + +1. Mentally (or actually) walk the layout through: small iPhone (e.g. iPhone SE), large iPhone (Pro Max), with and without dynamic type bumped. +2. Check both light and dark mode — switch the simulator's appearance and verify token-based colors still read. +3. With the keyboard up — does the focused input stay visible? Does the back/submit button still tap? +4. Safe areas — does anything sit under the notch or home indicator? +5. If you can't actually run it, say so explicitly rather than reporting success. + ## Testing ### Commands From 8a1bda568a1f91ebc1194908d4820a5db305956a Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 13 May 2026 17:54:08 -0400 Subject: [PATCH 21/94] Updated the task view to be a lot better --- .claude/settings.local.json | 36 --- apps/mobile/src/app/_layout.tsx | 40 +-- apps/mobile/src/app/chat/[id].tsx | 131 ++++------ apps/mobile/src/app/chat/index.tsx | 84 +++--- apps/mobile/src/app/task/[id].tsx | 244 ++++++++---------- apps/mobile/src/app/task/index.tsx | 27 +- .../src/components/FloatingBackButton.tsx | 42 +++ .../tasks/components/FloatingTaskHeader.tsx | 87 +++++++ .../tasks/components/TaskSessionView.tsx | 32 +-- 9 files changed, 342 insertions(+), 381 deletions(-) delete mode 100644 .claude/settings.local.json create mode 100644 apps/mobile/src/components/FloatingBackButton.tsx create mode 100644 apps/mobile/src/features/tasks/components/FloatingTaskHeader.tsx diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 233f90736..000000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(pnpm exec *)", - "Bash(xargs cat)", - "Bash(PYTHONPATH=/Users/tomowers/Library/Python/3.10/lib/python/site-packages python3 -c \"from fontTools.ttLib import TTFont; import brotli; print\\('ok'\\)\")", - "Bash(python3 -c \"import sys; print\\(sys.path\\)\")", - "Read(//opt/homebrew/lib/**)", - "Bash(python3 -m pip install --user --break-system-packages --quiet fonttools brotli)", - "Bash(python3 -c \"from fontTools.ttLib import TTFont; import brotli; print\\('ok', brotli.__version__\\)\")", - "Bash(mkdir -p /Users/tomowers/dev/posthog/code/apps/mobile/assets/fonts/OpenRunde)", - "Read(//private/tmp/**)", - "Bash(cat)", - "Bash(python3 /tmp/convert_woff.py)", - "Bash(ls -lh /Users/tomowers/dev/posthog/code/apps/mobile/assets/fonts/OpenRunde/ && rm /tmp/convert_woff.py)", - "Bash(python3 -c ' *)", - "Bash(python3 /tmp/normalize_fonts.py)", - "Bash(xargs grep -l \"^ Text,\\\\|^ Text$\\\\|, Text,\")", - "Bash(grep -E \"\\\\.\\(ts|tsx\\)$\")", - "Bash(node -e \"const p = require\\('phosphor-react-native'\\); console.log\\(Object.keys\\(p\\).filter\\(k => /[Cc]ircle/.test\\(k\\)\\).slice\\(0, 30\\).join\\(', '\\)\\)\")", - "Bash(grep -E \"\\\\.\\(tsx?|jsx?\\)$\")", - "Bash(grep -E \"\\\\.\\(tsx?\\)$\")", - "Bash(git -C /Users/tomowers/dev/posthog/code status)", - "Bash(npx tsc *)", - "Bash(git -C /Users/tomowers/dev/posthog/code add apps/mobile/src/app/task/index.tsx)", - "Bash(git -C /Users/tomowers/dev/posthog/code diff apps/mobile/src/features/tasks/stores/taskStore.ts apps/mobile/src/app/task/index.tsx)", - "Bash(git -C /Users/tomowers/dev/posthog/code status -s)", - "Bash(git -C /Users/tomowers/dev/posthog/code diff --stat HEAD apps/mobile)", - "Bash(npx biome *)", - "Bash(npx tsc *)" - ], - "additionalDirectories": [ - "/private/tmp" - ] - } -} diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 8db37e48d..b27bd83db 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -69,27 +69,12 @@ function RootLayoutNav({ isConnected }: RootLayoutNavProps) { - {/* Chat routes - only registered when AI chat feature is enabled */} + {/* Chat routes - only registered when AI chat feature is enabled. + Screens use a FloatingBackButton instead of the native header. */} {aiChatEnabled && ( <> - - + + )} @@ -104,25 +89,14 @@ function RootLayoutNav({ isConnected }: RootLayoutNavProps) { }} /> - {/* Task routes - modal presentation */} + {/* Task routes - modal presentation, no native header. */} (null); const { - conversation, thread, streamingActive, conversationLoading, @@ -60,17 +55,6 @@ export default function ChatDetailScreen() { [router], ); - const headerRight = useCallback(() => { - if (streamingActive) { - return ( - - Stop - - ); - } - return null; - }, [streamingActive, stopGeneration]); - const { height } = useReanimatedKeyboardAnimation(); // useReanimatedKeyboardAnimation returns negative height values @@ -89,85 +73,60 @@ export default function ChatDetailScreen() { if (loadError) { return ( - <> - - - - {loadError} - - router.back()} - className="rounded-lg bg-gray-3 px-4 py-2" - > - Go back - - - + + + {loadError} + router.back()} + className="rounded-lg bg-gray-3 px-4 py-2" + > + Go back + + ); } if (conversationLoading && thread.length === 0) { return ( - <> - - - - Loading conversation... - - + + + + Loading conversation... + ); } return ( - <> - + + {streamingActive && ( + + + Stop + + + )} + - - - {/* Fixed input at bottom */} - - - + {/* Fixed input at bottom */} + + - + ); } diff --git a/apps/mobile/src/app/chat/index.tsx b/apps/mobile/src/app/chat/index.tsx index 6b32366cc..9e974edd0 100644 --- a/apps/mobile/src/app/chat/index.tsx +++ b/apps/mobile/src/app/chat/index.tsx @@ -1,16 +1,14 @@ import { Text } from "@components/text"; -import { Stack } from "expo-router"; import { useCallback } from "react"; -import { TouchableOpacity } from "react-native"; +import { Pressable } 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 { FloatingBackButton } from "@/components/FloatingBackButton"; import { Composer, MessagesList, useChatStore } from "@/features/chat"; -import { useThemeColors } from "@/lib/theme"; export default function NewChatScreen() { const insets = useSafeAreaInsets(); - const themeColors = useThemeColors(); const { thread, streamingActive, askMax, stopGeneration, resetThread } = useChatStore(); @@ -21,24 +19,6 @@ export default function NewChatScreen() { [askMax], ); - const headerRight = useCallback(() => { - if (streamingActive) { - return ( - - Stop - - ); - } - if (thread.length > 0) { - return ( - - New - - ); - } - return null; - }, [streamingActive, thread.length, stopGeneration, resetThread]); - const { height } = useReanimatedKeyboardAnimation(); // useReanimatedKeyboardAnimation returns negative height values @@ -56,38 +36,40 @@ export default function NewChatScreen() { }, [insets.bottom]); return ( - <> - + + {/* Top-right Stop / New action that used to live in the header. */} + {(streamingActive || thread.length > 0) && ( + + + {streamingActive ? "Stop" : "New"} + + + )} + - - - {/* Fixed input at bottom */} - - - + {/* Fixed input at bottom */} + + - + ); } diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 1dfa09b7f..4cbf583ba 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -1,7 +1,7 @@ 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 { useLocalSearchParams, useRouter } from "expo-router"; import { useCallback, useEffect, useRef, useState } from "react"; import { ActionSheetIOS, @@ -13,9 +13,10 @@ import { import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; import Animated, { useAnimatedStyle } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { FloatingBackButton } from "@/components/FloatingBackButton"; import { getTask, runTaskInCloud } from "@/features/tasks/api"; +import { FloatingTaskHeader } from "@/features/tasks/components/FloatingTaskHeader"; import { TaskSessionView } from "@/features/tasks/components/TaskSessionView"; -import { DotBackground } from "@/features/tasks/composer/DotBackground"; import { DEFAULT_EXECUTION_MODE, DEFAULT_MODEL, @@ -403,145 +404,120 @@ export default function TaskDetailScreen() { if (error || (!task && !loading)) { return ( - <> - - - - {error || "Task not found"} - - router.back()} - className="rounded-lg bg-gray-3 px-4 py-2" - > - Go back - - - + + + + {error || "Task not found"} + + router.back()} + className="rounded-lg bg-gray-3 px-4 py-2" + > + Go back + + ); } 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(); - }, - ) - } - className="rounded-full bg-gray-4 px-3 py-1" - > - - Local - - - ) - : undefined, - }} + + + 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(); + }, + ) + } + className="rounded-full bg-gray-4 px-2.5 py-1" + > + Local + + ) : null + } /> - - {/* Subtle dotted background — matches the new-task screen. Sits below - the translated content so it stays put when the keyboard opens. */} - - - {showAutomationContext && automationContextLabel && ( - - - {automationName - ? `Started from automation: ${automationName}` - : automationContextLabel} - - - )} - - {/* 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. */} - + {showAutomationContext && automationContextLabel && ( + + + {automationName + ? `Started from automation: ${automationName}` + : automationContextLabel} + + + )} + + {/* 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. The FlatList + takes the available space above the composer (flex-1), so we + don't need to reserve composer height as paddingTop — only the + top header's space (paddingBottom in an inverted list) plus a + small visual buffer at the bottom. */} + + + {/* Loading overlay — covers the list while it does initial layout */} + {loading && ( + + + + {task?.latest_run ? "Connecting..." : "Loading task..."} + + + )} + + {/* Composer below the list in flex flow — its real height + determines how much vertical space the list above gets, so the + last message can never sit behind the input. Stays visible on + terminal runs so the user can send a follow-up that resumes. */} + + - - {/* Loading overlay — covers the list while it does initial layout */} - {loading && ( - - - - {task?.latest_run ? "Connecting..." : "Loading task..."} - - - )} - - {/* Fixed composer at bottom — stays visible even on terminal runs so - the user can send a follow-up message that resumes the task. */} - - - - - + + ); } diff --git a/apps/mobile/src/app/task/index.tsx b/apps/mobile/src/app/task/index.tsx index 31f7e1236..5f168f484 100644 --- a/apps/mobile/src/app/task/index.tsx +++ b/apps/mobile/src/app/task/index.tsx @@ -25,6 +25,7 @@ import { } from "react-native-keyboard-controller"; import Animated, { runOnJS, useAnimatedStyle } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { FloatingBackButton } from "@/components/FloatingBackButton"; import { createTask, runTaskInCloud } from "@/features/tasks/api"; import { GitHubConnectionPrompt } from "@/features/tasks/components/GitHubConnectionPrompt"; import { GitHubLoadNotice } from "@/features/tasks/components/GitHubLoadNotice"; @@ -277,40 +278,24 @@ export default function NewTaskScreen() { if (hasGithubIntegration === false) { return ( - <> - - + + + - + ); } return ( <> - + diff --git a/apps/mobile/src/components/FloatingBackButton.tsx b/apps/mobile/src/components/FloatingBackButton.tsx new file mode 100644 index 000000000..6c7b25f40 --- /dev/null +++ b/apps/mobile/src/components/FloatingBackButton.tsx @@ -0,0 +1,42 @@ +import { useRouter } from "expo-router"; +import { CaretLeft } from "phosphor-react-native"; +import { Pressable } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useThemeColors } from "@/lib/theme"; + +interface FloatingBackButtonProps { + /** Override the default `router.back()` action. */ + onPress?: () => void; +} + +/** + * Pill-shaped back button that floats over the top-left of a screen. Used in + * place of a native stack header so the content can fill the full screen + * (e.g. behind a dotted background). + */ +export function FloatingBackButton({ onPress }: FloatingBackButtonProps) { + const router = useRouter(); + const insets = useSafeAreaInsets(); + const themeColors = useThemeColors(); + + const handlePress = () => { + if (onPress) { + onPress(); + return; + } + if (router.canGoBack()) { + router.back(); + } + }; + + return ( + + + + ); +} diff --git a/apps/mobile/src/features/tasks/components/FloatingTaskHeader.tsx b/apps/mobile/src/features/tasks/components/FloatingTaskHeader.tsx new file mode 100644 index 000000000..959b9941b --- /dev/null +++ b/apps/mobile/src/features/tasks/components/FloatingTaskHeader.tsx @@ -0,0 +1,87 @@ +import { Text } from "@components/text"; +import { LinearGradient } from "expo-linear-gradient"; +import { useRouter } from "expo-router"; +import { CaretLeft } from "phosphor-react-native"; +import type { ReactNode } from "react"; +import { Pressable, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { toRgba, useThemeColors } from "@/lib/theme"; + +interface FloatingTaskHeaderProps { + title: string; + subtitle?: string | null; + /** Optional right-side action (e.g. a Local-run indicator). */ + rightSlot?: ReactNode; +} + +/** + * Floating header for the task detail screen — back arrow on the left, + * centered title + repo subtitle, optional right slot for actions. Sits over + * the content with a top-to-bottom fade so the scroll list disappears + * gracefully behind it rather than getting clipped by a hard edge. + */ +export function FloatingTaskHeader({ + title, + subtitle, + rightSlot, +}: FloatingTaskHeaderProps) { + const router = useRouter(); + const insets = useSafeAreaInsets(); + const themeColors = useThemeColors(); + + const handleBack = () => { + if (router.canGoBack()) router.back(); + }; + + // Fade height extends past the row so content scrolling up behind the title + // softens out instead of slamming into a hard edge. + const fadeHeight = insets.top + 88; + + return ( + + + + + + + + + + + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} + + + {rightSlot} + + + ); +} diff --git a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx index 4c5a75fd3..b3fbf7138 100644 --- a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx +++ b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx @@ -714,25 +714,22 @@ export function TaskSessionView({ const reversedMessages = useMemo(() => [...messages].reverse(), [messages]); const themeColors = useThemeColors(); const flatListRef = useRef(null); - const buttonRef = useRef(null); - const isScrolledRef = useRef(false); + // Inverted FlatList: scrollY is the distance from the visual bottom, so + // any non-trivial value means the user has scrolled up from the latest + // message. Use a small threshold to ignore iOS bounce. + const [scrolledFromBottom, setScrolledFromBottom] = useState(false); const scrollToBottom = useCallback(() => { + // Optimistically hide the button — the scroll animation will fire + // onScroll events too, but the throttle can leave the button visible + // for a beat after tap if we rely on those alone. + setScrolledFromBottom(false); 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", - }, - }); - } + setScrolledFromBottom(e.nativeEvent.contentOffset.y > 100); }, [], ); @@ -856,15 +853,10 @@ export function TaskSessionView({ ) : null} )} - + {scrolledFromBottom && ( - + )} ); } From 19ea4c2565127db3f4e1aae6c25d8a10a12f5f7f Mon Sep 17 00:00:00 2001 From: Annika <14750837+annikaschmid@users.noreply.github.com> Date: Wed, 13 May 2026 18:17:54 -0400 Subject: [PATCH 22/94] fix(mobile): improve automation run status updates --- apps/mobile/src/app/automation/[id].tsx | 3 + .../navigation/components/NavDrawer.tsx | 8 +- .../tasks/components/AutomationDetail.tsx | 5 +- .../tasks/components/AutomationItem.tsx | 10 +- .../tasks/components/AutomationList.tsx | 18 ++- .../components/AutomationStatusBadge.tsx | 34 ++--- .../tasks/hooks/useAutomations.test.ts | 22 +++ .../features/tasks/hooks/useAutomations.ts | 29 ++++ .../src/features/tasks/hooks/useTasks.test.ts | 129 ++++++++++++++++++ .../src/features/tasks/hooks/useTasks.ts | 32 +++++ apps/mobile/src/features/tasks/types.ts | 11 +- .../tasks/utils/automationStatus.test.ts | 36 +++++ .../features/tasks/utils/automationStatus.ts | 67 +++++++++ 13 files changed, 370 insertions(+), 34 deletions(-) create mode 100644 apps/mobile/src/features/tasks/hooks/useTasks.test.ts create mode 100644 apps/mobile/src/features/tasks/utils/automationStatus.test.ts create mode 100644 apps/mobile/src/features/tasks/utils/automationStatus.ts diff --git a/apps/mobile/src/app/automation/[id].tsx b/apps/mobile/src/app/automation/[id].tsx index c52cb1f2e..588f4183d 100644 --- a/apps/mobile/src/app/automation/[id].tsx +++ b/apps/mobile/src/app/automation/[id].tsx @@ -19,6 +19,7 @@ import { useRunTaskAutomation, useUpdateTaskAutomation, } from "@/features/tasks/hooks/useAutomations"; +import { useTask } from "@/features/tasks/hooks/useTasks"; import { useThemeColors } from "@/lib/theme"; export default function AutomationDetailScreen() { @@ -26,6 +27,7 @@ export default function AutomationDetailScreen() { const router = useRouter(); const themeColors = useThemeColors(); const { data: automation, isLoading, error } = useAutomation(id); + const { data: lastTask } = useTask(automation?.last_task_id ?? ""); const updateAutomation = useUpdateTaskAutomation(); const deleteAutomation = useDeleteTaskAutomation(); const runAutomation = useRunTaskAutomation(); @@ -139,6 +141,7 @@ export default function AutomationDetailScreen() { ) : ( setIsEditing(true)} onToggleEnabled={async () => { diff --git a/apps/mobile/src/features/navigation/components/NavDrawer.tsx b/apps/mobile/src/features/navigation/components/NavDrawer.tsx index ac877ae33..0c0df8547 100644 --- a/apps/mobile/src/features/navigation/components/NavDrawer.tsx +++ b/apps/mobile/src/features/navigation/components/NavDrawer.tsx @@ -12,8 +12,10 @@ import { View, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { OFFLINE_BANNER_HEIGHT } from "@/components/OfflineBanner"; import { TaskStatusIcon } from "@/features/tasks/components/TaskStatusIcon"; import { useTasks } from "@/features/tasks/hooks/useTasks"; +import { useNetworkStatus } from "@/hooks/useNetworkStatus"; import { useThemeColors } from "@/lib/theme"; import { useNavDrawerStore } from "../stores/navDrawerStore"; @@ -53,6 +55,7 @@ export function NavDrawer() { const pathname = usePathname(); const themeColors = useThemeColors(); const insets = useSafeAreaInsets(); + const { isConnected } = useNetworkStatus(); const { tasks } = useTasks(); const navigateTo = (target: string) => { @@ -104,6 +107,8 @@ export function NavDrawer() { const isOnInbox = pathname === "/inbox"; const isOnAutomations = pathname === "/automations"; const isOnSettings = pathname === "/settings"; + const drawerTop = isConnected ? 0 : insets.top + OFFLINE_BANNER_HEIGHT; + const drawerPaddingTop = isConnected ? insets.top + 12 : 12; return ( void; onToggleEnabled: () => void; @@ -15,6 +16,7 @@ interface AutomationDetailProps { export function AutomationDetail({ automation, + lastTaskRunStatus, isWorking = false, onRunNow, onToggleEnabled, @@ -31,6 +33,7 @@ export function AutomationDetail({ diff --git a/apps/mobile/src/features/tasks/components/AutomationItem.tsx b/apps/mobile/src/features/tasks/components/AutomationItem.tsx index 1f1d67983..e2f05e343 100644 --- a/apps/mobile/src/features/tasks/components/AutomationItem.tsx +++ b/apps/mobile/src/features/tasks/components/AutomationItem.tsx @@ -2,16 +2,21 @@ import { Text } from "@components/text"; import { format, formatDistanceToNow } from "date-fns"; import { memo } from "react"; import { Pressable, View } from "react-native"; -import type { TaskAutomation } from "../types"; +import type { TaskAutomation, TaskRun } from "../types"; import { formatAutomationScheduleSummary } from "../utils/automationSchedule"; import { AutomationStatusBadge } from "./AutomationStatusBadge"; interface AutomationItemProps { automation: TaskAutomation; onPress: (automation: TaskAutomation) => void; + lastTaskRunStatus?: TaskRun["status"] | null; } -function AutomationItemComponent({ automation, onPress }: AutomationItemProps) { +function AutomationItemComponent({ + automation, + onPress, + lastTaskRunStatus, +}: AutomationItemProps) { const lastRunDisplay = automation.last_run_at ? new Date(automation.last_run_at).getTime() > Date.now() - 24 * 60 * 60 * 1000 @@ -40,6 +45,7 @@ function AutomationItemComponent({ automation, onPress }: AutomationItemProps) { diff --git a/apps/mobile/src/features/tasks/components/AutomationList.tsx b/apps/mobile/src/features/tasks/components/AutomationList.tsx index 7be5073c7..22fd56c26 100644 --- a/apps/mobile/src/features/tasks/components/AutomationList.tsx +++ b/apps/mobile/src/features/tasks/components/AutomationList.tsx @@ -9,6 +9,7 @@ import { import { useThemeColors } from "@/lib/theme"; import { useAutomations } from "../hooks/useAutomations"; import { useIntegrations } from "../hooks/useIntegrations"; +import { useTasks } from "../hooks/useTasks"; import type { TaskAutomation } from "../types"; import { AutomationItem } from "./AutomationItem"; import { GitHubConnectionPrompt } from "./GitHubConnectionPrompt"; @@ -51,6 +52,9 @@ export function AutomationList({ onCreateAutomation, }: AutomationListProps) { const { automations, isLoading, error, refetch } = useAutomations(); + const { allTasks: automationTasks } = useTasks({ + originProduct: "automation", + }); const { error: integrationsError, hasGithubIntegration, @@ -66,6 +70,10 @@ export function AutomationList({ onAutomationPress?.(automation.id); }; + const taskStatusById = new Map( + automationTasks.map((task) => [task.id, task.latest_run?.status ?? null]), + ); + const isInitialLoading = (isLoading && automations.length === 0) || (automations.length === 0 && hasGithubIntegration === null); @@ -122,7 +130,15 @@ export function AutomationList({ data={automations} keyExtractor={(item) => item.id} renderItem={({ item }) => ( - + )} refreshControl={ diff --git a/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts b/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts index e7e3d6c32..661bc9728 100644 --- a/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts +++ b/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts @@ -30,6 +30,7 @@ vi.mock("../api", () => ({ import { automationKeys, + getAutomationPollingInterval, useAutomations, useCreateTaskAutomation, useUpdateTaskAutomation, @@ -159,6 +160,27 @@ describe("useAutomations", () => { unmount(); }); + it("only polls automation queries while a run is still active", () => { + expect(getAutomationPollingInterval(undefined)).toBe(false); + expect(getAutomationPollingInterval(automationPayload)).toBe(false); + expect( + getAutomationPollingInterval({ + ...automationPayload, + last_run_status: "running", + }), + ).toBe(5_000); + expect( + getAutomationPollingInterval([ + automationPayload, + { + ...automationPayload, + id: "automation-2", + last_run_status: "running", + }, + ]), + ).toBe(5_000); + }); + it("invalidates automation and task lists after create", async () => { const queryClient = new QueryClient(); const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); diff --git a/apps/mobile/src/features/tasks/hooks/useAutomations.ts b/apps/mobile/src/features/tasks/hooks/useAutomations.ts index 347707092..e22d3e6d1 100644 --- a/apps/mobile/src/features/tasks/hooks/useAutomations.ts +++ b/apps/mobile/src/features/tasks/hooks/useAutomations.ts @@ -17,6 +17,7 @@ import type { import { taskKeys } from "./useTasks"; const log = logger.scope("automations-mutations"); +const ACTIVE_AUTOMATION_POLLING_INTERVAL_MS = 5_000; export const automationKeys = { all: ["task-automations"] as const, @@ -26,6 +27,26 @@ export const automationKeys = { detail: (id: string) => [...automationKeys.details(), id] as const, }; +export function getAutomationPollingInterval( + automationData: TaskAutomation | TaskAutomation[] | undefined, +): number | false { + if (!automationData) { + return false; + } + + if (Array.isArray(automationData)) { + return automationData.some( + (automation) => automation.last_run_status === "running", + ) + ? ACTIVE_AUTOMATION_POLLING_INTERVAL_MS + : false; + } + + return automationData.last_run_status === "running" + ? ACTIVE_AUTOMATION_POLLING_INTERVAL_MS + : false; +} + function invalidateAutomationAndTaskLists( queryClient: ReturnType, ) { @@ -40,6 +61,10 @@ export function useAutomations() { queryKey: automationKeys.list(), queryFn: getTaskAutomations, enabled: !!projectId && !!oauthAccessToken, + refetchInterval: (query) => + getAutomationPollingInterval( + query.state.data as TaskAutomation[] | undefined, + ), }); return { @@ -57,6 +82,10 @@ export function useAutomation(automationId: string) { queryKey: automationKeys.detail(automationId), queryFn: () => getTaskAutomation(automationId), enabled: !!projectId && !!oauthAccessToken && !!automationId, + refetchInterval: (query) => + getAutomationPollingInterval( + query.state.data as TaskAutomation | undefined, + ), }); } diff --git a/apps/mobile/src/features/tasks/hooks/useTasks.test.ts b/apps/mobile/src/features/tasks/hooks/useTasks.test.ts new file mode 100644 index 000000000..6cd5c33ad --- /dev/null +++ b/apps/mobile/src/features/tasks/hooks/useTasks.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockUseAuthStore, mockUseUserQuery, mockUseTaskStore } = vi.hoisted( + () => ({ + mockUseAuthStore: vi.fn(), + mockUseUserQuery: vi.fn(), + mockUseTaskStore: vi.fn(), + }), +); + +vi.mock("@/features/auth", () => ({ + useAuthStore: mockUseAuthStore, + useUserQuery: mockUseUserQuery, +})); + +vi.mock("@/lib/logger", () => { + const mockLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + scope: () => mockLogger, + }; + + return { + logger: mockLogger, + }; +}); + +vi.mock("../api", () => ({ + createTask: vi.fn(), + deleteTask: vi.fn(), + getTask: vi.fn(), + getTasks: vi.fn(), + runTaskInCloud: vi.fn(), + updateTask: vi.fn(), +})); + +vi.mock("../stores/taskStore", () => ({ + filterAndSortTasks: vi.fn((tasks) => tasks), + useTaskStore: mockUseTaskStore, +})); + +import { getTaskPollingInterval } from "./useTasks"; + +const baseTask = { + id: "task-1", + task_number: 1, + slug: "task-1", + title: "Task 1", + description: "Do something", + created_at: "2026-05-13T00:00:00Z", + updated_at: "2026-05-13T00:00:00Z", + origin_product: "user_created", +}; + +describe("useTasks", () => { + beforeEach(() => { + mockUseAuthStore.mockReset(); + mockUseUserQuery.mockReset(); + mockUseTaskStore.mockReset(); + }); + + it("only polls task queries while a run is still active", () => { + expect(getTaskPollingInterval(undefined)).toBe(false); + expect(getTaskPollingInterval(baseTask)).toBe(false); + expect( + getTaskPollingInterval({ + ...baseTask, + latest_run: { + id: "run-1", + task: "task-1", + team: 1, + branch: null, + environment: "cloud", + status: "in_progress", + log_url: "https://example.com/logs", + error_message: null, + output: null, + state: {}, + created_at: "2026-05-13T00:00:00Z", + updated_at: "2026-05-13T00:00:00Z", + completed_at: null, + }, + }), + ).toBe(5_000); + expect( + getTaskPollingInterval([ + { + ...baseTask, + latest_run: { + id: "run-2", + task: "task-1", + team: 1, + branch: null, + environment: "cloud", + status: "completed", + log_url: "https://example.com/logs", + error_message: null, + output: null, + state: {}, + created_at: "2026-05-13T00:00:00Z", + updated_at: "2026-05-13T00:00:00Z", + completed_at: "2026-05-13T00:01:00Z", + }, + }, + { + ...baseTask, + id: "task-2", + latest_run: { + id: "run-3", + task: "task-2", + team: 1, + branch: null, + environment: "cloud", + status: "in_progress", + log_url: "https://example.com/logs", + error_message: null, + output: null, + state: {}, + created_at: "2026-05-13T00:00:00Z", + updated_at: "2026-05-13T00:00:00Z", + completed_at: null, + }, + }, + ]), + ).toBe(5_000); + }); +}); diff --git a/apps/mobile/src/features/tasks/hooks/useTasks.ts b/apps/mobile/src/features/tasks/hooks/useTasks.ts index 86c8e4f74..ac1edb516 100644 --- a/apps/mobile/src/features/tasks/hooks/useTasks.ts +++ b/apps/mobile/src/features/tasks/hooks/useTasks.ts @@ -13,6 +13,12 @@ import { filterAndSortTasks, useTaskStore } from "../stores/taskStore"; import type { CreateTaskOptions, Task } from "../types"; const log = logger.scope("tasks-mutations"); +const ACTIVE_TASK_POLLING_INTERVAL_MS = 5_000; +const TERMINAL_TASK_RUN_STATUSES = new Set([ + "completed", + "failed", + "cancelled", +]); export const taskKeys = { all: ["tasks"] as const, @@ -26,6 +32,28 @@ export const taskKeys = { detail: (id: string) => [...taskKeys.details(), id] as const, }; +export function getTaskPollingInterval( + taskData: Task | Task[] | undefined, +): number | false { + if (!taskData) { + return false; + } + + if (Array.isArray(taskData)) { + return taskData.some((task) => { + const status = task.latest_run?.status; + return !!status && !TERMINAL_TASK_RUN_STATUSES.has(status); + }) + ? ACTIVE_TASK_POLLING_INTERVAL_MS + : false; + } + + const status = taskData.latest_run?.status; + return status && !TERMINAL_TASK_RUN_STATUSES.has(status) + ? ACTIVE_TASK_POLLING_INTERVAL_MS + : false; +} + export function useTasks(filters?: { repository?: string; originProduct?: string; @@ -43,6 +71,8 @@ export function useTasks(filters?: { queryKey: taskKeys.list(queryFilters), queryFn: () => getTasks(queryFilters), enabled: !!projectId && !!oauthAccessToken && !!currentUser?.id, + refetchInterval: (query) => + getTaskPollingInterval(query.state.data as Task[] | undefined), }); const filteredTasks = filterAndSortTasks( @@ -68,6 +98,8 @@ export function useTask(taskId: string) { queryKey: taskKeys.detail(taskId), queryFn: () => getTask(taskId), enabled: !!projectId && !!oauthAccessToken && !!taskId, + refetchInterval: (query) => + getTaskPollingInterval(query.state.data as Task | undefined), }); } diff --git a/apps/mobile/src/features/tasks/types.ts b/apps/mobile/src/features/tasks/types.ts index 1ecf906d4..78d772720 100644 --- a/apps/mobile/src/features/tasks/types.ts +++ b/apps/mobile/src/features/tasks/types.ts @@ -39,7 +39,14 @@ export interface TaskRun { branch: string | null; stage?: string | null; environment?: "local" | "cloud"; - status: "started" | "in_progress" | "completed" | "failed"; + status: + | "not_started" + | "queued" + | "started" + | "in_progress" + | "completed" + | "failed" + | "cancelled"; log_url: string; error_message: string | null; output: Record | null; @@ -129,8 +136,6 @@ export interface CreateTaskOptions { title?: string; repository?: string; github_integration?: number; - signal_report?: string; - signal_report_task_relationship?: string; } export interface CreateTaskAutomationOptions { diff --git a/apps/mobile/src/features/tasks/utils/automationStatus.test.ts b/apps/mobile/src/features/tasks/utils/automationStatus.test.ts new file mode 100644 index 000000000..e1c5cb38d --- /dev/null +++ b/apps/mobile/src/features/tasks/utils/automationStatus.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { getAutomationStatusPresentation } from "./automationStatus"; + +describe("automationStatus", () => { + it("shows queued when the linked task run has not started work yet", () => { + expect( + getAutomationStatusPresentation({ + lastRunStatus: "running", + lastTaskRunStatus: "queued", + }), + ).toMatchObject({ + label: "Queued", + }); + }); + + it("shows running only when the linked task run is actively in progress", () => { + expect( + getAutomationStatusPresentation({ + lastRunStatus: "running", + lastTaskRunStatus: "in_progress", + }), + ).toMatchObject({ + label: "Running", + }); + }); + + it("falls back to automation status when task-run detail is unavailable", () => { + expect( + getAutomationStatusPresentation({ + lastRunStatus: "success", + }), + ).toMatchObject({ + label: "Success", + }); + }); +}); diff --git a/apps/mobile/src/features/tasks/utils/automationStatus.ts b/apps/mobile/src/features/tasks/utils/automationStatus.ts new file mode 100644 index 000000000..f7fe78443 --- /dev/null +++ b/apps/mobile/src/features/tasks/utils/automationStatus.ts @@ -0,0 +1,67 @@ +import type { TaskRun } from "../types"; + +export interface AutomationStatusInput { + lastRunStatus: string | null; + lastTaskRunStatus?: TaskRun["status"] | null; +} + +export interface AutomationStatusPresentation { + label: string; + className: string; +} + +export function getAutomationStatusPresentation({ + lastRunStatus, + lastTaskRunStatus, +}: AutomationStatusInput): AutomationStatusPresentation { + switch (lastTaskRunStatus) { + case "not_started": + case "queued": + return { + label: "Queued", + className: "bg-status-warning/20 text-status-warning", + }; + case "started": + case "in_progress": + return { + label: "Running", + className: "bg-status-info/20 text-status-info", + }; + case "completed": + return { + label: "Success", + className: "bg-status-success/20 text-status-success", + }; + case "failed": + case "cancelled": + return { + label: "Failed", + className: "bg-status-error/20 text-status-error", + }; + default: + break; + } + + switch (lastRunStatus) { + case "running": + return { + label: "Running", + className: "bg-status-info/20 text-status-info", + }; + case "success": + return { + label: "Success", + className: "bg-status-success/20 text-status-success", + }; + case "failed": + return { + label: "Failed", + className: "bg-status-error/20 text-status-error", + }; + default: + return { + label: "Never run", + className: "bg-gray-4 text-gray-11", + }; + } +} From 4dc29a164a3e772f844a6bdc5742b94e2f005dbb Mon Sep 17 00:00:00 2001 From: Oliver Browne Date: Wed, 13 May 2026 18:45:41 -0400 Subject: [PATCH 23/94] cater to degens --- apps/mobile/src/app/report/[id].tsx | 58 +++++++++++++++++++---------- 1 file changed, 38 insertions(+), 20 deletions(-) diff --git a/apps/mobile/src/app/report/[id].tsx b/apps/mobile/src/app/report/[id].tsx index e117fbc10..925b135cf 100644 --- a/apps/mobile/src/app/report/[id].tsx +++ b/apps/mobile/src/app/report/[id].tsx @@ -5,7 +5,13 @@ import * as Haptics from "expo-haptics"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { Lightning, Play, Warning } from "phosphor-react-native"; import { useCallback, useEffect, useState } from "react"; -import { ActivityIndicator, Pressable, ScrollView, View } from "react-native"; +import { + ActivityIndicator, + Platform, + Pressable, + ScrollView, + View, +} from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { getReportRepository } from "@/features/inbox/api"; import { useInboxReport } from "@/features/inbox/hooks/useInboxReports"; @@ -287,29 +293,41 @@ export default function ReportDetailScreen() { style={{ bottom: insets.bottom + 16 }} pointerEvents="box-none" > - + {Platform.OS === "ios" ? ( + + ({ opacity: pressed ? 0.8 : 1 })} + > + + + + Start task + + + + + ) : ( ({ opacity: pressed ? 0.8 : 1 })} + className="elevation-4 flex-row items-center gap-2 rounded-full border border-gray-6 bg-gray-2 px-6 py-3.5 shadow-lg active:opacity-80" > - - - - Start task - - + + + Start task + - + )} )} From c820e4bdfc16979ba73adb6027accb447aa7e5b7 Mon Sep 17 00:00:00 2001 From: Oliver Browne Date: Wed, 13 May 2026 18:51:25 -0400 Subject: [PATCH 24/94] pretty reports --- apps/mobile/src/app/report/[id].tsx | 11 +++-------- .../src/features/chat/components/MarkdownText.tsx | 4 ++-- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/app/report/[id].tsx b/apps/mobile/src/app/report/[id].tsx index 925b135cf..87e6e05fd 100644 --- a/apps/mobile/src/app/report/[id].tsx +++ b/apps/mobile/src/app/report/[id].tsx @@ -13,6 +13,7 @@ import { View, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { MarkdownText } from "@/features/chat/components/MarkdownText"; import { getReportRepository } from "@/features/inbox/api"; import { useInboxReport } from "@/features/inbox/hooks/useInboxReports"; import type { @@ -246,14 +247,8 @@ export default function ReportDetailScreen() { {/* Summary */} {report.summary && ( - - - - {report.summary} - + + )} diff --git a/apps/mobile/src/features/chat/components/MarkdownText.tsx b/apps/mobile/src/features/chat/components/MarkdownText.tsx index 0ce7301be..03ba01ea4 100644 --- a/apps/mobile/src/features/chat/components/MarkdownText.tsx +++ b/apps/mobile/src/features/chat/components/MarkdownText.tsx @@ -227,7 +227,7 @@ function renderInline(text: string): React.ReactNode[] { } else if (match[4]) { // Bold nodes.push( - + {match[4]} , ); @@ -304,7 +304,7 @@ export function MarkdownText({ content }: MarkdownTextProps) { return ( Date: Wed, 13 May 2026 18:48:35 -0400 Subject: [PATCH 25/94] Floating UI --- apps/mobile/src/app/(tabs)/tasks.tsx | 43 +-- .../navigation/components/NavDrawer.tsx | 329 ++++++++++-------- .../components/FloatingNewTaskButton.tsx | 39 +++ .../tasks/components/FloatingTasksHeader.tsx | 63 ++++ .../features/tasks/components/TaskList.tsx | 13 +- 5 files changed, 318 insertions(+), 169 deletions(-) create mode 100644 apps/mobile/src/features/tasks/components/FloatingNewTaskButton.tsx create mode 100644 apps/mobile/src/features/tasks/components/FloatingTasksHeader.tsx diff --git a/apps/mobile/src/app/(tabs)/tasks.tsx b/apps/mobile/src/app/(tabs)/tasks.tsx index 4b10532e4..86fef7779 100644 --- a/apps/mobile/src/app/(tabs)/tasks.tsx +++ b/apps/mobile/src/app/(tabs)/tasks.tsx @@ -1,11 +1,10 @@ -import { Text } from "@components/text"; import { useFocusEffect, useRouter } from "expo-router"; import { useCallback, useRef } from "react"; -import { InteractionManager, Pressable, View } from "react-native"; +import { InteractionManager, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { MenuButton } from "@/features/navigation/components/MenuButton"; +import { FloatingNewTaskButton } from "@/features/tasks/components/FloatingNewTaskButton"; +import { FloatingTasksHeader } from "@/features/tasks/components/FloatingTasksHeader"; import { - TaskFilterButton, TaskFilterMenu, useTaskFilterMenu, } from "@/features/tasks/components/TaskFilterMenu"; @@ -48,33 +47,21 @@ export default function TasksScreen() { [router], ); + // Header occupies insets.top + 6 (top pad) + 44 (button) + 8 (bottom pad), + // plus a small visual buffer so the first row isn't hugging the divider. + const headerHeight = insets.top + 64; + return ( - - - - - Code - - Your PostHog Code sessions - - - - - - New task - - - - + + + - + diff --git a/apps/mobile/src/features/navigation/components/NavDrawer.tsx b/apps/mobile/src/features/navigation/components/NavDrawer.tsx index 0c0df8547..c6aab0a67 100644 --- a/apps/mobile/src/features/navigation/components/NavDrawer.tsx +++ b/apps/mobile/src/features/navigation/components/NavDrawer.tsx @@ -1,16 +1,20 @@ import { Text } from "@components/text"; import { usePathname, useRouter } from "expo-router"; -import { Clock, GearSix, Plus, Tray } from "phosphor-react-native"; -import { type ReactNode, useEffect, useRef } from "react"; +import { Clock, GearSix, ListBullets, Plus, Tray } from "phosphor-react-native"; +import { memo, type ReactNode, useEffect } from "react"; import { - Animated, Dimensions, - Easing, Pressable, ScrollView, StyleSheet, View, } from "react-native"; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { OFFLINE_BANNER_HEIGHT } from "@/components/OfflineBanner"; import { TaskStatusIcon } from "@/features/tasks/components/TaskStatusIcon"; @@ -21,6 +25,8 @@ import { useNavDrawerStore } from "../stores/navDrawerStore"; const { width: SCREEN_WIDTH } = Dimensions.get("window"); const DRAWER_WIDTH = Math.min(320, Math.round(SCREEN_WIDTH * 0.85)); +const OPEN_DURATION = 280; +const CLOSE_DURATION = 220; interface DrawerItemProps { icon: ReactNode; @@ -33,13 +39,13 @@ function DrawerItem({ icon, label, active, onPress }: DrawerItemProps) { return ( - + {icon} {label} @@ -48,14 +54,23 @@ function DrawerItem({ icon, label, active, onPress }: DrawerItemProps) { ); } -export function NavDrawer() { - const isOpen = useNavDrawerStore((s) => s.isOpen); +interface NavDrawerContentProps { + paddingTop: number; +} + +/** + * Heavy drawer body — extracted so it doesn't re-render every time the open + * state toggles. `paddingTop` is the only prop and only changes when the + * offline banner appears/disappears, so the memo stays effective. + */ +const NavDrawerContent = memo(function NavDrawerContent({ + paddingTop, +}: NavDrawerContentProps) { const close = useNavDrawerStore((s) => s.close); const router = useRouter(); const pathname = usePathname(); const themeColors = useThemeColors(); const insets = useSafeAreaInsets(); - const { isConnected } = useNetworkStatus(); const { tasks } = useTasks(); const navigateTo = (target: string) => { @@ -64,34 +79,12 @@ export function NavDrawer() { router.replace(target); }; - const translateX = useRef(new Animated.Value(-DRAWER_WIDTH)).current; - const backdropOpacity = useRef(new Animated.Value(0)).current; - - useEffect(() => { - // Drawer is always mounted; only the animation values move. The native - // driver runs these off the JS thread, so a press triggers the slide - // instantly without re-rendering the (heavy) drawer subtree. - Animated.parallel([ - Animated.timing(translateX, { - toValue: isOpen ? 0 : -DRAWER_WIDTH, - duration: isOpen ? 280 : 220, - easing: isOpen ? Easing.out(Easing.cubic) : Easing.in(Easing.cubic), - useNativeDriver: true, - }), - Animated.timing(backdropOpacity, { - toValue: isOpen ? 1 : 0, - duration: isOpen ? 280 : 220, - easing: isOpen ? Easing.out(Easing.cubic) : Easing.in(Easing.cubic), - useNativeDriver: true, - }), - ]).start(); - }, [isOpen, translateX, backdropOpacity]); - const handleNewTask = () => { close(); router.push("/task"); }; + const handleTasks = () => navigateTo("/tasks"); const handleInbox = () => navigateTo("/inbox"); const handleAutomations = () => navigateTo("/automations"); const handleSettings = () => navigateTo("/settings"); @@ -104,12 +97,171 @@ export function NavDrawer() { const iconColor = themeColors.gray[11]; const iconColorActive = themeColors.gray[12]; + const isOnTasks = pathname === "/tasks"; const isOnInbox = pathname === "/inbox"; const isOnAutomations = pathname === "/automations"; const isOnSettings = pathname === "/settings"; + + return ( + + + PostHog Code + + + + } + label="New task" + onPress={handleNewTask} + /> + + } + label="Tasks" + active={isOnTasks} + onPress={handleTasks} + /> + + } + label="Inbox" + active={isOnInbox} + onPress={handleInbox} + /> + + } + label="Automations" + active={isOnAutomations} + onPress={handleAutomations} + /> + + + + + + + Tasks + + + + + {tasks.length === 0 ? ( + + No tasks yet + + ) : ( + tasks.map((task) => { + const taskHref = `/task/${task.id}`; + const active = pathname === taskHref; + return ( + handleTaskPress(task.id)} + className={`flex-row items-center gap-3 rounded-md px-3 py-2.5 ${active ? "bg-gray-3" : "active:bg-gray-2"}`} + > + + + + + {task.title} + + + ); + }) + )} + + + + + + + } + label="Settings" + active={isOnSettings} + onPress={handleSettings} + /> + + + ); +}); + +export function NavDrawer() { + // `isOpen` is read only to gate `pointerEvents`. The heavy drawer body is + // memoized below so this re-render is essentially free — it just flips a + // prop on the outer wrappers. + const isOpen = useNavDrawerStore((s) => s.isOpen); + const close = useNavDrawerStore((s) => s.close); + const insets = useSafeAreaInsets(); + const { isConnected } = useNetworkStatus(); + + // When offline, the banner occupies `insets.top + OFFLINE_BANNER_HEIGHT` at + // the top of the screen — push the panel down by that amount and drop the + // inner safe-area padding to compensate. const drawerTop = isConnected ? 0 : insets.top + OFFLINE_BANNER_HEIGHT; const drawerPaddingTop = isConnected ? insets.top + 12 : 12; + // Drive the slide off a SharedValue so the animation can start on the UI + // thread the instant the store updates, with no React render in the + // critical path. Imperative subscription avoids re-rendering NavDrawer + // before kicking off `withTiming`. + const progress = useSharedValue(0); + + useEffect(() => { + progress.value = useNavDrawerStore.getState().isOpen ? 1 : 0; + return useNavDrawerStore.subscribe((state, prev) => { + if (state.isOpen === prev.isOpen) return; + progress.value = withTiming(state.isOpen ? 1 : 0, { + duration: state.isOpen ? OPEN_DURATION : CLOSE_DURATION, + easing: state.isOpen + ? Easing.out(Easing.cubic) + : Easing.in(Easing.cubic), + }); + }); + }, [progress]); + + const drawerStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: -DRAWER_WIDTH + progress.value * DRAWER_WIDTH }], + })); + + const backdropStyle = useAnimatedStyle(() => ({ + opacity: progress.value, + })); + return ( {/* Touch-down close so the dismiss starts the moment the finger lands. */} @@ -127,112 +280,10 @@ export function NavDrawer() { - - PostHog - - - - } - label="New task" - onPress={handleNewTask} - /> - - } - label="Inbox" - active={isOnInbox} - onPress={handleInbox} - /> - - } - label="Automations" - active={isOnAutomations} - onPress={handleAutomations} - /> - - - - - - - Tasks - - - - - {tasks.length === 0 ? ( - - No tasks yet - - ) : ( - tasks.map((task) => { - const taskHref = `/task/${task.id}`; - const active = pathname === taskHref; - return ( - handleTaskPress(task.id)} - className={`flex-row items-center gap-2.5 rounded-md px-2.5 py-2 ${active ? "bg-gray-3" : "active:bg-gray-2"}`} - > - - - - - {task.title} - - - ); - }) - )} - - - - - - - } - label="Settings" - active={isOnSettings} - onPress={handleSettings} - /> - + ); diff --git a/apps/mobile/src/features/tasks/components/FloatingNewTaskButton.tsx b/apps/mobile/src/features/tasks/components/FloatingNewTaskButton.tsx new file mode 100644 index 000000000..669c30cbd --- /dev/null +++ b/apps/mobile/src/features/tasks/components/FloatingNewTaskButton.tsx @@ -0,0 +1,39 @@ +import { Plus } from "phosphor-react-native"; +import { Pressable } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/components/text"; +import { useThemeColors } from "@/lib/theme"; + +interface FloatingNewTaskButtonProps { + onPress: () => void; +} + +/** + * Pill-shaped FAB anchored to the bottom-right corner. Stays in thumb reach + * on phones of any size and respects the home indicator inset. + */ +export function FloatingNewTaskButton({ onPress }: FloatingNewTaskButtonProps) { + const insets = useSafeAreaInsets(); + const themeColors = useThemeColors(); + + return ( + + + New task + + ); +} diff --git a/apps/mobile/src/features/tasks/components/FloatingTasksHeader.tsx b/apps/mobile/src/features/tasks/components/FloatingTasksHeader.tsx new file mode 100644 index 000000000..46dec47d7 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/FloatingTasksHeader.tsx @@ -0,0 +1,63 @@ +import { Text } from "@components/text"; +import { LinearGradient } from "expo-linear-gradient"; +import { View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { MenuButton } from "@/features/navigation/components/MenuButton"; +import { toRgba, useThemeColors } from "@/lib/theme"; +import { TaskFilterButton } from "./TaskFilterMenu"; + +interface FloatingTasksHeaderProps { + onFilterPress: () => void; +} + +/** + * Floating header for the tasks list screen — hamburger menu on the left, + * centered "Code" title, filter button on the right. Sits over the content + * with a top-to-bottom fade so the list disappears gracefully behind it + * rather than getting clipped by a hard edge. + */ +export function FloatingTasksHeader({ + onFilterPress, +}: FloatingTasksHeaderProps) { + const insets = useSafeAreaInsets(); + const themeColors = useThemeColors(); + + const fadeHeight = insets.top + 88; + + return ( + + + + + + + + + PostHog Code + + + + + + + ); +} diff --git a/apps/mobile/src/features/tasks/components/TaskList.tsx b/apps/mobile/src/features/tasks/components/TaskList.tsx index 9390134f7..43aaae1c1 100644 --- a/apps/mobile/src/features/tasks/components/TaskList.tsx +++ b/apps/mobile/src/features/tasks/components/TaskList.tsx @@ -21,6 +21,8 @@ import { SwipeableTaskItem } from "./SwipeableTaskItem"; interface TaskListProps { onTaskPress?: (taskId: string) => void; onCreateTask?: () => void; + /** Top inset so the list can scroll behind a floating header. */ + contentInsetTop?: number; } interface CreateTaskEmptyStateProps { @@ -87,7 +89,11 @@ const DATE_GROUP_ORDER = [ "Earlier", ]; -export function TaskList({ onTaskPress, onCreateTask }: TaskListProps) { +export function TaskList({ + onTaskPress, + onCreateTask, + contentInsetTop = 0, +}: TaskListProps) { const { tasks, isLoading, error, refetch } = useTasks({ originProduct: "user_created", }); @@ -359,7 +365,10 @@ export function TaskList({ onTaskPress, onCreateTask }: TaskListProps) { tintColor={themeColors.accent[9]} /> } - contentContainerStyle={{ paddingBottom: 100 }} + contentContainerStyle={{ + paddingTop: contentInsetTop, + paddingBottom: 100, + }} /> ); } From 08d3168a2aa7a07461c65fd30a97fe1d18259b73 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 13 May 2026 20:19:47 -0400 Subject: [PATCH 26/94] Added inbox header --- apps/mobile/src/app/(tabs)/inbox.tsx | 71 +++-------- .../inbox/components/FloatingInboxHeader.tsx | 111 ++++++++++++++++++ .../features/inbox/components/ReportList.tsx | 12 +- 3 files changed, 139 insertions(+), 55 deletions(-) create mode 100644 apps/mobile/src/features/inbox/components/FloatingInboxHeader.tsx diff --git a/apps/mobile/src/app/(tabs)/inbox.tsx b/apps/mobile/src/app/(tabs)/inbox.tsx index 6e09e42ef..0fc4b8dba 100644 --- a/apps/mobile/src/app/(tabs)/inbox.tsx +++ b/apps/mobile/src/app/(tabs)/inbox.tsx @@ -1,23 +1,18 @@ -import { Text } from "@components/text"; import { useRouter } from "expo-router"; -import { FunnelSimple, UsersThree } from "phosphor-react-native"; import { useCallback, useState } from "react"; -import { Pressable, View } from "react-native"; +import { View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { FilterSheet } from "@/features/inbox/components/FilterSheet"; -import { LiveDot } from "@/features/inbox/components/LiveDot"; +import { FloatingInboxHeader } from "@/features/inbox/components/FloatingInboxHeader"; import { ReportList } from "@/features/inbox/components/ReportList"; import { ReviewerFilterSheet } from "@/features/inbox/components/ReviewerFilterSheet"; import { useInboxReports } from "@/features/inbox/hooks/useInboxReports"; import { useInboxFilterStore } from "@/features/inbox/stores/inboxFilterStore"; import type { SignalReport } from "@/features/inbox/types"; -import { MenuButton } from "@/features/navigation/components/MenuButton"; -import { useThemeColors } from "@/lib/theme"; export default function InboxScreen() { const insets = useSafeAreaInsets(); const router = useRouter(); - const themeColors = useThemeColors(); const { isFetching, error } = useInboxReports(); const [filterOpen, setFilterOpen] = useState(false); const [reviewerOpen, setReviewerOpen] = useState(false); @@ -32,55 +27,25 @@ export default function InboxScreen() { [router], ); + // Header occupies insets.top + 6 (top pad) + 44 (button stack) + 8 (bottom + // pad), plus a small buffer so the first row isn't hugging the fade edge. + const headerHeight = insets.top + 72; + return ( - - - - - - - Inbox - - - - - Signals and reports - - - setReviewerOpen(true)} - className={`h-9 flex-row items-center justify-center gap-1 rounded-md px-2 active:bg-gray-3 ${ - reviewerFilterCount > 0 ? "bg-gray-3" : "" - }`} - > - 0 - ? themeColors.gray[12] - : themeColors.gray[11] - } - /> - {reviewerFilterCount > 0 && ( - - {reviewerFilterCount} - - )} - - setFilterOpen(true)} - className="h-9 w-9 items-center justify-center rounded-md active:bg-gray-3" - > - - - - + + + setReviewerOpen(true)} + onFilterPress={() => setFilterOpen(true)} + /> - setFilterOpen(false)} /> void; + onFilterPress: () => void; +} + +/** + * Floating header for the inbox screen — hamburger menu on the left, + * centered "Inbox" title with live-polling indicator and subtitle, and + * reviewer / filter buttons on the right. Sits over the content with a + * top-to-bottom fade so the list disappears gracefully behind it. + */ +export function FloatingInboxHeader({ + isFetching, + hasError, + reviewerFilterCount, + onReviewerPress, + onFilterPress, +}: FloatingInboxHeaderProps) { + const insets = useSafeAreaInsets(); + const themeColors = useThemeColors(); + + const fadeHeight = insets.top + 96; + + return ( + + + + + + + + + + Inbox + + + + + Signals and reports + + + + + 0 ? "bg-gray-3" : "bg-gray-2" + }`} + > + 0 + ? themeColors.gray[12] + : themeColors.gray[11] + } + /> + {reviewerFilterCount > 0 && ( + + {reviewerFilterCount} + + )} + + + + + + + + ); +} diff --git a/apps/mobile/src/features/inbox/components/ReportList.tsx b/apps/mobile/src/features/inbox/components/ReportList.tsx index cd52f064d..974781d10 100644 --- a/apps/mobile/src/features/inbox/components/ReportList.tsx +++ b/apps/mobile/src/features/inbox/components/ReportList.tsx @@ -14,9 +14,13 @@ import { ReportListRow } from "./ReportListRow"; interface ReportListProps { onReportPress?: (report: SignalReport) => void; + contentInsetTop?: number; } -export function ReportList({ onReportPress }: ReportListProps) { +export function ReportList({ + onReportPress, + contentInsetTop = 0, +}: ReportListProps) { const { reports, totalCount, isLoading, error, refetch } = useInboxReports(); const themeColors = useThemeColors(); @@ -75,9 +79,13 @@ export function ReportList({ onReportPress }: ReportListProps) { refreshing={isLoading} onRefresh={() => refetch()} tintColor={themeColors.accent[9]} + progressViewOffset={contentInsetTop} /> } - contentContainerStyle={{ paddingBottom: 100 }} + contentContainerStyle={{ + paddingTop: contentInsetTop, + paddingBottom: 100, + }} /> ); } From b051e11e70ea6c26341a1a9cba4cdb4f7a52cab1 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 13 May 2026 20:26:16 -0400 Subject: [PATCH 27/94] Updated filter overlay --- .../tasks/components/TaskFilterMenu.tsx | 127 ++++++++---------- 1 file changed, 58 insertions(+), 69 deletions(-) diff --git a/apps/mobile/src/features/tasks/components/TaskFilterMenu.tsx b/apps/mobile/src/features/tasks/components/TaskFilterMenu.tsx index bb603471c..81c0d8bde 100644 --- a/apps/mobile/src/features/tasks/components/TaskFilterMenu.tsx +++ b/apps/mobile/src/features/tasks/components/TaskFilterMenu.tsx @@ -1,7 +1,7 @@ import { Text } from "@components/text"; -import { CircleIcon, FunnelSimple } from "phosphor-react-native"; +import { Check, FunnelSimple } from "phosphor-react-native"; import { useState } from "react"; -import { Modal, Pressable, View } from "react-native"; +import { Modal, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useUserQuery } from "@/features/auth"; import { useThemeColors } from "@/lib/theme"; @@ -11,60 +11,40 @@ import { useTaskStore, } from "../stores/taskStore"; -interface MenuSectionProps { - title: string; - children: React.ReactNode; +interface TaskFilterMenuProps { + open: boolean; + onClose: () => void; } -function MenuSection({ title, children }: MenuSectionProps) { +function SectionHeader({ title }: { title: string }) { return ( - - - {title} - - {children} - + + {title} + ); } -interface RadioRowProps { +interface OptionRowProps { label: string; selected: boolean; onPress: () => void; } -function RadioRow({ label, selected, onPress }: RadioRowProps) { +function OptionRow({ label, selected, onPress }: OptionRowProps) { const themeColors = useThemeColors(); return ( - - {selected ? ( - - - - ) : ( - - )} - - {label} + {label} + {selected && } ); } -interface TaskFilterMenuProps { - open: boolean; - onClose: () => void; -} - export function TaskFilterMenu({ open, onClose }: TaskFilterMenuProps) { + const insets = useSafeAreaInsets(); const organizeMode = useTaskStore((s) => s.organizeMode); const setOrganizeMode = useTaskStore((s) => s.setOrganizeMode); const sortMode = useTaskStore((s) => s.sortMode); @@ -73,7 +53,6 @@ export function TaskFilterMenu({ open, onClose }: TaskFilterMenuProps) { const setShowInternal = useTaskStore((s) => s.setShowInternal); const { data: userData } = useUserQuery(); const isStaff = userData?.is_staff === true; - const insets = useSafeAreaInsets(); const pickOrganize = (mode: OrganizeMode) => { setOrganizeMode(mode); @@ -85,73 +64,83 @@ export function TaskFilterMenu({ open, onClose }: TaskFilterMenuProps) { return ( - {/* Backdrop dismisses the menu */} - - {/* noop onPress so taps inside the menu don't bubble to the backdrop */} - {}} - className="absolute right-3 w-64 overflow-hidden rounded-xl border border-gray-6 bg-background" - style={{ - top: insets.top + 64, - shadowColor: "#000", - shadowOpacity: 0.12, - shadowRadius: 16, - shadowOffset: { width: 0, height: 4 }, - elevation: 8, + + {/* Header */} + + + Filter & Sort + + + + Done + + + + + - - + + pickOrganize("by-project")} /> - pickOrganize("chronological")} /> - - - + - - + + pickSort("created")} /> - pickSort("updated")} /> - + + {/* Task visibility (staff only) */} {isStaff ? ( <> - - - + + setShowInternal(false)} /> - setShowInternal(true)} /> - + ) : null} - - + + ); } From 0bbb8aecf6764d2bd1b29fd8a016b3257c8dacc6 Mon Sep 17 00:00:00 2001 From: Oliver Browne Date: Wed, 13 May 2026 20:31:16 -0400 Subject: [PATCH 28/94] fix task title bar --- apps/mobile/src/app/task/[id].tsx | 7 +++++-- apps/mobile/src/app/task/index.tsx | 4 +--- .../features/tasks/components/FloatingTaskHeader.tsx | 11 ++++++++--- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 4cbf583ba..f3b11890f 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -7,6 +7,7 @@ import { ActionSheetIOS, ActivityIndicator, Alert, + Platform, Pressable, View, } from "react-native"; @@ -452,7 +453,7 @@ export default function TaskDetailScreen() { {showAutomationContext && automationContextLabel && ( {automationName @@ -483,7 +484,9 @@ export default function TaskDetailScreen() { contentContainerStyle={{ paddingTop: 8, paddingBottom: - insets.top + 72 + (showAutomationContext ? 44 : 0), + (Platform.OS === "ios" ? 6 : insets.top) + + 60 + + (showAutomationContext ? 44 : 0), }} /> diff --git a/apps/mobile/src/app/task/index.tsx b/apps/mobile/src/app/task/index.tsx index 5f168f484..4a4827c24 100644 --- a/apps/mobile/src/app/task/index.tsx +++ b/apps/mobile/src/app/task/index.tsx @@ -25,7 +25,7 @@ import { } from "react-native-keyboard-controller"; import Animated, { runOnJS, useAnimatedStyle } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { FloatingBackButton } from "@/components/FloatingBackButton"; + import { createTask, runTaskInCloud } from "@/features/tasks/api"; import { GitHubConnectionPrompt } from "@/features/tasks/components/GitHubConnectionPrompt"; import { GitHubLoadNotice } from "@/features/tasks/components/GitHubLoadNotice"; @@ -279,7 +279,6 @@ export default function NewTaskScreen() { if (hasGithubIntegration === false) { return ( - - diff --git a/apps/mobile/src/features/tasks/components/FloatingTaskHeader.tsx b/apps/mobile/src/features/tasks/components/FloatingTaskHeader.tsx index 959b9941b..832844ef8 100644 --- a/apps/mobile/src/features/tasks/components/FloatingTaskHeader.tsx +++ b/apps/mobile/src/features/tasks/components/FloatingTaskHeader.tsx @@ -3,7 +3,7 @@ import { LinearGradient } from "expo-linear-gradient"; import { useRouter } from "expo-router"; import { CaretLeft } from "phosphor-react-native"; import type { ReactNode } from "react"; -import { Pressable, View } from "react-native"; +import { Platform, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { toRgba, useThemeColors } from "@/lib/theme"; @@ -33,9 +33,14 @@ export function FloatingTaskHeader({ if (router.canGoBack()) router.back(); }; + // iOS modals already provide their own top chrome (drag handle / rounded + // corners), so insets.top over-counts the space. Use a minimal fixed value + // on iOS and fall back to the real inset on Android. + const topInset = Platform.OS === "ios" ? 6 : insets.top; + // Fade height extends past the row so content scrolling up behind the title // softens out instead of slamming into a hard edge. - const fadeHeight = insets.top + 88; + const fadeHeight = topInset + 52; return ( Date: Wed, 13 May 2026 20:48:50 -0400 Subject: [PATCH 29/94] style(mobile): replace monospace UI fonts Match the mobile app more closely to the PostHog desktop typography by routing the mobile mono utility back to Open Runde and removing the unused JetBrains Mono bundle. This also keeps the human prompt styling aligned with the desktop message treatment. --- apps/mobile/app.json | 95 +------------------ .../features/chat/components/HumanMessage.tsx | 8 +- apps/mobile/tailwind.config.js | 2 +- 3 files changed, 9 insertions(+), 96 deletions(-) diff --git a/apps/mobile/app.json b/apps/mobile/app.json index fbdc22320..eb5a3fe1a 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -82,83 +82,6 @@ "weight": 700 } ] - }, - { - "fontFamily": "JetBrains Mono", - "fontDefinitions": [ - { - "path": "./assets/fonts/JetBrainsMono-Thin.ttf", - "weight": 100 - }, - { - "path": "./assets/fonts/JetBrainsMono-ThinItalic.ttf", - "weight": 100, - "style": "italic" - }, - { - "path": "./assets/fonts/JetBrainsMono-ExtraLight.ttf", - "weight": 200 - }, - { - "path": "./assets/fonts/JetBrainsMono-ExtraLightItalic.ttf", - "weight": 200, - "style": "italic" - }, - { - "path": "./assets/fonts/JetBrainsMono-Light.ttf", - "weight": 300 - }, - { - "path": "./assets/fonts/JetBrainsMono-LightItalic.ttf", - "weight": 300, - "style": "italic" - }, - { - "path": "./assets/fonts/JetBrainsMono-Regular.ttf", - "weight": 400 - }, - { - "path": "./assets/fonts/JetBrainsMono-Italic.ttf", - "weight": 400, - "style": "italic" - }, - { - "path": "./assets/fonts/JetBrainsMono-Medium.ttf", - "weight": 500 - }, - { - "path": "./assets/fonts/JetBrainsMono-MediumItalic.ttf", - "weight": 500, - "style": "italic" - }, - { - "path": "./assets/fonts/JetBrainsMono-SemiBold.ttf", - "weight": 600 - }, - { - "path": "./assets/fonts/JetBrainsMono-SemiBoldItalic.ttf", - "weight": 600, - "style": "italic" - }, - { - "path": "./assets/fonts/JetBrainsMono-Bold.ttf", - "weight": 700 - }, - { - "path": "./assets/fonts/JetBrainsMono-BoldItalic.ttf", - "weight": 700, - "style": "italic" - }, - { - "path": "./assets/fonts/JetBrainsMono-ExtraBold.ttf", - "weight": 800 - }, - { - "path": "./assets/fonts/JetBrainsMono-ExtraBoldItalic.ttf", - "weight": 800, - "style": "italic" - } - ] } ] }, @@ -167,23 +90,7 @@ "./assets/fonts/OpenRunde/OpenRunde-Regular.ttf", "./assets/fonts/OpenRunde/OpenRunde-Medium.ttf", "./assets/fonts/OpenRunde/OpenRunde-Semibold.ttf", - "./assets/fonts/OpenRunde/OpenRunde-Bold.ttf", - "./assets/fonts/JetBrainsMono-Thin.ttf", - "./assets/fonts/JetBrainsMono-ThinItalic.ttf", - "./assets/fonts/JetBrainsMono-ExtraLight.ttf", - "./assets/fonts/JetBrainsMono-ExtraLightItalic.ttf", - "./assets/fonts/JetBrainsMono-Light.ttf", - "./assets/fonts/JetBrainsMono-LightItalic.ttf", - "./assets/fonts/JetBrainsMono-Regular.ttf", - "./assets/fonts/JetBrainsMono-Italic.ttf", - "./assets/fonts/JetBrainsMono-Medium.ttf", - "./assets/fonts/JetBrainsMono-MediumItalic.ttf", - "./assets/fonts/JetBrainsMono-SemiBold.ttf", - "./assets/fonts/JetBrainsMono-SemiBoldItalic.ttf", - "./assets/fonts/JetBrainsMono-Bold.ttf", - "./assets/fonts/JetBrainsMono-BoldItalic.ttf", - "./assets/fonts/JetBrainsMono-ExtraBold.ttf", - "./assets/fonts/JetBrainsMono-ExtraBoldItalic.ttf" + "./assets/fonts/OpenRunde/OpenRunde-Bold.ttf" ] } } diff --git a/apps/mobile/src/features/chat/components/HumanMessage.tsx b/apps/mobile/src/features/chat/components/HumanMessage.tsx index 62f140e34..6fc4ed675 100644 --- a/apps/mobile/src/features/chat/components/HumanMessage.tsx +++ b/apps/mobile/src/features/chat/components/HumanMessage.tsx @@ -3,6 +3,7 @@ import * as Haptics from "expo-haptics"; import { useCallback } from "react"; import { Alert, Pressable, Text, View } from "react-native"; import { formatRelativeTime } from "@/lib/format"; +import { useThemeColors } from "@/lib/theme"; import { MarkdownText } from "./MarkdownText"; interface HumanMessageProps { @@ -11,6 +12,8 @@ interface HumanMessageProps { } export function HumanMessage({ content, timestamp }: HumanMessageProps) { + const themeColors = useThemeColors(); + const handleLongPress = useCallback(() => { Clipboard.setStringAsync(content).then(() => { Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); @@ -21,7 +24,10 @@ export function HumanMessage({ content, timestamp }: HumanMessageProps) { return ( - + diff --git a/apps/mobile/tailwind.config.js b/apps/mobile/tailwind.config.js index 63ee5fdbb..0bed59fdb 100644 --- a/apps/mobile/tailwind.config.js +++ b/apps/mobile/tailwind.config.js @@ -45,7 +45,7 @@ module.exports = { }, fontFamily: { sans: ["Open Runde"], - mono: ["JetBrains Mono"], + mono: ["Open Runde"], }, }, }, From f2a4f054468f85e446f495eb9778702fa46e0cde Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 13 May 2026 20:55:26 -0400 Subject: [PATCH 30/94] Added attachments --- apps/mobile/package.json | 2 + apps/mobile/src/app/task/[id].tsx | 20 ++- apps/mobile/src/app/task/index.tsx | 91 +++++++++-- .../components/FloatingNewTaskButton.tsx | 4 +- .../tasks/composer/TaskChatComposer.tsx | 69 ++++++-- .../composer/attachments/AttachmentSheet.tsx | 117 ++++++++++++++ .../composer/attachments/AttachmentsBar.tsx | 75 +++++++++ .../composer/attachments/buildCloudPrompt.ts | 151 ++++++++++++++++++ .../tasks/composer/attachments/cloudPrompt.ts | 16 ++ .../tasks/composer/attachments/pickers.ts | 119 ++++++++++++++ .../tasks/composer/attachments/types.ts | 39 +++++ .../features/tasks/stores/taskSessionStore.ts | 33 +++- pnpm-lock.yaml | 40 ++++- 13 files changed, 737 insertions(+), 39 deletions(-) create mode 100644 apps/mobile/src/features/tasks/composer/attachments/AttachmentSheet.tsx create mode 100644 apps/mobile/src/features/tasks/composer/attachments/AttachmentsBar.tsx create mode 100644 apps/mobile/src/features/tasks/composer/attachments/buildCloudPrompt.ts create mode 100644 apps/mobile/src/features/tasks/composer/attachments/cloudPrompt.ts create mode 100644 apps/mobile/src/features/tasks/composer/attachments/pickers.ts create mode 100644 apps/mobile/src/features/tasks/composer/attachments/types.ts diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 9638efbb0..b4dcd8201 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -39,10 +39,12 @@ "expo-crypto": "^15.0.8", "expo-dev-client": "~6.0.20", "expo-device": "~8.0.10", + "expo-document-picker": "~14.0.8", "expo-file-system": "~19.0.21", "expo-font": "^14.0.10", "expo-glass-effect": "~0.1.8", "expo-haptics": "^55.0.14", + "expo-image-picker": "~17.0.11", "expo-linear-gradient": "^15.0.8", "expo-linking": "~8.0.10", "expo-localization": "~17.0.8", diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index f3b11890f..80c594b8d 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -18,6 +18,9 @@ import { FloatingBackButton } from "@/components/FloatingBackButton"; import { getTask, runTaskInCloud } from "@/features/tasks/api"; import { FloatingTaskHeader } from "@/features/tasks/components/FloatingTaskHeader"; import { TaskSessionView } from "@/features/tasks/components/TaskSessionView"; +import { buildCloudPromptBlocks } from "@/features/tasks/composer/attachments/buildCloudPrompt"; +import { serializeCloudPrompt } from "@/features/tasks/composer/attachments/cloudPrompt"; +import type { PendingAttachment } from "@/features/tasks/composer/attachments/types"; import { DEFAULT_EXECUTION_MODE, DEFAULT_MODEL, @@ -179,16 +182,23 @@ export default function TaskDetailScreen() { // creates a fresh run that resumes from the previous one and queues the // message as pending_user_message. const handleSendAfterTerminal = useCallback( - async (text: string) => { + async (text: string, attachments: PendingAttachment[]) => { if (!taskId || !task) return; try { setRetrying(true); disconnectFromTask(taskId); + const pendingUserMessage = + attachments.length > 0 + ? serializeCloudPrompt( + await buildCloudPromptBlocks(text, attachments), + ) + : text; + const supportsReasoning = modelSupportsReasoning(composerModel); const updatedTask = await runTaskInCloud(taskId, { resumeFromRunId: task.latest_run?.id, - pendingUserMessage: text, + pendingUserMessage, runtimeAdapter: "claude", model: composerModel, reasoningEffort: supportsReasoning ? composerReasoning : undefined, @@ -219,16 +229,16 @@ export default function TaskDetailScreen() { ); const handleSendPrompt = useCallback( - (text: string) => { + (text: string, attachments: PendingAttachment[]) => { if (!taskId) return; Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); if (session?.terminalStatus) { - handleSendAfterTerminal(text); + handleSendAfterTerminal(text, attachments); return; } - sendPrompt(taskId, text).catch((err) => { + sendPrompt(taskId, text, attachments).catch((err) => { log.error("Failed to send prompt", err); Alert.alert( "Failed to send", diff --git a/apps/mobile/src/app/task/index.tsx b/apps/mobile/src/app/task/index.tsx index 4a4827c24..730af0654 100644 --- a/apps/mobile/src/app/task/index.tsx +++ b/apps/mobile/src/app/task/index.tsx @@ -29,6 +29,16 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { createTask, runTaskInCloud } from "@/features/tasks/api"; import { GitHubConnectionPrompt } from "@/features/tasks/components/GitHubConnectionPrompt"; import { GitHubLoadNotice } from "@/features/tasks/components/GitHubLoadNotice"; +import { AttachmentSheet } from "@/features/tasks/composer/attachments/AttachmentSheet"; +import { AttachmentsBar } from "@/features/tasks/composer/attachments/AttachmentsBar"; +import { buildCloudPromptBlocks } from "@/features/tasks/composer/attachments/buildCloudPrompt"; +import { serializeCloudPrompt } from "@/features/tasks/composer/attachments/cloudPrompt"; +import { + captureFromCamera, + pickDocument, + pickPhotoFromLibrary, +} from "@/features/tasks/composer/attachments/pickers"; +import type { PendingAttachment } from "@/features/tasks/composer/attachments/types"; import { DotBackground } from "@/features/tasks/composer/DotBackground"; import { DEFAULT_EXECUTION_MODE, @@ -157,6 +167,24 @@ export default function NewTaskScreen() { const [modeSheetOpen, setModeSheetOpen] = useState(false); const [modelSheetOpen, setModelSheetOpen] = useState(false); const [reasoningSheetOpen, setReasoningSheetOpen] = useState(false); + const [attachments, setAttachments] = useState([]); + const [attachmentSheetOpen, setAttachmentSheetOpen] = useState(false); + + const addAttachment = useCallback( + async (picker: () => Promise) => { + try { + const att = await picker(); + if (att) setAttachments((prev) => [...prev, att]); + } catch (err) { + log.error("Failed to pick attachment", err); + } + }, + [], + ); + + const removeAttachment = useCallback((id: string) => { + setAttachments((prev) => prev.filter((a) => a.id !== id)); + }, []); const selectedRepositoryOption = findRepositoryOption( repositoryOptions, @@ -173,11 +201,8 @@ export default function NewTaskScreen() { !!repositoryWarning && repositoryOptions.length === 0; const handleCreateTask = useCallback(async () => { - if ( - !prompt.trim() || - !isRepositorySelectionComplete(selection) || - creating - ) { + const hasContent = !!prompt.trim() || attachments.length > 0; + if (!hasContent || !isRepositorySelectionComplete(selection) || creating) { return; } @@ -185,10 +210,18 @@ export default function NewTaskScreen() { try { const trimmedPrompt = prompt.trim(); + // The task description is plain text (it shows up as the task title and + // in metadata). Attachments only enter the agent prompt via the cloud + // payload below. + const descriptionText = + trimmedPrompt || + (attachments.length === 1 + ? `Attached: ${attachments[0].fileName}` + : `Attached ${attachments.length} files`); const task = await createTask({ - description: trimmedPrompt, - title: trimmedPrompt.slice(0, 100), + description: descriptionText, + title: descriptionText.slice(0, 100), repository: selection.repository ?? undefined, github_integration: selection.integrationId ?? undefined, ...(signalReport @@ -199,10 +232,17 @@ export default function NewTaskScreen() { : {}), }); + const pendingUserMessage = + attachments.length > 0 + ? serializeCloudPrompt( + await buildCloudPromptBlocks(trimmedPrompt, attachments), + ) + : trimmedPrompt; + const supportsReasoning = modelSupportsReasoning(model); await runTaskInCloud(task.id, { - pendingUserMessage: trimmedPrompt, + pendingUserMessage, runtimeAdapter: "claude", model, reasoningEffort: supportsReasoning ? reasoning : undefined, @@ -216,6 +256,7 @@ export default function NewTaskScreen() { setCreating(false); } }, [ + attachments, creating, mode, model, @@ -227,7 +268,9 @@ export default function NewTaskScreen() { ]); const canSubmit = - !!prompt.trim() && isRepositorySelectionComplete(selection) && !creating; + (!!prompt.trim() || attachments.length > 0) && + isRepositorySelectionComplete(selection) && + !creating; const showReasoningPill = modelSupportsReasoning(model); if (isLoading && hasGithubIntegration === null) { @@ -358,6 +401,10 @@ export default function NewTaskScreen() { + { - // Attachments are not wired up yet in the mobile composer. - }} - className="h-9 w-9 items-center justify-center" + onPress={() => setAttachmentSheetOpen(true)} + accessibilityLabel="Add attachment" + accessibilityRole="button" + className="h-9 w-9 items-center justify-center active:opacity-60" > - + 0 + ? themeColors.accent[11] + : themeColors.gray[10] + } + weight={attachments.length > 0 ? "fill" : "regular"} + /> , }))} /> + + setAttachmentSheetOpen(false)} + onPickPhoto={() => addAttachment(pickPhotoFromLibrary)} + onPickCamera={() => addAttachment(captureFromCamera)} + onPickDocument={() => addAttachment(pickDocument)} + /> ); } diff --git a/apps/mobile/src/features/tasks/components/FloatingNewTaskButton.tsx b/apps/mobile/src/features/tasks/components/FloatingNewTaskButton.tsx index 669c30cbd..a4b40ac97 100644 --- a/apps/mobile/src/features/tasks/components/FloatingNewTaskButton.tsx +++ b/apps/mobile/src/features/tasks/components/FloatingNewTaskButton.tsx @@ -33,7 +33,9 @@ export function FloatingNewTaskButton({ onPress }: FloatingNewTaskButtonProps) { }} > - New task + + New task + ); } diff --git a/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx b/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx index 10ae32f30..2dcf31a5c 100644 --- a/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx +++ b/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx @@ -22,7 +22,16 @@ import { View, } from "react-native"; import { useVoiceRecording } from "@/features/chat"; +import { logger } from "@/lib/logger"; import { useThemeColors } from "@/lib/theme"; +import { AttachmentSheet } from "./attachments/AttachmentSheet"; +import { AttachmentsBar } from "./attachments/AttachmentsBar"; +import { + captureFromCamera, + pickDocument, + pickPhotoFromLibrary, +} from "./attachments/pickers"; +import type { PendingAttachment } from "./attachments/types"; import { DEFAULT_EXECUTION_MODE, DEFAULT_MODEL, @@ -40,8 +49,10 @@ import { import { Pill } from "./Pill"; import { SelectSheet } from "./SelectSheet"; +const log = logger.scope("task-chat-composer"); + interface TaskChatComposerProps { - onSend: (message: string) => void; + onSend: (message: string, attachments: PendingAttachment[]) => void; onStop?: () => void; disabled?: boolean; placeholder?: string; @@ -135,6 +146,8 @@ export function TaskChatComposer({ }: TaskChatComposerProps) { const themeColors = useThemeColors(); const [message, setMessage] = useState(""); + const [attachments, setAttachments] = useState([]); + const [attachmentSheetOpen, setAttachmentSheetOpen] = useState(false); const { status, startRecording, stopRecording, cancelRecording } = useVoiceRecording(); @@ -147,16 +160,33 @@ export function TaskChatComposer({ const showReasoningPill = modelSupportsReasoning(model); - const canSend = message.trim().length > 0 && !disabled && !isRecording; + const hasContent = message.trim().length > 0 || attachments.length > 0; + const canSend = hasContent && !disabled && !isRecording; const showStop = !isUserTurn && !canSend && !isRecording && !isTranscribing && !!onStop; const handleSend = () => { const trimmed = message.trim(); - if (!trimmed || disabled) return; + if (!hasContent || disabled) return; setMessage(""); + setAttachments([]); Keyboard.dismiss(); - onSend(trimmed); + onSend(trimmed, attachments); + }; + + const addAttachment = async ( + picker: () => Promise, + ) => { + try { + const att = await picker(); + if (att) setAttachments((prev) => [...prev, att]); + } catch (err) { + log.error("Failed to pick attachment", err); + } + }; + + const removeAttachment = (id: string) => { + setAttachments((prev) => prev.filter((a) => a.id !== id)); }; const handleMicPress = async () => { @@ -187,6 +217,10 @@ export function TaskChatComposer({ + { - /* attachments — coming soon */ - }} - className="h-9 w-9 items-center justify-center" + onPress={() => setAttachmentSheetOpen(true)} + disabled={disabled || isRecording} + accessibilityLabel="Add attachment" + accessibilityRole="button" + className="h-9 w-9 items-center justify-center active:opacity-60" > - + 0 + ? themeColors.accent[11] + : themeColors.gray[10] + } + weight={attachments.length > 0 ? "fill" : "regular"} + /> , }))} /> + + setAttachmentSheetOpen(false)} + onPickPhoto={() => addAttachment(pickPhotoFromLibrary)} + onPickCamera={() => addAttachment(captureFromCamera)} + onPickDocument={() => addAttachment(pickDocument)} + /> ); } diff --git a/apps/mobile/src/features/tasks/composer/attachments/AttachmentSheet.tsx b/apps/mobile/src/features/tasks/composer/attachments/AttachmentSheet.tsx new file mode 100644 index 000000000..e64a6b387 --- /dev/null +++ b/apps/mobile/src/features/tasks/composer/attachments/AttachmentSheet.tsx @@ -0,0 +1,117 @@ +import { Text } from "@components/text"; +import { Camera, FileText, Image as ImageIcon } from "phosphor-react-native"; +import { Modal, Pressable, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useThemeColors } from "@/lib/theme"; + +interface AttachmentSheetProps { + open: boolean; + onClose: () => void; + onPickPhoto: () => void; + onPickCamera: () => void; + onPickDocument: () => void; +} + +interface RowProps { + icon: React.ReactNode; + label: string; + description: string; + onPress: () => void; +} + +function Row({ icon, label, description, onPress }: RowProps) { + return ( + + + {icon} + + + {label} + {description} + + + ); +} + +export function AttachmentSheet({ + open, + onClose, + onPickPhoto, + onPickCamera, + onPickDocument, +}: AttachmentSheetProps) { + const themeColors = useThemeColors(); + const insets = useSafeAreaInsets(); + + return ( + + + {}} + className="mt-auto rounded-t-2xl border-gray-6 border-t bg-background" + style={{ + paddingBottom: insets.bottom + 12, + shadowColor: "#000", + shadowOpacity: 0.15, + shadowRadius: 20, + shadowOffset: { width: 0, height: -4 }, + elevation: 12, + }} + > + + + + + + + Add attachment + + + + + } + label="Photo library" + description="Pick a photo to share with the agent" + onPress={() => { + onClose(); + onPickPhoto(); + }} + /> + + } + label="Take photo" + description="Capture a new photo from the camera" + onPress={() => { + onClose(); + onPickCamera(); + }} + /> + + } + label="File" + description="Attach a text or code file from your device" + onPress={() => { + onClose(); + onPickDocument(); + }} + /> + + + + ); +} diff --git a/apps/mobile/src/features/tasks/composer/attachments/AttachmentsBar.tsx b/apps/mobile/src/features/tasks/composer/attachments/AttachmentsBar.tsx new file mode 100644 index 000000000..146a1b549 --- /dev/null +++ b/apps/mobile/src/features/tasks/composer/attachments/AttachmentsBar.tsx @@ -0,0 +1,75 @@ +import { Text } from "@components/text"; +import { FileText, X } from "phosphor-react-native"; +import { Image, Pressable, ScrollView, View } from "react-native"; +import { useThemeColors } from "@/lib/theme"; +import type { PendingAttachment } from "./types"; + +interface AttachmentsBarProps { + attachments: PendingAttachment[]; + onRemove: (id: string) => void; +} + +function truncate(name: string, max = 18): string { + if (name.length <= max) return name; + const ext = name.lastIndexOf("."); + if (ext > 0 && name.length - ext <= 6) { + return `${name.slice(0, max - (name.length - ext) - 1)}…${name.slice(ext)}`; + } + return `${name.slice(0, max - 1)}…`; +} + +export function AttachmentsBar({ attachments, onRemove }: AttachmentsBarProps) { + const themeColors = useThemeColors(); + if (attachments.length === 0) return null; + + return ( + + {attachments.map((att) => ( + + {att.kind === "image" ? ( + + ) : ( + + + + {truncate(att.fileName, 22)} + + + )} + onRemove(att.id)} + hitSlop={8} + accessibilityLabel={`Remove ${att.fileName}`} + className="-top-1.5 -right-1.5 absolute h-5 w-5 items-center justify-center rounded-full bg-gray-12 active:opacity-80" + > + + + + ))} + + ); +} diff --git a/apps/mobile/src/features/tasks/composer/attachments/buildCloudPrompt.ts b/apps/mobile/src/features/tasks/composer/attachments/buildCloudPrompt.ts new file mode 100644 index 000000000..d5870bb1f --- /dev/null +++ b/apps/mobile/src/features/tasks/composer/attachments/buildCloudPrompt.ts @@ -0,0 +1,151 @@ +import * as FileSystem from "expo-file-system/legacy"; +import type { CloudPromptBlock, PendingAttachment } from "./types"; + +const MAX_EMBEDDED_TEXT_CHARS = 100_000; +const MAX_EMBEDDED_IMAGE_BYTES = 5 * 1024 * 1024; + +const TEXT_MIME_PREFIXES = ["text/"]; +const TEXT_MIME_TYPES = new Set([ + "application/json", + "application/xml", + "application/javascript", + "application/typescript", + "application/x-sh", + "application/x-yaml", + "application/x-toml", +]); +const TEXT_EXTENSIONS = new Set([ + "c", + "cc", + "cfg", + "conf", + "cpp", + "cs", + "css", + "csv", + "env", + "gitignore", + "go", + "h", + "hpp", + "html", + "ini", + "java", + "js", + "json", + "jsx", + "log", + "md", + "mjs", + "py", + "rb", + "rs", + "scss", + "sh", + "sql", + "svg", + "toml", + "ts", + "tsx", + "txt", + "xml", + "yaml", + "yml", + "zsh", +]); + +function getExt(fileName: string): string { + const dot = fileName.lastIndexOf("."); + return dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : ""; +} + +function isTextAttachment(mimeType: string, fileName: string): boolean { + const mt = mimeType.toLowerCase(); + if (TEXT_MIME_PREFIXES.some((p) => mt.startsWith(p))) return true; + if (TEXT_MIME_TYPES.has(mt)) return true; + return TEXT_EXTENSIONS.has(getExt(fileName)); +} + +function getTextMimeType(fileName: string, fallback: string): string { + const ext = getExt(fileName); + switch (ext) { + case "json": + return "application/json"; + case "md": + return "text/markdown"; + case "svg": + return "image/svg+xml"; + case "xml": + return "application/xml"; + default: + return fallback.startsWith("text/") ? fallback : "text/plain"; + } +} + +function truncateText(text: string): string { + if (text.length <= MAX_EMBEDDED_TEXT_CHARS) return text; + return `${text.slice(0, MAX_EMBEDDED_TEXT_CHARS)}\n\n[Attachment truncated to ${MAX_EMBEDDED_TEXT_CHARS.toLocaleString()} characters for this cloud prompt.]`; +} + +function estimateBase64Bytes(base64: string): number { + const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0; + return Math.floor((base64.length * 3) / 4) - padding; +} + +async function buildBlock(att: PendingAttachment): Promise { + if (att.kind === "image") { + const base64 = await FileSystem.readAsStringAsync(att.uri, { + encoding: FileSystem.EncodingType.Base64, + }); + if (estimateBase64Bytes(base64) > MAX_EMBEDDED_IMAGE_BYTES) { + throw new Error( + `${att.fileName} is too large for a cloud image attachment (max 5 MB).`, + ); + } + return { + type: "image", + data: base64, + mimeType: att.mimeType || "image/jpeg", + uri: `attachment://${att.fileName}`, + }; + } + + // Document attachment — must be text-readable. + if (!isTextAttachment(att.mimeType, att.fileName)) { + throw new Error( + `Cloud attachments support text and image files. Unsupported: ${att.fileName}`, + ); + } + const text = await FileSystem.readAsStringAsync(att.uri, { + encoding: FileSystem.EncodingType.UTF8, + }); + return { + type: "resource", + resource: { + uri: `attachment://${att.fileName}`, + text: truncateText(text), + mimeType: getTextMimeType(att.fileName, att.mimeType), + }, + }; +} + +/** + * Reads each attachment from disk and assembles the cloud-prompt block array + * the agent server expects. Throws if any individual attachment fails so the + * caller can surface a single, attributable error to the user. + */ +export async function buildCloudPromptBlocks( + text: string, + attachments: PendingAttachment[], +): Promise { + const blocks: CloudPromptBlock[] = []; + const trimmed = text.trim(); + if (trimmed) blocks.push({ type: "text", text: trimmed }); + for (const attachment of attachments) { + blocks.push(await buildBlock(attachment)); + } + if (blocks.length === 0) { + throw new Error("Cloud prompt cannot be empty"); + } + return blocks; +} diff --git a/apps/mobile/src/features/tasks/composer/attachments/cloudPrompt.ts b/apps/mobile/src/features/tasks/composer/attachments/cloudPrompt.ts new file mode 100644 index 000000000..35f885cdc --- /dev/null +++ b/apps/mobile/src/features/tasks/composer/attachments/cloudPrompt.ts @@ -0,0 +1,16 @@ +import type { CloudPromptBlock } from "./types"; + +/** + * Wire format prefix shared with `packages/shared/src/cloud-prompt.ts`. The + * backend's `deserializeCloudPrompt` looks for this prefix and decodes the + * trailing JSON as `{ blocks: ContentBlock[] }`. Plain-text prompts without + * attachments are sent as strings (no prefix) so chat echoes stay readable. + */ +export const CLOUD_PROMPT_PREFIX = "__twig_cloud_prompt_v1__:"; + +export function serializeCloudPrompt(blocks: CloudPromptBlock[]): string { + if (blocks.length === 1 && blocks[0].type === "text") { + return blocks[0].text.trim(); + } + return `${CLOUD_PROMPT_PREFIX}${JSON.stringify({ blocks })}`; +} diff --git a/apps/mobile/src/features/tasks/composer/attachments/pickers.ts b/apps/mobile/src/features/tasks/composer/attachments/pickers.ts new file mode 100644 index 000000000..2112807f7 --- /dev/null +++ b/apps/mobile/src/features/tasks/composer/attachments/pickers.ts @@ -0,0 +1,119 @@ +import * as DocumentPicker from "expo-document-picker"; +import * as ImagePicker from "expo-image-picker"; +import { Alert } from "react-native"; +import { logger } from "@/lib/logger"; +import type { PendingAttachment } from "./types"; + +const log = logger.scope("attachments"); + +function makeId(): string { + return `att-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`; +} + +function inferImageMime(uri: string, mime?: string | null): string { + if (mime) return mime; + const lower = uri.toLowerCase(); + if (lower.endsWith(".png")) return "image/png"; + if (lower.endsWith(".gif")) return "image/gif"; + if (lower.endsWith(".webp")) return "image/webp"; + return "image/jpeg"; +} + +function deriveFileName(uri: string, fallback: string): string { + const last = uri.split("/").pop(); + if (last && last.length > 0) return decodeURIComponent(last.split("?")[0]); + return fallback; +} + +/** + * Open the photo library and return the picked image as a PendingAttachment. + * Returns `null` if the user cancels or permission is denied. + */ +export async function pickPhotoFromLibrary(): Promise { + const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (!perm.granted) { + Alert.alert( + "Photo access needed", + "Allow PostHog to access your photos in Settings to attach images.", + ); + return null; + } + + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ["images"], + quality: 0.8, + allowsMultipleSelection: false, + exif: false, + }); + if (result.canceled || result.assets.length === 0) return null; + + const asset = result.assets[0]; + return { + kind: "image", + id: makeId(), + uri: asset.uri, + fileName: asset.fileName ?? deriveFileName(asset.uri, "image.jpg"), + mimeType: inferImageMime(asset.uri, asset.mimeType), + sizeBytes: asset.fileSize, + }; +} + +/** + * Open the camera and return the captured photo as a PendingAttachment. + */ +export async function captureFromCamera(): Promise { + const perm = await ImagePicker.requestCameraPermissionsAsync(); + if (!perm.granted) { + Alert.alert( + "Camera access needed", + "Allow PostHog to use your camera in Settings to capture attachments.", + ); + return null; + } + + const result = await ImagePicker.launchCameraAsync({ + mediaTypes: ["images"], + quality: 0.8, + exif: false, + }); + if (result.canceled || result.assets.length === 0) return null; + + const asset = result.assets[0]; + return { + kind: "image", + id: makeId(), + uri: asset.uri, + fileName: asset.fileName ?? deriveFileName(asset.uri, "photo.jpg"), + mimeType: inferImageMime(asset.uri, asset.mimeType), + sizeBytes: asset.fileSize, + }; +} + +/** + * Open the document picker for text/code files. Binary documents are accepted + * by the picker but rejected at send-time by `buildCloudPromptBlocks` because + * the cloud agent only supports text or image content. + */ +export async function pickDocument(): Promise { + try { + const result = await DocumentPicker.getDocumentAsync({ + type: "*/*", + copyToCacheDirectory: true, + multiple: false, + }); + if (result.canceled || result.assets.length === 0) return null; + + const asset = result.assets[0]; + return { + kind: "document", + id: makeId(), + uri: asset.uri, + fileName: asset.name, + mimeType: asset.mimeType ?? "application/octet-stream", + sizeBytes: asset.size, + }; + } catch (err) { + log.error("Document picker failed", err); + return null; + } +} diff --git a/apps/mobile/src/features/tasks/composer/attachments/types.ts b/apps/mobile/src/features/tasks/composer/attachments/types.ts new file mode 100644 index 000000000..f2fee3799 --- /dev/null +++ b/apps/mobile/src/features/tasks/composer/attachments/types.ts @@ -0,0 +1,39 @@ +/** + * In-flight attachment held by the composer until the message is sent. + * `uri` points at the picker's local file (e.g. ph://… or file://…). Bytes are + * read lazily at send-time so we never hold large base64 strings in memory. + */ +export type PendingAttachment = + | { + kind: "image"; + id: string; + uri: string; + fileName: string; + mimeType: string; + sizeBytes?: number; + } + | { + kind: "document"; + id: string; + uri: string; + fileName: string; + mimeType: string; + sizeBytes?: number; + }; + +/** + * Minimal subset of `@agentclientprotocol/sdk`'s `ContentBlock` that the + * backend's `deserializeCloudPrompt` accepts. We mirror the wire shape rather + * than depending on the ACP SDK from the mobile bundle. + */ +export type CloudPromptBlock = + | { type: "text"; text: string } + | { type: "image"; data: string; mimeType: string; uri?: string } + | { + type: "resource"; + resource: { + uri: string; + text: string; + mimeType: string; + }; + }; diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts index fe9574670..ddcb60b96 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts @@ -12,6 +12,9 @@ import { runTaskInCloud, sendCloudCommand, } from "../api"; +import { buildCloudPromptBlocks } from "../composer/attachments/buildCloudPrompt"; +import { serializeCloudPrompt } from "../composer/attachments/cloudPrompt"; +import type { PendingAttachment } from "../composer/attachments/types"; import type { SessionEvent, SessionNotification, @@ -142,7 +145,11 @@ interface TaskSessionStore { connectToTask: (task: Task) => Promise; disconnectFromTask: (taskId: string) => void; - sendPrompt: (taskId: string, prompt: string) => Promise; + sendPrompt: ( + taskId: string, + prompt: string, + attachments?: PendingAttachment[], + ) => Promise; sendPermissionResponse: ( taskId: string, args: { @@ -341,7 +348,11 @@ export const useTaskSessionStore = create((set, get) => ({ logger.debug("Disconnected from task", { taskId }); }, - sendPrompt: async (taskId: string, prompt: string) => { + sendPrompt: async ( + taskId: string, + prompt: string, + attachments: PendingAttachment[] = [], + ) => { const session = get().getSessionForTask(taskId); if (!session) { throw new Error("No active session for task"); @@ -351,9 +362,17 @@ export const useTaskSessionStore = create((set, get) => ({ // 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. + // The local echo always shows the plain prompt text in the chat. When + // attachments are present we send a structured cloud-prompt blob on the + // wire (`__twig_cloud_prompt_v1__:…`) so the agent receives the image + // and resource blocks alongside the text. + const wirePayload = + attachments.length > 0 + ? serializeCloudPrompt( + await buildCloudPromptBlocks(prompt, attachments), + ) + : prompt; + const ts = Date.now(); const userEvent: SessionEvent = { type: "session_update", @@ -387,7 +406,7 @@ export const useTaskSessionStore = create((set, get) => ({ try { await sendCloudCommand(taskId, session.taskRunId, "user_message", { - content: prompt, + content: wirePayload, }); logger.debug("Sent cloud command user_message", { taskId, @@ -434,7 +453,7 @@ export const useTaskSessionStore = create((set, get) => ({ previousRunId: session.taskRunId, }); try { - await get()._resumeCloudRun(taskId, session.taskRunId, prompt); + await get()._resumeCloudRun(taskId, session.taskRunId, wirePayload); return; } catch (resumeErr) { logger.error("Failed to resume cloud run", resumeErr); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7717af116..305195b5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -566,6 +566,9 @@ importers: expo-device: specifier: ~8.0.10 version: 8.0.10(expo@54.0.33) + expo-document-picker: + specifier: ~14.0.8 + version: 14.0.8(expo@54.0.33) expo-file-system: specifier: ~19.0.21 version: 19.0.21(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0)) @@ -578,6 +581,9 @@ importers: expo-haptics: specifier: ^55.0.14 version: 55.0.14(expo@54.0.33) + expo-image-picker: + specifier: ~17.0.11 + version: 17.0.11(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) @@ -592,7 +598,7 @@ importers: version: 0.32.17(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-router: specifier: ~6.0.17 - version: 6.0.23(@expo/metro-runtime@6.1.2)(@testing-library/react-native@13.3.3(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0))(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(expo-constants@18.0.13)(expo-linking@8.0.11)(expo@54.0.33)(react-dom@19.1.0(react@19.1.0))(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-screens@4.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) + version: 6.0.23(97f7c790f8736a7689a3c00f6dec9af6) expo-secure-store: specifier: ^15.0.8 version: 15.0.8(expo@54.0.33) @@ -7122,6 +7128,11 @@ packages: peerDependencies: expo: '*' + expo-document-picker@14.0.8: + resolution: {integrity: sha512-3tyQKpPqWWFlI8p9RiMX1+T1Zge5mEKeBuXWp1h8PEItFMUDSiOJbQ112sfdC6Hxt8wSxreV9bCRl/NgBdt+fA==} + peerDependencies: + expo: '*' + expo-file-system@19.0.21: resolution: {integrity: sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg==} peerDependencies: @@ -7147,6 +7158,16 @@ packages: peerDependencies: expo: '*' + expo-image-loader@6.0.0: + resolution: {integrity: sha512-nKs/xnOGw6ACb4g26xceBD57FKLFkSwEUTDXEDF3Gtcu3MqF3ZIYd3YM+sSb1/z9AKV1dYT7rMSGVNgsveXLIQ==} + peerDependencies: + expo: '*' + + expo-image-picker@17.0.11: + resolution: {integrity: sha512-/apkoyukDvsCHHb9fzP+F34A1uQqSzUtYH/2P/xJACNEwq+mwEXjXvVU8bzlJq6ih0Qo1+tpVivIa7B9kYSwOQ==} + peerDependencies: + expo: '*' + expo-json-utils@0.15.0: resolution: {integrity: sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ==} @@ -14017,7 +14038,7 @@ snapshots: wrap-ansi: 7.0.0 ws: 8.19.0 optionalDependencies: - expo-router: 6.0.23(@expo/metro-runtime@6.1.2)(@testing-library/react-native@13.3.3(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0))(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(expo-constants@18.0.13)(expo-linking@8.0.11)(expo@54.0.33)(react-dom@19.1.0(react@19.1.0))(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-screens@4.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-router: 6.0.23(97f7c790f8736a7689a3c00f6dec9af6) react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0) transitivePeerDependencies: - bufferutil @@ -19300,6 +19321,10 @@ snapshots: 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) ua-parser-js: 0.7.41 + expo-document-picker@14.0.8(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-file-system@19.0.21(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: 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) @@ -19322,6 +19347,15 @@ snapshots: 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-image-loader@6.0.0(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-image-picker@17.0.11(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-image-loader: 6.0.0(expo@54.0.33) + expo-json-utils@0.15.0: {} expo-keep-awake@15.0.8(expo@54.0.33)(react@19.1.0): @@ -19388,7 +19422,7 @@ snapshots: transitivePeerDependencies: - supports-color - expo-router@6.0.23(@expo/metro-runtime@6.1.2)(@testing-library/react-native@13.3.3(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0))(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(expo-constants@18.0.13)(expo-linking@8.0.11)(expo@54.0.33)(react-dom@19.1.0(react@19.1.0))(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-screens@4.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-router@6.0.23(97f7c790f8736a7689a3c00f6dec9af6): dependencies: '@expo/metro-runtime': 6.1.2(expo@54.0.33)(react-dom@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/schema-utils': 0.1.8 From 97a8074e13e4ccf610672894129f8202a482652c Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 13 May 2026 20:55:40 -0400 Subject: [PATCH 31/94] git ignore --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 3d9297a76..1da4e924f 100644 --- a/.gitignore +++ b/.gitignore @@ -62,4 +62,6 @@ plugins/posthog/local-skills/ # Symlinked copies of posthog, to make developing against those APIs easier posthog-sym -.claude/settings.local.json \ No newline at end of file +.claude/settings.local.json + +apps/mobile/ROADMAP.md \ No newline at end of file From f7f4cd1b6e3c05ab332774e5a9c55842ff789838 Mon Sep 17 00:00:00 2001 From: dylan Date: Wed, 13 May 2026 20:59:40 -0400 Subject: [PATCH 32/94] fix this --- apps/mobile/src/app/(tabs)/inbox.tsx | 4 +-- .../inbox/components/FloatingInboxHeader.tsx | 29 ++++++++----------- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/apps/mobile/src/app/(tabs)/inbox.tsx b/apps/mobile/src/app/(tabs)/inbox.tsx index 0fc4b8dba..3c92f0882 100644 --- a/apps/mobile/src/app/(tabs)/inbox.tsx +++ b/apps/mobile/src/app/(tabs)/inbox.tsx @@ -27,9 +27,9 @@ export default function InboxScreen() { [router], ); - // Header occupies insets.top + 6 (top pad) + 44 (button stack) + 8 (bottom + // Header occupies insets.top + 6 (top pad) + 40 (MenuButton) + 8 (bottom // pad), plus a small buffer so the first row isn't hugging the fade edge. - const headerHeight = insets.top + 72; + const headerHeight = insets.top + 60; return ( diff --git a/apps/mobile/src/features/inbox/components/FloatingInboxHeader.tsx b/apps/mobile/src/features/inbox/components/FloatingInboxHeader.tsx index cc2b017d3..bcf5dd94d 100644 --- a/apps/mobile/src/features/inbox/components/FloatingInboxHeader.tsx +++ b/apps/mobile/src/features/inbox/components/FloatingInboxHeader.tsx @@ -17,9 +17,9 @@ interface FloatingInboxHeaderProps { /** * Floating header for the inbox screen — hamburger menu on the left, - * centered "Inbox" title with live-polling indicator and subtitle, and - * reviewer / filter buttons on the right. Sits over the content with a - * top-to-bottom fade so the list disappears gracefully behind it. + * centered "Inbox" title with live-polling indicator, and reviewer / filter + * buttons on the right. Sits over the content with a top-to-bottom fade so + * the list disappears gracefully behind it. */ export function FloatingInboxHeader({ isFetching, @@ -31,7 +31,7 @@ export function FloatingInboxHeader({ const insets = useSafeAreaInsets(); const themeColors = useThemeColors(); - const fadeHeight = insets.top + 96; + const fadeHeight = insets.top + 80; return ( - - - - Inbox - - - - - Signals and reports + + + Inbox + @@ -98,7 +93,7 @@ export function FloatingInboxHeader({ From df9823b7a5194159c465ab306b4723b366274c30 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 13 May 2026 21:09:55 -0400 Subject: [PATCH 33/94] Added voice recording --- apps/mobile/src/app/task/index.tsx | 73 ++++++- .../src/features/chat/components/Composer.tsx | 14 +- .../features/chat/hooks/useVoiceRecording.ts | 195 ++++++++++++------ .../tasks/composer/TaskChatComposer.tsx | 20 +- 4 files changed, 224 insertions(+), 78 deletions(-) diff --git a/apps/mobile/src/app/task/index.tsx b/apps/mobile/src/app/task/index.tsx index 730af0654..ea1e4a89a 100644 --- a/apps/mobile/src/app/task/index.tsx +++ b/apps/mobile/src/app/task/index.tsx @@ -5,11 +5,13 @@ import { BrainIcon, CaretDown, GithubLogo, + MicrophoneIcon, PaperclipIcon, PauseIcon, PencilIcon, Robot, ShieldCheck, + StopIcon, } from "phosphor-react-native"; import { useCallback, useState } from "react"; import { @@ -26,6 +28,7 @@ import { import Animated, { runOnJS, useAnimatedStyle } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useVoiceRecording } from "@/features/chat"; import { createTask, runTaskInCloud } from "@/features/tasks/api"; import { GitHubConnectionPrompt } from "@/features/tasks/components/GitHubConnectionPrompt"; import { GitHubLoadNotice } from "@/features/tasks/components/GitHubLoadNotice"; @@ -170,6 +173,33 @@ export default function NewTaskScreen() { const [attachments, setAttachments] = useState([]); const [attachmentSheetOpen, setAttachmentSheetOpen] = useState(false); + const appendTranscript = useCallback((transcript: string) => { + setPrompt((prev) => (prev ? `${prev} ${transcript}` : transcript)); + }, []); + + const { + status: voiceStatus, + startRecording, + stopRecording, + cancelRecording, + } = useVoiceRecording({ onTranscript: appendTranscript }); + const isRecording = voiceStatus === "recording"; + const isTranscribing = voiceStatus === "transcribing"; + + const handleMicPress = useCallback(async () => { + if (isRecording) { + await stopRecording(); + } else if (!isTranscribing) { + await startRecording(); + } + }, [isRecording, isTranscribing, startRecording, stopRecording]); + + const handleMicLongPress = useCallback(async () => { + if (isRecording) { + await cancelRecording(); + } + }, [isRecording, cancelRecording]); + const addAttachment = useCallback( async (picker: () => Promise) => { try { @@ -267,10 +297,9 @@ export default function NewTaskScreen() { signalReport, ]); + const hasContent = !!prompt.trim() || attachments.length > 0; const canSubmit = - (!!prompt.trim() || attachments.length > 0) && - isRepositorySelectionComplete(selection) && - !creating; + hasContent && isRepositorySelectionComplete(selection) && !creating; const showReasoningPill = modelSupportsReasoning(model); if (isLoading && hasGithubIntegration === null) { @@ -476,18 +505,44 @@ export default function NewTaskScreen() { - {creating ? ( + {creating || isTranscribing ? ( - ) : ( + ) : isRecording ? ( + + ) : hasContent ? ( + ) : ( + )} diff --git a/apps/mobile/src/features/chat/components/Composer.tsx b/apps/mobile/src/features/chat/components/Composer.tsx index 6d12d35bf..54e0fe10c 100644 --- a/apps/mobile/src/features/chat/components/Composer.tsx +++ b/apps/mobile/src/features/chat/components/Composer.tsx @@ -1,7 +1,7 @@ import { GlassContainer, GlassView } from "expo-glass-effect"; import * as Haptics from "expo-haptics"; import { ArrowUp, Microphone, Stop } from "phosphor-react-native"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { ActivityIndicator, Animated, @@ -86,8 +86,13 @@ export function Composer({ }: ComposerProps) { const themeColors = useThemeColors(); const [message, setMessage] = useState(""); + + const appendTranscript = useCallback((transcript: string) => { + setMessage((prev) => (prev ? `${prev} ${transcript}` : transcript)); + }, []); + const { status, startRecording, stopRecording, cancelRecording } = - useVoiceRecording(); + useVoiceRecording({ onTranscript: appendTranscript }); const isRecording = status === "recording"; const isTranscribing = status === "transcribing"; @@ -102,10 +107,7 @@ export function Composer({ const handleMicPress = async () => { if (isRecording) { - const transcript = await stopRecording(); - if (transcript) { - setMessage((prev) => (prev ? `${prev} ${transcript}` : transcript)); - } + await stopRecording(); } else if (!isTranscribing) { await startRecording(); } diff --git a/apps/mobile/src/features/chat/hooks/useVoiceRecording.ts b/apps/mobile/src/features/chat/hooks/useVoiceRecording.ts index 1708f0414..a3b5aae11 100644 --- a/apps/mobile/src/features/chat/hooks/useVoiceRecording.ts +++ b/apps/mobile/src/features/chat/hooks/useVoiceRecording.ts @@ -1,39 +1,94 @@ import { ExpoSpeechRecognitionModule } from "expo-speech-recognition"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { logger } from "@/lib/logger"; const log = logger.scope("voice-recording"); type RecordingStatus = "idle" | "recording" | "transcribing" | "error"; +interface UseVoiceRecordingOptions { + /** + * Fires with the final transcript whenever recognition finishes — whether + * the caller invoked stopRecording() or the engine ended on its own + * (silence timeout on iOS 17-, `isFinal` result on iOS 18+/Android, etc). + * Use this to append voice input to a text field reliably; the previous + * promise-only API silently dropped transcripts when the engine auto-ended + * before the user tapped stop. + */ + onTranscript?: (transcript: string) => void; +} + interface UseVoiceRecordingReturn { status: RecordingStatus; error: string | null; startRecording: () => Promise; - stopRecording: () => Promise; + stopRecording: () => Promise; cancelRecording: () => Promise; } -export function useVoiceRecording(): UseVoiceRecordingReturn { +export function useVoiceRecording( + options: UseVoiceRecordingOptions = {}, +): UseVoiceRecordingReturn { const [status, setStatus] = useState("idle"); const [error, setError] = useState(null); const transcriptRef = useRef(""); - const resolveRef = useRef<((text: string | null) => void) | null>(null); const listenersRef = useRef<(() => void)[]>([]); - - const cleanup = useCallback(() => { + /** Resolves stopRecording() when a final event arrives. Null otherwise. */ + const stopWaitRef = useRef<(() => void) | null>(null); + /** Set by cancelRecording so the next event discards the transcript. */ + const canceledRef = useRef(false); + /** Keep the latest callback without re-attaching listeners. */ + const onTranscriptRef = useRef(options.onTranscript); + useEffect(() => { + onTranscriptRef.current = options.onTranscript; + }); + + const removeListeners = useCallback(() => { for (const remove of listenersRef.current) { - remove(); + try { + remove(); + } catch {} } listenersRef.current = []; - resolveRef.current = null; - transcriptRef.current = ""; }, []); + /** + * Called when the engine emits a terminal event: a `result` with + * `isFinal: true`, an `end` event, or a non-fatal error like `no-speech`. + * Delivers the transcript via the callback and tears down listeners. + * + * This must work even when the user hasn't called stopRecording() — on + * iOS 17- the engine ends after ~3s of silence regardless, and on iOS 18+ + * a short utterance can finalize before the user taps stop. + */ + const handleFinalEvent = useCallback(() => { + const wasCanceled = canceledRef.current; + canceledRef.current = false; + const text = wasCanceled ? "" : transcriptRef.current.trim(); + log.debug("final event", { text: text.slice(0, 60), wasCanceled }); + + removeListeners(); + transcriptRef.current = ""; + setStatus("idle"); + + if (text) { + onTranscriptRef.current?.(text); + } + + const waiter = stopWaitRef.current; + stopWaitRef.current = null; + if (waiter) waiter(); + }, [removeListeners]); + const startRecording = useCallback(async () => { try { - setError(null); + // Tear down any lingering state from a prior session so we don't pick + // up stale listeners or transcripts. + removeListeners(); + stopWaitRef.current = null; + canceledRef.current = false; transcriptRef.current = ""; + setError(null); if (!ExpoSpeechRecognitionModule.isRecognitionAvailable()) { setError("Speech recognition is not available on this device"); @@ -49,18 +104,14 @@ export function useVoiceRecording(): UseVoiceRecordingReturn { return; } - // 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"); + if (best) transcriptRef.current = best; + log.debug("result", { isFinal: event.isFinal, hasText: !!best }); + if (event.isFinal) { + handleFinalEvent(); } }, ); @@ -68,31 +119,29 @@ export function useVoiceRecording(): UseVoiceRecordingReturn { 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"); + log.debug("error event", { + code: event.error, + message: event.message, + }); + // "no-speech" and "aborted" are non-fatal — fall through the + // normal end path so any accumulated transcript still delivers. + if (event.error === "no-speech" || event.error === "aborted") { + handleFinalEvent(); return; } setError(event.message || "Speech recognition failed"); - if (resolveRef.current) { - resolveRef.current(null); - } - cleanup(); + removeListeners(); + transcriptRef.current = ""; + const waiter = stopWaitRef.current; + stopWaitRef.current = null; setStatus("error"); + waiter?.(); }, ); - // 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"); - } + log.debug("end event", { hasTranscript: !!transcriptRef.current }); + handleFinalEvent(); }); listenersRef.current = [ @@ -117,42 +166,72 @@ export function useVoiceRecording(): UseVoiceRecordingReturn { setError("Failed to start speech recognition"); setStatus("error"); } - }, [cleanup]); + }, [removeListeners, handleFinalEvent]); - const stopRecording = useCallback(async (): Promise => { - if (status !== "recording") { - return null; - } + const stopRecording = useCallback(async (): Promise => { + // Engine already auto-finished — transcript was delivered via callback. + if (status !== "recording") return; 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; + try { + ExpoSpeechRecognitionModule.stop(); + } catch (err) { + log.warn("Speech recognition stop failed", err); + } + + // Wait briefly for the platform's final event so the transcript is fully + // formed. Some Android engines never fire result/end after a manual stop; + // the timeout flushes whatever interim results we captured so the caller + // doesn't hang on "Transcribing…" forever. + await new Promise((resolve) => { + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + resolve(); + }; const timeoutId = setTimeout(() => { - timedOut = true; - log.warn("Speech recognition did not finalize, falling back"); - cleanup(); + log.warn("stop timeout — flushing interim transcript"); + const text = transcriptRef.current.trim(); + removeListeners(); + transcriptRef.current = ""; + stopWaitRef.current = null; setStatus("idle"); - resolve(transcriptRef.current || null); - }, 5000); - resolveRef.current = (value) => { - if (timedOut) return; + if (text) onTranscriptRef.current?.(text); + finish(); + }, 1500); + stopWaitRef.current = () => { clearTimeout(timeoutId); - resolve(value); + finish(); }; - ExpoSpeechRecognitionModule.stop(); }); - }, [status, cleanup]); + }, [status, removeListeners]); const cancelRecording = useCallback(async () => { - ExpoSpeechRecognitionModule.abort(); - cleanup(); + canceledRef.current = true; + try { + ExpoSpeechRecognitionModule.abort(); + } catch {} + removeListeners(); + transcriptRef.current = ""; + const waiter = stopWaitRef.current; + stopWaitRef.current = null; setStatus("idle"); setError(null); - }, [cleanup]); + waiter?.(); + }, [removeListeners]); + + // Tear down any in-flight recognition on unmount so events don't dispatch + // into a torn-down component. + useEffect(() => { + return () => { + try { + ExpoSpeechRecognitionModule.abort(); + } catch {} + removeListeners(); + }; + }, [removeListeners]); return { status, diff --git a/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx b/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx index 2dcf31a5c..9a0730698 100644 --- a/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx +++ b/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx @@ -10,7 +10,13 @@ import { ShieldCheck, Stop, } from "phosphor-react-native"; -import { type ReactNode, useEffect, useRef, useState } from "react"; +import { + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from "react"; import { ActivityIndicator, Animated, @@ -148,8 +154,13 @@ export function TaskChatComposer({ const [message, setMessage] = useState(""); const [attachments, setAttachments] = useState([]); const [attachmentSheetOpen, setAttachmentSheetOpen] = useState(false); + + const appendTranscript = useCallback((transcript: string) => { + setMessage((prev) => (prev ? `${prev} ${transcript}` : transcript)); + }, []); + const { status, startRecording, stopRecording, cancelRecording } = - useVoiceRecording(); + useVoiceRecording({ onTranscript: appendTranscript }); const isRecording = status === "recording"; const isTranscribing = status === "transcribing"; @@ -191,10 +202,7 @@ export function TaskChatComposer({ const handleMicPress = async () => { if (isRecording) { - const transcript = await stopRecording(); - if (transcript) { - setMessage((prev) => (prev ? `${prev} ${transcript}` : transcript)); - } + await stopRecording(); } else if (!isTranscribing) { await startRecording(); } From 282f6a76d7299ba7390f180617543a2867f62281 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 13 May 2026 21:24:27 -0400 Subject: [PATCH 34/94] Added git status to a task --- apps/mobile/src/app/task/[id].tsx | 6 +- .../tasks/components/PrStatusBadge.tsx | 57 ++++++++++++++++ .../src/features/tasks/hooks/usePrStatus.ts | 65 +++++++++++++++++++ 3 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 apps/mobile/src/features/tasks/components/PrStatusBadge.tsx create mode 100644 apps/mobile/src/features/tasks/hooks/usePrStatus.ts diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 80c594b8d..f1eafa191 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -17,6 +17,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { FloatingBackButton } from "@/components/FloatingBackButton"; import { getTask, runTaskInCloud } from "@/features/tasks/api"; import { FloatingTaskHeader } from "@/features/tasks/components/FloatingTaskHeader"; +import { PrStatusBadge } from "@/features/tasks/components/PrStatusBadge"; import { TaskSessionView } from "@/features/tasks/components/TaskSessionView"; import { buildCloudPromptBlocks } from "@/features/tasks/composer/attachments/buildCloudPrompt"; import { serializeCloudPrompt } from "@/features/tasks/composer/attachments/cloudPrompt"; @@ -342,6 +343,7 @@ export default function TaskDetailScreen() { // 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 prUrl = task?.latest_run?.output?.pr_url as string | undefined; const [isStale, setIsStale] = useState(false); useEffect(() => { if (!isLocal || !session?.isPromptPending) { @@ -436,7 +438,9 @@ export default function TaskDetailScreen() { title={loading ? "Loading..." : task?.title || "Task"} subtitle={task?.repository ?? undefined} rightSlot={ - isLocal ? ( + prUrl ? ( + + ) : isLocal ? ( ActionSheetIOS.showActionSheetWithOptions( diff --git a/apps/mobile/src/features/tasks/components/PrStatusBadge.tsx b/apps/mobile/src/features/tasks/components/PrStatusBadge.tsx new file mode 100644 index 000000000..245464664 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/PrStatusBadge.tsx @@ -0,0 +1,57 @@ +import { GitMerge, GitPullRequest } from "phosphor-react-native"; +import { Linking, Pressable } from "react-native"; +import { toRgba, useThemeColors } from "@/lib/theme"; +import { usePrStatus } from "../hooks/usePrStatus"; + +interface PrStatusBadgeProps { + prUrl: string; +} + +// Mirrors the desktop "merged" PR color (Radix purple-9 family). Theme tokens +// don't include a purple, and merged-PR purple is recognisable enough that a +// fixed value works in both light and dark. +const MERGED_COLOR = "#8e4ec6"; + +export function PrStatusBadge({ prUrl }: PrStatusBadgeProps) { + const themeColors = useThemeColors(); + const { data: status } = usePrStatus(prUrl); + + const handlePress = () => { + Linking.openURL(prUrl).catch(() => {}); + }; + + let color: string = themeColors.gray[11]; + let Icon: typeof GitPullRequest = GitPullRequest; + let label = "Open PR"; + + if (status?.merged) { + color = MERGED_COLOR; + Icon = GitMerge; + label = "Open merged PR"; + } else if (status?.state === "closed") { + color = themeColors.status.error; + label = "Open closed PR"; + } else if (status?.draft) { + color = themeColors.gray[11]; + label = "Open draft PR"; + } else if (status?.state === "open") { + color = themeColors.status.success; + label = "Open PR"; + } + + return ( + + + + ); +} diff --git a/apps/mobile/src/features/tasks/hooks/usePrStatus.ts b/apps/mobile/src/features/tasks/hooks/usePrStatus.ts new file mode 100644 index 000000000..3f195fb2c --- /dev/null +++ b/apps/mobile/src/features/tasks/hooks/usePrStatus.ts @@ -0,0 +1,65 @@ +import { useQuery } from "@tanstack/react-query"; +import { logger } from "@/lib/logger"; + +const log = logger.scope("usePrStatus"); + +export interface PrStatus { + state: "open" | "closed"; + merged: boolean; + draft: boolean; +} + +function parsePrUrl( + prUrl: string, +): { owner: string; repo: string; number: string } | null { + const match = prUrl.match( + /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/, + ); + if (!match) return null; + return { owner: match[1], repo: match[2], number: match[3] }; +} + +export const prStatusKeys = { + byUrl: (prUrl: string) => ["pr-status", prUrl] as const, +}; + +// Fetches PR state via GitHub's public REST API. Public repos respond without +// auth; private repos return 404 — in that case we resolve to `null` and the +// UI falls back to a neutral icon (still tappable to open the PR). +export function usePrStatus(prUrl: string | null | undefined) { + return useQuery({ + queryKey: prStatusKeys.byUrl(prUrl ?? ""), + enabled: !!prUrl, + staleTime: 60_000, + retry: 1, + queryFn: async (): Promise => { + if (!prUrl) return null; + const parsed = parsePrUrl(prUrl); + if (!parsed) return null; + + try { + const res = await fetch( + `https://api.github.com/repos/${parsed.owner}/${parsed.repo}/pulls/${parsed.number}`, + { headers: { Accept: "application/vnd.github+json" } }, + ); + if (!res.ok) { + log.info("PR details unavailable", { status: res.status }); + return null; + } + const data = (await res.json()) as { + state: string; + merged: boolean; + draft: boolean; + }; + return { + state: data.state === "closed" ? "closed" : "open", + merged: !!data.merged, + draft: !!data.draft, + }; + } catch (err) { + log.warn("Failed to fetch PR status", err); + return null; + } + }, + }); +} From 80b20661fd74edfb6d0320efeeafebbb752ff23f Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 13 May 2026 21:28:00 -0400 Subject: [PATCH 35/94] Fixed task header fade --- .../tasks/components/FloatingTaskHeader.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/features/tasks/components/FloatingTaskHeader.tsx b/apps/mobile/src/features/tasks/components/FloatingTaskHeader.tsx index 832844ef8..7c5de27e4 100644 --- a/apps/mobile/src/features/tasks/components/FloatingTaskHeader.tsx +++ b/apps/mobile/src/features/tasks/components/FloatingTaskHeader.tsx @@ -38,9 +38,11 @@ export function FloatingTaskHeader({ // on iOS and fall back to the real inset on Android. const topInset = Platform.OS === "ios" ? 6 : insets.top; - // Fade height extends past the row so content scrolling up behind the title - // softens out instead of slamming into a hard edge. - const fadeHeight = topInset + 52; + // Fade height extends well past the title row so content scrolling up + // behind the header gets a long, gentle transition instead of crashing + // into the subtitle. Header row content sits in roughly the first + // (topInset + 44)pt; the rest is pure fade. + const fadeHeight = topInset + 96; return ( From ce8220ae5a2b241968c343cb33cc7cf343e0a66d Mon Sep 17 00:00:00 2001 From: Oliver Browne Date: Wed, 13 May 2026 21:31:58 -0400 Subject: [PATCH 36/94] swipe right on me baby --- apps/mobile/src/app/(tabs)/inbox.tsx | 63 ++- apps/mobile/src/app/(tabs)/settings.tsx | 31 ++ apps/mobile/src/app/_layout.tsx | 3 + apps/mobile/src/app/review.tsx | 66 +++ .../inbox/components/InboxViewToggle.tsx | 68 +++ .../inbox/components/SwipeableReportCard.tsx | 297 +++++++++++++ .../features/inbox/components/TinderView.tsx | 392 ++++++++++++++++++ .../inbox/stores/dismissedReportsStore.ts | 42 ++ 8 files changed, 956 insertions(+), 6 deletions(-) create mode 100644 apps/mobile/src/app/review.tsx create mode 100644 apps/mobile/src/features/inbox/components/InboxViewToggle.tsx create mode 100644 apps/mobile/src/features/inbox/components/SwipeableReportCard.tsx create mode 100644 apps/mobile/src/features/inbox/components/TinderView.tsx create mode 100644 apps/mobile/src/features/inbox/stores/dismissedReportsStore.ts diff --git a/apps/mobile/src/app/(tabs)/inbox.tsx b/apps/mobile/src/app/(tabs)/inbox.tsx index 3c92f0882..30b3c7bc6 100644 --- a/apps/mobile/src/app/(tabs)/inbox.tsx +++ b/apps/mobile/src/app/(tabs)/inbox.tsx @@ -1,25 +1,56 @@ import { useRouter } from "expo-router"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { FilterSheet } from "@/features/inbox/components/FilterSheet"; import { FloatingInboxHeader } from "@/features/inbox/components/FloatingInboxHeader"; +import { InboxViewToggle } from "@/features/inbox/components/InboxViewToggle"; import { ReportList } from "@/features/inbox/components/ReportList"; import { ReviewerFilterSheet } from "@/features/inbox/components/ReviewerFilterSheet"; +import { TinderView } from "@/features/inbox/components/TinderView"; import { useInboxReports } from "@/features/inbox/hooks/useInboxReports"; +import { useDismissedReportsStore } from "@/features/inbox/stores/dismissedReportsStore"; import { useInboxFilterStore } from "@/features/inbox/stores/inboxFilterStore"; +import { useInboxStore } from "@/features/inbox/stores/inboxStore"; import type { SignalReport } from "@/features/inbox/types"; +import { useIntegrations } from "@/features/tasks/hooks/useIntegrations"; + +type InboxViewMode = "list" | "tinder"; export default function InboxScreen() { const insets = useSafeAreaInsets(); const router = useRouter(); - const { isFetching, error } = useInboxReports(); + const { reports, isFetching, isLoading, error } = useInboxReports(); const [filterOpen, setFilterOpen] = useState(false); const [reviewerOpen, setReviewerOpen] = useState(false); + const [viewMode, setViewMode] = useState("list"); const reviewerFilterCount = useInboxFilterStore( (s) => s.suggestedReviewerFilter.length, ); + // ── Tinder mode data ────────────────────────────────────────────────────── + const dismissedIds = useDismissedReportsStore((s) => s.dismissedIds); + const setCurrentIndex = useInboxStore((s) => s.setCurrentIndex); + const { repositoryOptions } = useIntegrations(); + + // Same data as the list view, filtered to reports where the user is a + // suggested reviewer (the eye icon in the list) and not yet dismissed. + const tinderReports = useMemo( + () => + reports.filter( + (r) => r.is_suggested_reviewer && !dismissedIds.includes(r.id), + ), + [reports, dismissedIds], + ); + + // Reset card index when switching to tinder mode + useEffect(() => { + if (viewMode === "tinder") { + setCurrentIndex(0); + } + }, [viewMode, setCurrentIndex]); + + // ── List mode handlers ──────────────────────────────────────────────────── const handleReportPress = useCallback( (report: SignalReport) => { router.push(`/report/${report.id}`); @@ -27,16 +58,34 @@ export default function InboxScreen() { [router], ); + const handleTaskStarted = useCallback( + (taskId: string) => { + router.push(`/task/${taskId}`); + }, + [router], + ); + // Header occupies insets.top + 6 (top pad) + 40 (MenuButton) + 8 (bottom // pad), plus a small buffer so the first row isn't hugging the fade edge. const headerHeight = insets.top + 60; return ( - + {viewMode === "list" ? ( + + ) : ( + + + + )} setFilterOpen(true)} /> + + setFilterOpen(false)} /> s.dismissedIds.length); + const clearDismissed = useDismissedReportsStore((s) => s.clearDismissed); + const handleLogout = async () => { await logout(); router.replace("/auth"); @@ -169,6 +173,33 @@ export default function SettingsScreen() { + {/* Inbox */} + + Inbox + + + + Dismissed reports + + + {dismissedCount} report{dismissedCount !== 1 ? "s" : ""}{" "} + dismissed in review mode + + + 0 + ? "border-gray-6 bg-gray-3" + : "border-gray-4 opacity-40" + }`} + > + Clear + + + + {/* All Settings Button */} )} + {/* Tinder-style inbox review */} + + {/* Report detail - modal presentation */} s.dismissedIds); + const setCurrentIndex = useInboxStore((s) => s.setCurrentIndex); + const { repositoryOptions } = useIntegrations(); + + const { reports, isLoading } = useInboxReports(); + + const tinderReports = useMemo( + () => + reports.filter( + (r) => r.is_suggested_reviewer && !dismissedIds.includes(r.id), + ), + [reports, dismissedIds], + ); + + // Reset card index each time the review screen mounts + useEffect(() => { + setCurrentIndex(0); + }, [setCurrentIndex]); + + const handleTaskStarted = useCallback( + (taskId: string) => { + router.push(`/task/${taskId}`); + }, + [router], + ); + + if (isLoading) { + return ( + + + + Loading reports... + + ); + } + + return ( + + + + + + + ); +} diff --git a/apps/mobile/src/features/inbox/components/InboxViewToggle.tsx b/apps/mobile/src/features/inbox/components/InboxViewToggle.tsx new file mode 100644 index 000000000..9ffbad6bc --- /dev/null +++ b/apps/mobile/src/features/inbox/components/InboxViewToggle.tsx @@ -0,0 +1,68 @@ +import * as Haptics from "expo-haptics"; +import { Cards, ListBullets } from "phosphor-react-native"; +import { Pressable, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useThemeColors } from "@/lib/theme"; + +type InboxViewMode = "list" | "tinder"; + +interface InboxViewToggleProps { + mode: InboxViewMode; + onModeChange: (mode: InboxViewMode) => void; +} + +/** + * Floating pill toggle at the bottom of the inbox screen. Two icons — list + * view and tinder/card view — with the active one highlighted. + */ +export function InboxViewToggle({ mode, onModeChange }: InboxViewToggleProps) { + const insets = useSafeAreaInsets(); + const themeColors = useThemeColors(); + + const handlePress = (next: InboxViewMode) => { + if (next === mode) return; + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + onModeChange(next); + }; + + return ( + + + handlePress("list")} + hitSlop={4} + className={`items-center justify-center rounded-full px-5 py-3 ${mode === "list" ? "bg-accent-9" : "active:bg-gray-3"}`} + > + + + handlePress("tinder")} + hitSlop={4} + className={`items-center justify-center rounded-full px-5 py-3 ${mode === "tinder" ? "bg-accent-9" : "active:bg-gray-3"}`} + > + + + + + ); +} diff --git a/apps/mobile/src/features/inbox/components/SwipeableReportCard.tsx b/apps/mobile/src/features/inbox/components/SwipeableReportCard.tsx new file mode 100644 index 000000000..20b3c0dba --- /dev/null +++ b/apps/mobile/src/features/inbox/components/SwipeableReportCard.tsx @@ -0,0 +1,297 @@ +import { Text } from "@components/text"; +import { formatDistanceToNow } from "date-fns"; +import * as Haptics from "expo-haptics"; +import { GithubLogo, Lightning } from "phosphor-react-native"; +import { useRef } from "react"; +import { Animated, PanResponder, Pressable, View } from "react-native"; +import { MarkdownText } from "@/features/chat/components/MarkdownText"; +import { useThemeColors } from "@/lib/theme"; +import type { + SignalReport, + SignalReportPriority, + SignalReportStatus, +} from "../types"; +import { inboxStatusLabel } from "../utils"; + +const SWIPE_THRESHOLD = 120; +const TAP_THRESHOLD = 10; + +const statusColorMap: Record = { + ready: { bg: "bg-status-success/20", text: "text-status-success" }, + pending_input: { bg: "bg-accent-3", text: "text-accent-11" }, + in_progress: { bg: "bg-status-warning/20", text: "text-status-warning" }, + candidate: { bg: "bg-status-info/20", text: "text-status-info" }, + potential: { bg: "bg-gray-5/20", text: "text-gray-9" }, + failed: { bg: "bg-status-error/20", text: "text-status-error" }, + suppressed: { bg: "bg-gray-5/20", text: "text-gray-9" }, + deleted: { bg: "bg-gray-5/20", text: "text-gray-9" }, +}; + +const priorityColorMap: Record< + SignalReportPriority, + { bg: string; text: string } +> = { + P0: { bg: "bg-status-error/20", text: "text-status-error" }, + P1: { bg: "bg-status-warning/20", text: "text-status-warning" }, + P2: { bg: "bg-status-info/20", text: "text-status-info" }, + P3: { bg: "bg-gray-5/20", text: "text-gray-9" }, + P4: { bg: "bg-gray-5/20", text: "text-gray-9" }, +}; + +interface SwipeableReportCardProps { + report: SignalReport; + onDismiss: (reportId: string) => void; + onAccept: (report: SignalReport) => void; + onExpand: (report: SignalReport) => void; + isTopCard: boolean; + /** Vertical offset in px — cards further back sit lower. */ + stackOffset?: number; + /** Repository slug to show at the bottom of the card. */ + repo?: string | null; +} + +function StatusBadge({ status }: { status: SignalReportStatus }) { + const colors = statusColorMap[status] ?? statusColorMap.potential; + return ( + + + {inboxStatusLabel(status)} + + + ); +} + +function PriorityBadge({ priority }: { priority: SignalReportPriority }) { + const colors = priorityColorMap[priority] ?? priorityColorMap.P4; + return ( + + + {priority} + + + ); +} + +export function SwipeableReportCard({ + report, + onDismiss, + onAccept, + onExpand, + isTopCard, + stackOffset = 0, + repo, +}: SwipeableReportCardProps) { + const themeColors = useThemeColors(); + const translateX = useRef(new Animated.Value(0)).current; + const maxDxRef = useRef(0); + + const propsRef = useRef({ report, onDismiss, onAccept, onExpand }); + propsRef.current = { report, onDismiss, onAccept, onExpand }; + + const panResponder = useRef( + PanResponder.create({ + onStartShouldSetPanResponder: () => false, + onMoveShouldSetPanResponder: (_, gesture) => + Math.abs(gesture.dx) > 8 && Math.abs(gesture.dx) > Math.abs(gesture.dy), + onPanResponderTerminationRequest: () => false, + onPanResponderGrant: () => { + maxDxRef.current = 0; + }, + onPanResponderMove: (_, gesture) => { + maxDxRef.current = Math.max(maxDxRef.current, Math.abs(gesture.dx)); + translateX.setValue(gesture.dx); + }, + onPanResponderRelease: (_, gesture) => { + const p = propsRef.current; + + // Tap detection: no significant movement + if (maxDxRef.current < TAP_THRESHOLD) { + translateX.setValue(0); + p.onExpand(p.report); + return; + } + + if (gesture.dx > SWIPE_THRESHOLD) { + // Swipe right → accept + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + Animated.timing(translateX, { + toValue: 500, + duration: 200, + useNativeDriver: true, + }).start(() => { + p.onAccept(p.report); + }); + } else if (gesture.dx < -SWIPE_THRESHOLD) { + // Swipe left → dismiss + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + Animated.timing(translateX, { + toValue: -500, + duration: 200, + useNativeDriver: true, + }).start(() => { + p.onDismiss(p.report.id); + }); + } else { + // Spring back + Animated.spring(translateX, { + toValue: 0, + useNativeDriver: true, + tension: 40, + friction: 8, + }).start(); + } + }, + onPanResponderTerminate: () => { + Animated.spring(translateX, { + toValue: 0, + useNativeDriver: true, + }).start(); + }, + }), + ).current; + + const rotate = translateX.interpolate({ + inputRange: [-200, 0, 200], + outputRange: ["-12deg", "0deg", "12deg"], + extrapolate: "clamp", + }); + + const acceptOpacity = translateX.interpolate({ + inputRange: [0, 120], + outputRange: [0, 1], + extrapolate: "clamp", + }); + + const dismissOpacity = translateX.interpolate({ + inputRange: [-120, 0], + outputRange: [1, 0], + extrapolate: "clamp", + }); + + const updatedAt = formatDistanceToNow(new Date(report.updated_at), { + addSuffix: true, + }); + + // Non-top cards: static, offset down, no gestures + if (!isTopCard) { + return ( + + + + ); + } + + return ( + + {/* Accept overlay — LEFT side (visible when tilting right) */} + + + + + {/* Dismiss overlay — RIGHT side (visible when tilting left) */} + + + + + onExpand(report)} + className="flex-1 active:opacity-80" + > + + + + ); +} + +interface CardContentProps { + report: SignalReport; + updatedAt: string; + themeColors: ReturnType; + repo?: string | null; +} + +function CardContent({ + report, + updatedAt, + themeColors, + repo, +}: CardContentProps) { + return ( + + {/* Title */} + + {report.title ?? "Untitled report"} + + + {/* Badges row */} + + + {report.priority && } + + + {/* Summary — fills remaining space so footer sticks to the bottom */} + + {report.summary && } + + + {/* Footer: signal count + time */} + + + + + {report.signal_count} signal{report.signal_count !== 1 ? "s" : ""} + + + · + {updatedAt} + + + {/* Repo pill */} + {repo && ( + + + + + {repo} + + + + )} + + ); +} diff --git a/apps/mobile/src/features/inbox/components/TinderView.tsx b/apps/mobile/src/features/inbox/components/TinderView.tsx new file mode 100644 index 000000000..60dd6a63b --- /dev/null +++ b/apps/mobile/src/features/inbox/components/TinderView.tsx @@ -0,0 +1,392 @@ +import { Text } from "@components/text"; +import { formatDistanceToNow } from "date-fns"; +import * as Haptics from "expo-haptics"; +import { Check, GithubLogo, Lightning, X } from "phosphor-react-native"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + ActivityIndicator, + Modal, + Pressable, + ScrollView, + View, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { MarkdownText } from "@/features/chat/components/MarkdownText"; +import { createTask, runTaskInCloud } from "@/features/tasks/api"; +import type { + CreateTaskOptions, + RepositoryOption, +} from "@/features/tasks/types"; +import { logger } from "@/lib/logger"; +import { useThemeColors } from "@/lib/theme"; +import { getReportRepository } from "../api"; +import { useDismissedReportsStore } from "../stores/dismissedReportsStore"; +import { useInboxStore } from "../stores/inboxStore"; +import type { + SignalReport, + SignalReportPriority, + SignalReportStatus, +} from "../types"; +import { inboxStatusLabel } from "../utils"; +import { SwipeableReportCard } from "./SwipeableReportCard"; + +const log = logger.scope("tinder-view"); + +// ─── Badge helpers (duplicated from SwipeableReportCard to avoid barrel exports) ─── + +const statusColorMap: Record = { + ready: { bg: "bg-status-success/20", text: "text-status-success" }, + pending_input: { bg: "bg-accent-3", text: "text-accent-11" }, + in_progress: { bg: "bg-status-warning/20", text: "text-status-warning" }, + candidate: { bg: "bg-status-info/20", text: "text-status-info" }, + potential: { bg: "bg-gray-5/20", text: "text-gray-9" }, + failed: { bg: "bg-status-error/20", text: "text-status-error" }, + suppressed: { bg: "bg-gray-5/20", text: "text-gray-9" }, + deleted: { bg: "bg-gray-5/20", text: "text-gray-9" }, +}; + +const priorityColorMap: Record< + SignalReportPriority, + { bg: string; text: string } +> = { + P0: { bg: "bg-status-error/20", text: "text-status-error" }, + P1: { bg: "bg-status-warning/20", text: "text-status-warning" }, + P2: { bg: "bg-status-info/20", text: "text-status-info" }, + P3: { bg: "bg-gray-5/20", text: "text-gray-9" }, + P4: { bg: "bg-gray-5/20", text: "text-gray-9" }, +}; + +function StatusBadge({ status }: { status: SignalReportStatus }) { + const colors = statusColorMap[status] ?? statusColorMap.potential; + return ( + + + {inboxStatusLabel(status)} + + + ); +} + +function PriorityBadge({ priority }: { priority: SignalReportPriority }) { + const colors = priorityColorMap[priority] ?? priorityColorMap.P4; + return ( + + + {priority} + + + ); +} + +// ─── Empty state ─── + +function EmptyState() { + const dismissedCount = useDismissedReportsStore((s) => s.dismissedIds.length); + const clearDismissed = useDismissedReportsStore((s) => s.clearDismissed); + + return ( + + 🎉 + + All caught up! + + + You've reviewed all reports assigned to you. Check back later for new + ones. + + {dismissedCount > 0 && ( + + + Reset {dismissedCount} dismissed + + + )} + + ); +} + +// ─── Main component ─── + +interface TinderViewProps { + reports: SignalReport[]; + repositoryOptions: RepositoryOption[]; + onTaskStarted: (taskId: string) => void; + isLoading?: boolean; +} + +export function TinderView({ + reports, + repositoryOptions, + onTaskStarted, + isLoading, +}: TinderViewProps) { + const themeColors = useThemeColors(); + + // Store state + const currentIndex = useInboxStore((s) => s.currentIndex); + const advanceCard = useInboxStore((s) => s.advanceCard); + const dismissReport = useDismissedReportsStore((s) => s.dismissReport); + + // Local state + const [expandedReport, setExpandedReport] = useState( + null, + ); + const [creating, setCreating] = useState(false); + const [error, setError] = useState(null); + + const handleDismiss = useCallback( + (reportId: string) => { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + dismissReport(reportId); + // Don't advanceCard() — the parent filters dismissed IDs from the + // reports array, so removing the report shifts the next one into + // the current index position automatically. + }, + [dismissReport], + ); + + const handleAccept = useCallback( + async (report: SignalReport) => { + setCreating(true); + setError(null); + try { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + + // 1. Get the repo from the report artefacts + const repo = await getReportRepository(report.id); + + // 2. Find matching repository option to get integrationId + const match = repo + ? repositoryOptions.find( + (o) => o.repository.toLowerCase() === repo.toLowerCase(), + ) + : null; + + // 3. Create the task + const prompt = `Act on this signal report. Investigate the root cause, implement the fix, and open a PR if appropriate.\n\n${report.summary ?? ""}`; + const task = await createTask({ + description: prompt, + title: prompt.slice(0, 100), + repository: match?.repository ?? repo ?? undefined, + github_integration: match?.integrationId ?? undefined, + ...{ + signal_report: report.id, + signal_report_task_relationship: "implementation", + }, + } as CreateTaskOptions); + + // 4. Run it + await runTaskInCloud(task.id, { + pendingUserMessage: prompt, + runtimeAdapter: "claude", + model: "claude-opus-4-7", + initialPermissionMode: "plan", + }); + + advanceCard(); + onTaskStarted(task.id); + } catch (e) { + const message = + e instanceof Error ? e.message : "Failed to create task"; + log.error("Accept failed", message); + setError(message); + } finally { + setCreating(false); + } + }, + [repositoryOptions, advanceCard, onTaskStarted], + ); + + const currentReport = + currentIndex < reports.length ? reports[currentIndex] : null; + + // ── Repo resolution ──────────────────────────────────────────────────────── + const [repoMap, setRepoMap] = useState>({}); + const fetchingRef = useRef>(new Set()); + + // Lazily resolve repos for the next few visible cards + useEffect(() => { + const upcoming = reports.slice(currentIndex, currentIndex + 3); + for (const r of upcoming) { + if (r.id in repoMap || fetchingRef.current.has(r.id)) continue; + fetchingRef.current.add(r.id); + getReportRepository(r.id) + .then((repo) => setRepoMap((prev) => ({ ...prev, [r.id]: repo }))) + .catch(() => setRepoMap((prev) => ({ ...prev, [r.id]: null }))) + .finally(() => fetchingRef.current.delete(r.id)); + } + }, [reports, currentIndex, repoMap]); + + const STACK_OFFSET = 12; // px between each stacked card + const MAX_VISIBLE = 3; + + return ( + + {isLoading ? ( + + + + Loading reports... + + + ) : !currentReport ? ( + + + + ) : ( + + + {reports + .slice(currentIndex, currentIndex + MAX_VISIBLE) + .reverse() + .map((report, i, arr) => { + const depth = arr.length - 1 - i; + return ( + + ); + })} + + + )} + + {/* Error display */} + {error && ( + + {error} + + )} + + {/* Expanded report modal */} + setExpandedReport(null)} + > + {expandedReport && ( + + + {/* Header with close button */} + + + {expandedReport.title ?? "Untitled report"} + + setExpandedReport(null)} + hitSlop={10} + className="pl-3 active:opacity-70" + > + + + + + {/* Scrollable content */} + + {/* Badges */} + + + {expandedReport.priority && ( + + )} + + + {/* Summary */} + {expandedReport.summary && ( + + )} + + {/* Signal count + time */} + + + + + {expandedReport.signal_count} signal + {expandedReport.signal_count !== 1 ? "s" : ""} + + + + Updated{" "} + {formatDistanceToNow(new Date(expandedReport.updated_at), { + addSuffix: true, + })} + + + + {/* Repo pill */} + {repoMap[expandedReport.id] && ( + + + + + {repoMap[expandedReport.id]} + + + + )} + + + {/* Bottom action buttons */} + + { + handleDismiss(expandedReport.id); + setExpandedReport(null); + }} + className="h-16 w-16 items-center justify-center rounded-full border-2 border-status-error bg-status-error/10 active:bg-status-error/20" + hitSlop={8} + > + + + { + handleAccept(expandedReport); + setExpandedReport(null); + }} + className="h-16 w-16 items-center justify-center rounded-full border-2 border-status-success bg-status-success/10 active:bg-status-success/20" + disabled={creating} + hitSlop={8} + > + {creating ? ( + + ) : ( + + )} + + + + + )} + + + ); +} diff --git a/apps/mobile/src/features/inbox/stores/dismissedReportsStore.ts b/apps/mobile/src/features/inbox/stores/dismissedReportsStore.ts new file mode 100644 index 000000000..3dad48f7e --- /dev/null +++ b/apps/mobile/src/features/inbox/stores/dismissedReportsStore.ts @@ -0,0 +1,42 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +interface DismissedReportsState { + dismissedIds: string[]; +} + +interface DismissedReportsActions { + dismissReport: (reportId: string) => void; + undismissReport: (reportId: string) => void; + clearDismissed: () => void; +} + +type DismissedReportsStore = DismissedReportsState & DismissedReportsActions; + +export const useDismissedReportsStore = create()( + persist( + (set) => ({ + dismissedIds: [], + dismissReport: (reportId) => + set((state) => ({ + dismissedIds: state.dismissedIds.includes(reportId) + ? state.dismissedIds + : [...state.dismissedIds, reportId], + })), + undismissReport: (reportId) => + set((state) => ({ + dismissedIds: state.dismissedIds.filter((id) => id !== reportId), + })), + clearDismissed: () => set({ dismissedIds: [] }), + }), + { + name: "dismissed-reports-storage", + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => ({ dismissedIds: state.dismissedIds }), + }, + ), +); + +export const isDismissed = (reportId: string) => + useDismissedReportsStore.getState().dismissedIds.includes(reportId); From 2d501152cee5a836914f58d542dca53164dfd7ce Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 13 May 2026 21:48:51 -0400 Subject: [PATCH 37/94] Added file diff viewer --- apps/mobile/src/app/_layout.tsx | 10 ++ apps/mobile/src/app/pr-diff.tsx | 75 +++++++++++ apps/mobile/src/app/task/[id].tsx | 6 +- .../features/tasks/components/FileDiff.tsx | 119 ++++++++++++++++++ .../tasks/components/FloatingTaskHeader.tsx | 7 +- .../tasks/components/PrDiffStatsBadge.tsx | 54 ++++++++ .../tasks/components/PrStatusBadge.tsx | 2 +- .../features/tasks/hooks/usePrChangedFiles.ts | 66 ++++++++++ .../src/features/tasks/hooks/usePrStatus.ts | 6 + .../src/features/tasks/utils/parsePatch.ts | 46 +++++++ 10 files changed, 388 insertions(+), 3 deletions(-) create mode 100644 apps/mobile/src/app/pr-diff.tsx create mode 100644 apps/mobile/src/features/tasks/components/FileDiff.tsx create mode 100644 apps/mobile/src/features/tasks/components/PrDiffStatsBadge.tsx create mode 100644 apps/mobile/src/features/tasks/hooks/usePrChangedFiles.ts create mode 100644 apps/mobile/src/features/tasks/utils/parsePatch.ts diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 8ca3104f4..c01c55837 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -120,6 +120,16 @@ function RootLayoutNav({ isConnected }: RootLayoutNavProps) { headerTintColor: themeColors.gray[12], }} /> + ); } diff --git a/apps/mobile/src/app/pr-diff.tsx b/apps/mobile/src/app/pr-diff.tsx new file mode 100644 index 000000000..37f10e8fd --- /dev/null +++ b/apps/mobile/src/app/pr-diff.tsx @@ -0,0 +1,75 @@ +import { Text } from "@components/text"; +import { useLocalSearchParams } from "expo-router"; +import { ActivityIndicator, FlatList, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { FileDiff } from "@/features/tasks/components/FileDiff"; +import { + type ChangedFile, + usePrChangedFiles, +} from "@/features/tasks/hooks/usePrChangedFiles"; +import { useThemeColors } from "@/lib/theme"; + +export default function PrDiffScreen() { + const { prUrl } = useLocalSearchParams<{ prUrl?: string }>(); + const themeColors = useThemeColors(); + const insets = useSafeAreaInsets(); + + const { data: files, isLoading } = usePrChangedFiles(prUrl ?? null); + + if (isLoading) { + return ( + + + + ); + } + + if (!files || files.length === 0) { + return ( + + + No file diffs available.{"\n"} + Private repositories require authentication. + + + ); + } + + const totalAdditions = files.reduce((s, f) => s + f.additions, 0); + const totalDeletions = files.reduce((s, f) => s + f.deletions, 0); + + return ( + + data={files} + keyExtractor={(f) => f.filename} + renderItem={({ item }) => } + className="flex-1 bg-background" + contentContainerStyle={{ + paddingHorizontal: 12, + paddingTop: 8, + paddingBottom: insets.bottom + 16, + }} + ListHeaderComponent={ + + + {files.length} file{files.length === 1 ? "" : "s"} changed + + + + +{totalAdditions} + + + −{totalDeletions} + + + + } + /> + ); +} diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index f1eafa191..5e850c789 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -17,6 +17,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { FloatingBackButton } from "@/components/FloatingBackButton"; import { getTask, runTaskInCloud } from "@/features/tasks/api"; import { FloatingTaskHeader } from "@/features/tasks/components/FloatingTaskHeader"; +import { PrDiffStatsBadge } from "@/features/tasks/components/PrDiffStatsBadge"; import { PrStatusBadge } from "@/features/tasks/components/PrStatusBadge"; import { TaskSessionView } from "@/features/tasks/components/TaskSessionView"; import { buildCloudPromptBlocks } from "@/features/tasks/composer/attachments/buildCloudPrompt"; @@ -439,7 +440,10 @@ export default function TaskDetailScreen() { subtitle={task?.repository ?? undefined} rightSlot={ prUrl ? ( - + <> + + + ) : isLocal ? ( diff --git a/apps/mobile/src/features/tasks/components/FileDiff.tsx b/apps/mobile/src/features/tasks/components/FileDiff.tsx new file mode 100644 index 000000000..38e33f9af --- /dev/null +++ b/apps/mobile/src/features/tasks/components/FileDiff.tsx @@ -0,0 +1,119 @@ +import { Text } from "@components/text"; +import { Platform, View } from "react-native"; +import { toRgba, useThemeColors } from "@/lib/theme"; +import type { ChangedFile } from "../hooks/usePrChangedFiles"; +import { type DiffLine, parsePatch } from "../utils/parsePatch"; + +interface FileDiffProps { + file: ChangedFile; +} + +const MONO_FONT = Platform.OS === "ios" ? "Menlo" : "monospace"; + +function statusLabel(status: ChangedFile["status"]): string { + switch (status) { + case "added": + return "Added"; + case "removed": + return "Deleted"; + case "renamed": + return "Renamed"; + case "modified": + return "Modified"; + default: + return status; + } +} + +function linePrefix(type: DiffLine["type"]): string { + if (type === "add") return "+"; + if (type === "delete") return "-"; + return " "; +} + +export function FileDiff({ file }: FileDiffProps) { + const themeColors = useThemeColors(); + const hunks = file.patch ? parsePatch(file.patch) : []; + + const addBg = toRgba(themeColors.status.success, 0.14); + const delBg = toRgba(themeColors.status.error, 0.14); + const hunkBg = themeColors.gray[3]; + + const colorFor = (type: DiffLine["type"]): string => { + if (type === "add") return themeColors.status.success; + if (type === "delete") return themeColors.status.error; + if (type === "no-newline") return themeColors.gray[9]; + return themeColors.gray[12]; + }; + const bgFor = (type: DiffLine["type"]): string => { + if (type === "add") return addBg; + if (type === "delete") return delBg; + return "transparent"; + }; + + return ( + + + + {file.previous_filename + ? `${file.previous_filename} → ${file.filename}` + : file.filename} + + + +{file.additions} + + + −{file.deletions} + + + + {hunks.length > 0 ? ( + hunks.map((hunk) => ( + + + + {hunk.header} + + + {hunk.lines.map((line) => ( + + + {linePrefix(line.type)} + {line.content} + + + ))} + + )) + ) : ( + + + {statusLabel(file.status)} — no preview available + + + )} + + ); +} diff --git a/apps/mobile/src/features/tasks/components/FloatingTaskHeader.tsx b/apps/mobile/src/features/tasks/components/FloatingTaskHeader.tsx index 7c5de27e4..c12189fc1 100644 --- a/apps/mobile/src/features/tasks/components/FloatingTaskHeader.tsx +++ b/apps/mobile/src/features/tasks/components/FloatingTaskHeader.tsx @@ -88,7 +88,12 @@ export function FloatingTaskHeader({ ) : null} - {rightSlot} + + {rightSlot} + ); diff --git a/apps/mobile/src/features/tasks/components/PrDiffStatsBadge.tsx b/apps/mobile/src/features/tasks/components/PrDiffStatsBadge.tsx new file mode 100644 index 000000000..86a79a3ee --- /dev/null +++ b/apps/mobile/src/features/tasks/components/PrDiffStatsBadge.tsx @@ -0,0 +1,54 @@ +import { Text } from "@components/text"; +import { useRouter } from "expo-router"; +import { Pressable } from "react-native"; +import { useThemeColors } from "@/lib/theme"; +import { usePrStatus } from "../hooks/usePrStatus"; + +interface PrDiffStatsBadgeProps { + prUrl: string; +} + +function compact(n: number): string { + if (n < 1000) return String(n); + if (n < 10_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`; + if (n < 1_000_000) return `${Math.round(n / 1000)}k`; + return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`; +} + +export function PrDiffStatsBadge({ prUrl }: PrDiffStatsBadgeProps) { + const themeColors = useThemeColors(); + const router = useRouter(); + const { data } = usePrStatus(prUrl); + + // Hide while loading or when the GitHub API call failed (e.g. private repo + // without auth). The PR status icon next door still tells the user a PR + // exists; we just can't show the diff numbers. + if (!data) return null; + + const handlePress = () => { + router.push({ pathname: "/pr-diff", params: { prUrl } }); + }; + + return ( + + + +{compact(data.additions)} + + + −{compact(data.deletions)} + + + ); +} diff --git a/apps/mobile/src/features/tasks/components/PrStatusBadge.tsx b/apps/mobile/src/features/tasks/components/PrStatusBadge.tsx index 245464664..ead34a002 100644 --- a/apps/mobile/src/features/tasks/components/PrStatusBadge.tsx +++ b/apps/mobile/src/features/tasks/components/PrStatusBadge.tsx @@ -43,7 +43,7 @@ export function PrStatusBadge({ prUrl }: PrStatusBadgeProps) { ["pr-changed-files", prUrl] as const, +}; + +// Fetches the file-level diff for a PR via GitHub's public REST API. Public +// repos respond without auth; private repos return 404 — handled as an empty +// list so the screen can render a friendly "no preview" state. +export function usePrChangedFiles(prUrl: string | null | undefined) { + return useQuery({ + queryKey: prChangedFilesKeys.byUrl(prUrl ?? ""), + enabled: !!prUrl, + staleTime: 60_000, + retry: 1, + queryFn: async (): Promise => { + if (!prUrl) return []; + const p = parsePrUrl(prUrl); + if (!p) return []; + + try { + const res = await fetch( + `https://api.github.com/repos/${p.owner}/${p.repo}/pulls/${p.number}/files?per_page=100`, + { headers: { Accept: "application/vnd.github+json" } }, + ); + if (!res.ok) { + log.info("PR files unavailable", { status: res.status }); + return []; + } + return (await res.json()) as ChangedFile[]; + } catch (err) { + log.warn("Failed to fetch PR files", err); + return []; + } + }, + }); +} diff --git a/apps/mobile/src/features/tasks/hooks/usePrStatus.ts b/apps/mobile/src/features/tasks/hooks/usePrStatus.ts index 3f195fb2c..0c4ed080e 100644 --- a/apps/mobile/src/features/tasks/hooks/usePrStatus.ts +++ b/apps/mobile/src/features/tasks/hooks/usePrStatus.ts @@ -7,6 +7,8 @@ export interface PrStatus { state: "open" | "closed"; merged: boolean; draft: boolean; + additions: number; + deletions: number; } function parsePrUrl( @@ -50,11 +52,15 @@ export function usePrStatus(prUrl: string | null | undefined) { state: string; merged: boolean; draft: boolean; + additions?: number; + deletions?: number; }; return { state: data.state === "closed" ? "closed" : "open", merged: !!data.merged, draft: !!data.draft, + additions: data.additions ?? 0, + deletions: data.deletions ?? 0, }; } catch (err) { log.warn("Failed to fetch PR status", err); diff --git a/apps/mobile/src/features/tasks/utils/parsePatch.ts b/apps/mobile/src/features/tasks/utils/parsePatch.ts new file mode 100644 index 000000000..071ddf51b --- /dev/null +++ b/apps/mobile/src/features/tasks/utils/parsePatch.ts @@ -0,0 +1,46 @@ +export type DiffLineType = "context" | "add" | "delete" | "no-newline"; + +export interface DiffLine { + type: DiffLineType; + content: string; + // Stable position-based key (the line's index in the original patch + // string). Used as a React key so we don't trip the no-array-index-key + // rule when iterating in the renderer. + key: string; +} + +export interface Hunk { + header: string; + lines: DiffLine[]; +} + +// GitHub's `patch` field contains only hunk content (no `diff --git` / `---` +// / `+++` headers). Each hunk starts with `@@ -A,B +C,D @@` followed by lines +// prefixed by ' ' (context), '+' (addition), '-' (deletion), or '\' (no +// newline marker). We don't need line numbers on mobile — the prefix and +// background colour already convey direction. +export function parsePatch(patch: string): Hunk[] { + const hunks: Hunk[] = []; + const raw = patch.split("\n"); + let current: Hunk | null = null; + + for (let i = 0; i < raw.length; i++) { + const line = raw[i]; + if (line.startsWith("@@")) { + if (current) hunks.push(current); + current = { header: line, lines: [] }; + continue; + } + if (!current) continue; + + let type: DiffLineType; + if (line.startsWith("+")) type = "add"; + else if (line.startsWith("-")) type = "delete"; + else if (line.startsWith("\\")) type = "no-newline"; + else type = "context"; + + current.lines.push({ type, content: line.slice(1), key: String(i) }); + } + if (current) hunks.push(current); + return hunks; +} From 28aaa878cb671060fe35e38ddc7f72f19c398d75 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 13 May 2026 21:57:54 -0400 Subject: [PATCH 38/94] chat feed updates --- .../chat/components/GithubRefChip.tsx | 24 ++++ .../features/chat/components/HumanMessage.tsx | 69 ++++++++++- .../features/chat/components/MarkdownText.tsx | 108 +++++++++++++----- .../tasks/components/TaskSessionView.tsx | 56 +++++++-- apps/mobile/src/lib/githubIssueUrl.ts | 31 +++++ 5 files changed, 246 insertions(+), 42 deletions(-) create mode 100644 apps/mobile/src/features/chat/components/GithubRefChip.tsx create mode 100644 apps/mobile/src/lib/githubIssueUrl.ts diff --git a/apps/mobile/src/features/chat/components/GithubRefChip.tsx b/apps/mobile/src/features/chat/components/GithubRefChip.tsx new file mode 100644 index 000000000..e7b0a6fb5 --- /dev/null +++ b/apps/mobile/src/features/chat/components/GithubRefChip.tsx @@ -0,0 +1,24 @@ +import { Linking, Text } from "react-native"; + +interface GithubRefChipProps { + href: string; + kind: "issue" | "pr"; + label: string; +} + +// Rendered as a plain so it can be embedded inline within markdown +// paragraphs (RN does not allow children inside ). The icon +// from the desktop chip is omitted for the same reason — visual distinction +// comes from the chip background + monospace + accent color. +export function GithubRefChip({ href, kind, label }: GithubRefChipProps) { + return ( + Linking.openURL(href)} + className="rounded-md bg-gray-3 px-1.5 py-0.5 font-mono text-[11px] text-accent-11" + accessibilityRole="link" + accessibilityLabel={`GitHub ${kind === "pr" ? "pull request" : "issue"} ${label}`} + > + {label} + + ); +} diff --git a/apps/mobile/src/features/chat/components/HumanMessage.tsx b/apps/mobile/src/features/chat/components/HumanMessage.tsx index 6fc4ed675..a8102c684 100644 --- a/apps/mobile/src/features/chat/components/HumanMessage.tsx +++ b/apps/mobile/src/features/chat/components/HumanMessage.tsx @@ -1,9 +1,17 @@ import * as Clipboard from "expo-clipboard"; import * as Haptics from "expo-haptics"; -import { useCallback } from "react"; -import { Alert, Pressable, Text, View } from "react-native"; +import { LinearGradient } from "expo-linear-gradient"; +import { CaretDown, CaretUp } from "phosphor-react-native"; +import { useCallback, useState } from "react"; +import { + Alert, + type LayoutChangeEvent, + Pressable, + Text, + View, +} from "react-native"; import { formatRelativeTime } from "@/lib/format"; -import { useThemeColors } from "@/lib/theme"; +import { toRgba, useThemeColors } from "@/lib/theme"; import { MarkdownText } from "./MarkdownText"; interface HumanMessageProps { @@ -11,8 +19,16 @@ interface HumanMessageProps { timestamp?: number; } +const COLLAPSED_MAX_HEIGHT = 160; + export function HumanMessage({ content, timestamp }: HumanMessageProps) { const themeColors = useThemeColors(); + const [isExpanded, setIsExpanded] = useState(false); + const [contentHeight, setContentHeight] = useState(null); + + const isOverflowing = + contentHeight !== null && contentHeight > COLLAPSED_MAX_HEIGHT; + const collapse = isOverflowing && !isExpanded; const handleLongPress = useCallback(() => { Clipboard.setStringAsync(content).then(() => { @@ -21,6 +37,10 @@ export function HumanMessage({ content, timestamp }: HumanMessageProps) { }); }, [content]); + const handleLayout = useCallback((e: LayoutChangeEvent) => { + setContentHeight(e.nativeEvent.layout.height); + }, []); + return ( @@ -28,7 +48,48 @@ export function HumanMessage({ content, timestamp }: HumanMessageProps) { className="mt-3 border-l-2 bg-gray-2 py-2 pr-3 pl-3" style={{ borderColor: themeColors.accent[9] }} > - + + + + + {collapse && ( + + )} + + {isOverflowing && ( + setIsExpanded((v) => !v)} + hitSlop={6} + className="mt-1 flex-row items-center gap-1 self-start" + > + {isExpanded ? ( + + ) : ( + + )} + + {isExpanded ? "Show less" : "Show more"} + + + )} {timestamp && ( diff --git a/apps/mobile/src/features/chat/components/MarkdownText.tsx b/apps/mobile/src/features/chat/components/MarkdownText.tsx index 03ba01ea4..d3e850b4f 100644 --- a/apps/mobile/src/features/chat/components/MarkdownText.tsx +++ b/apps/mobile/src/features/chat/components/MarkdownText.tsx @@ -1,7 +1,9 @@ import { useMemo } from "react"; import { Linking, ScrollView, Text, View } from "react-native"; +import { parseGithubIssueUrl } from "@/lib/githubIssueUrl"; import { getColorForClass, highlightCode } from "@/lib/syntax-highlight"; import { useThemeColors } from "@/lib/theme"; +import { GithubRefChip } from "./GithubRefChip"; interface MarkdownTextProps { content: string; @@ -200,9 +202,10 @@ function openUrl(url: string) { function renderInline(text: string): React.ReactNode[] { const nodes: React.ReactNode[] = []; - // Links must come first to avoid bold/italic consuming text inside [] + // Links must come first to avoid bold/italic consuming text inside []. + // Order after links: strikethrough, bold, italic, inline code. const pattern = - /(\[([^\]]+)\]\(([^)]+)\)|\*\*(.+?)\*\*|\*(.+?)\*|`([^`]+)`)/g; + /(\[([^\]]+)\]\(([^)]+)\)|~~(.+?)~~|\*\*(.+?)\*\*|\*(.+?)\*|`([^`]+)`)/g; let lastIndex = 0; let match: RegExpExecArray | null = null; @@ -214,38 +217,63 @@ function renderInline(text: string): React.ReactNode[] { if (match[2] && match[3]) { // Link: [text](url) + const linkText = match[2]; const url = match[3]; + const githubRef = parseGithubIssueUrl(url); + if (githubRef) { + const isAutoLink = linkText === url; + const label = isAutoLink + ? `${githubRef.owner}/${githubRef.repo}#${githubRef.number}` + : linkText; + nodes.push( + , + ); + } else { + nodes.push( + openUrl(url)} + > + {linkText} + {" ↗"} + , + ); + } + } else if (match[4]) { + // Strikethrough: ~~text~~ nodes.push( - openUrl(url)} - > - {match[2]} + + {match[4]} , ); - } else if (match[4]) { + } else if (match[5]) { // Bold nodes.push( - {match[4]} + {match[5]} , ); - } else if (match[5]) { + } else if (match[6]) { // Italic nodes.push( - {match[5]} + {match[6]} , ); - } else if (match[6]) { + } else if (match[7]) { // Inline code nodes.push( - {match[6]} + {match[7]} , ); } @@ -319,19 +347,43 @@ export function MarkdownText({ content }: MarkdownTextProps) { case "list": return ( - {block.items?.map((item, idx) => ( - - - {block.ordered ? `${idx + 1}.` : "•"} - - - {renderInline(item)} - - - ))} + {block.items?.map((item, idx) => { + const taskMatch = !block.ordered + ? item.match(/^\[([ xX])\]\s+(.*)$/) + : null; + const isTask = taskMatch !== null; + const isChecked = isTask && /[xX]/.test(taskMatch[1]); + const itemText = isTask ? taskMatch[2] : item; + return ( + + {isTask ? ( + + {isChecked ? "☑" : "☐"} + + ) : ( + + {block.ordered ? `${idx + 1}.` : "•"} + + )} + + {renderInline(itemText)} + + + ); + })} ); @@ -409,7 +461,7 @@ export function MarkdownText({ content }: MarkdownTextProps) { case "blockquote": return ( - + {renderInline(block.content)} diff --git a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx index b3fbf7138..c1a07b1a6 100644 --- a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx +++ b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx @@ -385,22 +385,58 @@ function processNewEvents( return { messages: state.lastSnapshot, plan: state.plan }; } +const THOUGHT_COLLAPSED_LINE_COUNT = 5; + function CollapsedThought({ content }: { content: string }) { const themeColors = useThemeColors(); const [expanded, setExpanded] = useState(false); + const [showAllLines, setShowAllLines] = useState(false); + + const hasContent = content.trim().length > 0; + const contentLines = content.split("\n"); + const isLineCollapsible = + hasContent && contentLines.length > THOUGHT_COLLAPSED_LINE_COUNT; + const hiddenLineCount = contentLines.length - THOUGHT_COLLAPSED_LINE_COUNT; + const displayedContent = + showAllLines || !isLineCollapsible + ? content + : contentLines.slice(0, THOUGHT_COLLAPSED_LINE_COUNT).join("\n"); return ( - setExpanded(!expanded)} className="px-4 py-0.5"> - - - Thought - - {expanded && ( - - {content} - + + { + if (!hasContent) return; + setExpanded((v) => !v); + if (!expanded) setShowAllLines(false); + }} + className="flex-row items-center gap-2" + > + + Thinking + + {expanded && hasContent && ( + + + {displayedContent} + + {isLineCollapsible && !showAllLines && ( + setShowAllLines(true)} + className="mt-1 self-start" + hitSlop={6} + > + + +{hiddenLineCount} more lines + + + )} + )} - + ); } diff --git a/apps/mobile/src/lib/githubIssueUrl.ts b/apps/mobile/src/lib/githubIssueUrl.ts new file mode 100644 index 000000000..1214102c8 --- /dev/null +++ b/apps/mobile/src/lib/githubIssueUrl.ts @@ -0,0 +1,31 @@ +export type GithubRefKind = "issue" | "pr"; + +export interface ParsedGithubIssueUrl { + kind: GithubRefKind; + owner: string; + repo: string; + number: number; + normalizedUrl: string; +} + +const GITHUB_ISSUE_URL_PATTERN = + /^https?:\/\/github\.com\/([^/\s]+)\/([^/\s]+)\/(issues|pull)\/(\d+)(?:[/?#].*)?$/; + +export function parseGithubIssueUrl(text: string): ParsedGithubIssueUrl | null { + const trimmed = text.trim(); + const match = trimmed.match(GITHUB_ISSUE_URL_PATTERN); + if (!match) return null; + + const [, owner, repo, segment, rawNumber] = match; + const number = Number(rawNumber); + if (!Number.isInteger(number) || number <= 0) return null; + + const kind: GithubRefKind = segment === "pull" ? "pr" : "issue"; + return { + kind, + owner, + repo, + number, + normalizedUrl: `https://github.com/${owner}/${repo}/${segment}/${number}`, + }; +} From c846cfd447191f05527fbab928585e2982372091 Mon Sep 17 00:00:00 2001 From: Annika <14750837+annikaschmid@users.noreply.github.com> Date: Wed, 13 May 2026 22:06:03 -0400 Subject: [PATCH 39/94] Improve mobile tool call details and session activity --- apps/mobile/src/app/task/[id].tsx | 22 +-- .../features/chat/components/ToolMessage.tsx | 95 ++++++++++++- .../chat/utils/posthogExecDisplay.test.ts | 108 +++++++++++++++ .../features/chat/utils/posthogExecDisplay.ts | 109 +++++++++++++++ .../chat/utils/thinkingMessages.test.ts | 23 +++ .../features/chat/utils/thinkingMessages.ts | 20 ++- .../tasks/components/TaskSessionView.tsx | 70 +++++++++- .../tasks/utils/sessionActivity.test.ts | 131 ++++++++++++++++++ .../features/tasks/utils/sessionActivity.ts | 121 ++++++++++++++++ 9 files changed, 671 insertions(+), 28 deletions(-) create mode 100644 apps/mobile/src/features/chat/utils/posthogExecDisplay.test.ts create mode 100644 apps/mobile/src/features/chat/utils/posthogExecDisplay.ts create mode 100644 apps/mobile/src/features/chat/utils/thinkingMessages.test.ts create mode 100644 apps/mobile/src/features/tasks/utils/sessionActivity.test.ts create mode 100644 apps/mobile/src/features/tasks/utils/sessionActivity.ts diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index f3b11890f..63339b0c8 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -31,6 +31,7 @@ import { taskKeys } from "@/features/tasks/hooks/useTasks"; import { useTaskSessionStore } from "@/features/tasks/stores/taskSessionStore"; import { useTaskStore } from "@/features/tasks/stores/taskStore"; import type { Task } from "@/features/tasks/types"; +import { getSessionActivityPhase } from "@/features/tasks/utils/sessionActivity"; import { logger } from "@/lib/logger"; import { useThemeColors } from "@/lib/theme"; @@ -367,24 +368,9 @@ export default function TaskDetailScreen() { } }, [taskId, task, disconnectFromTask, connectToTask, updateTaskInCache]); - 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; + const activityPhase = getSessionActivityPhase({ retrying, session }); + const isConnecting = activityPhase === "connecting"; + const isThinking = activityPhase === "working"; const showAutomationContext = fromAutomation === "1" || task?.origin_product === "automation"; const automationContextLabel = diff --git a/apps/mobile/src/features/chat/components/ToolMessage.tsx b/apps/mobile/src/features/chat/components/ToolMessage.tsx index de3417823..d19a32b8e 100644 --- a/apps/mobile/src/features/chat/components/ToolMessage.tsx +++ b/apps/mobile/src/features/chat/components/ToolMessage.tsx @@ -22,6 +22,11 @@ import { TouchableOpacity, View, } from "react-native"; +import { + formatPosthogExecBody, + getPostHogExecDisplay, + isPostHogExecTool, +} from "@/features/chat/utils/posthogExecDisplay"; import { getColorForClass, highlightCode, @@ -59,6 +64,8 @@ const kindIcons: Record = { other: Wrench, }; +const POSTHOG_EXEC_INPUT_PREVIEW_MAX_LENGTH = 120; + 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. @@ -142,6 +149,7 @@ interface CreateTaskArgs { export interface ToolMessageProps { toolName: string; + rawToolName?: string; kind?: ToolKind; status: ToolStatus; args?: Record; @@ -316,6 +324,10 @@ function shortenPath(path: string, maxLen = 48): string { return `…/${parts.slice(-2).join("/")}`; } +function truncateText(text: string, maxLen: number): string { + return text.length > maxLen ? `${text.slice(0, maxLen)}...` : text; +} + // 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. @@ -752,6 +764,7 @@ function CreateTaskPreview({ export function ToolMessage({ toolName, + rawToolName, kind, status, args, @@ -766,9 +779,10 @@ export function ToolMessage({ const isFailed = status === "error"; const displayTitle = formatToolTitle(toolName, args); const KindIcon = kind ? kindIcons[kind] : Wrench; + const effectiveToolName = rawToolName ?? toolName; const isCreateTask = - toolName.toLowerCase() === "create_task" || kind === "create_task"; + effectiveToolName.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 @@ -917,6 +931,85 @@ export function ToolMessage({ const isRunning = status === "running"; const isCompleted = status === "completed"; const resultText = extractResultText(result); + const isPostHogExec = isPostHogExecTool(effectiveToolName); + const posthogExecDisplay = isPostHogExec ? getPostHogExecDisplay(args) : null; + + if (isPostHogExec) { + const label = posthogExecDisplay?.label ?? "exec"; + const inputPreview = posthogExecDisplay?.input; + const fullInput = + formatPosthogExecBody(posthogExecDisplay?.input) ?? + (typeof args?.command === "string" ? args.command : undefined); + const outputText = resultText ? stripAnsi(resultText) : null; + const hasOutput = !!outputText?.trim(); + const isExpandable = !!fullInput || hasOutput; + + return ( + + isExpandable && setIsOpen(!isOpen)} + className="flex-row items-center gap-2" + disabled={!isExpandable} + > + {isLoading ? ( + + ) : ( + + )} + + posthog - {label} (MCP) + + {isPending && ( + Queued + )} + {isFailed && ( + + Failed + + )} + + + {inputPreview && !isPending && ( + + {truncateText(inputPreview, POSTHOG_EXEC_INPUT_PREVIEW_MAX_LENGTH)} + + )} + + {isOpen && fullInput && ( + + + {fullInput} + + + )} + + {isOpen && isCompleted && hasOutput && outputText && ( + + + {outputText} + + + )} + + ); + } // Execute/Bash: show description + command subtitle + expandable output if (resolvedKind === "execute") { diff --git a/apps/mobile/src/features/chat/utils/posthogExecDisplay.test.ts b/apps/mobile/src/features/chat/utils/posthogExecDisplay.test.ts new file mode 100644 index 000000000..c2a81f3e0 --- /dev/null +++ b/apps/mobile/src/features/chat/utils/posthogExecDisplay.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { + formatPosthogExecBody, + getPostHogExecDisplay, + isPostHogExecTool, +} from "./posthogExecDisplay"; + +describe("isPostHogExecTool", () => { + it("matches the bare posthog exec tool", () => { + expect(isPostHogExecTool("mcp__posthog__exec")).toBe(true); + }); + + it("matches plugin-prefixed variants", () => { + expect(isPostHogExecTool("mcp__posthog_posthog__exec")).toBe(true); + expect(isPostHogExecTool("mcp__plugin_posthog_posthog__exec")).toBe(true); + expect(isPostHogExecTool("mcp__posthog_cloud__exec")).toBe(true); + }); + + it("rejects other MCP tools", () => { + expect(isPostHogExecTool("mcp__posthog__list")).toBe(false); + expect(isPostHogExecTool("mcp__other__exec")).toBe(false); + expect(isPostHogExecTool("Bash")).toBe(false); + }); +}); + +describe("getPostHogExecDisplay", () => { + it("collapses `call ` to the bare sub-tool label", () => { + expect(getPostHogExecDisplay({ command: "call experiment-list" })).toEqual({ + label: "experiment-list", + input: undefined, + }); + }); + + it("uses the JSON args portion as input", () => { + expect( + getPostHogExecDisplay({ + command: 'call execute-sql {"query":"SELECT 1"}', + }), + ).toEqual({ + label: "execute-sql", + input: '{"query":"SELECT 1"}', + }); + }); + + it("formats `info ` with no args", () => { + expect(getPostHogExecDisplay({ command: "info execute-sql" })).toEqual({ + label: "Read execute-sql", + input: undefined, + }); + }); + + it("formats `schema ` as a dotted locator", () => { + expect( + getPostHogExecDisplay({ + command: "schema query-trends series", + }), + ).toEqual({ + label: "Inspect query-trends.series", + input: undefined, + }); + }); + + it("uses the regex pattern as input for search", () => { + expect(getPostHogExecDisplay({ command: "search query-" })).toEqual({ + label: "Search tools", + input: "query-", + }); + }); + + it("formats bare `tools`", () => { + expect(getPostHogExecDisplay({ command: "tools" })).toEqual({ + label: "List tools", + input: undefined, + }); + }); + + it("prefers an explicit object `input` over command-embedded args", () => { + expect( + getPostHogExecDisplay({ + command: "call execute-sql", + input: { query: "SELECT 1" }, + }), + ).toEqual({ + label: "execute-sql", + input: '{"query":"SELECT 1"}', + }); + }); + + it("returns null for malformed input", () => { + expect(getPostHogExecDisplay(undefined)).toBeNull(); + expect(getPostHogExecDisplay({})).toBeNull(); + expect(getPostHogExecDisplay({ command: "call" })).toBeNull(); + }); +}); + +describe("formatPosthogExecBody", () => { + it("pretty-prints JSON object payloads", () => { + expect(formatPosthogExecBody('{"id":3}')).toBe('{\n "id": 3\n}'); + }); + + it("returns non-JSON strings unchanged", () => { + expect(formatPosthogExecBody("query-")).toBe("query-"); + }); + + it("returns JSON primitives unchanged", () => { + expect(formatPosthogExecBody("42")).toBe("42"); + }); +}); diff --git a/apps/mobile/src/features/chat/utils/posthogExecDisplay.ts b/apps/mobile/src/features/chat/utils/posthogExecDisplay.ts new file mode 100644 index 000000000..d46e759f2 --- /dev/null +++ b/apps/mobile/src/features/chat/utils/posthogExecDisplay.ts @@ -0,0 +1,109 @@ +/** + * Mirrors the desktop PostHog MCP exec display logic so mobile unwraps the + * dispatched sub-tool instead of showing the raw `exec` transport wrapper. + * + * Supported verbs: + * tools + * search + * info + * schema [field_path] + * call [--json] + */ + +const POSTHOG_EXEC_TOOL_RE = /^mcp__(?:plugin_)?posthog(?:_[^_]+)*__exec$/; + +const POSTHOG_VERB_RE = + /^\s*(tools|search|info|schema|call)(?:\s+([\s\S]*))?\s*$/; +const POSTHOG_CALL_BODY_RE = /^(?:--json\s+)?([a-zA-Z0-9_-]+)\s*([\s\S]*)$/; +const POSTHOG_TOOL_NAME_RE = /^([a-zA-Z0-9_-]+)\s*([\s\S]*)$/; + +export interface PostHogExecDisplay { + label: string; + input?: string; +} + +export function isPostHogExecTool(toolName: string): boolean { + return POSTHOG_EXEC_TOOL_RE.test(toolName); +} + +export function getPostHogExecDisplay( + toolInput: unknown, +): PostHogExecDisplay | null { + if (!toolInput || typeof toolInput !== "object") return null; + const obj = toolInput as { command?: unknown; input?: unknown }; + + if (typeof obj.command !== "string") return null; + const verbMatch = obj.command.match(POSTHOG_VERB_RE); + if (!verbMatch) return null; + + const verb = verbMatch[1] as "tools" | "search" | "info" | "schema" | "call"; + const rest = (verbMatch[2] ?? "").trim(); + const explicitInput = readExplicitInput(obj.input); + + switch (verb) { + case "tools": + return { label: "List tools", input: undefined }; + + case "search": + return { + label: "Search tools", + input: explicitInput ?? (rest.length > 0 ? rest : undefined), + }; + + case "info": + return rest.length > 0 + ? { label: `Read ${rest}`, input: undefined } + : { label: "Read tool", input: undefined }; + + case "schema": { + const match = rest.match(POSTHOG_TOOL_NAME_RE); + if (!match) return { label: "Inspect schema", input: undefined }; + const subTool = match[1]; + const fieldPath = (match[2] ?? "").trim(); + const path = + explicitInput ?? (fieldPath.length > 0 ? fieldPath : undefined); + return { + label: path + ? `Inspect ${subTool}.${path}` + : `Inspect ${subTool} fields`, + input: undefined, + }; + } + + case "call": { + const match = rest.match(POSTHOG_CALL_BODY_RE); + if (!match) return null; + const subTool = match[1]; + const args = (match[2] ?? "").trim(); + return { + label: subTool, + input: explicitInput ?? (args.length > 0 ? args : undefined), + }; + } + } +} + +function readExplicitInput(value: unknown): string | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value === "string") return value.trim() || undefined; + try { + return JSON.stringify(value); + } catch { + return undefined; + } +} + +export function formatPosthogExecBody( + input: string | undefined, +): string | undefined { + if (!input) return undefined; + try { + const parsed = JSON.parse(input); + if (parsed && typeof parsed === "object") { + return JSON.stringify(parsed, null, 2); + } + } catch { + // Not JSON; show the raw input. + } + return input; +} diff --git a/apps/mobile/src/features/chat/utils/thinkingMessages.test.ts b/apps/mobile/src/features/chat/utils/thinkingMessages.test.ts new file mode 100644 index 000000000..de528d156 --- /dev/null +++ b/apps/mobile/src/features/chat/utils/thinkingMessages.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from "vitest"; +import { + getRandomThinkingActivity, + getRandomThinkingMessage, + THINKING_MESSAGES, +} from "./thinkingMessages"; + +describe("thinkingMessages", () => { + it("includes the whimsical cloud-run loading messages from desktop", () => { + expect(THINKING_MESSAGES).toContain("Kerfuffling"); + expect(THINKING_MESSAGES).toContain("Flibbertigibbeting"); + expect(THINKING_MESSAGES).toContain("Discombobulating"); + }); + + it("returns a bare activity label and a message variant with ellipsis", () => { + const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0); + + expect(getRandomThinkingActivity()).toBe("Booping"); + expect(getRandomThinkingMessage()).toBe("Booping..."); + + randomSpy.mockRestore(); + }); +}); diff --git a/apps/mobile/src/features/chat/utils/thinkingMessages.ts b/apps/mobile/src/features/chat/utils/thinkingMessages.ts index d3535a783..35bd41b35 100644 --- a/apps/mobile/src/features/chat/utils/thinkingMessages.ts +++ b/apps/mobile/src/features/chat/utils/thinkingMessages.ts @@ -73,10 +73,26 @@ export const THINKING_MESSAGES = [ "Scuttling", "Framing", "Sharpening", + "Flibbertigibbeting", + "Kerfuffling", + "Dithering", + "Discombobulating", + "Rambling", + "Befuddling", + "Waffling", + "Muckling", + "Hobnobbing", + "Galumphing", + "Puttering", + "Whiffling", "Thinking", ]; -export function getRandomThinkingMessage(): string { +export function getRandomThinkingActivity(): string { const randomIndex = Math.floor(Math.random() * THINKING_MESSAGES.length); - return `${THINKING_MESSAGES[randomIndex]}...`; + return THINKING_MESSAGES[randomIndex]; +} + +export function getRandomThinkingMessage(): string { + return `${getRandomThinkingActivity()}...`; } diff --git a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx index b3fbf7138..47dfdf4bd 100644 --- a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx +++ b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx @@ -20,6 +20,7 @@ import { ToolMessage, type ToolStatus, } from "@/features/chat"; +import { getRandomThinkingActivity } from "@/features/chat/utils/thinkingMessages"; import { useThemeColors } from "@/lib/theme"; import type { PlanEntry, SessionEvent, SessionNotification } from "../types"; import { PlanStatusBar } from "./PlanStatusBar"; @@ -47,6 +48,7 @@ interface TaskSessionViewProps { interface ToolData { toolName: string; + rawToolName?: string; toolCallId: string; status: ToolStatus; args?: Record; @@ -131,6 +133,7 @@ function parseSessionNotification( type: "tool", toolData: { toolName: update.title ?? "Unknown Tool", + rawToolName: meta?.toolName, toolCallId: update.toolCallId ?? "", status: mapToolStatus(update.status), args: update.rawInput, @@ -145,6 +148,7 @@ function parseSessionNotification( type: "tool_update", toolData: { toolName: update.title ?? "Unknown Tool", + rawToolName: meta?.toolName, toolCallId: update.toolCallId ?? "", status: mapToolStatus(update.status), args: update.rawInput, @@ -176,6 +180,20 @@ function isQuestionTool(toolData?: ToolData): boolean { return false; } +function hasPendingQuestionMessage(message: ParsedMessage): boolean { + const isPendingQuestion = + message.type === "tool" && + isQuestionTool(message.toolData) && + (message.toolData?.status === "pending" || + message.toolData?.status === "running"); + + if (isPendingQuestion) { + return true; + } + + return message.children?.some(hasPendingQuestionMessage) ?? false; +} + // Mutable processor state persisted across renders via useRef. // Only new events (past processedIdx) are processed on each call. interface EventProcessorState { @@ -568,7 +586,10 @@ function AgentToolCard({ { const interval = setInterval(() => { @@ -612,13 +634,21 @@ function ThinkingIndicator() { return () => clearInterval(interval); }, []); + useEffect(() => { + const interval = setInterval(() => { + setActivity(getRandomThinkingActivity()); + }, 2000); + return () => clearInterval(interval); + }, []); + return ( - Thinking{".".repeat(dots)} + {activity} + {".".repeat(dots)} @@ -630,9 +660,9 @@ function ThinkingIndicator() { } function ConnectingIndicator() { - const themeColors = useThemeColors(); const [dots, setDots] = useState(1); const elapsed = useElapsedTimer(); + const themeColors = useThemeColors(); useEffect(() => { const interval = setInterval(() => { @@ -714,6 +744,29 @@ export function TaskSessionView({ const reversedMessages = useMemo(() => [...messages].reverse(), [messages]); const themeColors = useThemeColors(); const flatListRef = useRef(null); + const hasPendingQuestion = useMemo( + () => messages.some(hasPendingQuestionMessage), + [messages], + ); + const showActivityIndicator = agentActive && !hasPendingQuestion; + const effectiveContentContainerStyle = useMemo(() => { + const baseStyle = (contentContainerStyle ?? {}) as { + paddingTop?: number; + [key: string]: unknown; + }; + + if (!showActivityIndicator) { + return baseStyle; + } + + return { + ...baseStyle, + // In the inverted list, paddingTop becomes visual bottom spacing. + // Reserve enough room so the floating activity indicator never + // covers the last visible row while the agent is working. + paddingTop: (baseStyle.paddingTop ?? 0) + 28, + }; + }, [contentContainerStyle, showActivityIndicator]); // Inverted FlatList: scrollY is the distance from the visual bottom, so // any non-trivial value means the user has scrolled up from the latest // message. Use a small threshold to ignore iOS bounce. @@ -765,7 +818,10 @@ export function TaskSessionView({ return ( item.id} inverted - contentContainerStyle={contentContainerStyle} + contentContainerStyle={effectiveContentContainerStyle} keyboardDismissMode="interactive" keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator @@ -844,7 +900,7 @@ export function TaskSessionView({ /> {/* Thinking/connecting indicators absolutely positioned above the Composer area. Rendered outside FlatList to avoid inverted-list double-mount bugs. */} - {(isConnecting || isThinking) && ( + {showActivityIndicator && ( {isConnecting ? ( diff --git a/apps/mobile/src/features/tasks/utils/sessionActivity.test.ts b/apps/mobile/src/features/tasks/utils/sessionActivity.test.ts new file mode 100644 index 000000000..79cfd09bf --- /dev/null +++ b/apps/mobile/src/features/tasks/utils/sessionActivity.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import type { SessionEvent } from "../types"; +import { + getSessionActivityPhase, + isSessionAwaitingUserInput, +} from "./sessionActivity"; + +function buildQuestionToolCall( + status: "pending" | "in_progress" | "completed", +) { + return { + type: "session_update", + ts: 1, + notification: { + update: { + sessionUpdate: "tool_call", + toolCallId: "question-1", + status, + rawInput: { + questions: [{ question: "Proceed?", options: [] }], + }, + _meta: { + claudeCode: { + toolName: "AskUserQuestion", + }, + }, + }, + }, + } satisfies SessionEvent; +} + +describe("getSessionActivityPhase", () => { + it("treats retrying as connecting", () => { + expect( + getSessionActivityPhase({ + retrying: true, + session: { isPromptPending: true, awaitingAgentOutput: false }, + }), + ).toBe("connecting"); + }); + + it("stays connecting until the agent emits visible output", () => { + expect( + getSessionActivityPhase({ + retrying: false, + session: { isPromptPending: true, awaitingAgentOutput: true }, + }), + ).toBe("connecting"); + }); + + it("shows working only after the agent is actively in a turn", () => { + expect( + getSessionActivityPhase({ + retrying: false, + session: { isPromptPending: true, awaitingAgentOutput: false }, + }), + ).toBe("working"); + }); + + it("returns idle once the agent is no longer working", () => { + expect( + getSessionActivityPhase({ + retrying: false, + session: { isPromptPending: false, awaitingAgentOutput: false }, + }), + ).toBe("idle"); + + expect( + getSessionActivityPhase({ + retrying: false, + session: { + isPromptPending: true, + awaitingAgentOutput: false, + terminalStatus: "completed", + }, + }), + ).toBe("idle"); + }); + + it("returns idle while the agent is paused on a question tool", () => { + expect( + getSessionActivityPhase({ + retrying: false, + session: { + isPromptPending: true, + awaitingAgentOutput: false, + events: [buildQuestionToolCall("pending")], + }, + }), + ).toBe("idle"); + }); +}); + +describe("isSessionAwaitingUserInput", () => { + it("detects unresolved question tools", () => { + expect(isSessionAwaitingUserInput([buildQuestionToolCall("pending")])).toBe( + true, + ); + }); + + it("clears the waiting state once the user responds", () => { + const events: SessionEvent[] = [ + buildQuestionToolCall("pending"), + { + type: "session_update", + ts: 2, + notification: { + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "Yes" }, + }, + }, + }, + ]; + + expect(isSessionAwaitingUserInput(events)).toBe(false); + }); + + it("honors explicit awaiting-user-input backend markers", () => { + const events: SessionEvent[] = [ + { + type: "acp_message", + direction: "agent", + ts: 1, + message: { method: "_posthog/awaiting_user_input" }, + }, + ]; + + expect(isSessionAwaitingUserInput(events)).toBe(true); + }); +}); diff --git a/apps/mobile/src/features/tasks/utils/sessionActivity.ts b/apps/mobile/src/features/tasks/utils/sessionActivity.ts new file mode 100644 index 000000000..b131e1305 --- /dev/null +++ b/apps/mobile/src/features/tasks/utils/sessionActivity.ts @@ -0,0 +1,121 @@ +import type { SessionEvent, SessionNotification } from "../types"; + +export type SessionActivityPhase = "idle" | "connecting" | "working"; + +interface SessionActivityState { + isPromptPending?: boolean; + awaitingAgentOutput?: boolean; + terminalStatus?: "failed" | "completed"; + events?: SessionEvent[]; +} + +function isQuestionNotification(notification: SessionNotification): boolean { + const update = notification.update; + if (!update) return false; + + const rawToolName = update._meta?.claudeCode?.toolName; + if (typeof rawToolName === "string" && /question/i.test(rawToolName)) { + return true; + } + + const rawInput = update.rawInput; + if (!rawInput) return false; + + if (Array.isArray(rawInput.questions)) { + return true; + } + + const nestedInput = rawInput.input; + return ( + typeof nestedInput === "object" && + nestedInput !== null && + Array.isArray((nestedInput as { questions?: unknown }).questions) + ); +} + +function isPendingQuestionStatus( + status?: "pending" | "in_progress" | "completed" | "failed" | null, +): boolean { + return status === null || status === "pending" || status === "in_progress"; +} + +export function isSessionAwaitingUserInput( + events: SessionEvent[] = [], +): boolean { + let awaitingUserInput = false; + const questionStatuses = new Map< + string, + "pending" | "in_progress" | "completed" | "failed" | null | undefined + >(); + + for (const event of events) { + if (event.type === "session_update") { + const update = event.notification.update; + const sessionUpdate = update?.sessionUpdate; + + if (sessionUpdate === "user_message_chunk") { + awaitingUserInput = false; + questionStatuses.clear(); + continue; + } + + if ( + (sessionUpdate === "tool_call" || + sessionUpdate === "tool_call_update") && + isQuestionNotification(event.notification) + ) { + questionStatuses.set( + update?.toolCallId ?? `question-${event.ts}`, + update?.status, + ); + awaitingUserInput = [...questionStatuses.values()].some((status) => + isPendingQuestionStatus(status), + ); + } + + continue; + } + + const method = + event.message && typeof event.message === "object" + ? (event.message as { method?: string }).method + : undefined; + + if (method === "_posthog/awaiting_user_input") { + awaitingUserInput = true; + continue; + } + + if ( + method === "_posthog/turn_complete" || + method === "_posthog/task_complete" || + method === "_posthog/error" + ) { + awaitingUserInput = false; + questionStatuses.clear(); + } + } + + return awaitingUserInput; +} + +export function getSessionActivityPhase(args: { + retrying: boolean; + session?: SessionActivityState | null; +}): SessionActivityPhase { + const { retrying, session } = args; + + if (retrying) { + return "connecting"; + } + + if (!session?.isPromptPending || session.terminalStatus) { + return "idle"; + } + + if (isSessionAwaitingUserInput(session.events)) { + return "idle"; + } + + return session.awaitingAgentOutput ? "connecting" : "working"; +} From 9e8770c7b16f23e4b69ce3b3debf77ee5e35134b Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 13 May 2026 22:08:31 -0400 Subject: [PATCH 40/94] Render images in chat --- .../features/chat/components/HumanMessage.tsx | 99 +++++++++++++------ .../chat/components/MarkdownImage.tsx | 92 +++++++++++++++++ .../features/chat/components/MarkdownText.tsx | 25 +++++ .../tasks/components/TaskSessionView.tsx | 45 +++++++-- .../features/tasks/stores/taskSessionStore.ts | 9 ++ apps/mobile/src/features/tasks/types.ts | 12 +++ 6 files changed, 247 insertions(+), 35 deletions(-) create mode 100644 apps/mobile/src/features/chat/components/MarkdownImage.tsx diff --git a/apps/mobile/src/features/chat/components/HumanMessage.tsx b/apps/mobile/src/features/chat/components/HumanMessage.tsx index a8102c684..8409071c2 100644 --- a/apps/mobile/src/features/chat/components/HumanMessage.tsx +++ b/apps/mobile/src/features/chat/components/HumanMessage.tsx @@ -1,7 +1,7 @@ import * as Clipboard from "expo-clipboard"; import * as Haptics from "expo-haptics"; import { LinearGradient } from "expo-linear-gradient"; -import { CaretDown, CaretUp } from "phosphor-react-native"; +import { CaretDown, CaretUp, File as FileIcon } from "phosphor-react-native"; import { useCallback, useState } from "react"; import { Alert, @@ -12,16 +12,29 @@ import { } from "react-native"; import { formatRelativeTime } from "@/lib/format"; import { toRgba, useThemeColors } from "@/lib/theme"; +import { MarkdownImage } from "./MarkdownImage"; import { MarkdownText } from "./MarkdownText"; +export interface HumanMessageAttachment { + kind: "image" | "document"; + uri: string; + fileName: string; + mimeType?: string; +} + interface HumanMessageProps { content: string; timestamp?: number; + attachments?: HumanMessageAttachment[]; } const COLLAPSED_MAX_HEIGHT = 160; -export function HumanMessage({ content, timestamp }: HumanMessageProps) { +export function HumanMessage({ + content, + timestamp, + attachments, +}: HumanMessageProps) { const themeColors = useThemeColors(); const [isExpanded, setIsExpanded] = useState(false); const [contentHeight, setContentHeight] = useState(null); @@ -29,6 +42,8 @@ export function HumanMessage({ content, timestamp }: HumanMessageProps) { const isOverflowing = contentHeight !== null && contentHeight > COLLAPSED_MAX_HEIGHT; const collapse = isOverflowing && !isExpanded; + const hasContent = content.trim().length > 0; + const hasAttachments = (attachments?.length ?? 0) > 0; const handleLongPress = useCallback(() => { Clipboard.setStringAsync(content).then(() => { @@ -48,33 +63,35 @@ export function HumanMessage({ content, timestamp }: HumanMessageProps) { className="mt-3 border-l-2 bg-gray-2 py-2 pr-3 pl-3" style={{ borderColor: themeColors.accent[9] }} > - - - + {hasContent && ( + + + + + {collapse && ( + + )} - {collapse && ( - - )} - - {isOverflowing && ( + )} + {hasContent && isOverflowing && ( setIsExpanded((v) => !v)} hitSlop={6} @@ -90,6 +107,32 @@ export function HumanMessage({ content, timestamp }: HumanMessageProps) { )} + {hasAttachments && ( + + {attachments?.map((att) => + att.kind === "image" ? ( + + ) : ( + + + + {att.fileName} + + + ), + )} + + )} {timestamp && ( diff --git a/apps/mobile/src/features/chat/components/MarkdownImage.tsx b/apps/mobile/src/features/chat/components/MarkdownImage.tsx new file mode 100644 index 000000000..328035c1c --- /dev/null +++ b/apps/mobile/src/features/chat/components/MarkdownImage.tsx @@ -0,0 +1,92 @@ +import { ImageBroken } from "phosphor-react-native"; +import { useEffect, useState } from "react"; +import { + ActivityIndicator, + Image, + Linking, + Pressable, + Text, + View, +} from "react-native"; +import { useThemeColors } from "@/lib/theme"; + +interface MarkdownImageProps { + url: string; + alt?: string; +} + +type LoadState = + | { status: "loading" } + | { status: "loaded"; aspectRatio: number } + | { status: "error" }; + +const MAX_HEIGHT = 320; + +export function MarkdownImage({ url, alt }: MarkdownImageProps) { + const themeColors = useThemeColors(); + const [state, setState] = useState({ status: "loading" }); + + useEffect(() => { + let cancelled = false; + setState({ status: "loading" }); + Image.getSize( + url, + (width, height) => { + if (cancelled) return; + const aspectRatio = height > 0 ? width / height : 1; + setState({ status: "loaded", aspectRatio }); + }, + () => { + if (cancelled) return; + setState({ status: "error" }); + }, + ); + return () => { + cancelled = true; + }; + }, [url]); + + if (state.status === "error") { + return ( + + + + {alt || "Failed to load image"} + + + ); + } + + if (state.status === "loading") { + return ( + + + + ); + } + + return ( + Linking.openURL(url)} + accessibilityRole="image" + accessibilityLabel={alt || "Image"} + className="active:opacity-80" + > + + {alt ? ( + + {alt} + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/chat/components/MarkdownText.tsx b/apps/mobile/src/features/chat/components/MarkdownText.tsx index d3e850b4f..6401fc822 100644 --- a/apps/mobile/src/features/chat/components/MarkdownText.tsx +++ b/apps/mobile/src/features/chat/components/MarkdownText.tsx @@ -4,6 +4,9 @@ import { parseGithubIssueUrl } from "@/lib/githubIssueUrl"; import { getColorForClass, highlightCode } from "@/lib/syntax-highlight"; import { useThemeColors } from "@/lib/theme"; import { GithubRefChip } from "./GithubRefChip"; +import { MarkdownImage } from "./MarkdownImage"; + +const IMAGE_LINE_PATTERN = /^!\[([^\]]*)\]\(([^)\s]+)\)\s*$/; interface MarkdownTextProps { content: string; @@ -54,6 +57,7 @@ interface Block { | "list" | "table" | "blockquote" + | "image" | "hr"; content: string; language?: string; @@ -61,6 +65,8 @@ interface Block { items?: string[]; ordered?: boolean; rows?: string[][]; + url?: string; + alt?: string; } function parseBlocks(text: string): Block[] { @@ -85,6 +91,19 @@ function parseBlocks(text: string): Block[] { continue; } + // Image on its own line: ![alt](url) + const imageMatch = line.match(IMAGE_LINE_PATTERN); + if (imageMatch) { + blocks.push({ + type: "image", + content: "", + alt: imageMatch[1], + url: imageMatch[2], + }); + i++; + continue; + } + // Heading const headingMatch = line.match(/^(#{1,6})\s+(.+)$/); if (headingMatch) { @@ -175,6 +194,7 @@ function parseBlocks(text: string): Block[] { lines[i].trim() !== "" && !lines[i].startsWith("```") && !lines[i].match(/^#{1,6}\s/) && + !IMAGE_LINE_PATTERN.test(lines[i]) && !/^\s*[-*]\s/.test(lines[i]) && !/^\s*\d+[.)]\s/.test(lines[i]) && !/^>\s?/.test(lines[i]) && @@ -468,6 +488,11 @@ export function MarkdownText({ content }: MarkdownTextProps) { ); + case "image": + return block.url ? ( + + ) : null; + case "hr": return ; diff --git a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx index c1a07b1a6..50fbe4184 100644 --- a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx +++ b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx @@ -21,7 +21,12 @@ import { type ToolStatus, } from "@/features/chat"; import { useThemeColors } from "@/lib/theme"; -import type { PlanEntry, SessionEvent, SessionNotification } from "../types"; +import type { + PlanEntry, + SessionEvent, + SessionNotification, + SessionNotificationAttachment, +} from "../types"; import { PlanStatusBar } from "./PlanStatusBar"; import { QuestionCard } from "./QuestionCard"; @@ -62,6 +67,7 @@ interface ParsedMessage { ts?: number; toolData?: ToolData; children?: ParsedMessage[]; + attachments?: SessionNotificationAttachment[]; } function mapToolStatus( @@ -82,7 +88,12 @@ function mapToolStatus( } type ParsedNotification = - | { type: "user" | "agent" | "agent_complete" | "thought"; content: string } + | { + type: "user"; + content: string; + attachments?: SessionNotificationAttachment[]; + } + | { type: "agent" | "agent_complete" | "thought"; content: string } | { type: "tool" | "tool_update"; toolData: ToolData } | { type: "plan"; entries: PlanEntry[] }; @@ -97,11 +108,24 @@ function parseSessionNotification( switch (update.sessionUpdate) { case "user_message_chunk": case "agent_message_chunk": { - if (update.content?.type === "text") { + const hasText = update.content?.type === "text"; + const isUser = update.sessionUpdate === "user_message_chunk"; + if (isUser) { + const attachments = update.attachments; + // Drop only if there's neither text nor attachments to render. + if (!hasText && (!attachments || attachments.length === 0)) { + return null; + } return { - type: - update.sessionUpdate === "user_message_chunk" ? "user" : "agent", - content: update.content.text, + type: "user", + content: hasText ? (update.content?.text ?? "") : "", + attachments, + }; + } + if (hasText) { + return { + type: "agent", + content: update.content?.text ?? "", }; } return null; @@ -283,6 +307,7 @@ function processNewEvents( type: "user", content: parsed.content ?? "", ts: event.ts, + attachments: parsed.attachments, }); state.lastAgentMsgIdx = null; break; @@ -774,7 +799,13 @@ export function TaskSessionView({ ({ item }: { item: ParsedMessage }) => { switch (item.type) { case "user": - return ; + return ( + + ); case "agent": return ( ((set, get) => ({ update: { sessionUpdate: "user_message_chunk", content: { type: "text", text: prompt }, + attachments: + attachments.length > 0 + ? attachments.map((a) => ({ + kind: a.kind, + uri: a.uri, + fileName: a.fileName, + mimeType: a.mimeType, + })) + : undefined, }, }, }; diff --git a/apps/mobile/src/features/tasks/types.ts b/apps/mobile/src/features/tasks/types.ts index 78d772720..40459b387 100644 --- a/apps/mobile/src/features/tasks/types.ts +++ b/apps/mobile/src/features/tasks/types.ts @@ -69,10 +69,22 @@ export interface StoredLogEntry { direction?: "client" | "agent"; } +export interface SessionNotificationAttachment { + kind: "image" | "document"; + uri: string; + fileName: string; + mimeType?: string; +} + export interface SessionNotification { update?: { sessionUpdate?: string; content?: { type: string; text: string }; + // Sidecar carrying user-uploaded attachments on user_message_chunk events. + // The wire format embeds the bytes themselves in a separate serialized + // cloud-prompt payload sent to the agent; this field exists only so the + // local feed can render the attachments alongside the echoed text. + attachments?: SessionNotificationAttachment[]; title?: string; toolCallId?: string; status?: "pending" | "in_progress" | "completed" | "failed" | null; From 06b6ee22691914135ec2cc0ea2b7982c18637d01 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 13 May 2026 22:13:37 -0400 Subject: [PATCH 41/94] Persist images --- .../tasks/stores/attachmentEchoStore.ts | 65 +++++++++++++++++++ .../features/tasks/stores/taskSessionStore.ts | 62 +++++++++++++++--- 2 files changed, 118 insertions(+), 9 deletions(-) create mode 100644 apps/mobile/src/features/tasks/stores/attachmentEchoStore.ts diff --git a/apps/mobile/src/features/tasks/stores/attachmentEchoStore.ts b/apps/mobile/src/features/tasks/stores/attachmentEchoStore.ts new file mode 100644 index 000000000..4a02ba409 --- /dev/null +++ b/apps/mobile/src/features/tasks/stores/attachmentEchoStore.ts @@ -0,0 +1,65 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; +import type { SessionNotificationAttachment } from "../types"; + +/** + * Echoes of user messages that carried attachments, keyed by `taskRunId`. + * Persisted to disk so that re-entering a task — which discards the + * in-memory session and re-reads history from S3 — can still render the + * attachments the user sent locally. The cloud log doesn't surface attachment + * data on `user_message_chunk` events, so without this cache they would + * disappear after the screen unmounts. + * + * Entries are pushed in send-order. Re-hydration matches them positionally + * against the historical `user_message_chunk` events (Nth user message gets + * the Nth recorded echo) with a text-equality guard to degrade gracefully if + * the orders ever diverge. + */ +export interface AttachmentEcho { + text: string; + attachments: SessionNotificationAttachment[]; +} + +interface AttachmentEchoState { + echoes: Record; + recordEcho: ( + taskRunId: string, + text: string, + attachments: SessionNotificationAttachment[], + ) => void; + getEchoes: (taskRunId: string) => AttachmentEcho[]; + clearEchoes: (taskRunId: string) => void; +} + +export const useAttachmentEchoStore = create()( + persist( + (set, get) => ({ + echoes: {}, + recordEcho: (taskRunId, text, attachments) => { + if (attachments.length === 0) return; + set((state) => { + const existing = state.echoes[taskRunId] ?? []; + return { + echoes: { + ...state.echoes, + [taskRunId]: [...existing, { text, attachments }], + }, + }; + }); + }, + getEchoes: (taskRunId) => get().echoes[taskRunId] ?? [], + clearEchoes: (taskRunId) => { + set((state) => { + const { [taskRunId]: _, ...rest } = state.echoes; + return { echoes: rest }; + }); + }, + }), + { + name: "posthog-attachment-echoes", + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => ({ echoes: state.echoes }), + }, + ), +); diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts index ab66ad74e..457489963 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts @@ -18,6 +18,7 @@ import type { PendingAttachment } from "../composer/attachments/types"; import type { SessionEvent, SessionNotification, + SessionNotificationAttachment, StoredLogEntry, Task, } from "../types"; @@ -26,6 +27,37 @@ import { parseSessionLogs, } from "../utils/parseSessionLogs"; import { playMeepSound } from "../utils/sounds"; +import { useAttachmentEchoStore } from "./attachmentEchoStore"; + +// Match historical `user_message_chunk` events (text-only, as the cloud +// stores them) against locally-cached attachment echoes by position+text. +// Echoes are written in send-order; we walk user messages in receive-order +// and zip them up. Drift (text mismatch at the same index) is treated as a +// no-op rather than a misattribution. +function reinjectAttachmentEchoes( + taskRunId: string, + events: SessionEvent[], +): void { + const echoes = useAttachmentEchoStore.getState().getEchoes(taskRunId); + if (echoes.length === 0) return; + + let echoIdx = 0; + for (const event of events) { + if (echoIdx >= echoes.length) return; + if (event.type !== "session_update") continue; + const update = event.notification?.update; + if (update?.sessionUpdate !== "user_message_chunk") continue; + if (update.attachments && update.attachments.length > 0) { + echoIdx++; + continue; + } + const echo = echoes[echoIdx]; + echoIdx++; + if (echo.text === (update.content?.text ?? "")) { + update.attachments = echo.attachments; + } + } +} // 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 @@ -271,6 +303,12 @@ export const useTaskSessionStore = create((set, get) => ({ notifications, ); + // Re-inject locally-cached attachment metadata into historical user + // messages. Cloud logs only carry text on `user_message_chunk` events, + // so without this merge any images the user attached before navigating + // away would disappear when the session is rehydrated from S3. + reinjectAttachmentEchoes(latestRunId, historicalEvents); + // 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. @@ -374,6 +412,15 @@ export const useTaskSessionStore = create((set, get) => ({ : prompt; const ts = Date.now(); + const echoAttachments: SessionNotificationAttachment[] = + attachments.length > 0 + ? attachments.map((a) => ({ + kind: a.kind, + uri: a.uri, + fileName: a.fileName, + mimeType: a.mimeType, + })) + : []; const userEvent: SessionEvent = { type: "session_update", ts, @@ -381,18 +428,15 @@ export const useTaskSessionStore = create((set, get) => ({ update: { sessionUpdate: "user_message_chunk", content: { type: "text", text: prompt }, - attachments: - attachments.length > 0 - ? attachments.map((a) => ({ - kind: a.kind, - uri: a.uri, - fileName: a.fileName, - mimeType: a.mimeType, - })) - : undefined, + attachments: echoAttachments.length > 0 ? echoAttachments : undefined, }, }, }; + if (echoAttachments.length > 0) { + useAttachmentEchoStore + .getState() + .recordEcho(session.taskRunId, prompt, echoAttachments); + } set((state) => { const current = state.sessions[session.taskRunId]; From 028d5264dbe39eeadd7632a5d4e40395f05fe8c5 Mon Sep 17 00:00:00 2001 From: Oliver Browne Date: Wed, 13 May 2026 22:23:37 -0400 Subject: [PATCH 42/94] tinder tweaks --- apps/mobile/src/app/(tabs)/inbox.tsx | 8 -- apps/mobile/src/app/review.tsx | 12 +-- apps/mobile/src/app/task/index.tsx | 14 +++- .../inbox/components/SwipeableReportCard.tsx | 25 +++--- .../features/inbox/components/TinderView.tsx | 77 ++++++++++++++++--- apps/mobile/src/features/tasks/api.ts | 11 ++- 6 files changed, 102 insertions(+), 45 deletions(-) diff --git a/apps/mobile/src/app/(tabs)/inbox.tsx b/apps/mobile/src/app/(tabs)/inbox.tsx index 30b3c7bc6..d730311a0 100644 --- a/apps/mobile/src/app/(tabs)/inbox.tsx +++ b/apps/mobile/src/app/(tabs)/inbox.tsx @@ -58,13 +58,6 @@ export default function InboxScreen() { [router], ); - const handleTaskStarted = useCallback( - (taskId: string) => { - router.push(`/task/${taskId}`); - }, - [router], - ); - // Header occupies insets.top + 6 (top pad) + 40 (MenuButton) + 8 (bottom // pad), plus a small buffer so the first row isn't hugging the fade edge. const headerHeight = insets.top + 60; @@ -81,7 +74,6 @@ export default function InboxScreen() { diff --git a/apps/mobile/src/app/review.tsx b/apps/mobile/src/app/review.tsx index 4dc66e1c5..8a03d9b48 100644 --- a/apps/mobile/src/app/review.tsx +++ b/apps/mobile/src/app/review.tsx @@ -1,6 +1,5 @@ import { Text } from "@components/text"; -import { useRouter } from "expo-router"; -import { useCallback, useEffect, useMemo } from "react"; +import { useEffect, useMemo } from "react"; import { ActivityIndicator, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { FloatingBackButton } from "@/components/FloatingBackButton"; @@ -12,7 +11,6 @@ import { useIntegrations } from "@/features/tasks/hooks/useIntegrations"; import { useThemeColors } from "@/lib/theme"; export default function ReviewScreen() { - const router = useRouter(); const insets = useSafeAreaInsets(); const themeColors = useThemeColors(); const dismissedIds = useDismissedReportsStore((s) => s.dismissedIds); @@ -34,13 +32,6 @@ export default function ReviewScreen() { setCurrentIndex(0); }, [setCurrentIndex]); - const handleTaskStarted = useCallback( - (taskId: string) => { - router.push(`/task/${taskId}`); - }, - [router], - ); - if (isLoading) { return ( @@ -58,7 +49,6 @@ export default function ReviewScreen() { diff --git a/apps/mobile/src/app/task/index.tsx b/apps/mobile/src/app/task/index.tsx index ea1e4a89a..1ea07b3d1 100644 --- a/apps/mobile/src/app/task/index.tsx +++ b/apps/mobile/src/app/task/index.tsx @@ -62,7 +62,10 @@ import { RepositoryPickerSheet } from "@/features/tasks/composer/RepositoryPicke import { SelectSheet } from "@/features/tasks/composer/SelectSheet"; import { useIntegrations } from "@/features/tasks/hooks/useIntegrations"; import { useTaskStore } from "@/features/tasks/stores/taskStore"; -import type { RepositorySelection } from "@/features/tasks/types"; +import type { + CreateTaskOptions, + RepositorySelection, +} from "@/features/tasks/types"; import { findRepositoryOption, isRepositorySelectionComplete, @@ -256,11 +259,12 @@ export default function NewTaskScreen() { github_integration: selection.integrationId ?? undefined, ...(signalReport ? { + origin_product: "signal_report", signal_report: signalReport, signal_report_task_relationship: "implementation", } : {}), - }); + } as CreateTaskOptions); const pendingUserMessage = attachments.length > 0 @@ -277,6 +281,12 @@ export default function NewTaskScreen() { model, reasoningEffort: supportsReasoning ? reasoning : undefined, initialPermissionMode: mode, + ...(signalReport + ? { + runSource: "signal_report" as const, + signalReportId: signalReport, + } + : {}), }); router.replace(`/task/${task.id}`); diff --git a/apps/mobile/src/features/inbox/components/SwipeableReportCard.tsx b/apps/mobile/src/features/inbox/components/SwipeableReportCard.tsx index 20b3c0dba..d28ccbe0f 100644 --- a/apps/mobile/src/features/inbox/components/SwipeableReportCard.tsx +++ b/apps/mobile/src/features/inbox/components/SwipeableReportCard.tsx @@ -269,7 +269,7 @@ function CardContent({ {report.summary && } - {/* Footer: signal count + time */} + {/* Footer: signal count + time + repo */} @@ -279,19 +279,18 @@ function CardContent({ · {updatedAt} + {repo && ( + <> + · + + + + {repo} + + + + )} - - {/* Repo pill */} - {repo && ( - - - - - {repo} - - - - )} ); } diff --git a/apps/mobile/src/features/inbox/components/TinderView.tsx b/apps/mobile/src/features/inbox/components/TinderView.tsx index 60dd6a63b..f5e930d7d 100644 --- a/apps/mobile/src/features/inbox/components/TinderView.tsx +++ b/apps/mobile/src/features/inbox/components/TinderView.tsx @@ -1,6 +1,7 @@ import { Text } from "@components/text"; import { formatDistanceToNow } from "date-fns"; import * as Haptics from "expo-haptics"; +import { useRouter } from "expo-router"; import { Check, GithubLogo, Lightning, X } from "phosphor-react-native"; import { useCallback, useEffect, useRef, useState } from "react"; import { @@ -10,7 +11,10 @@ import { ScrollView, View, } from "react-native"; -import { SafeAreaView } from "react-native-safe-area-context"; +import { + SafeAreaView, + useSafeAreaInsets, +} from "react-native-safe-area-context"; import { MarkdownText } from "@/features/chat/components/MarkdownText"; import { createTask, runTaskInCloud } from "@/features/tasks/api"; import type { @@ -113,17 +117,17 @@ function EmptyState() { interface TinderViewProps { reports: SignalReport[]; repositoryOptions: RepositoryOption[]; - onTaskStarted: (taskId: string) => void; isLoading?: boolean; } export function TinderView({ reports, repositoryOptions, - onTaskStarted, isLoading, }: TinderViewProps) { const themeColors = useThemeColors(); + const router = useRouter(); + const insets = useSafeAreaInsets(); // Store state const currentIndex = useInboxStore((s) => s.currentIndex); @@ -136,6 +140,23 @@ export function TinderView({ ); const [creating, setCreating] = useState(false); const [error, setError] = useState(null); + const [toast, setToast] = useState<{ + taskId: string | null; + title: string; + pending: boolean; + } | null>(null); + const toastTimer = useRef | null>(null); + + const showToastPending = useCallback((title: string) => { + if (toastTimer.current) clearTimeout(toastTimer.current); + setToast({ taskId: null, title, pending: true }); + }, []); + + const showToastDone = useCallback((taskId: string, title: string) => { + if (toastTimer.current) clearTimeout(toastTimer.current); + setToast({ taskId, title, pending: false }); + toastTimer.current = setTimeout(() => setToast(null), 10_000); + }, []); const handleDismiss = useCallback( (reportId: string) => { @@ -152,6 +173,7 @@ export function TinderView({ async (report: SignalReport) => { setCreating(true); setError(null); + showToastPending(report.title ?? "Untitled report"); try { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); @@ -169,13 +191,12 @@ export function TinderView({ const prompt = `Act on this signal report. Investigate the root cause, implement the fix, and open a PR if appropriate.\n\n${report.summary ?? ""}`; const task = await createTask({ description: prompt, - title: prompt.slice(0, 100), + title: prompt.slice(0, 255), repository: match?.repository ?? repo ?? undefined, github_integration: match?.integrationId ?? undefined, - ...{ - signal_report: report.id, - signal_report_task_relationship: "implementation", - }, + origin_product: "signal_report", + signal_report: report.id, + signal_report_task_relationship: "implementation", } as CreateTaskOptions); // 4. Run it @@ -184,20 +205,23 @@ export function TinderView({ runtimeAdapter: "claude", model: "claude-opus-4-7", initialPermissionMode: "plan", + runSource: "signal_report", + signalReportId: report.id, }); advanceCard(); - onTaskStarted(task.id); + showToastDone(task.id, report.title ?? "Untitled report"); } catch (e) { const message = e instanceof Error ? e.message : "Failed to create task"; log.error("Accept failed", message); setError(message); + setToast(null); } finally { setCreating(false); } }, - [repositoryOptions, advanceCard, onTaskStarted], + [repositoryOptions, advanceCard, showToastPending, showToastDone], ); const currentReport = @@ -268,6 +292,39 @@ export function TinderView({ )} + {/* "Task started" toast — sits above the mode switcher pill */} + {toast && ( + { + if (toast.pending || !toast.taskId) return; + setToast(null); + router.push(`/task/${toast.taskId}`); + }} + disabled={toast.pending} + className="elevation-4 absolute inset-x-4 flex-row items-center justify-between rounded-2xl bg-status-success px-5 py-4 shadow-lg active:opacity-80" + style={{ bottom: insets.bottom + 76 }} + > + + + {toast.pending ? "Starting task\u2026" : "Task started"} + + + {toast.title} + + + {toast.pending ? ( + + ) : ( + + View → + + )} + + )} + {/* Expanded report modal */} Date: Wed, 13 May 2026 22:31:59 -0400 Subject: [PATCH 43/94] Upgraded settings a bunch --- apps/mobile/assets/sounds/drop.mp3 | Bin 0 -> 2856 bytes apps/mobile/assets/sounds/knock.mp3 | Bin 0 -> 1608 bytes apps/mobile/assets/sounds/ring.mp3 | Bin 0 -> 4086 bytes apps/mobile/assets/sounds/shoot.mp3 | Bin 0 -> 4789 bytes apps/mobile/assets/sounds/slide.mp3 | Bin 0 -> 3591 bytes apps/mobile/src/app/(tabs)/_layout.tsx | 1 - apps/mobile/src/app/(tabs)/settings.tsx | 225 --------- apps/mobile/src/app/_layout.tsx | 14 +- apps/mobile/src/app/settings/index.tsx | 476 ++++++++++++++++++ apps/mobile/src/app/task/index.tsx | 17 +- .../navigation/components/NavDrawer.tsx | 10 +- .../preferences/stores/preferencesStore.ts | 56 +++ .../components/FloatingSettingsHeader.tsx | 74 +++ .../settings/components/SettingsRow.tsx | 69 +++ .../settings/components/SettingsSection.tsx | 36 ++ .../mobile/src/features/tasks/utils/sounds.ts | 57 ++- 16 files changed, 794 insertions(+), 241 deletions(-) create mode 100644 apps/mobile/assets/sounds/drop.mp3 create mode 100644 apps/mobile/assets/sounds/knock.mp3 create mode 100644 apps/mobile/assets/sounds/ring.mp3 create mode 100644 apps/mobile/assets/sounds/shoot.mp3 create mode 100644 apps/mobile/assets/sounds/slide.mp3 delete mode 100644 apps/mobile/src/app/(tabs)/settings.tsx create mode 100644 apps/mobile/src/app/settings/index.tsx create mode 100644 apps/mobile/src/features/settings/components/FloatingSettingsHeader.tsx create mode 100644 apps/mobile/src/features/settings/components/SettingsRow.tsx create mode 100644 apps/mobile/src/features/settings/components/SettingsSection.tsx diff --git a/apps/mobile/assets/sounds/drop.mp3 b/apps/mobile/assets/sounds/drop.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..d2da29d0e33021d3e351081debb5ff3c4b42367c GIT binary patch literal 2856 zcmchZdsGuw9>?$GK|%;5Kmq|l1_<&L0|CV<8U-qZP{9C#F9L)Vp%t-!h>uKoX;HQa zQl&i#VpVkIA%fD1L+0({zG$Jl2aorrcIE;#q5oF;&tcT&Eg#}F_%Of_ z0GbADnjuNYZXHE4oY2vrqjiSsI_~IrJj1Y#-*vzlv`TQz@wjFWzP2z6myFGXG|dFt zcXamg>>X;lq_|yYO3(08K*a^46#y~sHVBw}rCk)SoSB9~-^Pn0MyU+X-W5uu0s-?3 z2=^9x?_8pu5@7M}j3JWI2ivAUIV@k?F{a8niSVWw*+z$uy5&$+& z8!gjDsJ=F`At6A(MDTDA6>tF4`846@g-%qe9tpuDC$g&~WjR)o`Iw31JH;0Ou#_l5 zZ8fI(x0n%{empC#YBT5DQ>N$(TKI%O<$8pt^aWkYQbL{bJ@An-7U3!ZKo}^f;nz%W4XF2v7XNa%=JoO1@u0x zPG2knNOCsML*bwAVQMiyG$q+$(A2R!>d3DAgf;q1mvv7!QDe!n=;euSy>bYrE``ez z1*F=x#7w52Z&5$_A@4H-`nuzU_g$#*A&wrA31Sju-bK5fh)0!TF@?A%zo&5C?=h59 zOhB&iIRI#J<38DI%ZT;%OQkfXJ9_yqQ+;FACcT2#RTM%D*5yR@ zC{V5y@eS#|7T3)7gmjx3u^i~-=Av)|I$}c2@k$I&HeTVdt+VY*I2C%(HVk=md=xN4 zeYoe>yEoFgc5dm|!6KUhQ+XGX+Taf&3#Rs@Z&5~U?5U}>8%~wy0U4XaX^_h7yU)k} z5O&@Ab^dl?Y@;brC^pT$V#{pF29H>0+6Xr`-H=1C%N`wf7kRhrJCY@}Gv%2q^R8d( z;oi@>U>_FK<^H*v@Mr*l8rRE(n&tPOM_;k|T558Hr)K$l-;$r-87OVu>t2_2>vYWn zX;ptGvns}WB-Csq*dgobe9iotUs*Lj$ehB^7Pb-)84zCfOOvNez~9bNBwI59QGN~} zW2|)V@3I^Invv3Ixna$3bP?2`(y5%$m-N*zRrTfwsQ{vf`37_jJE`y^gaJTuJ09DL zVEg-*4mJ2-veiwjOX`1nyl;jLqq zdTmDX?GS1Jy9~1ghK~i{TzPuZ+jJx(NhHvVMLJSF$Qh_ zg(O$**9oQ<4}ES_OrVmj;UFBE6B}?}q4o0|8)tBXk}>anIzP05qYl+rByR1^ioPHL z`mp<4apm3Ry^)P}`O+=PcTLsBPhE?+N+YTt=_=H8L}lf=5QF%Yc{iUNvU(0>w1OfQ zMV0~fVm1{_q}2K72mX#RxqfF3N#%vN)9SAlnyF43w@eH)q4&2K5d~$tglW+XUkKas z_G4^nrX(W+W7Odtx!Vi`_x1ah)j13)`dHBc@wc|-Ijw7*C=f#7PtDGJ+P%j77u{dQ zK^TobmLW&Aeg?3yIQ#ajFHHk=e)Mns&3P!Xde+bVGNt{6M9)(NqIvvPVZqbg);lu} zmrW+z>Y%SA>`I$%wz?F8zojP|>IVR}6;MC3=>o{XZ%T&X__5q=yQ957;+eB^QF@T$ z<$G>j{^_;MC8zh-*_Pe2LYGSjKyOV`uv=sK?cHqo(LKpEM@7a8HJP*zAF;pG3Yh>% zCKb@zTxwqiEtq`J-Lz${q9yKT1B3%6*%hjCJV(Xo z4xJ}kWT7v;N`jM`22Y*8c@1iK(wEriz?`3kj#zVaswvcH*CFx~m2Ow1zpc#wVnP6r zFqeOgarUjz=8y@+XuGZOm16URP8<110RaV1*D>n zU5DtZ|Ca)$RFgaEYAsB{)<@|S5Yu+Jo&|${rUIy8N(U)w%(^29GkK+(2mGwRpq&d) z-9C7yCHzAt?L4m)>SvUYalIhR*%hTbvJb?fc#?jT#s1Lcor>CdR1CE}@W+oLZPpJ! Nz5@9F&!0HYKLKkU;U541 literal 0 HcmV?d00001 diff --git a/apps/mobile/assets/sounds/knock.mp3 b/apps/mobile/assets/sounds/knock.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..674527c806a2266c67f8e5f3273aeae370470ad6 GIT binary patch literal 1608 zcmeZtF=k-^0p*b3U{@f`&%nU!lUSB!YN2Otre|ni0G5Ri|9?9iK;lA}5t(`EKo(FR z13Lo)n>52z6tD^f>_!1+P{Cak@D2t1M*$$4fsRZDI?~V>#DM`7wgw23O?orCZ!(zu z|M!6-3=EuG6z#q)U{F#P&8W8(a9~ihth*uQ%*fz#J9x6stBV$EeI-8qy?@7Ce5a?U zheY#(A9Vo&JPAMS>J~T{b{_l}VEE~Sv*qbe4|^SE{_*3;VmbfdhvF2L!$0getO6b| zFeqK0^3Zp7`=iE&1py3fMs0m5M!aqUJbhV@_+6T}C2eDJ<4SNj;mjDOB*SKQhKHf% zKmywtP6mw#t_BAQFXl&kR28@zDq4G21h|+KyKD&TKBc!~r{^LahiTo~M`tOls8ODx zmtGQfU%^fFl2%IG|4%wev1&fuN8dkKp~K7R_kV?yP)p;YRSYb;i{Bq$U`VVISJQNn zOEY+gnee$Ik-^CPAz7;Rn!Hxeb` zVRBgSJph}=mUTQKE9s>9LglmDxNj%UY+P`paGugdpSe-l3uo}MwSDZ(1sTbZQQ6Vn zzYUns`5tTx(CKUmb17nBjqQ^n6h;=2VyOBly|5iBiz2W#3Tg|iV19uo8us}y9c zssEg#!b_}X64Zh0dEIcRM%49WszO3E&>o)z783Lms}y9<-^SyAyw%?(DrQ(QDF^l# a(8@p8m=7>8{sSg<1qKF_1_lON_y+(+HzsEQ literal 0 HcmV?d00001 diff --git a/apps/mobile/assets/sounds/ring.mp3 b/apps/mobile/assets/sounds/ring.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..b5b1b8bd1170dcc08b774e368ad09d1ed6599732 GIT binary patch literal 4086 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 L^1tou-_-pB#U$6_ literal 0 HcmV?d00001 diff --git a/apps/mobile/assets/sounds/shoot.mp3 b/apps/mobile/assets/sounds/shoot.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..bb3f9bc308d187a5252c02ab6511ffc89b16e18d GIT binary patch literal 4789 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 - diff --git a/apps/mobile/src/app/(tabs)/settings.tsx b/apps/mobile/src/app/(tabs)/settings.tsx deleted file mode 100644 index 2f5fc1b9d..000000000 --- a/apps/mobile/src/app/(tabs)/settings.tsx +++ /dev/null @@ -1,225 +0,0 @@ -import { router } from "expo-router"; -import { - Linking, - ScrollView, - Switch, - Text, - TouchableOpacity, - View, -} from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useAuthStore, useUserQuery } from "@/features/auth"; -import { useDismissedReportsStore } from "@/features/inbox/stores/dismissedReportsStore"; -import { MenuButton } from "@/features/navigation/components/MenuButton"; -import { usePushTokenStore } from "@/features/notifications/stores/pushTokenStore"; -import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; -import { logger } from "@/lib/logger"; - -export default function SettingsScreen() { - const { logout, cloudRegion, getCloudUrlFromRegion } = useAuthStore(); - const { data: userData } = useUserQuery(); - const insets = useSafeAreaInsets(); - 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 pushNotificationsEnabled = usePreferencesStore( - (s) => s.pushNotificationsEnabled, - ); - const setPushNotificationsEnabled = usePreferencesStore( - (s) => s.setPushNotificationsEnabled, - ); - - const handleTogglePushNotifications = (enabled: boolean) => { - setPushNotificationsEnabled(enabled); - if (enabled) { - usePushTokenStore - .getState() - .registerAndUpload() - .catch((error) => { - logger.warn("Push token registration failed", error); - }); - } else { - usePushTokenStore - .getState() - .clear() - .catch((error) => { - logger.warn("Push token clear failed", error); - }); - } - }; - - const dismissedCount = useDismissedReportsStore((s) => s.dismissedIds.length); - const clearDismissed = useDismissedReportsStore((s) => s.clearDismissed); - - const handleLogout = async () => { - await logout(); - router.replace("/auth"); - }; - - const handleOpenSettings = () => { - if (!cloudRegion) return; - const baseUrl = getCloudUrlFromRegion(cloudRegion); - Linking.openURL(`${baseUrl}/settings`); - }; - - return ( - - - {/* Header */} - - - Settings - - - {/* Organization */} - - - Organization - - - Region - - {cloudRegion?.toUpperCase() || "—"} - - - - Display name - - {userData?.organization?.name || "—"} - - - - - {/* Project */} - - - Project - - - Display name - - {userData?.team?.name || "—"} - - - - - {/* Profile */} - - - Profile - - - First name - - {userData?.first_name || "—"} - - - - Last name - - {userData?.last_name || "—"} - - - - Email - - {userData?.email || "—"} - - - - - {/* Labs */} - - Labs - - Experimental features - - - - - PostHog AI chat - - - Show the Chats tab for PostHog AI conversations - - - - - - - - Enable pings - - - Play a sound when a task completes - - - - - - - - Push notifications - - - Get notified when a task finishes or needs your input - - - - - - - {/* Inbox */} - - Inbox - - - - Dismissed reports - - - {dismissedCount} report{dismissedCount !== 1 ? "s" : ""}{" "} - dismissed in review mode - - - 0 - ? "border-gray-6 bg-gray-3" - : "border-gray-4 opacity-40" - }`} - > - Clear - - - - - {/* All Settings Button */} - - - All settings - - - - {/* Logout Button */} - - - Sign out - - - - - ); -} diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index c01c55837..3e80c07ec 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -81,6 +81,10 @@ function RootLayoutNav({ isConnected }: RootLayoutNavProps) { {/* Tinder-style inbox review */} + {/* Settings — pushed on top of whatever the user was viewing, so + back / iOS swipe-back / Android hardware-back all return to it. */} + + {/* Report detail - modal presentation */} s.theme); + + // Sync the user's theme preference into NativeWind's color scheme so the + // entire app honours it (including light/dark/system). + useEffect(() => { + setColorScheme(themePreference); + }, [themePreference, setColorScheme]); + const themeVars = colorScheme === "dark" ? darkTheme : lightTheme; const { isConnected } = useNetworkStatus(); diff --git a/apps/mobile/src/app/settings/index.tsx b/apps/mobile/src/app/settings/index.tsx new file mode 100644 index 000000000..07b01973e --- /dev/null +++ b/apps/mobile/src/app/settings/index.tsx @@ -0,0 +1,476 @@ +import { Text } from "@components/text"; +import { router } from "expo-router"; +import { ArrowSquareOut, CaretRight, SpeakerHigh } from "phosphor-react-native"; +import { useState } from "react"; +import { Linking, Pressable, ScrollView, Switch, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useAuthStore, useUserQuery } from "@/features/auth"; +import { useDismissedReportsStore } from "@/features/inbox/stores/dismissedReportsStore"; +import { usePushTokenStore } from "@/features/notifications/stores/pushTokenStore"; +import { + type CompletionSound, + type InitialTaskMode, + type ThemePreference, + usePreferencesStore, +} from "@/features/preferences/stores/preferencesStore"; +import { FloatingSettingsHeader } from "@/features/settings/components/FloatingSettingsHeader"; +import { SettingsRow } from "@/features/settings/components/SettingsRow"; +import { SettingsSection } from "@/features/settings/components/SettingsSection"; +import { SelectSheet } from "@/features/tasks/composer/SelectSheet"; +import { playCompletionSound } from "@/features/tasks/utils/sounds"; +import { logger } from "@/lib/logger"; +import { useThemeColors } from "@/lib/theme"; + +const THEME_OPTIONS = [ + { value: "system", label: "Match system" }, + { value: "light", label: "Light" }, + { value: "dark", label: "Dark" }, +] as const; + +const SOUND_OPTIONS: ReadonlyArray<{ value: CompletionSound; label: string }> = + [ + { value: "meep", label: "Meep" }, + { value: "knock", label: "Knock" }, + { value: "ring", label: "Ring" }, + { value: "shoot", label: "Shoot" }, + { value: "slide", label: "Slide" }, + { value: "drop", label: "Drop" }, + ]; + +const VOLUME_OPTIONS = [ + { value: "25", label: "Quiet (25%)" }, + { value: "50", label: "Normal (50%)" }, + { value: "75", label: "Loud (75%)" }, + { value: "100", label: "Max (100%)" }, +] as const; + +const TASK_MODE_OPTIONS = [ + { + value: "plan", + label: "Plan", + description: "New tasks always start in Plan mode", + }, + { + value: "last_used", + label: "Last used", + description: "Remember the mode you picked last time", + }, +] as const; + +function themeLabel(theme: ThemePreference): string { + return THEME_OPTIONS.find((o) => o.value === theme)?.label ?? "Match system"; +} + +function soundLabel(sound: CompletionSound): string { + return SOUND_OPTIONS.find((o) => o.value === sound)?.label ?? "Meep"; +} + +function volumeLabel(volume: number): string { + if (volume >= 100) return "Max"; + if (volume >= 75) return "Loud"; + if (volume >= 50) return "Normal"; + return "Quiet"; +} + +function taskModeLabel(mode: InitialTaskMode): string { + return TASK_MODE_OPTIONS.find((o) => o.value === mode)?.label ?? "Plan"; +} + +export default function SettingsScreen() { + const themeColors = useThemeColors(); + const insets = useSafeAreaInsets(); + + 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 pushNotificationsEnabled = usePreferencesStore( + (s) => s.pushNotificationsEnabled, + ); + const setPushNotificationsEnabled = usePreferencesStore( + (s) => s.setPushNotificationsEnabled, + ); + const theme = usePreferencesStore((s) => s.theme); + const setTheme = usePreferencesStore((s) => s.setTheme); + const completionSound = usePreferencesStore((s) => s.completionSound); + const setCompletionSound = usePreferencesStore((s) => s.setCompletionSound); + const completionVolume = usePreferencesStore((s) => s.completionVolume); + const setCompletionVolume = usePreferencesStore((s) => s.setCompletionVolume); + const defaultInitialTaskMode = usePreferencesStore( + (s) => s.defaultInitialTaskMode, + ); + const setDefaultInitialTaskMode = usePreferencesStore( + (s) => s.setDefaultInitialTaskMode, + ); + const hedgehogMode = usePreferencesStore((s) => s.hedgehogMode); + const setHedgehogMode = usePreferencesStore((s) => s.setHedgehogMode); + + const dismissedCount = useDismissedReportsStore((s) => s.dismissedIds.length); + const clearDismissed = useDismissedReportsStore((s) => s.clearDismissed); + + const [themeSheetOpen, setThemeSheetOpen] = useState(false); + const [soundSheetOpen, setSoundSheetOpen] = useState(false); + const [volumeSheetOpen, setVolumeSheetOpen] = useState(false); + const [taskModeSheetOpen, setTaskModeSheetOpen] = useState(false); + + const handleTogglePushNotifications = (enabled: boolean) => { + setPushNotificationsEnabled(enabled); + if (enabled) { + usePushTokenStore + .getState() + .registerAndUpload() + .catch((error) => { + logger.warn("Push token registration failed", error); + }); + } else { + usePushTokenStore + .getState() + .clear() + .catch((error) => { + logger.warn("Push token clear failed", error); + }); + } + }; + + const handleLogout = async () => { + await logout(); + router.replace("/auth"); + }; + + const handleOpenWebSettings = () => { + if (!cloudRegion) return; + const baseUrl = getCloudUrlFromRegion(cloudRegion); + Linking.openURL(`${baseUrl}/settings`).catch(() => {}); + }; + + const handleTestSound = () => { + playCompletionSound().catch(() => {}); + }; + + // Top padding leaves room for the floating header (insets.top + ~52). Bottom + // padding clears the home indicator and gives breathing room past the last + // row so it never hides behind it. + const contentPaddingTop = insets.top + 60; + const contentPaddingBottom = insets.bottom + 32; + + return ( + + + + + {/* Appearance */} + + setThemeSheetOpen(true)} + showDivider={false} + rightSlot={ + <> + + {themeLabel(theme)} + + + + } + /> + + + {/* Notifications */} + + + } + /> + + } + /> + + + {/* Sound */} + {pingsEnabled ? ( + + setSoundSheetOpen(true)} + rightSlot={ + <> + + + + + {soundLabel(completionSound)} + + + + } + /> + setVolumeSheetOpen(true)} + showDivider={false} + rightSlot={ + <> + + {volumeLabel(completionVolume)} ({completionVolume}%) + + + + } + /> + + ) : null} + + {/* Input */} + + setTaskModeSheetOpen(true)} + showDivider={false} + rightSlot={ + <> + + {taskModeLabel(defaultInitialTaskMode)} + + + + } + /> + + + {/* Labs */} + + + } + /> + + } + /> + + + {/* Inbox */} + + 0 ? "border-gray-6 bg-gray-3 active:opacity-60" : "border-gray-4 opacity-40"}`} + > + + Clear + + + } + /> + + + {/* Organization */} + + + {cloudRegion?.toUpperCase() || "—"} + + } + /> + + {userData?.organization?.name || "—"} + + } + /> + + + {/* Project */} + + + {userData?.team?.name || "—"} + + } + /> + + + {/* Profile */} + + + {userData?.first_name || "—"} + + } + /> + + {userData?.last_name || "—"} + + } + /> + + {userData?.email || "—"} + + } + /> + + + {/* Account */} + + + } + /> + + Sign out + + } + /> + + + + setTheme(value as ThemePreference)} + onClose={() => setThemeSheetOpen(false)} + options={THEME_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + /> + + { + const next = value as CompletionSound; + setCompletionSound(next); + playCompletionSound(next).catch(() => {}); + }} + onClose={() => setSoundSheetOpen(false)} + options={SOUND_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + /> + + { + const next = parseInt(value, 10); + setCompletionVolume(next); + playCompletionSound(undefined, next).catch(() => {}); + }} + onClose={() => setVolumeSheetOpen(false)} + options={VOLUME_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + /> + + + setDefaultInitialTaskMode(value as InitialTaskMode) + } + onClose={() => setTaskModeSheetOpen(false)} + options={TASK_MODE_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + description: option.description, + }))} + /> + + ); +} diff --git a/apps/mobile/src/app/task/index.tsx b/apps/mobile/src/app/task/index.tsx index 1ea07b3d1..a7b4774b7 100644 --- a/apps/mobile/src/app/task/index.tsx +++ b/apps/mobile/src/app/task/index.tsx @@ -29,6 +29,7 @@ import Animated, { runOnJS, useAnimatedStyle } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useVoiceRecording } from "@/features/chat"; +import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; import { createTask, runTaskInCloud } from "@/features/tasks/api"; import { GitHubConnectionPrompt } from "@/features/tasks/components/GitHubConnectionPrompt"; import { GitHubLoadNotice } from "@/features/tasks/components/GitHubLoadNotice"; @@ -164,7 +165,15 @@ export default function NewTaskScreen() { }, [setLastRepository], ); - const [mode, setMode] = useState(DEFAULT_EXECUTION_MODE); + const [mode, setMode] = useState(() => { + const prefs = usePreferencesStore.getState(); + if (prefs.defaultInitialTaskMode === "last_used") { + const last = prefs.lastNewTaskMode; + const isValidMode = EXECUTION_MODES.some((m) => m.value === last); + if (isValidMode) return last as ExecutionMode; + } + return DEFAULT_EXECUTION_MODE; + }); const [model, setModel] = useState(DEFAULT_MODEL); const [reasoning, setReasoning] = useState(DEFAULT_REASONING); @@ -583,7 +592,11 @@ export default function NewTaskScreen() { open={modeSheetOpen} title="Execution mode" value={mode} - onChange={(value) => setMode(value as ExecutionMode)} + onChange={(value) => { + const next = value as ExecutionMode; + setMode(next); + usePreferencesStore.getState().setLastNewTaskMode(next); + }} onClose={() => setModeSheetOpen(false)} options={EXECUTION_MODES.map((executionMode) => ({ value: executionMode.value, diff --git a/apps/mobile/src/features/navigation/components/NavDrawer.tsx b/apps/mobile/src/features/navigation/components/NavDrawer.tsx index c6aab0a67..2b57d0fd1 100644 --- a/apps/mobile/src/features/navigation/components/NavDrawer.tsx +++ b/apps/mobile/src/features/navigation/components/NavDrawer.tsx @@ -87,7 +87,13 @@ const NavDrawerContent = memo(function NavDrawerContent({ const handleTasks = () => navigateTo("/tasks"); const handleInbox = () => navigateTo("/inbox"); const handleAutomations = () => navigateTo("/automations"); - const handleSettings = () => navigateTo("/settings"); + // Settings is pushed (not replaced) so back / swipe-back returns the user + // to whichever tab they were viewing when they opened the drawer. + const handleSettings = () => { + close(); + if (pathname === "/settings") return; + router.push("/settings"); + }; const handleHome = () => navigateTo("/tasks"); const handleTaskPress = (taskId: string) => { @@ -202,7 +208,7 @@ const NavDrawerContent = memo(function NavDrawerContent({ - + void; @@ -9,6 +21,24 @@ interface PreferencesState { setPingsEnabled: (enabled: boolean) => void; pushNotificationsEnabled: boolean; setPushNotificationsEnabled: (enabled: boolean) => void; + + theme: ThemePreference; + setTheme: (theme: ThemePreference) => void; + + completionSound: CompletionSound; + setCompletionSound: (sound: CompletionSound) => void; + completionVolume: number; + setCompletionVolume: (volume: number) => void; + + defaultInitialTaskMode: InitialTaskMode; + setDefaultInitialTaskMode: (mode: InitialTaskMode) => void; + /** Most recent mode the user picked in the new-task composer. Persisted so + * `defaultInitialTaskMode === "last_used"` can pre-fill it next time. */ + lastNewTaskMode: string; + setLastNewTaskMode: (mode: string) => void; + + hedgehogMode: boolean; + setHedgehogMode: (enabled: boolean) => void; } export const usePreferencesStore = create()( @@ -21,6 +51,26 @@ export const usePreferencesStore = create()( pushNotificationsEnabled: true, setPushNotificationsEnabled: (enabled) => set({ pushNotificationsEnabled: enabled }), + + theme: "system", + setTheme: (theme) => set({ theme }), + + completionSound: "meep", + setCompletionSound: (sound) => set({ completionSound: sound }), + completionVolume: 70, + setCompletionVolume: (volume) => + set({ + completionVolume: Math.max(0, Math.min(100, Math.round(volume))), + }), + + defaultInitialTaskMode: "plan", + setDefaultInitialTaskMode: (mode) => + set({ defaultInitialTaskMode: mode }), + lastNewTaskMode: "plan", + setLastNewTaskMode: (mode) => set({ lastNewTaskMode: mode }), + + hedgehogMode: false, + setHedgehogMode: (enabled) => set({ hedgehogMode: enabled }), }), { name: "posthog-preferences", @@ -29,6 +79,12 @@ export const usePreferencesStore = create()( aiChatEnabled: state.aiChatEnabled, pingsEnabled: state.pingsEnabled, pushNotificationsEnabled: state.pushNotificationsEnabled, + theme: state.theme, + completionSound: state.completionSound, + completionVolume: state.completionVolume, + defaultInitialTaskMode: state.defaultInitialTaskMode, + lastNewTaskMode: state.lastNewTaskMode, + hedgehogMode: state.hedgehogMode, }), }, ), diff --git a/apps/mobile/src/features/settings/components/FloatingSettingsHeader.tsx b/apps/mobile/src/features/settings/components/FloatingSettingsHeader.tsx new file mode 100644 index 000000000..4c7a5c876 --- /dev/null +++ b/apps/mobile/src/features/settings/components/FloatingSettingsHeader.tsx @@ -0,0 +1,74 @@ +import { Text } from "@components/text"; +import { LinearGradient } from "expo-linear-gradient"; +import { useRouter } from "expo-router"; +import { CaretLeft } from "phosphor-react-native"; +import { Pressable, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { toRgba, useThemeColors } from "@/lib/theme"; + +/** + * Floating header for the settings screen — back arrow on the left and a + * centered "Settings" title. Sits over the content with a top-to-bottom fade + * so the scrolled list disappears gracefully behind it. + */ +export function FloatingSettingsHeader() { + const router = useRouter(); + const insets = useSafeAreaInsets(); + const themeColors = useThemeColors(); + + const handleBack = () => { + if (router.canGoBack()) { + router.back(); + return; + } + router.replace("/tasks"); + }; + + const fadeHeight = insets.top + 88; + + return ( + + + + + + + + + + + Settings + + + + {/* Right spacer to keep the title visually centered against the back button. */} + + + + ); +} diff --git a/apps/mobile/src/features/settings/components/SettingsRow.tsx b/apps/mobile/src/features/settings/components/SettingsRow.tsx new file mode 100644 index 000000000..d60cb39b0 --- /dev/null +++ b/apps/mobile/src/features/settings/components/SettingsRow.tsx @@ -0,0 +1,69 @@ +import { Text } from "@components/text"; +import type { ReactNode } from "react"; +import { Pressable, View } from "react-native"; + +interface BaseRowProps { + label: string; + description?: string; + /** Right-aligned content (value, switch, chevron, etc.). */ + rightSlot?: ReactNode; + /** Set `false` to hide the bottom divider — typically the last row in a section. */ + showDivider?: boolean; +} + +interface DisplayRowProps extends BaseRowProps { + onPress?: never; + disabled?: never; +} + +interface PressableRowProps extends BaseRowProps { + onPress: () => void; + disabled?: boolean; +} + +type SettingsRowProps = DisplayRowProps | PressableRowProps; + +/** + * One row inside a `SettingsSection`. Label + optional description on the + * left, action / value on the right. Used as a building block for switch + * rows, picker rows, info rows, and action rows. + */ +export function SettingsRow(props: SettingsRowProps) { + const { label, description, rightSlot, showDivider = true } = props; + const onPress = "onPress" in props ? props.onPress : undefined; + const disabled = "disabled" in props ? props.disabled : undefined; + + const Body = ( + + + {label} + {description ? ( + + {description} + + ) : null} + + {rightSlot ? ( + + {rightSlot} + + ) : null} + + ); + + if (onPress) { + return ( + + {Body} + + ); + } + + return Body; +} diff --git a/apps/mobile/src/features/settings/components/SettingsSection.tsx b/apps/mobile/src/features/settings/components/SettingsSection.tsx new file mode 100644 index 000000000..f9526e4f7 --- /dev/null +++ b/apps/mobile/src/features/settings/components/SettingsSection.tsx @@ -0,0 +1,36 @@ +import { Text } from "@components/text"; +import type { ReactNode } from "react"; +import { View } from "react-native"; + +interface SettingsSectionProps { + title: string; + description?: string; + children: ReactNode; +} + +/** + * Grouped section of setting rows. Renders a labelled title above a rounded + * card. Mirrors the desktop `SettingRow` grouping pattern: a small section + * heading and an outlined panel of rows below it. + */ +export function SettingsSection({ + title, + description, + children, +}: SettingsSectionProps) { + return ( + + + {title} + + {description ? ( + + {description} + + ) : null} + + {children} + + + ); +} diff --git a/apps/mobile/src/features/tasks/utils/sounds.ts b/apps/mobile/src/features/tasks/utils/sounds.ts index 8460a0cb3..89c69f748 100644 --- a/apps/mobile/src/features/tasks/utils/sounds.ts +++ b/apps/mobile/src/features/tasks/utils/sounds.ts @@ -1,23 +1,60 @@ import { Audio } from "expo-av"; +import { + type CompletionSound, + usePreferencesStore, +} from "@/features/preferences/stores/preferencesStore"; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const dropAsset = require("../../../../assets/sounds/drop.mp3"); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const knockAsset = require("../../../../assets/sounds/knock.mp3"); // eslint-disable-next-line @typescript-eslint/no-require-imports const meepAsset = require("../../../../assets/sounds/meep.mp3"); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const ringAsset = require("../../../../assets/sounds/ring.mp3"); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const shootAsset = require("../../../../assets/sounds/shoot.mp3"); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const slideAsset = require("../../../../assets/sounds/slide.mp3"); + +const SOUND_ASSETS: Record = { + meep: meepAsset, + knock: knockAsset, + ring: ringAsset, + shoot: shootAsset, + slide: slideAsset, + drop: dropAsset, +}; let audioModeConfigured = false; -export async function playMeepSound(): Promise { - if (!audioModeConfigured) { - await Audio.setAudioModeAsync({ - playsInSilentModeIOS: true, - }); - audioModeConfigured = true; - } - const { sound } = await Audio.Sound.createAsync(meepAsset, { +async function ensureAudioMode(): Promise { + if (audioModeConfigured) return; + await Audio.setAudioModeAsync({ playsInSilentModeIOS: true }); + audioModeConfigured = true; +} + +export async function playCompletionSound( + sound?: CompletionSound, + volume?: number, +): Promise { + const prefs = usePreferencesStore.getState(); + const which = sound ?? prefs.completionSound; + const vol = (volume ?? prefs.completionVolume) / 100; + await ensureAudioMode(); + const { sound: player } = await Audio.Sound.createAsync(SOUND_ASSETS[which], { shouldPlay: true, + volume: Math.max(0, Math.min(1, vol)), }); - sound.setOnPlaybackStatusUpdate((status) => { + player.setOnPlaybackStatusUpdate((status) => { if (status.isLoaded && status.didJustFinish) { - sound.unloadAsync(); + player.unloadAsync(); } }); } + +// Kept as an alias so existing call sites continue to work; routes through +// the user's selected completion sound. +export function playMeepSound(): Promise { + return playCompletionSound(); +} From 49c1692fc981bb68a1a1c6e5f24d704c0498defc Mon Sep 17 00:00:00 2001 From: Annika <14750837+annikaschmid@users.noreply.github.com> Date: Wed, 13 May 2026 22:33:19 -0400 Subject: [PATCH 44/94] Adjust mobile task icon precedence --- .../tasks/components/TaskStatusIcon.test.ts | 97 +++++++++++++++++++ .../tasks/components/TaskStatusIcon.tsx | 27 +++--- .../tasks/components/taskStatusIconKind.ts | 42 ++++++++ 3 files changed, 150 insertions(+), 16 deletions(-) create mode 100644 apps/mobile/src/features/tasks/components/TaskStatusIcon.test.ts create mode 100644 apps/mobile/src/features/tasks/components/taskStatusIconKind.ts diff --git a/apps/mobile/src/features/tasks/components/TaskStatusIcon.test.ts b/apps/mobile/src/features/tasks/components/TaskStatusIcon.test.ts new file mode 100644 index 000000000..080e0f1ac --- /dev/null +++ b/apps/mobile/src/features/tasks/components/TaskStatusIcon.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import type { Task } from "../types"; +import { getTaskStatusIconKind } from "./taskStatusIconKind"; + +function makeTask(latestRun?: Partial>): Task { + return { + id: "task-1", + task_number: 1, + slug: "task-1", + title: "Test task", + description: "", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + origin_product: "code", + latest_run: latestRun + ? { + id: "run-1", + task: "task-1", + team: 1, + branch: null, + stage: null, + environment: "local", + status: "not_started", + log_url: "", + error_message: null, + output: null, + state: {}, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + completed_at: null, + ...latestRun, + } + : undefined, + }; +} + +describe("getTaskStatusIconKind", () => { + it("prioritizes PR over cloud status", () => { + const task = makeTask({ + environment: "cloud", + status: "in_progress", + output: { pr_url: "https://github.com/PostHog/code/pull/123" }, + }); + + expect(getTaskStatusIconKind(task)).toBe("pr"); + }); + + it("shows chat for cloud tasks without a PR, regardless of run status", () => { + expect( + getTaskStatusIconKind( + makeTask({ environment: "cloud", status: "queued" }), + ), + ).toBe("chat"); + + expect( + getTaskStatusIconKind( + makeTask({ environment: "cloud", status: "in_progress" }), + ), + ).toBe("chat"); + + expect( + getTaskStatusIconKind( + makeTask({ environment: "cloud", status: "started" }), + ), + ).toBe("chat"); + + expect( + getTaskStatusIconKind( + makeTask({ environment: "cloud", status: "completed" }), + ), + ).toBe("chat"); + + expect( + getTaskStatusIconKind( + makeTask({ environment: "cloud", status: "cancelled" }), + ), + ).toBe("chat"); + }); + + it("preserves local run-state icons", () => { + expect( + getTaskStatusIconKind( + makeTask({ environment: "local", status: "in_progress" }), + ), + ).toBe("running"); + + expect( + getTaskStatusIconKind( + makeTask({ environment: "local", status: "failed" }), + ), + ).toBe("failed"); + }); + + it("falls back to chat when a task has no run yet", () => { + expect(getTaskStatusIconKind(makeTask())).toBe("chat"); + }); +}); diff --git a/apps/mobile/src/features/tasks/components/TaskStatusIcon.tsx b/apps/mobile/src/features/tasks/components/TaskStatusIcon.tsx index 9cec8c8e6..06c992f04 100644 --- a/apps/mobile/src/features/tasks/components/TaskStatusIcon.tsx +++ b/apps/mobile/src/features/tasks/components/TaskStatusIcon.tsx @@ -1,4 +1,5 @@ import { + ChatCircle, CheckCircle, CircleIcon, CircleNotch, @@ -6,9 +7,10 @@ import { XCircle, } from "phosphor-react-native"; import { memo, useEffect, useRef } from "react"; -import { Animated, Easing, View } from "react-native"; +import { Animated, Easing } from "react-native"; import { useThemeColors } from "@/lib/theme"; import type { Task } from "../types"; +import { getTaskStatusIconKind } from "./taskStatusIconKind"; interface TaskStatusIconProps { task: Task; @@ -17,11 +19,10 @@ interface TaskStatusIconProps { function TaskStatusIconComponent({ task, size = 16 }: TaskStatusIconProps) { const colors = useThemeColors(); - const prUrl = task.latest_run?.output?.pr_url as string | undefined; - const status = task.latest_run?.status; + const iconKind = getTaskStatusIconKind(task); const rotation = useRef(new Animated.Value(0)).current; - const isRunning = !prUrl && status === "in_progress"; + const isRunning = iconKind === "running"; useEffect(() => { if (!isRunning) { @@ -41,24 +42,23 @@ function TaskStatusIconComponent({ task, size = 16 }: TaskStatusIconProps) { return () => loop.stop(); }, [isRunning, rotation]); - // Priority: PR open > completed > failed > running > started > backlog - if (prUrl) { + if (iconKind === "pr") { return ( ); } - if (status === "completed") { + if (iconKind === "completed") { return ( ); } - if (status === "failed") { + if (iconKind === "failed") { return ; } - if (status === "in_progress") { + if (iconKind === "running") { const spin = rotation.interpolate({ inputRange: [0, 1], outputRange: ["0deg", "360deg"], @@ -70,16 +70,11 @@ function TaskStatusIconComponent({ task, size = 16 }: TaskStatusIconProps) { ); } - if (status === "started") { + if (iconKind === "started") { return ; } - // Backlog / no run yet - return ( - - - - ); + return ; } export const TaskStatusIcon = memo(TaskStatusIconComponent); diff --git a/apps/mobile/src/features/tasks/components/taskStatusIconKind.ts b/apps/mobile/src/features/tasks/components/taskStatusIconKind.ts new file mode 100644 index 000000000..fa7fbcd35 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/taskStatusIconKind.ts @@ -0,0 +1,42 @@ +import type { Task } from "../types"; + +export type TaskStatusIconKind = + | "pr" + | "completed" + | "failed" + | "running" + | "started" + | "chat"; + +export function getTaskStatusIconKind(task: Task): TaskStatusIconKind { + const prUrl = task.latest_run?.output?.pr_url as string | undefined; + const status = task.latest_run?.status; + const environment = task.latest_run?.environment; + + // Match desktop semantics, but let PR win when a cloud task also has one. + if (prUrl) { + return "pr"; + } + + if (environment === "cloud") { + return "chat"; + } + + if (status === "completed") { + return "completed"; + } + + if (status === "failed") { + return "failed"; + } + + if (status === "in_progress") { + return "running"; + } + + if (status === "queued" || status === "started") { + return "started"; + } + + return "chat"; +} From 03850746027446b21cb31d7fb52183032d3ba62c Mon Sep 17 00:00:00 2001 From: Annika <14750837+annikaschmid@users.noreply.github.com> Date: Wed, 13 May 2026 22:37:28 -0400 Subject: [PATCH 45/94] Guard mobile attachment pickers --- .../tasks/composer/attachments/pickers.ts | 44 ++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/tasks/composer/attachments/pickers.ts b/apps/mobile/src/features/tasks/composer/attachments/pickers.ts index 2112807f7..b3e220f16 100644 --- a/apps/mobile/src/features/tasks/composer/attachments/pickers.ts +++ b/apps/mobile/src/features/tasks/composer/attachments/pickers.ts @@ -1,5 +1,3 @@ -import * as DocumentPicker from "expo-document-picker"; -import * as ImagePicker from "expo-image-picker"; import { Alert } from "react-native"; import { logger } from "@/lib/logger"; import type { PendingAttachment } from "./types"; @@ -25,11 +23,47 @@ function deriveFileName(uri: string, fallback: string): string { return fallback; } +function showNativeModuleAlert(kind: "document" | "image"): void { + const label = kind === "document" ? "file attachments" : "photo attachments"; + + Alert.alert( + "App update needed", + `This build does not include the native module for ${label} yet. Rebuild and reinstall the mobile app on your device to use this feature.`, + ); +} + +async function loadDocumentPicker(): Promise< + typeof import("expo-document-picker") | null +> { + try { + return await import("expo-document-picker"); + } catch (err) { + log.error("Document picker native module unavailable", err); + showNativeModuleAlert("document"); + return null; + } +} + +async function loadImagePicker(): Promise< + typeof import("expo-image-picker") | null +> { + try { + return await import("expo-image-picker"); + } catch (err) { + log.error("Image picker native module unavailable", err); + showNativeModuleAlert("image"); + return null; + } +} + /** * Open the photo library and return the picked image as a PendingAttachment. * Returns `null` if the user cancels or permission is denied. */ export async function pickPhotoFromLibrary(): Promise { + const ImagePicker = await loadImagePicker(); + if (!ImagePicker) return null; + const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (!perm.granted) { Alert.alert( @@ -62,6 +96,9 @@ export async function pickPhotoFromLibrary(): Promise * Open the camera and return the captured photo as a PendingAttachment. */ export async function captureFromCamera(): Promise { + const ImagePicker = await loadImagePicker(); + if (!ImagePicker) return null; + const perm = await ImagePicker.requestCameraPermissionsAsync(); if (!perm.granted) { Alert.alert( @@ -96,6 +133,9 @@ export async function captureFromCamera(): Promise { */ export async function pickDocument(): Promise { try { + const DocumentPicker = await loadDocumentPicker(); + if (!DocumentPicker) return null; + const result = await DocumentPicker.getDocumentAsync({ type: "*/*", copyToCacheDirectory: true, From 6fbe8b900e8bceac90399e0268bc490f62eb90fb Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 13 May 2026 22:35:49 -0400 Subject: [PATCH 46/94] Removed Hedgehog mode --- apps/mobile/src/app/settings/index.tsx | 12 +----------- .../features/preferences/stores/preferencesStore.ts | 7 ------- 2 files changed, 1 insertion(+), 18 deletions(-) diff --git a/apps/mobile/src/app/settings/index.tsx b/apps/mobile/src/app/settings/index.tsx index 07b01973e..607cc9486 100644 --- a/apps/mobile/src/app/settings/index.tsx +++ b/apps/mobile/src/app/settings/index.tsx @@ -105,9 +105,6 @@ export default function SettingsScreen() { const setDefaultInitialTaskMode = usePreferencesStore( (s) => s.setDefaultInitialTaskMode, ); - const hedgehogMode = usePreferencesStore((s) => s.hedgehogMode); - const setHedgehogMode = usePreferencesStore((s) => s.setHedgehogMode); - const dismissedCount = useDismissedReportsStore((s) => s.dismissedIds.length); const clearDismissed = useDismissedReportsStore((s) => s.clearDismissed); @@ -275,16 +272,9 @@ export default function SettingsScreen() { - } - /> - + } /> diff --git a/apps/mobile/src/features/preferences/stores/preferencesStore.ts b/apps/mobile/src/features/preferences/stores/preferencesStore.ts index a3b5ddd75..64d307ba3 100644 --- a/apps/mobile/src/features/preferences/stores/preferencesStore.ts +++ b/apps/mobile/src/features/preferences/stores/preferencesStore.ts @@ -36,9 +36,6 @@ interface PreferencesState { * `defaultInitialTaskMode === "last_used"` can pre-fill it next time. */ lastNewTaskMode: string; setLastNewTaskMode: (mode: string) => void; - - hedgehogMode: boolean; - setHedgehogMode: (enabled: boolean) => void; } export const usePreferencesStore = create()( @@ -68,9 +65,6 @@ export const usePreferencesStore = create()( set({ defaultInitialTaskMode: mode }), lastNewTaskMode: "plan", setLastNewTaskMode: (mode) => set({ lastNewTaskMode: mode }), - - hedgehogMode: false, - setHedgehogMode: (enabled) => set({ hedgehogMode: enabled }), }), { name: "posthog-preferences", @@ -84,7 +78,6 @@ export const usePreferencesStore = create()( completionVolume: state.completionVolume, defaultInitialTaskMode: state.defaultInitialTaskMode, lastNewTaskMode: state.lastNewTaskMode, - hedgehogMode: state.hedgehogMode, }), }, ), From 574c2e239cd4820477352da5e83246358391c444 Mon Sep 17 00:00:00 2001 From: dylan Date: Wed, 13 May 2026 23:05:51 -0400 Subject: [PATCH 47/94] enhance the reports inbox --- apps/mobile/src/app/_layout.tsx | 10 +- apps/mobile/src/app/report/[id].tsx | 345 +++++++++++------- apps/mobile/src/features/inbox/api.ts | 86 ++++- .../inbox/components/DismissReportSheet.tsx | 192 ++++++++++ .../inbox/components/FloatingInboxHeader.tsx | 8 +- .../features/inbox/components/SignalCard.tsx | 252 +++++++++++++ .../inbox/components/SuggestedReviewers.tsx | 67 ++++ apps/mobile/src/features/inbox/constants.ts | 23 ++ .../features/inbox/hooks/useInboxReports.ts | 44 ++- apps/mobile/src/features/inbox/types.ts | 92 +++++ apps/mobile/src/lib/api.ts | 25 +- 11 files changed, 979 insertions(+), 165 deletions(-) create mode 100644 apps/mobile/src/features/inbox/components/DismissReportSheet.tsx create mode 100644 apps/mobile/src/features/inbox/components/SignalCard.tsx create mode 100644 apps/mobile/src/features/inbox/components/SuggestedReviewers.tsx diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index c01c55837..0a8e93c4d 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -81,15 +81,11 @@ function RootLayoutNav({ isConnected }: RootLayoutNavProps) { {/* Tinder-style inbox review */} - {/* Report detail - modal presentation */} + {/* Report detail - modal presentation, no native header + (the in-content title block is the canonical header). */} {/* Task routes - modal presentation, no native header. */} diff --git a/apps/mobile/src/app/report/[id].tsx b/apps/mobile/src/app/report/[id].tsx index 87e6e05fd..0a119d0eb 100644 --- a/apps/mobile/src/app/report/[id].tsx +++ b/apps/mobile/src/app/report/[id].tsx @@ -1,24 +1,35 @@ import { Text } from "@components/text"; import { differenceInHours, format, formatDistanceToNow } from "date-fns"; -import { GlassContainer, GlassView } from "expo-glass-effect"; import * as Haptics from "expo-haptics"; -import { Stack, useLocalSearchParams, useRouter } from "expo-router"; -import { Lightning, Play, Warning } from "phosphor-react-native"; -import { useCallback, useEffect, useState } from "react"; +import { useLocalSearchParams, useRouter } from "expo-router"; import { - ActivityIndicator, - Platform, - Pressable, - ScrollView, - View, -} from "react-native"; + CaretDown, + CaretRight, + Lightning, + Play, + Plus, + ThumbsDown, + Warning, +} from "phosphor-react-native"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { ActivityIndicator, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { MarkdownText } from "@/features/chat/components/MarkdownText"; import { getReportRepository } from "@/features/inbox/api"; -import { useInboxReport } from "@/features/inbox/hooks/useInboxReports"; +import { DismissReportSheet } from "@/features/inbox/components/DismissReportSheet"; +import { SignalCard } from "@/features/inbox/components/SignalCard"; +import { SuggestedReviewers } from "@/features/inbox/components/SuggestedReviewers"; +import { + useInboxReport, + useInboxReportArtefacts, + useInboxReportSignals, +} from "@/features/inbox/hooks/useInboxReports"; import type { + ActionabilityJudgmentContent, + SignalFindingContent, SignalReportPriority, SignalReportStatus, + SuggestedReviewer, } from "@/features/inbox/types"; import { inboxStatusLabel } from "@/features/inbox/utils"; import { useThemeColors } from "@/lib/theme"; @@ -42,6 +53,24 @@ const priorityColorMap: Record = { P4: { bg: "bg-gray-5/20", text: "text-gray-9" }, }; +const actionabilityColorMap: Record = { + immediately_actionable: { + bg: "bg-status-success/20", + text: "text-status-success", + }, + requires_human_input: { + bg: "bg-status-warning/20", + text: "text-status-warning", + }, + not_actionable: { bg: "bg-gray-5/20", text: "text-gray-9" }, +}; + +const actionabilityLabel: Record = { + immediately_actionable: "Actionable", + requires_human_input: "Needs input", + not_actionable: "Not actionable", +}; + function StatusBadge({ status }: { status: SignalReportStatus }) { const colors = statusColorMap[status] ?? statusColorMap.potential; return ( @@ -64,11 +93,14 @@ function PriorityBadge({ priority }: { priority: SignalReportPriority }) { ); } -function SectionHeader({ title }: { title: string }) { +function ActionabilityBadge({ value }: { value: string }) { + const colors = + actionabilityColorMap[value] ?? actionabilityColorMap.not_actionable; + const label = actionabilityLabel[value] ?? value; return ( - - {title} - + + {label} + ); } @@ -79,6 +111,11 @@ export default function ReportDetailScreen() { const insets = useSafeAreaInsets(); const { data: report, isLoading, error } = useInboxReport(reportId ?? null); const [reportRepo, setReportRepo] = useState(null); + const [dismissOpen, setDismissOpen] = useState(false); + const [signalsExpanded, setSignalsExpanded] = useState(false); + + const artefactsQuery = useInboxReportArtefacts(reportId ?? null); + const signalsQuery = useInboxReportSignals(reportId ?? null); useEffect(() => { if (!reportId) return; @@ -93,6 +130,49 @@ export default function ReportDetailScreen() { }; }, [reportId]); + // ── Derive artefact bits ──────────────────────────────────────────────── + const artefacts = artefactsQuery.data?.results ?? []; + + const actionabilityJudgment = + useMemo((): ActionabilityJudgmentContent | null => { + for (const a of artefacts) { + if (a.type === "actionability_judgment") { + return a.content as ActionabilityJudgmentContent; + } + } + return null; + }, [artefacts]); + + const suggestedReviewers = useMemo((): SuggestedReviewer[] => { + for (const a of artefacts) { + if (a.type === "suggested_reviewers") { + return (a.content as SuggestedReviewer[]) ?? []; + } + } + return []; + }, [artefacts]); + + const findingsBySignalId = useMemo(() => { + const map = new Map(); + for (const a of artefacts) { + if (a.type === "signal_finding") { + const c = a.content as SignalFindingContent; + map.set(c.signal_id, c); + } + } + return map; + }, [artefacts]); + + const allSignals = signalsQuery.data?.signals ?? []; + // Match web: split session_problem evidence from main Signals list. + const signals = allSignals.filter( + (s) => + !( + s.source_product === "session_replay" && + s.source_type === "session_problem" + ), + ); + const handleStartTask = useCallback(() => { if (!report) return; Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); @@ -107,49 +187,32 @@ export default function ReportDetailScreen() { }); }, [report, router, reportRepo]); + const handleDismissed = useCallback(() => { + setDismissOpen(false); + if (router.canGoBack()) router.back(); + }, [router]); + if (error) { return ( - <> - - - - Failed to load report - - router.back()} - className="rounded-lg bg-gray-3 px-4 py-2" - > - Go back - - - + + + Failed to load report + + router.back()} + className="rounded-lg bg-gray-3 px-4 py-2" + > + Go back + + ); } if (isLoading || !report) { return ( - <> - - - - - + + + ); } @@ -173,18 +236,17 @@ export default function ReportDetailScreen() { report.actionability === "immediately_actionable" && report.already_addressed !== true); + const alreadyAddressed = + report.already_addressed ?? + actionabilityJudgment?.already_addressed ?? + false; + + const primaryActionLabel = isAwaitingInput + ? "Implement as new task" + : "Start task"; + return ( <> - {report.priority && } + {report.actionability && ( + + )} {report.is_suggested_reviewer && ( @@ -204,13 +269,6 @@ export default function ReportDetailScreen() { )} - {report.already_addressed && ( - - - May be addressed - - - )} {/* Title */} @@ -245,6 +303,20 @@ export default function ReportDetailScreen() { )} + {/* Already-addressed banner */} + {alreadyAddressed && ( + + + + This issue may already be addressed in recent code changes. + + + )} + {/* Summary */} {report.summary && ( @@ -252,79 +324,86 @@ export default function ReportDetailScreen() { )} - {/* Actionability info */} - {report.actionability && ( - - - - - {report.actionability === "immediately_actionable" - ? "This report is immediately actionable — a task can be created directly." - : report.actionability === "requires_human_input" - ? "This report needs human input before it can be acted on." - : "This report is not directly actionable at this time."} - - - - )} + {/* Suggested reviewers */} + - {/* PR link */} - {report.implementation_pr_url && ( + {/* Signals */} + {signals.length > 0 && ( - - - - {report.implementation_pr_url} + setSignalsExpanded((v) => !v)} + hitSlop={6} + accessibilityRole="button" + accessibilityState={{ expanded: signalsExpanded }} + className="mb-2 flex-row items-center gap-1.5 self-start py-1 active:opacity-60" + > + {signalsExpanded ? ( + + ) : ( + + )} + + Signals ({signals.length}) - + + {signalsExpanded && ( + + {signals.map((signal) => ( + + ))} + + )} )} + {signalsQuery.isLoading && ( + Loading signals… + )} - {/* Floating "Start task" button */} - {canStartTask && ( - + setDismissOpen(true)} + accessibilityLabel="Dismiss report" + className="flex-row items-center gap-2 rounded-full border border-gray-6 bg-background px-5 py-3.5 shadow-lg active:opacity-80" > - {Platform.OS === "ios" ? ( - - ({ opacity: pressed ? 0.8 : 1 })} - > - - - - Start task - - - - - ) : ( - - - - Start task - - - )} - - )} + + + Dismiss + + + + {canStartTask && ( + + {isAwaitingInput ? ( + + ) : ( + + )} + + {primaryActionLabel} + + + )} + + + setDismissOpen(false)} + onDismissed={handleDismissed} + /> ); } diff --git a/apps/mobile/src/features/inbox/api.ts b/apps/mobile/src/features/inbox/api.ts index 3342171dd..7838f6dc9 100644 --- a/apps/mobile/src/features/inbox/api.ts +++ b/apps/mobile/src/features/inbox/api.ts @@ -2,14 +2,18 @@ import { fetch } from "expo/fetch"; import { HttpError } from "@/features/tasks/api"; import { getBaseUrl, getHeaders, getProjectId } from "@/lib/api"; import { logger } from "@/lib/logger"; +import type { DismissalReasonOptionValue } from "./constants"; const log = logger.scope("inbox-api"); import type { AvailableSuggestedReviewer, AvailableSuggestedReviewersResponse, + ReportArtefact, SignalProcessingStateResponse, SignalReport, + SignalReportArtefactsResponse, + SignalReportSignalsResponse, SignalReportsQueryParams, SignalReportsResponse, SignalReportTask, @@ -175,10 +179,9 @@ export async function getSignalReportTasks( return data.results ?? []; } -/** Resolve the repository associated with a signal report via its repo_selection artefact. */ -export async function getReportRepository( +export async function getSignalReportArtefacts( reportId: string, -): Promise { +): Promise { const baseUrl = getBaseUrl(); const projectId = getProjectId(); const headers = getHeaders(); @@ -195,22 +198,51 @@ export async function getReportRepository( status: response.status, body: body.slice(0, 500), }); - return null; + return { results: [], count: 0 }; + } + + const data = await response.json(); + const results: ReportArtefact[] = data.results ?? []; + return { results, count: data.count ?? results.length }; +} + +export async function getSignalReportSignals( + reportId: string, +): Promise { + const baseUrl = getBaseUrl(); + const projectId = getProjectId(); + const headers = getHeaders(); + + const response = await fetch( + `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/signals/`, + { headers }, + ); + + if (!response.ok) { + log.warn("Failed to fetch report signals", { + reportId, + status: response.status, + }); + return { signals: [] }; } const data = await response.json(); - const artefacts: { type: string; content: unknown }[] = data.results ?? []; - const repoArtefact = artefacts.find((a) => a.type === "repo_selection"); + return { signals: data.signals ?? [] }; +} +/** Resolve the repository associated with a signal report via its repo_selection artefact. */ +export async function getReportRepository( + reportId: string, +): Promise { + const { results } = await getSignalReportArtefacts(reportId); + const repoArtefact = results.find((a) => a.type === "repo_selection"); if (!repoArtefact) return null; - // content may be a JSON string or an already-parsed object let parsed: unknown = repoArtefact.content; if (typeof parsed === "string") { try { parsed = JSON.parse(parsed); } catch { - // Plain string like "org/repo" return (parsed as string).toLowerCase(); } } @@ -224,3 +256,41 @@ export async function getReportRepository( return null; } + +export interface DismissSignalReportInput { + reason: DismissalReasonOptionValue; + note?: string; +} + +export async function dismissSignalReport( + reportId: string, + input: DismissSignalReportInput, +): Promise { + const baseUrl = getBaseUrl(); + const projectId = getProjectId(); + const headers = getHeaders(); + + const response = await fetch( + `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/state/`, + { + method: "POST", + headers: { ...headers, "Content-Type": "application/json" }, + body: JSON.stringify({ + state: "suppressed", + dismissal_reason: input.reason, + ...(input.note?.trim() ? { dismissal_note: input.note.trim() } : {}), + }), + }, + ); + + if (!response.ok) { + const errorText = await response.text().catch(() => ""); + throw new HttpError( + response.status, + response.statusText, + errorText || "Failed to dismiss signal report", + ); + } + + return await response.json(); +} diff --git a/apps/mobile/src/features/inbox/components/DismissReportSheet.tsx b/apps/mobile/src/features/inbox/components/DismissReportSheet.tsx new file mode 100644 index 000000000..648f1b90c --- /dev/null +++ b/apps/mobile/src/features/inbox/components/DismissReportSheet.tsx @@ -0,0 +1,192 @@ +import { Text } from "@components/text"; +import * as Haptics from "expo-haptics"; +import { Check } from "phosphor-react-native"; +import { useEffect, useState } from "react"; +import { + ActivityIndicator, + KeyboardAvoidingView, + Modal, + Platform, + Pressable, + ScrollView, + TextInput, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useThemeColors } from "@/lib/theme"; +import { + DISMISSAL_REASON_OPTIONS, + type DismissalReasonOptionValue, +} from "../constants"; +import { useDismissReport } from "../hooks/useInboxReports"; + +interface DismissReportSheetProps { + visible: boolean; + reportId: string; + reportTitle: string; + onClose: () => void; + onDismissed: () => void; +} + +export function DismissReportSheet({ + visible, + reportId, + reportTitle, + onClose, + onDismissed, +}: DismissReportSheetProps) { + const insets = useSafeAreaInsets(); + const themeColors = useThemeColors(); + const [reason, setReason] = useState(null); + const [note, setNote] = useState(""); + const [error, setError] = useState(null); + const dismiss = useDismissReport(reportId); + + useEffect(() => { + if (visible) { + setReason(null); + setNote(""); + setError(null); + } + }, [visible]); + + const handleConfirm = async () => { + if (!reason || dismiss.isPending) return; + setError(null); + try { + await dismiss.mutateAsync({ reason, note: note.trim() || undefined }); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + onDismissed(); + } catch (err) { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + setError( + err instanceof Error + ? err.message + : "Could not dismiss this report. Please try again.", + ); + } + }; + + const canSubmit = !!reason && !dismiss.isPending; + + return ( + + + + {/* Header */} + + + Dismiss report + + + Cancel + + + + + + {`This will remove "${reportTitle}" from your inbox. Your feedback is saved on the report and helps the agent.`} + + + + Reason + + + {DISMISSAL_REASON_OPTIONS.map((option, idx) => { + const selected = reason === option.value; + return ( + setReason(option.value)} + hitSlop={4} + className={`flex-row items-center justify-between px-3 py-3.5 active:bg-gray-3 ${ + idx > 0 ? "border-gray-5 border-t" : "" + }`} + > + + {option.label} + + {selected && ( + + )} + + ); + })} + + + + Note (optional) + + + + {error && ( + + {error} + + )} + + + {/* Sticky submit */} + + + {dismiss.isPending ? ( + + ) : ( + + Dismiss & teach the agent + + )} + + + + + + ); +} diff --git a/apps/mobile/src/features/inbox/components/FloatingInboxHeader.tsx b/apps/mobile/src/features/inbox/components/FloatingInboxHeader.tsx index bcf5dd94d..e6a0b1e90 100644 --- a/apps/mobile/src/features/inbox/components/FloatingInboxHeader.tsx +++ b/apps/mobile/src/features/inbox/components/FloatingInboxHeader.tsx @@ -51,12 +51,16 @@ export function FloatingInboxHeader({ /> - + = { + issue_created: "New issue", + issue_reopened: "Issue reopened", + issue_spiking: "Volume spike", +}; + +function sourceLine(signal: Signal): string { + const { source_product, source_type } = signal; + if (source_product === "error_tracking") { + const label = + ERROR_TRACKING_TYPE_LABELS[source_type] ?? source_type.replace(/_/g, " "); + return `Error tracking · ${label}`; + } + if (source_product === "session_replay" && source_type === "session_problem") + return "Session replay · Session problem"; + if (source_product === "llm_analytics" && source_type === "evaluation") + return "LLM analytics · Evaluation"; + if (source_product === "zendesk" && source_type === "ticket") + return "Zendesk · Ticket"; + if (source_product === "github" && source_type === "issue") + return "GitHub · Issue"; + if (source_product === "linear" && source_type === "issue") + return "Linear · Issue"; + const product = source_product.replace(/_/g, " "); + const type = source_type.replace(/_/g, " "); + return `${product} · ${type}`; +} + +function SourceIcon({ + product, + size = 14, + color, +}: { + product: string; + size?: number; + color: string; +}) { + switch (product) { + case "error_tracking": + return ; + case "github": + return ; + case "session_replay": + return ; + case "llm_analytics": + return ; + case "zendesk": + return ; + case "linear": + return ; + default: + return ; + } +} + +function truncateBody(body: string): string { + if (body.length <= COLLAPSE_THRESHOLD) return body; + const truncated = body.slice(0, COLLAPSE_THRESHOLD); + const lastNewline = truncated.lastIndexOf("\n"); + const cut = + lastNewline > COLLAPSE_THRESHOLD * 0.5 ? lastNewline : COLLAPSE_THRESHOLD; + let result = truncated.slice(0, cut); + const fenceCount = (result.match(/^```/gm) || []).length; + if (fenceCount % 2 !== 0) { + const lastFence = result.lastIndexOf("```"); + const afterFence = result.slice(lastFence + 3).trim(); + if (!afterFence) { + result = result.slice(0, lastFence).trimEnd(); + } else { + result += "\n```"; + } + } + return `${result}\n\n…`; +} + +function CollapsibleBody({ body }: { body: string }) { + const themeColors = useThemeColors(); + const [expanded, setExpanded] = useState(false); + const isLong = body.length > COLLAPSE_THRESHOLD; + const processed = body.replace(/\\`/g, "`"); + const display = isLong && !expanded ? truncateBody(processed) : processed; + + return ( + + + {isLong && ( + setExpanded((v) => !v)} + hitSlop={6} + className="mt-1 flex-row items-center gap-1 self-start py-1 active:opacity-60" + > + {expanded ? ( + + ) : ( + + )} + + {expanded ? "Show less" : "Show more"} + + + )} + + ); +} + +function CodePathsDisclosure({ paths }: { paths: string[] }) { + const themeColors = useThemeColors(); + const [expanded, setExpanded] = useState(false); + if (paths.length === 0) return null; + + return ( + + setExpanded((v) => !v)} + hitSlop={6} + className="flex-row items-center gap-1 self-start py-1 active:opacity-60" + > + {expanded ? ( + + ) : ( + + )} + + + Relevant code ({paths.length}) + + + {expanded && ( + + {paths.map((raw) => { + const trimmed = raw.trim(); + const parenIdx = trimmed.indexOf(" ("); + const filePath = + parenIdx >= 0 ? trimmed.slice(0, parenIdx) : trimmed; + const comment = parenIdx >= 0 ? trimmed.slice(parenIdx + 1) : null; + return ( + + + {filePath} + + {comment && ( + {comment} + )} + + ); + })} + + )} + + ); +} + +function VerifiedBadge({ verified }: { verified: boolean }) { + const themeColors = useThemeColors(); + const color = verified ? themeColors.status.success : themeColors.gray[9]; + const Icon = verified ? CheckCircle : Question; + return ( + + + + {verified ? "Verified" : "Unverified"} + + + ); +} + +interface SignalCardProps { + signal: Signal; + finding?: SignalFindingContent; +} + +export function SignalCard({ signal, finding }: SignalCardProps) { + const themeColors = useThemeColors(); + const verified = finding?.verified; + const codePaths = finding?.relevant_code_paths ?? []; + + const extra = signal.extra ?? {}; + const issueUrl = + typeof extra.html_url === "string" ? (extra.html_url as string) : null; + const issueNumber = + typeof extra.number === "number" ? (extra.number as number) : null; + const ticketUrl = + typeof extra.url === "string" ? (extra.url as string) : null; + + const externalUrl = issueUrl ?? ticketUrl ?? null; + + return ( + + {/* Header */} + + + + {sourceLine(signal)} + + {verified !== undefined && } + + + {/* Body */} + + + {/* Footer meta (lightweight, no source-specific extras for v1) */} + {(issueNumber !== null || externalUrl) && ( + + {issueNumber !== null && ( + + #{issueNumber} + + )} + + {externalUrl && ( + Linking.openURL(externalUrl)} + hitSlop={6} + className="flex-row items-center gap-1 active:opacity-60" + > + Open + + + )} + + )} + + + + ); +} diff --git a/apps/mobile/src/features/inbox/components/SuggestedReviewers.tsx b/apps/mobile/src/features/inbox/components/SuggestedReviewers.tsx new file mode 100644 index 000000000..ee8e1ead7 --- /dev/null +++ b/apps/mobile/src/features/inbox/components/SuggestedReviewers.tsx @@ -0,0 +1,67 @@ +import { Text } from "@components/text"; +import { Eye } from "phosphor-react-native"; +import { Image, Linking, Pressable, ScrollView, View } from "react-native"; +import { useThemeColors } from "@/lib/theme"; +import type { SuggestedReviewer } from "../types"; + +interface SuggestedReviewersProps { + reviewers: SuggestedReviewer[]; + meUuid?: string | null; +} + +export function SuggestedReviewers({ + reviewers, + meUuid, +}: SuggestedReviewersProps) { + const themeColors = useThemeColors(); + if (reviewers.length === 0) return null; + + return ( + + + Suggested reviewers + + + {reviewers.map((reviewer) => { + const isMe = + !!reviewer.user?.uuid && !!meUuid && reviewer.user.uuid === meUuid; + const displayName = + reviewer.user?.first_name ?? + reviewer.github_name ?? + reviewer.github_login; + return ( + + Linking.openURL(`https://github.com/${reviewer.github_login}`) + } + hitSlop={4} + className="flex-row items-center gap-2 rounded-full border border-gray-6 bg-gray-2 py-1.5 pr-3 pl-1.5 active:opacity-70" + > + + {displayName} + {isMe && ( + + + + )} + + ); + })} + + + ); +} diff --git a/apps/mobile/src/features/inbox/constants.ts b/apps/mobile/src/features/inbox/constants.ts index be3f886b4..ee5861623 100644 --- a/apps/mobile/src/features/inbox/constants.ts +++ b/apps/mobile/src/features/inbox/constants.ts @@ -4,3 +4,26 @@ export const INBOX_PIPELINE_STATUS_FILTER = /** Polling interval for inbox queries (ms). */ export const INBOX_REFETCH_INTERVAL_MS = 5_000; + +/** + * Reasons offered when the user dismisses a signal report. + * Mirrors apps/code/src/shared/dismissalReasons.ts. + */ +export const DISMISSAL_REASON_OPTIONS = [ + { + value: "already_fixed", + label: "Already fixed", + snoozesInsteadOfDismiss: true, + }, + { value: "report_unclear", label: "Report is unclear to me" }, + { value: "analysis_wrong", label: "Agent's analysis is wrong" }, + { value: "wontfix_intentional", label: "Won't fix — intentional behavior" }, + { + value: "wontfix_irrelevant", + label: "Won't fix — issue is real but insignificant", + }, + { value: "other", label: "Something else…" }, +] as const; + +export type DismissalReasonOptionValue = + (typeof DISMISSAL_REASON_OPTIONS)[number]["value"]; diff --git a/apps/mobile/src/features/inbox/hooks/useInboxReports.ts b/apps/mobile/src/features/inbox/hooks/useInboxReports.ts index 973ee659f..4cf936d4c 100644 --- a/apps/mobile/src/features/inbox/hooks/useInboxReports.ts +++ b/apps/mobile/src/features/inbox/hooks/useInboxReports.ts @@ -1,9 +1,13 @@ -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useAuthStore } from "@/features/auth"; import { + type DismissSignalReportInput, + dismissSignalReport, getAvailableSuggestedReviewers, getSignalProcessingState, getSignalReport, + getSignalReportArtefacts, + getSignalReportSignals, getSignalReports, } from "../api"; import { INBOX_REFETCH_INTERVAL_MS } from "../constants"; @@ -12,6 +16,8 @@ import type { AvailableSuggestedReviewersResponse, SignalProcessingStateResponse, SignalReport, + SignalReportArtefactsResponse, + SignalReportSignalsResponse, SignalReportsQueryParams, SignalReportsResponse, } from "../types"; @@ -26,6 +32,10 @@ export const inboxKeys = { list: (params?: SignalReportsQueryParams) => [...inboxKeys.all, "list", params ?? {}] as const, detail: (reportId: string) => [...inboxKeys.all, reportId, "detail"] as const, + artefacts: (reportId: string) => + [...inboxKeys.all, reportId, "artefacts"] as const, + signals: (reportId: string) => + [...inboxKeys.all, reportId, "signals"] as const, processingState: ["inbox", "signal-processing-state"] as const, }; @@ -103,3 +113,35 @@ export function useAvailableSuggestedReviewers(options?: { refetchInterval: 60_000, }); } + +export function useInboxReportArtefacts(reportId: string | null) { + const { projectId, oauthAccessToken } = useAuthStore(); + + return useQuery({ + queryKey: inboxKeys.artefacts(reportId ?? ""), + queryFn: () => getSignalReportArtefacts(reportId!), + enabled: !!projectId && !!oauthAccessToken && !!reportId, + }); +} + +export function useInboxReportSignals(reportId: string | null) { + const { projectId, oauthAccessToken } = useAuthStore(); + + return useQuery({ + queryKey: inboxKeys.signals(reportId ?? ""), + queryFn: () => getSignalReportSignals(reportId!), + enabled: !!projectId && !!oauthAccessToken && !!reportId, + }); +} + +export function useDismissReport(reportId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input) => dismissSignalReport(reportId, input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: inboxKeys.detail(reportId) }); + queryClient.invalidateQueries({ queryKey: inboxKeys.all }); + }, + }); +} diff --git a/apps/mobile/src/features/inbox/types.ts b/apps/mobile/src/features/inbox/types.ts index 9ecbbf485..c7a0eca73 100644 --- a/apps/mobile/src/features/inbox/types.ts +++ b/apps/mobile/src/features/inbox/types.ts @@ -77,3 +77,95 @@ export interface SignalReportTask { task_id: string; created_at: string; } + +export interface Signal { + signal_id: string; + content: string; + source_product: string; + source_type: string; + source_id: string; + weight: number; + timestamp: string; + extra: Record; +} + +export interface SignalFindingContent { + signal_id: string; + relevant_code_paths: string[]; + relevant_commit_hashes: Record; + data_queried: string; + verified: boolean; +} + +export interface PriorityJudgmentContent { + explanation: string; + priority: SignalReportPriority; +} + +export interface ActionabilityJudgmentContent { + explanation: string; + actionability: SignalReportActionability; + already_addressed: boolean; +} + +export interface SuggestedReviewerCommit { + sha: string; + url: string; + reason: string; +} + +export interface SuggestedReviewerUser { + id: number; + uuid: string; + email: string; + first_name: string; + last_name: string; +} + +export interface SuggestedReviewer { + github_login: string; + github_name: string | null; + relevant_commits: SuggestedReviewerCommit[]; + user: SuggestedReviewerUser | null; +} + +export type ReportArtefact = + | { + id: string; + type: "priority_judgment"; + created_at: string; + content: PriorityJudgmentContent; + } + | { + id: string; + type: "actionability_judgment"; + created_at: string; + content: ActionabilityJudgmentContent; + } + | { + id: string; + type: "signal_finding"; + created_at: string; + content: SignalFindingContent; + } + | { + id: string; + type: "suggested_reviewers"; + created_at: string; + content: SuggestedReviewer[]; + } + | { + id: string; + type: string; + created_at: string; + content: unknown; + }; + +export interface SignalReportArtefactsResponse { + results: ReportArtefact[]; + count: number; +} + +export interface SignalReportSignalsResponse { + signals: Signal[]; +} diff --git a/apps/mobile/src/lib/api.ts b/apps/mobile/src/lib/api.ts index 74fc9eaa6..30d7751c3 100644 --- a/apps/mobile/src/lib/api.ts +++ b/apps/mobile/src/lib/api.ts @@ -40,21 +40,17 @@ export async function registerPushToken(args: { platform: string; }): Promise { const baseUrl = getBaseUrl(); - const projectId = getProjectId(); const headers = getHeaders(); - const response = await fetch( - `${baseUrl}/api/projects/${projectId}/users/@me/push_tokens/`, - { - method: "POST", - headers, - body: JSON.stringify(args), - }, - ); + // Push tokens are per-user, not per-project — endpoint lives under + // /api/users/@me/ alongside the other user-scoped APIs. + const response = await fetch(`${baseUrl}/api/users/@me/push_tokens/`, { + method: "POST", + headers, + body: JSON.stringify(args), + }); if (!response.ok) { - // Endpoint may not exist yet (backend rollout in posthog/posthog is a - // separate PR). Log at debug so we can verify the call without spamming. log.debug("registerPushToken non-OK response", { status: response.status, }); @@ -64,13 +60,14 @@ export async function registerPushToken(args: { export async function deletePushToken(args: { token: string }): Promise { const baseUrl = getBaseUrl(); - const projectId = getProjectId(); const headers = getHeaders(); + // Unregister is a POST sub-action (not DELETE) because some clients and + // proxies strip request bodies on DELETE. const response = await fetch( - `${baseUrl}/api/projects/${projectId}/users/@me/push_tokens/`, + `${baseUrl}/api/users/@me/push_tokens/unregister/`, { - method: "DELETE", + method: "POST", headers, body: JSON.stringify(args), }, From 480e17228dd09673187775ef5dc6bee69e76fa0a Mon Sep 17 00:00:00 2001 From: Oliver Browne Date: Wed, 13 May 2026 23:18:49 -0400 Subject: [PATCH 48/94] tinder for all --- apps/mobile/src/app/(tabs)/inbox.tsx | 8 ++------ apps/mobile/src/app/review.tsx | 5 +---- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/app/(tabs)/inbox.tsx b/apps/mobile/src/app/(tabs)/inbox.tsx index d730311a0..504da788e 100644 --- a/apps/mobile/src/app/(tabs)/inbox.tsx +++ b/apps/mobile/src/app/(tabs)/inbox.tsx @@ -33,13 +33,9 @@ export default function InboxScreen() { const setCurrentIndex = useInboxStore((s) => s.setCurrentIndex); const { repositoryOptions } = useIntegrations(); - // Same data as the list view, filtered to reports where the user is a - // suggested reviewer (the eye icon in the list) and not yet dismissed. + // Same data as the list view, just excluding dismissed reports. const tinderReports = useMemo( - () => - reports.filter( - (r) => r.is_suggested_reviewer && !dismissedIds.includes(r.id), - ), + () => reports.filter((r) => !dismissedIds.includes(r.id)), [reports, dismissedIds], ); diff --git a/apps/mobile/src/app/review.tsx b/apps/mobile/src/app/review.tsx index 8a03d9b48..a31d20050 100644 --- a/apps/mobile/src/app/review.tsx +++ b/apps/mobile/src/app/review.tsx @@ -20,10 +20,7 @@ export default function ReviewScreen() { const { reports, isLoading } = useInboxReports(); const tinderReports = useMemo( - () => - reports.filter( - (r) => r.is_suggested_reviewer && !dismissedIds.includes(r.id), - ), + () => reports.filter((r) => !dismissedIds.includes(r.id)), [reports, dismissedIds], ); From 64aad3f64e45b6ca56417e8e9b712c447d7a0b00 Mon Sep 17 00:00:00 2001 From: Annika <14750837+annikaschmid@users.noreply.github.com> Date: Wed, 13 May 2026 23:26:49 -0400 Subject: [PATCH 49/94] Match mobile app icon to desktop app --- apps/mobile/app.json | 5 +- apps/mobile/assets/adaptive-icon.png | Bin 45900 -> 30605 bytes apps/mobile/assets/app-icon.png | Bin 0 -> 30605 bytes apps/mobile/assets/app.icon/Assets/logo.png | Bin 0 -> 30605 bytes apps/mobile/assets/app.icon/icon.json | 31 ++++ apps/mobile/assets/favicon.png | Bin 666 -> 1449 bytes .../Assets/posthog-logo-white.svg | 7 - .../posthog.icon/Assets/posthog-logo.svg | 1 - apps/mobile/assets/posthog.icon/icon.json | 162 ------------------ 9 files changed, 34 insertions(+), 172 deletions(-) create mode 100644 apps/mobile/assets/app-icon.png create mode 100644 apps/mobile/assets/app.icon/Assets/logo.png create mode 100644 apps/mobile/assets/app.icon/icon.json delete mode 100644 apps/mobile/assets/posthog.icon/Assets/posthog-logo-white.svg delete mode 100644 apps/mobile/assets/posthog.icon/Assets/posthog-logo.svg delete mode 100644 apps/mobile/assets/posthog.icon/icon.json diff --git a/apps/mobile/app.json b/apps/mobile/app.json index eb5a3fe1a..2fe68fe9b 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -8,13 +8,14 @@ "newArchEnabled": true, "bundler": "metro", "scheme": "posthog", + "icon": "./assets/app-icon.png", "splash": { "image": "./assets/splash-icon.png", "resizeMode": "contain", "backgroundColor": "#0f0f0f" }, "ios": { - "icon": "./assets/posthog.icon", + "icon": "./assets/app.icon", "supportsTablet": true, "bundleIdentifier": "com.posthog.code.mobile", "infoPlist": { @@ -29,7 +30,7 @@ "android": { "adaptiveIcon": { "foregroundImage": "./assets/adaptive-icon.png", - "backgroundColor": "#E6E6E6" + "backgroundColor": "#FFFFFF" }, "edgeToEdgeEnabled": true, "predictiveBackGestureEnabled": false, diff --git a/apps/mobile/assets/adaptive-icon.png b/apps/mobile/assets/adaptive-icon.png index 4a807e01fe6b163cc61546361e26b7b90cd1994e..c44991ba0846a21bd236d20787aa7df2d032de76 100644 GIT binary patch literal 30605 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 V?N2cofU1MEPU@V^xAP738`rxh(1W|+E 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

AAeT>Xy1>o0Q6J0G}X4lq#P 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 literal 0 HcmV?d00001 diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js index f79b5ed0b..3217bf8bb 100644 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -7,6 +7,19 @@ const monorepoRoot = path.resolve(projectRoot, "../.."); const config = getDefaultConfig(projectRoot); +// Treat .svg files as React components via react-native-svg-transformer so +// we can `import Icon from "./logo.svg"` and render it like any RN component. +const { transformer, resolver } = config; +config.transformer = { + ...transformer, + babelTransformerPath: require.resolve("react-native-svg-transformer/expo"), +}; +config.resolver = { + ...resolver, + assetExts: resolver.assetExts.filter((ext) => ext !== "svg"), + sourceExts: [...resolver.sourceExts, "svg"], +}; + // Watch monorepo root for changes config.watchFolders = [monorepoRoot]; diff --git a/apps/mobile/package.json b/apps/mobile/package.json index b4dcd8201..98a06a10d 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -25,6 +25,8 @@ }, "dependencies": { "@expo/ui": "0.2.0-beta.9", + "@modelcontextprotocol/ext-apps": "^1.2.2", + "@modelcontextprotocol/sdk": "^1.29.0", "@react-native-async-storage/async-storage": "^2.2.0", "@react-native-community/netinfo": "^12.0.1", "@tanstack/react-query": "^5.90.12", @@ -76,6 +78,7 @@ "@types/react": "^19.1.0", "@types/react-test-renderer": "^19.1.0", "@vitejs/plugin-react": "^4.7.0", + "react-native-svg-transformer": "^1.5.3", "react-test-renderer": "^19.1.0", "tailwindcss": "^3.4.18", "typescript": "~5.9.2", diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index e1cd6e7ec..7055f6897 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -85,6 +85,21 @@ function RootLayoutNav({ isConnected }: RootLayoutNavProps) { back / iOS swipe-back / Android hardware-back all return to it. */} + {/* MCP servers — marketplace + installed management. */} + + + + + {/* Report detail - modal presentation, no native header (the in-content title block is the canonical header). */} ("none"); + const [apiKey, setApiKey] = useState(""); + const [clientId, setClientId] = useState(""); + const [clientSecret, setClientSecret] = useState(""); + + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async () => { + setError(null); + if (!name.trim()) return setError("Name is required"); + if (!url.trim()) return setError("URL is required"); + + setSubmitting(true); + try { + await installCustomWithOAuth({ + name: name.trim(), + url: url.trim(), + auth_type: authType, + description: description.trim() || undefined, + api_key: + authType === "api_key" && apiKey.trim() ? apiKey.trim() : undefined, + client_id: + authType === "oauth" && clientId.trim() ? clientId.trim() : undefined, + client_secret: + authType === "oauth" && clientSecret.trim() + ? clientSecret.trim() + : undefined, + }); + await installations.refetch(); + router.back(); + } catch (err) { + log.warn("Custom install failed", err); + setError(err instanceof Error ? err.message : "Install failed"); + setSubmitting(false); + } + }; + + return ( + + + + + Name + + + URL + + + Description (optional) + + + Auth type + + {AUTH_OPTIONS.map((option) => { + const active = authType === option.value; + return ( + setAuthType(option.value)} + className={`flex-1 items-center rounded-lg border px-3 py-2 ${active ? "border-accent-9 bg-accent-3" : "border-gray-5 bg-card active:bg-gray-2"}`} + > + + {option.label} + + + ); + })} + + + {authType === "api_key" ? ( + <> + API key + + + ) : null} + + {authType === "oauth" ? ( + <> + + + + You'll be sent to the provider to sign in after submitting. + + + OAuth client ID + + OAuth client secret + + + ) : null} + + {error ? ( + {error} + ) : null} + + + {submitting ? ( + + ) : ( + + Install + + )} + + + + + ); +} + +function FieldLabel({ children }: { children: string }) { + return ( + + {children} + + ); +} + +function FieldInput(props: React.ComponentProps) { + const themeColors = useThemeColors(); + return ( + + ); +} diff --git a/apps/mobile/src/app/mcp-servers/index.tsx b/apps/mobile/src/app/mcp-servers/index.tsx new file mode 100644 index 000000000..6fccebcb9 --- /dev/null +++ b/apps/mobile/src/app/mcp-servers/index.tsx @@ -0,0 +1,234 @@ +import { Text } from "@components/text"; +import { useRouter } from "expo-router"; +import { MagnifyingGlass, Plus, PuzzlePiece } from "phosphor-react-native"; +import { useMemo, useState } from "react"; +import { + ActivityIndicator, + FlatList, + Pressable, + RefreshControl, + TextInput, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { FloatingMcpHeader } from "@/features/mcp/components/FloatingMcpHeader"; +import { + installationToRowProps, + McpServerRow, + recommendedToRowProps, +} from "@/features/mcp/components/McpServerRow"; +import { useMcpInstallations, useMcpMarketplace } from "@/features/mcp/hooks"; +import type { + McpRecommendedServer, + McpServerInstallation, +} from "@/features/mcp/types"; +import { useThemeColors } from "@/lib/theme"; + +type Tab = "installed" | "marketplace"; + +export default function McpServersScreen() { + const themeColors = useThemeColors(); + const insets = useSafeAreaInsets(); + const router = useRouter(); + const [tab, setTab] = useState("installed"); + const [search, setSearch] = useState(""); + + const installations = useMcpInstallations(); + const marketplace = useMcpMarketplace(); + + const installedNames = useMemo( + () => new Set((installations.data ?? []).map((i) => i.name)), + [installations.data], + ); + + const handleAddCustom = () => router.push("/mcp-servers/add-custom"); + + const handleOpenInstallation = (i: McpServerInstallation) => + router.push({ + pathname: "/mcp-servers/installation/[id]", + params: { id: i.id }, + }); + + const handleOpenTemplate = (t: McpRecommendedServer) => + router.push({ + pathname: "/mcp-servers/template/[id]", + params: { id: t.id }, + }); + + const filteredInstallations = useMemo(() => { + const list = installations.data ?? []; + if (!search) return list; + const q = search.toLowerCase(); + return list.filter( + (i) => + (i.display_name ?? i.name).toLowerCase().includes(q) || + i.url?.toLowerCase().includes(q), + ); + }, [installations.data, search]); + + const filteredMarketplace = useMemo(() => { + const list = marketplace.data ?? []; + if (!search) return list; + const q = search.toLowerCase(); + return list.filter( + (t) => + t.name.toLowerCase().includes(q) || + t.description?.toLowerCase().includes(q), + ); + }, [marketplace.data, search]); + + const isPending = + tab === "installed" ? installations.isPending : marketplace.isPending; + + return ( + + + + + {/* Tab bar */} + + {(["installed", "marketplace"] as const).map((t) => { + const active = tab === t; + return ( + setTab(t)} + className={`flex-1 items-center rounded-lg border px-3 py-2 ${active ? "border-accent-9 bg-accent-3" : "border-gray-5 bg-card active:bg-gray-2"}`} + > + + {t === "installed" ? "Installed" : "Marketplace"} + + + ); + })} + + + {/* Search */} + + + + + + + + {isPending ? ( + + + + ) : tab === "installed" ? ( + i.id} + renderItem={({ item }) => ( + + )} + ListEmptyComponent={ + setTab("marketplace")} + onAddCustom={handleAddCustom} + /> + } + refreshControl={ + installations.refetch()} + tintColor={themeColors.accent[9]} + /> + } + contentContainerStyle={{ paddingBottom: insets.bottom + 24 }} + /> + ) : ( + t.id} + renderItem={({ item }) => ( + + )} + ListEmptyComponent={} + refreshControl={ + marketplace.refetch()} + tintColor={themeColors.accent[9]} + /> + } + contentContainerStyle={{ paddingBottom: insets.bottom + 24 }} + /> + )} + + + ); +} + +function InstalledEmpty({ + onBrowsePress, + onAddCustom, +}: { + onBrowsePress: () => void; + onAddCustom: () => void; +}) { + const themeColors = useThemeColors(); + return ( + + + + + + No MCP servers installed + + + MCP servers extend your agent with extra tools. Browse the marketplace + or add a custom URL to get started. + + + + + Browse marketplace + + + + + + Add custom + + + + + ); +} + +function MarketplaceEmpty() { + return ( + + + No servers match your search. + + + ); +} diff --git a/apps/mobile/src/app/mcp-servers/installation/[id].tsx b/apps/mobile/src/app/mcp-servers/installation/[id].tsx new file mode 100644 index 000000000..dcae9d321 --- /dev/null +++ b/apps/mobile/src/app/mcp-servers/installation/[id].tsx @@ -0,0 +1,347 @@ +import { Text } from "@components/text"; +import { router, useLocalSearchParams } from "expo-router"; +import { + ArrowsClockwise, + CheckCircle, + Trash, + Warning, +} from "phosphor-react-native"; +import { useMemo, useState } from "react"; +import { + ActivityIndicator, + Alert, + Pressable, + RefreshControl, + ScrollView, + Switch, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { FloatingMcpHeader } from "@/features/mcp/components/FloatingMcpHeader"; +import { ServerIcon } from "@/features/mcp/components/ServerIcon"; +import { + useMcpInstallations, + useMcpInstallationTools, + useRefreshMcpInstallationTools, + useUninstallMcpServer, + useUpdateMcpServerInstallation, + useUpdateMcpToolApproval, +} from "@/features/mcp/hooks"; +import { reauthorizeInstallation } from "@/features/mcp/oauth"; +import { getMcpConnectionManager } from "@/features/mcp/service"; +import type { McpApprovalState } from "@/features/mcp/types"; +import { isStdioServer } from "@/features/mcp/types"; +import { logger } from "@/lib/logger"; +import { useThemeColors } from "@/lib/theme"; + +const log = logger.scope("mcp-installation-detail"); + +export default function McpInstallationDetailScreen() { + const { id } = useLocalSearchParams<{ id: string }>(); + const themeColors = useThemeColors(); + const insets = useSafeAreaInsets(); + + const installations = useMcpInstallations(); + const installation = useMemo( + () => installations.data?.find((i) => i.id === id) ?? null, + [installations.data, id], + ); + + const tools = useMcpInstallationTools(installation?.id ?? null); + const refreshMutation = useRefreshMcpInstallationTools(); + const uninstallMutation = useUninstallMcpServer(); + const updateMutation = useUpdateMcpServerInstallation(); + const approvalMutation = useUpdateMcpToolApproval(); + + const [reauthLoading, setReauthLoading] = useState(false); + + if (installations.isPending || !installation) { + return ( + + + + {installations.isPending ? ( + + ) : ( + + Installation not found. + + )} + + + ); + } + + const stdio = isStdioServer(installation); + + const handleEnabledChange = (enabled: boolean) => { + updateMutation.mutate({ + installationId: installation.id, + updates: { is_enabled: enabled }, + }); + }; + + const handleReauthorize = async () => { + setReauthLoading(true); + try { + await reauthorizeInstallation(installation.id); + await installations.refetch(); + } catch (err) { + log.warn("Reauth failed", err); + } finally { + setReauthLoading(false); + } + }; + + const handleUninstall = () => { + Alert.alert( + "Uninstall server", + `Remove "${installation.display_name || installation.name}"? Any task using its tools will lose access.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Uninstall", + style: "destructive", + onPress: async () => { + try { + await uninstallMutation.mutateAsync(installation.id); + await getMcpConnectionManager().close(installation.id); + await installations.refetch(); + router.back(); + } catch (err) { + log.warn("Uninstall failed", err); + Alert.alert( + "Failed to uninstall", + err instanceof Error ? err.message : "Unknown error", + ); + } + }, + }, + ], + ); + }; + + const handleApprovalChange = (toolName: string, state: McpApprovalState) => { + approvalMutation.mutate({ + installationId: installation.id, + toolName, + approval_state: state, + }); + }; + + return ( + + + + { + installations.refetch(); + tools.refetch(); + }} + tintColor={themeColors.accent[9]} + /> + } + > + {/* Header */} + + + + + {installation.display_name || installation.name} + + {installation.description ? ( + + {installation.description} + + ) : null} + + + + {/* Status pills */} + + + + {installation.auth_type === "oauth" + ? "OAuth" + : installation.auth_type === "api_key" + ? "API key" + : "No auth"} + + + {stdio ? ( + + + Desktop only + + + ) : null} + {installation.needs_reauth ? ( + + + + Needs reauth + + + ) : null} + + + {/* Enabled toggle */} + + + + Enabled + + + Allow your agent to call this server's tools + + + + + + {/* Reauth */} + {installation.needs_reauth ? ( + + {reauthLoading ? ( + + ) : ( + + Reauthorize + + )} + + ) : null} + + {/* Tools */} + + + Tools ({installation.tool_count}) + + refreshMutation.mutate(installation.id)} + disabled={refreshMutation.isPending} + hitSlop={8} + className="flex-row items-center gap-1 rounded-md bg-gray-3 px-2 py-1 active:opacity-60" + > + + + Refresh + + + + + {tools.isPending ? ( + + + + ) : tools.data?.length ? ( + + {tools.data.map((tool, idx) => { + const last = idx === (tools.data?.length ?? 0) - 1; + const isApproved = tool.approval_state === "approved"; + return ( + + + + + {tool.display_name || tool.tool_name} + + {tool.description ? ( + + {tool.description} + + ) : null} + + + handleApprovalChange( + tool.tool_name, + isApproved ? "needs_approval" : "approved", + ) + } + hitSlop={6} + className="flex-row items-center gap-1" + > + {isApproved ? ( + <> + + + Approved + + + ) : ( + + + Approve + + + )} + + + + ); + })} + + ) : ( + + + No tools discovered yet. Tap Refresh to retry. + + + )} + + {/* Uninstall */} + + + + Uninstall server + + + + + ); +} diff --git a/apps/mobile/src/app/mcp-servers/template/[id].tsx b/apps/mobile/src/app/mcp-servers/template/[id].tsx new file mode 100644 index 000000000..2f37ba11a --- /dev/null +++ b/apps/mobile/src/app/mcp-servers/template/[id].tsx @@ -0,0 +1,260 @@ +import { Text } from "@components/text"; +import { router, useLocalSearchParams } from "expo-router"; +import { Lock, Warning } from "phosphor-react-native"; +import { useMemo, useState } from "react"; +import { + ActivityIndicator, + Linking, + Pressable, + ScrollView, + TextInput, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { FloatingMcpHeader } from "@/features/mcp/components/FloatingMcpHeader"; +import { ServerIcon } from "@/features/mcp/components/ServerIcon"; +import { + useInstallMcpTemplate, + useMcpInstallations, + useMcpMarketplace, +} from "@/features/mcp/hooks"; +import { installTemplateWithOAuth } from "@/features/mcp/oauth"; +import { isStdioServer } from "@/features/mcp/types"; +import { logger } from "@/lib/logger"; +import { useThemeColors } from "@/lib/theme"; + +const log = logger.scope("mcp-template-detail"); + +export default function McpTemplateDetailScreen() { + const { id } = useLocalSearchParams<{ id: string }>(); + const themeColors = useThemeColors(); + const insets = useSafeAreaInsets(); + + const marketplace = useMcpMarketplace(); + const installations = useMcpInstallations(); + const template = useMemo( + () => marketplace.data?.find((t) => t.id === id) ?? null, + [marketplace.data, id], + ); + const installed = useMemo( + () => + template + ? (installations.data ?? []).some((i) => i.name === template.name) + : false, + [installations.data, template], + ); + + const [apiKey, setApiKey] = useState(""); + const [installing, setInstalling] = useState(false); + const [error, setError] = useState(null); + const installMutation = useInstallMcpTemplate(); + + if (marketplace.isPending) { + return ( + + ); + } + + if (!template) { + return ( + + + + + Template not found. + + + + ); + } + + const stdio = isStdioServer(template); + + const handleInstall = async () => { + if (!template) return; + setError(null); + setInstalling(true); + try { + if (template.auth_type === "oauth") { + const result = await installTemplateWithOAuth({ + template_id: template.id, + }); + if (result === "cancelled") { + setInstalling(false); + return; + } + } else if (template.auth_type === "api_key") { + if (!apiKey.trim()) { + setError("API key is required"); + setInstalling(false); + return; + } + await installMutation.mutateAsync({ + template_id: template.id, + api_key: apiKey.trim(), + }); + } else { + await installMutation.mutateAsync({ template_id: template.id }); + } + // Refresh and pop back to the list. + await installations.refetch(); + router.back(); + } catch (err) { + log.warn("Install failed", err); + setError(err instanceof Error ? err.message : "Install failed"); + setInstalling(false); + } + }; + + return ( + + + + + + + + {template.name} + + {template.category ? ( + + {template.category} + + ) : null} + + + + {template.description ? ( + + {template.description} + + ) : null} + + + + + + {template.auth_type === "oauth" + ? "OAuth" + : template.auth_type === "api_key" + ? "API key" + : "No auth"} + + + {stdio ? ( + + + Desktop only + + + ) : null} + + {template.url ? ( + + {template.url} + + ) : null} + + + {template.docs_url ? ( + Linking.openURL(template.docs_url as string)} + className="mb-4 rounded-lg border border-gray-5 bg-card px-3 py-2 active:bg-gray-2" + > + + View docs ↗ + + + ) : null} + + {stdio ? ( + + + + This server uses stdio and can't run on mobile. Install it from + the desktop client to use it on this device. + + + ) : null} + + {template.auth_type === "api_key" && !stdio ? ( + + + API key + + + + ) : null} + + {template.auth_type === "oauth" && !stdio ? ( + + + + You'll be sent to the provider to sign in, then bounced back to + the app once you authorize. + + + ) : null} + + {error ? ( + {error} + ) : null} + + + {installing ? ( + + ) : ( + + {installed ? "Already installed" : "Install"} + + )} + + + + ); +} + +function Loading({ + topInset, + themeColor, +}: { + topInset: number; + themeColor: string; +}) { + return ( + + + + + + + ); +} diff --git a/apps/mobile/src/app/settings/index.tsx b/apps/mobile/src/app/settings/index.tsx index 607cc9486..91df70f6d 100644 --- a/apps/mobile/src/app/settings/index.tsx +++ b/apps/mobile/src/app/settings/index.tsx @@ -279,6 +279,17 @@ export default function SettingsScreen() { /> + {/* Integrations */} + + router.push("/mcp-servers")} + showDivider={false} + rightSlot={} + /> + + {/* Inbox */} + + + ); + } + if (isPostHogExec) { const label = posthogExecDisplay?.label ?? "exec"; const inputPreview = posthogExecDisplay?.input; diff --git a/apps/mobile/src/features/mcp/api.ts b/apps/mobile/src/features/mcp/api.ts new file mode 100644 index 000000000..ee783827b --- /dev/null +++ b/apps/mobile/src/features/mcp/api.ts @@ -0,0 +1,194 @@ +import { fetch } from "expo/fetch"; +import { getBaseUrl, getHeaders, getProjectId } from "@/lib/api"; +import type { + InstallCustomMcpServerOptions, + InstallMcpTemplateOptions, + McpApprovalState, + McpInstallationTool, + McpInstallResponse, + McpOAuthRedirectResponse, + McpRecommendedServer, + McpServerInstallation, + UpdateMcpServerInstallationOptions, +} from "./types"; + +function mcpBaseUrl(): string { + const base = getBaseUrl(); + const projectId = getProjectId(); + return `${base}/api/environments/${projectId}/mcp_server_installations`; +} + +async function readJsonOrThrow( + response: Response, + errorPrefix: string, +): Promise { + if (!response.ok) { + const data = (await response.json().catch(() => ({}))) as { + detail?: string; + }; + throw new Error(data.detail ?? `${errorPrefix}: ${response.statusText}`); + } + return (await response.json()) as T; +} + +/** GET /api/environments/{teamId}/mcp_servers/ — marketplace templates. */ +export async function getMcpRecommendedServers(): Promise< + McpRecommendedServer[] +> { + const base = getBaseUrl(); + const projectId = getProjectId(); + const response = await fetch( + `${base}/api/environments/${projectId}/mcp_servers/`, + { headers: getHeaders() }, + ); + const data = await readJsonOrThrow< + McpRecommendedServer[] | { results?: McpRecommendedServer[] } + >(response, "Failed to fetch MCP servers"); + return Array.isArray(data) ? data : (data.results ?? []); +} + +/** GET /api/environments/{teamId}/mcp_server_installations/ */ +export async function getMcpServerInstallations(): Promise< + McpServerInstallation[] +> { + const response = await fetch(`${mcpBaseUrl()}/`, { headers: getHeaders() }); + const data = await readJsonOrThrow< + McpServerInstallation[] | { results?: McpServerInstallation[] } + >(response, "Failed to fetch MCP server installations"); + return Array.isArray(data) ? data : (data.results ?? []); +} + +/** POST /api/environments/{teamId}/mcp_server_installations/install_custom/ */ +export async function installCustomMcpServer( + options: InstallCustomMcpServerOptions, +): Promise { + const response = await fetch(`${mcpBaseUrl()}/install_custom/`, { + method: "POST", + headers: getHeaders(), + body: JSON.stringify(options), + }); + return readJsonOrThrow( + response, + "Failed to install MCP server", + ); +} + +/** POST /api/environments/{teamId}/mcp_server_installations/install_template/ */ +export async function installMcpTemplate( + options: InstallMcpTemplateOptions, +): Promise { + const response = await fetch(`${mcpBaseUrl()}/install_template/`, { + method: "POST", + headers: getHeaders(), + body: JSON.stringify(options), + }); + return readJsonOrThrow( + response, + "Failed to install MCP template", + ); +} + +/** PATCH /api/environments/{teamId}/mcp_server_installations/{id}/ */ +export async function updateMcpServerInstallation( + installationId: string, + updates: UpdateMcpServerInstallationOptions, +): Promise { + const response = await fetch(`${mcpBaseUrl()}/${installationId}/`, { + method: "PATCH", + headers: getHeaders(), + body: JSON.stringify(updates), + }); + return readJsonOrThrow( + response, + "Failed to update MCP server", + ); +} + +/** DELETE /api/environments/{teamId}/mcp_server_installations/{id}/ */ +export async function uninstallMcpServer( + installationId: string, +): Promise { + const response = await fetch(`${mcpBaseUrl()}/${installationId}/`, { + method: "DELETE", + headers: getHeaders(), + }); + if (!response.ok && response.status !== 204) { + throw new Error(`Failed to uninstall MCP server: ${response.statusText}`); + } +} + +/** GET /api/environments/{teamId}/mcp_server_installations/authorize/?installation_id={id} */ +export async function authorizeMcpInstallation(options: { + installation_id: string; + install_source?: "posthog" | "posthog-code" | "posthog-mobile"; + posthog_code_callback_url?: string; +}): Promise { + const params = new URLSearchParams(); + params.set("installation_id", options.installation_id); + if (options.install_source) { + params.set("install_source", options.install_source); + } + if (options.posthog_code_callback_url) { + params.set("posthog_code_callback_url", options.posthog_code_callback_url); + } + const response = await fetch( + `${mcpBaseUrl()}/authorize/?${params.toString()}`, + { headers: getHeaders() }, + ); + return readJsonOrThrow( + response, + "Failed to authorize MCP installation", + ); +} + +/** GET /api/environments/{teamId}/mcp_server_installations/{id}/tools/ */ +export async function getMcpInstallationTools( + installationId: string, + options: { includeRemoved?: boolean } = {}, +): Promise { + const params = new URLSearchParams(); + if (options.includeRemoved) params.set("include_removed", "1"); + const query = params.toString(); + const response = await fetch( + `${mcpBaseUrl()}/${installationId}/tools/${query ? `?${query}` : ""}`, + { headers: getHeaders() }, + ); + const data = await readJsonOrThrow< + McpInstallationTool[] | { results?: McpInstallationTool[] } + >(response, "Failed to fetch MCP installation tools"); + return Array.isArray(data) ? data : (data.results ?? []); +} + +/** PATCH /api/environments/{teamId}/mcp_server_installations/{id}/tools/{name}/ */ +export async function updateMcpToolApproval( + installationId: string, + toolName: string, + approval_state: McpApprovalState, +): Promise { + const response = await fetch( + `${mcpBaseUrl()}/${installationId}/tools/${encodeURIComponent(toolName)}/`, + { + method: "PATCH", + headers: getHeaders(), + body: JSON.stringify({ approval_state }), + }, + ); + return readJsonOrThrow( + response, + "Failed to update tool approval", + ); +} + +/** POST /api/environments/{teamId}/mcp_server_installations/{id}/tools/refresh/ */ +export async function refreshMcpInstallationTools( + installationId: string, +): Promise { + const response = await fetch( + `${mcpBaseUrl()}/${installationId}/tools/refresh/`, + { method: "POST", headers: getHeaders() }, + ); + const data = await readJsonOrThrow< + McpInstallationTool[] | { results?: McpInstallationTool[] } + >(response, "Failed to refresh MCP tools"); + return Array.isArray(data) ? data : (data.results ?? []); +} diff --git a/apps/mobile/src/features/mcp/components/FloatingMcpHeader.tsx b/apps/mobile/src/features/mcp/components/FloatingMcpHeader.tsx new file mode 100644 index 000000000..2bff69844 --- /dev/null +++ b/apps/mobile/src/features/mcp/components/FloatingMcpHeader.tsx @@ -0,0 +1,89 @@ +import { Text } from "@components/text"; +import { LinearGradient } from "expo-linear-gradient"; +import { useRouter } from "expo-router"; +import { CaretLeft, Plus } from "phosphor-react-native"; +import type { ReactNode } from "react"; +import { Pressable, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { toRgba, useThemeColors } from "@/lib/theme"; + +interface FloatingMcpHeaderProps { + title: string; + onAddPress?: () => void; + rightSlot?: ReactNode; +} + +export function FloatingMcpHeader({ + title, + onAddPress, + rightSlot, +}: FloatingMcpHeaderProps) { + const router = useRouter(); + const insets = useSafeAreaInsets(); + const themeColors = useThemeColors(); + + const handleBack = () => { + if (router.canGoBack()) router.back(); + else router.replace("/tasks"); + }; + + const fadeHeight = insets.top + 88; + + return ( + + + + + + + + + + + {title} + + + + + {rightSlot ?? + (onAddPress ? ( + + + + ) : null)} + + + + ); +} diff --git a/apps/mobile/src/features/mcp/components/McpAppHost.tsx b/apps/mobile/src/features/mcp/components/McpAppHost.tsx new file mode 100644 index 000000000..9dd7d0262 --- /dev/null +++ b/apps/mobile/src/features/mcp/components/McpAppHost.tsx @@ -0,0 +1,305 @@ +import { Text } from "@components/text"; +import type { McpUiDisplayMode } from "@modelcontextprotocol/ext-apps/app-bridge"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import * as WebBrowser from "expo-web-browser"; +import { ArrowsIn, ArrowsOut, Warning } from "phosphor-react-native"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { + ActivityIndicator, + Modal, + Pressable, + StyleSheet, + useColorScheme, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import WebView, { type WebViewMessageEvent } from "react-native-webview"; +import { useThemeColors } from "@/lib/theme"; +import { useMcpInstallations } from "../hooks"; +import { sandboxProxyHtml } from "../sandbox/sandboxProxyHtml"; +import { useMcpUiResource } from "../sandbox/useMcpUiResource"; +import { type Phase, useMobileAppBridge } from "../sandbox/useMobileAppBridge"; +import { getMcpConnectionManager } from "../service"; +import { parseMcpToolName } from "../utils/mcpToolName"; + +interface McpAppHostProps { + /** Raw tool name from the agent — `mcp____`. */ + rawToolName: string; + /** Args the agent sent to the tool, if any. */ + toolArgs?: Record; + /** The tool result the agent already received, if completed. */ + toolResult?: unknown; + status: "pending" | "running" | "completed" | "error"; +} + +const INLINE_MIN_HEIGHT = 180; +const INLINE_MAX_HEIGHT = 520; + +function asCallToolResult(value: unknown): CallToolResult | null { + if (!value || typeof value !== "object") return null; + const v = value as { content?: unknown }; + if (!Array.isArray(v.content)) return null; + return value as CallToolResult; +} + +export function McpAppHost(props: McpAppHostProps) { + const themeColors = useThemeColors(); + const scheme = useColorScheme(); + const isDarkMode = scheme === "dark"; + const insets = useSafeAreaInsets(); + + const parsed = useMemo( + () => parseMcpToolName(props.rawToolName), + [props.rawToolName], + ); + + const installations = useMcpInstallations(); + const installation = useMemo(() => { + if (!parsed) return null; + return ( + installations.data?.find((i) => i.name === parsed.serverName) ?? null + ); + }, [installations.data, parsed]); + + const uiResource = useMcpUiResource({ + installation, + toolName: parsed?.toolName ?? "", + }); + + const webViewRef = useRef(null); + const [webViewWidth, setWebViewWidth] = useState(0); + const [phase, setPhase] = useState("loading"); + const [displayMode, setDisplayMode] = useState("inline"); + const [iframeHeight, setIframeHeight] = useState(INLINE_MIN_HEIGHT); + + const handleProxyToolCall = useCallback( + async (args: { + serverName: string; + toolName: string; + args?: Record; + }) => { + if (!installation) { + throw new Error("MCP installation unavailable"); + } + return getMcpConnectionManager().callTool({ + installationId: installation.id, + serverName: installation.name, + proxyUrl: installation.proxy_url, + toolName: args.toolName, + arguments: args.args, + }); + }, + [installation], + ); + + const handleProxyResourceRead = useCallback( + async (args: { serverName: string; uri: string }) => { + if (!installation) { + throw new Error("MCP installation unavailable"); + } + return getMcpConnectionManager().readResource({ + installationId: installation.id, + serverName: installation.name, + proxyUrl: installation.proxy_url, + uri: args.uri, + }); + }, + [installation], + ); + + const handleOpenLink = useCallback(async (args: { url: string }) => { + await WebBrowser.openBrowserAsync(args.url); + }, []); + + const { handleWebViewMessage } = useMobileAppBridge({ + webViewRef, + uiResource: uiResource.data?.resource ?? null, + serverName: parsed?.serverName ?? "", + toolDefinition: uiResource.data?.tool ?? null, + toolInput: props.toolArgs ?? null, + existingToolResult: + props.status === "completed" || props.status === "error" + ? asCallToolResult(props.toolResult) + : null, + themeColors, + isDarkMode, + displayMode, + containerWidth: webViewWidth, + safeAreaInsets: insets, + onPhaseChange: setPhase, + onSizeChange: setIframeHeight, + onDisplayModeChange: setDisplayMode, + proxyToolCall: handleProxyToolCall, + proxyResourceRead: handleProxyResourceRead, + openLink: handleOpenLink, + }); + + const onMessage = useCallback( + (event: WebViewMessageEvent) => { + handleWebViewMessage(event.nativeEvent.data); + }, + [handleWebViewMessage], + ); + + if (!parsed) { + return null; + } + + if (installations.isPending) { + return ; + } + + if (!installation) { + return ( + + ); + } + + if (uiResource.isError) { + return ( + + ); + } + + if (uiResource.isPending) { + return ; + } + + if (!uiResource.data) { + // Tool doesn't expose a UI resource — render nothing and fall back to + // the parent's default tool view. + return null; + } + + const inlineHeight = Math.min( + Math.max(iframeHeight, INLINE_MIN_HEIGHT), + INLINE_MAX_HEIGHT, + ); + + const webView = ( + setWebViewWidth(e.nativeEvent.layout.width)} + className="overflow-hidden rounded-lg border border-gray-5 bg-card" + style={{ + height: displayMode === "fullscreen" ? "100%" : inlineHeight, + }} + > + + {phase !== "initialized" && phase !== "error" ? ( + + + + ) : null} + + ); + + return ( + + + + {parsed.serverName} · {parsed.toolName} + + + setDisplayMode( + displayMode === "fullscreen" ? "inline" : "fullscreen", + ) + } + hitSlop={8} + className="active:opacity-60" + > + {displayMode === "fullscreen" ? ( + + ) : ( + + )} + + + + {displayMode === "fullscreen" ? ( + setDisplayMode("inline")} + > + + + + {parsed.toolName} + + setDisplayMode("inline")} + hitSlop={8} + className="active:opacity-60" + > + + + + {webView} + + + ) : ( + webView + )} + + ); +} + +function LoadingCard({ + themeColors, + message, +}: { + themeColors: ReturnType; + message: string; +}) { + return ( + + + {message} + + ); +} + +function ErrorCard({ + themeColors, + message, +}: { + themeColors: ReturnType; + message: string; +}) { + return ( + + + {message} + + ); +} diff --git a/apps/mobile/src/features/mcp/components/McpServerRow.tsx b/apps/mobile/src/features/mcp/components/McpServerRow.tsx new file mode 100644 index 000000000..a2526fa34 --- /dev/null +++ b/apps/mobile/src/features/mcp/components/McpServerRow.tsx @@ -0,0 +1,143 @@ +import { Text } from "@components/text"; +import { CaretRight, Lock, Warning } from "phosphor-react-native"; +import type { ReactNode } from "react"; +import { Pressable, View } from "react-native"; +import { useThemeColors } from "@/lib/theme"; +import type { McpRecommendedServer, McpServerInstallation } from "../types"; +import { isStdioServer } from "../types"; +import { ServerIcon } from "./ServerIcon"; + +interface McpServerRowProps { + title: string; + subtitle?: string; + description?: string; + authType?: "api_key" | "oauth" | "none"; + badge?: ReactNode; + isStdio?: boolean; + needsReauth?: boolean; + installed?: boolean; + iconKey?: string | null; + onPress: () => void; +} + +function authBadge(auth?: "api_key" | "oauth" | "none") { + if (auth === "oauth") return "OAuth"; + if (auth === "api_key") return "API key"; + return null; +} + +export function McpServerRow({ + title, + subtitle, + description, + authType, + badge, + isStdio, + needsReauth, + installed, + iconKey, + onPress, +}: McpServerRowProps) { + const themeColors = useThemeColors(); + const auth = authBadge(authType); + + return ( + + + + + + {title} + + {installed ? ( + + + Installed + + + ) : null} + {isStdio ? ( + + + Desktop only + + + ) : null} + {needsReauth ? ( + + + + Reauth + + + ) : null} + {badge} + + {subtitle ? ( + + {subtitle} + + ) : null} + {description ? ( + + {description} + + ) : null} + + + {auth ? ( + + {authType === "oauth" ? ( + + ) : null} + + {auth} + + + ) : null} + + + + ); +} + +export function recommendedToRowProps( + template: McpRecommendedServer, + installedNames: Set, + onPress: (template: McpRecommendedServer) => void, +): McpServerRowProps { + return { + title: template.name, + description: template.description, + authType: template.auth_type, + isStdio: isStdioServer(template), + installed: installedNames.has(template.name), + iconKey: template.icon_key, + onPress: () => onPress(template), + }; +} + +export function installationToRowProps( + installation: McpServerInstallation, + onPress: (installation: McpServerInstallation) => void, +): McpServerRowProps { + return { + title: installation.display_name || installation.name, + subtitle: installation.url, + authType: installation.auth_type, + isStdio: isStdioServer(installation), + needsReauth: installation.needs_reauth, + installed: true, + iconKey: installation.icon_key, + onPress: () => onPress(installation), + }; +} diff --git a/apps/mobile/src/features/mcp/components/ServerIcon.tsx b/apps/mobile/src/features/mcp/components/ServerIcon.tsx new file mode 100644 index 000000000..23865cb90 --- /dev/null +++ b/apps/mobile/src/features/mcp/components/ServerIcon.tsx @@ -0,0 +1,43 @@ +import { PuzzlePiece } from "phosphor-react-native"; +import { Image, View } from "react-native"; +import { useThemeColors } from "@/lib/theme"; +import { resolveServerLogo } from "./serverIcons"; + +interface ServerIconProps { + iconKey?: string | null; + size?: number; + className?: string; +} + +/** + * Renders the brand logo for an MCP server, keyed by `icon_key` from the + * PostHog cloud schema. Falls back to a generic plug glyph when the icon + * key is missing or doesn't match the bundled set. + */ +export function ServerIcon({ iconKey, size = 32, className }: ServerIconProps) { + const themeColors = useThemeColors(); + const logo = resolveServerLogo(iconKey); + + return ( + + {logo?.kind === "svg" ? ( + + ) : logo?.kind === "png" ? ( + + ) : ( + + )} + + ); +} diff --git a/apps/mobile/src/features/mcp/components/serverIcons.ts b/apps/mobile/src/features/mcp/components/serverIcons.ts new file mode 100644 index 000000000..6fcdf42ad --- /dev/null +++ b/apps/mobile/src/features/mcp/components/serverIcons.ts @@ -0,0 +1,104 @@ +import type { ComponentType } from "react"; +import type { ImageSourcePropType } from "react-native"; +import type { SvgProps } from "react-native-svg"; + +// SVG imports are turned into React components by react-native-svg-transformer. +import AtlassianSvg from "../../../../assets/services/atlassian.svg"; +import BoxSvg from "../../../../assets/services/box.svg"; +import BrowserbaseSvg from "../../../../assets/services/browserbase.svg"; +import CanvaSvg from "../../../../assets/services/canva.svg"; +import ClerkSvg from "../../../../assets/services/clerk.svg"; +import ClickHouseSvg from "../../../../assets/services/clickhouse.svg"; +import CloudflareSvg from "../../../../assets/services/cloudflare.svg"; +import Context7Svg from "../../../../assets/services/context7.svg"; +import DatadogSvg from "../../../../assets/services/datadog.svg"; +import FigmaSvg from "../../../../assets/services/figma.svg"; +import FiretigerSvg from "../../../../assets/services/firetiger.svg"; +import GitHubSvg from "../../../../assets/services/github.svg"; +import GitLabSvg from "../../../../assets/services/gitlab.svg"; +import HexSvg from "../../../../assets/services/hex.svg"; +import HubSpotSvg from "../../../../assets/services/hubspot.svg"; +import LinearSvg from "../../../../assets/services/linear.svg"; +import MondaySvg from "../../../../assets/services/monday.svg"; +import NeonSvg from "../../../../assets/services/neon.svg"; +import NotionSvg from "../../../../assets/services/notion.svg"; +import PagerDutySvg from "../../../../assets/services/pagerduty.svg"; +import PlanetScaleSvg from "../../../../assets/services/planetscale.svg"; +import PostmanSvg from "../../../../assets/services/postman.svg"; +import PrismaSvg from "../../../../assets/services/prisma.svg"; +import RenderSvg from "../../../../assets/services/render.svg"; +import SanitySvg from "../../../../assets/services/sanity.svg"; +import SentrySvg from "../../../../assets/services/sentry.svg"; +import SupabaseSvg from "../../../../assets/services/supabase.svg"; + +// PNG imports — Metro resolves `require()` of an image to an asset module id +// suitable for ``. +const AiropsPng: ImageSourcePropType = require("../../../../assets/services/airops.png"); +const AttioPng: ImageSourcePropType = require("../../../../assets/services/attio.png"); +const CirclePng: ImageSourcePropType = require("../../../../assets/services/circle.png"); +const CiscoThousandeyesPng: ImageSourcePropType = require("../../../../assets/services/cisco_thousandeyes.png"); +const LaunchDarklyPng: ImageSourcePropType = require("../../../../assets/services/launchdarkly.png"); +const SlackPng: ImageSourcePropType = require("../../../../assets/services/slack.png"); +const StripePng: ImageSourcePropType = require("../../../../assets/services/stripe.png"); +const SveltePng: ImageSourcePropType = require("../../../../assets/services/svelte.png"); +const WixPng: ImageSourcePropType = require("../../../../assets/services/wix.png"); + +export type ServerLogo = + | { kind: "svg"; component: ComponentType } + | { kind: "png"; source: ImageSourcePropType }; + +function svg(component: ComponentType): ServerLogo { + return { kind: "svg", component }; +} + +function png(source: ImageSourcePropType): ServerLogo { + return { kind: "png", source }; +} + +/** Lookup map keyed by `McpServerInstallation.icon_key` / + * `McpRecommendedServer.icon_key`. Mirrors the desktop `BRAND_ICONS`. */ +export const SERVER_LOGOS: Record = { + airops: png(AiropsPng), + atlassian: svg(AtlassianSvg), + attio: png(AttioPng), + box: svg(BoxSvg), + browserbase: svg(BrowserbaseSvg), + canva: svg(CanvaSvg), + circle: png(CirclePng), + cisco_thousandeyes: png(CiscoThousandeyesPng), + clerk: svg(ClerkSvg), + clickhouse: svg(ClickHouseSvg), + cloudflare: svg(CloudflareSvg), + context7: svg(Context7Svg), + datadog: svg(DatadogSvg), + figma: svg(FigmaSvg), + firetiger: svg(FiretigerSvg), + github: svg(GitHubSvg), + gitlab: svg(GitLabSvg), + hex: svg(HexSvg), + hubspot: svg(HubSpotSvg), + launchdarkly: png(LaunchDarklyPng), + linear: svg(LinearSvg), + monday: svg(MondaySvg), + neon: svg(NeonSvg), + notion: svg(NotionSvg), + pagerduty: svg(PagerDutySvg), + planetscale: svg(PlanetScaleSvg), + postman: svg(PostmanSvg), + prisma: svg(PrismaSvg), + render: svg(RenderSvg), + sanity: svg(SanitySvg), + sentry: svg(SentrySvg), + slack: png(SlackPng), + stripe: png(StripePng), + supabase: svg(SupabaseSvg), + svelte: png(SveltePng), + wix: png(WixPng), +}; + +export function resolveServerLogo( + iconKey: string | null | undefined, +): ServerLogo | null { + if (!iconKey) return null; + return SERVER_LOGOS[iconKey] ?? null; +} diff --git a/apps/mobile/src/features/mcp/hooks.ts b/apps/mobile/src/features/mcp/hooks.ts new file mode 100644 index 000000000..9c724aea1 --- /dev/null +++ b/apps/mobile/src/features/mcp/hooks.ts @@ -0,0 +1,141 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + authorizeMcpInstallation, + getMcpInstallationTools, + getMcpRecommendedServers, + getMcpServerInstallations, + installCustomMcpServer, + installMcpTemplate, + refreshMcpInstallationTools, + uninstallMcpServer, + updateMcpServerInstallation, + updateMcpToolApproval, +} from "./api"; +import type { + InstallCustomMcpServerOptions, + InstallMcpTemplateOptions, + McpApprovalState, + UpdateMcpServerInstallationOptions, +} from "./types"; + +const mcpKeys = { + all: ["mcp"] as const, + marketplace: () => [...mcpKeys.all, "marketplace"] as const, + installations: () => [...mcpKeys.all, "installations"] as const, + tools: (installationId: string) => + [...mcpKeys.all, "tools", installationId] as const, +}; + +export function useMcpMarketplace() { + return useQuery({ + queryKey: mcpKeys.marketplace(), + queryFn: getMcpRecommendedServers, + staleTime: 5 * 60 * 1000, + }); +} + +export function useMcpInstallations() { + return useQuery({ + queryKey: mcpKeys.installations(), + queryFn: getMcpServerInstallations, + staleTime: 30 * 1000, + }); +} + +export function useMcpInstallationTools(installationId: string | null) { + return useQuery({ + queryKey: mcpKeys.tools(installationId ?? ""), + queryFn: () => getMcpInstallationTools(installationId as string), + enabled: !!installationId, + staleTime: 30 * 1000, + }); +} + +function invalidateInstallations( + queryClient: ReturnType, +) { + queryClient.invalidateQueries({ queryKey: mcpKeys.installations() }); +} + +export function useInstallCustomMcpServer() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (options: InstallCustomMcpServerOptions) => + installCustomMcpServer(options), + onSuccess: () => invalidateInstallations(queryClient), + }); +} + +export function useInstallMcpTemplate() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (options: InstallMcpTemplateOptions) => + installMcpTemplate(options), + onSuccess: () => invalidateInstallations(queryClient), + }); +} + +export function useUpdateMcpServerInstallation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + installationId, + updates, + }: { + installationId: string; + updates: UpdateMcpServerInstallationOptions; + }) => updateMcpServerInstallation(installationId, updates), + onSuccess: () => invalidateInstallations(queryClient), + }); +} + +export function useUninstallMcpServer() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (installationId: string) => uninstallMcpServer(installationId), + onSuccess: () => invalidateInstallations(queryClient), + }); +} + +export function useAuthorizeMcpInstallation() { + return useMutation({ + mutationFn: (args: Parameters[0]) => + authorizeMcpInstallation(args), + }); +} + +export function useRefreshMcpInstallationTools() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (installationId: string) => + refreshMcpInstallationTools(installationId), + onSuccess: (_, installationId) => { + queryClient.invalidateQueries({ + queryKey: mcpKeys.tools(installationId), + }); + invalidateInstallations(queryClient); + }, + }); +} + +export function useUpdateMcpToolApproval() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + installationId, + toolName, + approval_state, + }: { + installationId: string; + toolName: string; + approval_state: McpApprovalState; + }) => updateMcpToolApproval(installationId, toolName, approval_state), + onSuccess: (_, { installationId }) => { + queryClient.invalidateQueries({ + queryKey: mcpKeys.tools(installationId), + }); + }, + }); +} + +export const MCP_QUERY_KEYS = mcpKeys; diff --git a/apps/mobile/src/features/mcp/oauth.ts b/apps/mobile/src/features/mcp/oauth.ts new file mode 100644 index 000000000..190fabd6b --- /dev/null +++ b/apps/mobile/src/features/mcp/oauth.ts @@ -0,0 +1,114 @@ +import * as Linking from "expo-linking"; +import * as WebBrowser from "expo-web-browser"; +import { + authorizeMcpInstallation, + installCustomMcpServer, + installMcpTemplate, +} from "./api"; +import type { + InstallCustomMcpServerOptions, + InstallMcpTemplateOptions, + McpInstallResponse, + McpServerInstallation, +} from "./types"; +import { isOAuthRedirect } from "./types"; + +/** Custom URL scheme registered via app.json (`scheme: "posthog"`). The cloud + * bounces the OAuth redirect back to this URL once the provider completes + * auth, and `expo-linking` catches it on both iOS and Android. */ +export const OAUTH_CALLBACK_URL = "posthog://mcp-oauth/callback"; + +const INSTALL_SOURCE = "posthog-code" as const; + +/** + * Open the cloud-provided redirect URL in the system browser and wait for the + * user to complete the OAuth dance. Resolves once the cloud bounces the + * callback back to `OAUTH_CALLBACK_URL`, or once the user dismisses the + * browser without completing. + */ +async function waitForOAuthCallback( + redirectUrl: string, +): Promise<"completed" | "cancelled"> { + // `openAuthSessionAsync` automatically dismisses the browser sheet on the + // first incoming deep link matching our scheme — handy for OAuth on both + // iOS (ASWebAuthenticationSession) and Android (Custom Tabs). + const result = await WebBrowser.openAuthSessionAsync( + redirectUrl, + OAUTH_CALLBACK_URL, + ); + + if (result.type === "success") return "completed"; + return "cancelled"; +} + +/** + * Run the install flow for a marketplace template. If the cloud responds with + * an OAuth redirect, take the user through it and resolve once they're back. + */ +export async function installTemplateWithOAuth( + options: Omit< + InstallMcpTemplateOptions, + "install_source" | "posthog_code_callback_url" + >, +): Promise { + const response: McpInstallResponse = await installMcpTemplate({ + ...options, + install_source: INSTALL_SOURCE, + posthog_code_callback_url: OAUTH_CALLBACK_URL, + }); + + if (!isOAuthRedirect(response)) return response; + + const outcome = await waitForOAuthCallback(response.redirect_url); + if (outcome === "cancelled") return "cancelled"; + + // Cloud has stored the refresh token on success — we don't get the + // installation row back from the OAuth dance, so callers refetch the + // installations list. + return "cancelled"; +} + +/** Same as install-template, for custom servers. */ +export async function installCustomWithOAuth( + options: Omit< + InstallCustomMcpServerOptions, + "install_source" | "posthog_code_callback_url" + >, +): Promise { + const response: McpInstallResponse = await installCustomMcpServer({ + ...options, + install_source: INSTALL_SOURCE, + posthog_code_callback_url: OAUTH_CALLBACK_URL, + }); + + if (!isOAuthRedirect(response)) return response; + const outcome = await waitForOAuthCallback(response.redirect_url); + return outcome === "cancelled" ? "cancelled" : "cancelled"; +} + +/** + * Trigger a re-auth flow for an installation whose OAuth token has expired + * (cloud sets `needs_reauth: true`). Opens the cloud's authorize endpoint, + * which returns a redirect URL, then runs the same WebBrowser session. + */ +export async function reauthorizeInstallation( + installationId: string, +): Promise<"completed" | "cancelled"> { + const { redirect_url } = await authorizeMcpInstallation({ + installation_id: installationId, + install_source: INSTALL_SOURCE, + posthog_code_callback_url: OAUTH_CALLBACK_URL, + }); + return waitForOAuthCallback(redirect_url); +} + +/** Subscribe to deep-link events for the OAuth callback. Mainly useful when + * the WebBrowser session can't auto-dismiss for some reason. */ +export function onOAuthCallback(handler: (url: string) => void): { + remove(): void; +} { + const subscription = Linking.addEventListener("url", ({ url }) => { + if (url.startsWith(OAUTH_CALLBACK_URL)) handler(url); + }); + return subscription; +} diff --git a/apps/mobile/src/features/mcp/sandbox/mcpAppTheme.ts b/apps/mobile/src/features/mcp/sandbox/mcpAppTheme.ts new file mode 100644 index 000000000..612bb880c --- /dev/null +++ b/apps/mobile/src/features/mcp/sandbox/mcpAppTheme.ts @@ -0,0 +1,113 @@ +import type { McpUiStyles } from "@modelcontextprotocol/ext-apps/app-bridge"; +import type { ThemeColors } from "@/lib/theme"; + +/** + * Builds the `McpUiStyles` payload an MCP App receives at initialize / on + * host-context-changed. Mirrors the desktop builder (apps/code/src/renderer/ + * features/mcp-apps/utils/mcp-app-theme.ts) but reads from our mobile + * `ThemeColors` instead of computed CSS variables (which don't exist in RN). + * + * MCP Apps render inside a sandboxed WebView, so we hand them a minimal but + * complete set of design tokens — they have no other access to our theme. + */ +export function buildMcpHostStyles( + themeColors: ThemeColors, + isDarkMode: boolean, +): { variables: McpUiStyles; css: { fonts: string } } { + const variables: Record = { + "--color-background-primary": themeColors.background, + "--color-background-secondary": themeColors.gray[2], + "--color-background-tertiary": themeColors.gray[3], + "--color-background-inverse": themeColors.gray[12], + "--color-background-ghost": "transparent", + "--color-background-info": themeColors.accent[3], + "--color-background-danger": isDarkMode ? "#3b0d0d" : "#fde8e8", + "--color-background-success": isDarkMode ? "#0f2d18" : "#dcfce7", + "--color-background-warning": isDarkMode ? "#3a2c00" : "#fef3c7", + "--color-background-disabled": themeColors.gray[3], + + "--color-text-primary": themeColors.gray[12], + "--color-text-secondary": themeColors.gray[11], + "--color-text-tertiary": themeColors.gray[10], + "--color-text-inverse": themeColors.gray[1], + "--color-text-ghost": themeColors.gray[10], + "--color-text-info": themeColors.accent[11], + "--color-text-danger": themeColors.status.error, + "--color-text-success": themeColors.status.success, + "--color-text-warning": themeColors.status.warning, + "--color-text-disabled": themeColors.gray[6], + + "--color-border-primary": themeColors.gray[6], + "--color-border-secondary": themeColors.gray[5], + "--color-border-tertiary": themeColors.gray[3], + "--color-border-inverse": themeColors.gray[12], + "--color-border-ghost": "transparent", + "--color-border-info": themeColors.accent[6], + "--color-border-danger": themeColors.status.error, + "--color-border-success": themeColors.status.success, + "--color-border-warning": themeColors.status.warning, + "--color-border-disabled": themeColors.gray[5], + + "--color-ring-primary": themeColors.accent[9], + "--color-ring-secondary": themeColors.gray[6], + "--color-ring-inverse": themeColors.gray[1], + "--color-ring-info": themeColors.accent[9], + "--color-ring-danger": themeColors.status.error, + "--color-ring-success": themeColors.status.success, + "--color-ring-warning": themeColors.status.warning, + + "--font-sans": + "-apple-system, BlinkMacSystemFont, 'Open Runde', 'Segoe UI', Roboto, sans-serif", + "--font-mono": "ui-monospace, 'JetBrains Mono', 'SF Mono', monospace", + + "--font-weight-normal": "400", + "--font-weight-medium": "500", + "--font-weight-semibold": "600", + "--font-weight-bold": "700", + + "--font-text-xs-size": "12px", + "--font-text-sm-size": "14px", + "--font-text-md-size": "16px", + "--font-text-lg-size": "18px", + "--font-heading-xs-size": "18px", + "--font-heading-sm-size": "20px", + "--font-heading-md-size": "24px", + "--font-heading-lg-size": "28px", + "--font-heading-xl-size": "32px", + "--font-heading-2xl-size": "48px", + "--font-heading-3xl-size": "60px", + + "--font-text-xs-line-height": "1.5", + "--font-text-sm-line-height": "1.5", + "--font-text-md-line-height": "1.5", + "--font-text-lg-line-height": "1.5", + "--font-heading-xs-line-height": "1.3", + "--font-heading-sm-line-height": "1.3", + "--font-heading-md-line-height": "1.25", + "--font-heading-lg-line-height": "1.25", + "--font-heading-xl-line-height": "1.2", + "--font-heading-2xl-line-height": "1.2", + "--font-heading-3xl-line-height": "1.1", + + "--border-radius-xs": "2px", + "--border-radius-sm": "4px", + "--border-radius-md": "8px", + "--border-radius-lg": "12px", + "--border-radius-xl": "16px", + "--border-radius-full": "9999px", + + "--border-width-regular": "1px", + + "--shadow-hairline": `0 0 0 1px ${themeColors.gray[6]}`, + "--shadow-sm": "0 1px 2px rgba(0,0,0,0.05)", + "--shadow-md": + "0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -1px rgba(0,0,0,0.06)", + "--shadow-lg": + "0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -2px rgba(0,0,0,0.05)", + }; + + return { + variables: variables as unknown as McpUiStyles, + css: { fonts: "" }, + }; +} diff --git a/apps/mobile/src/features/mcp/sandbox/sandboxProxyHtml.ts b/apps/mobile/src/features/mcp/sandbox/sandboxProxyHtml.ts new file mode 100644 index 000000000..c637f0286 --- /dev/null +++ b/apps/mobile/src/features/mcp/sandbox/sandboxProxyHtml.ts @@ -0,0 +1,117 @@ +/** + * Sandbox proxy HTML for mobile MCP Apps. + * + * Mirrors the desktop double-iframe pattern (see + * apps/code/src/shared/mcp-sandbox-proxy.html) but routes messages through + * `react-native-webview`'s bridge instead of `window.parent.postMessage`. + * + * RN host ←→ WebView (this HTML, the outer "proxy") ←→ Inner iframe (MCP App) + * + * Wire format on both sides is JSON-RPC, identical to desktop. The differences + * are only in how messages cross the boundary: + * + * - Inbound (RN → WebView): RN calls `window.__mcpReceive(jsonString)` via + * `webView.injectJavaScript`. + * - Outbound (WebView → RN): the proxy calls + * `window.ReactNativeWebView.postMessage(jsonString)`. + * + * The inner iframe still uses standard `window.parent.postMessage` to reach + * the proxy — that part doesn't change. + */ +export const sandboxProxyHtml = ` + + + + + + + + + +`; diff --git a/apps/mobile/src/features/mcp/sandbox/useMcpUiResource.ts b/apps/mobile/src/features/mcp/sandbox/useMcpUiResource.ts new file mode 100644 index 000000000..3e8d41541 --- /dev/null +++ b/apps/mobile/src/features/mcp/sandbox/useMcpUiResource.ts @@ -0,0 +1,80 @@ +import { + getToolUiResourceUri, + RESOURCE_MIME_TYPE, +} from "@modelcontextprotocol/ext-apps/app-bridge"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { useQuery } from "@tanstack/react-query"; +import { getMcpConnectionManager } from "../service"; +import type { McpServerInstallation, McpUiResource } from "../types"; + +interface UseMcpUiResourceArgs { + installation: McpServerInstallation | null; + toolName: string; +} + +interface UiResourceBundle { + resource: McpUiResource; + tool: Tool; +} + +/** + * Resolve the MCP App UI resource for a given installation + tool. Connects + * to the MCP server (lazy, cached), lists its tools to find the UI URI on + * `_meta.ui.resourceUri`, then reads the resource and returns its HTML + * payload alongside the tool definition. + */ +export function useMcpUiResource({ + installation, + toolName, +}: UseMcpUiResourceArgs) { + return useQuery({ + queryKey: ["mcp", "ui-resource", installation?.id ?? null, toolName], + queryFn: async () => { + if (!installation) return null; + const manager = getMcpConnectionManager(); + const args = { + installationId: installation.id, + serverName: installation.name, + proxyUrl: installation.proxy_url, + }; + const tool = await manager.getTool({ ...args, toolName }); + if (!tool) return null; + const uri = getToolUiResourceUri(tool); + if (!uri) return null; + + const readResult = await manager.readResource({ ...args, uri }); + const contents = readResult.contents.find((c) => c.uri === uri) as + | (Record & { uri: string }) + | undefined; + const textValue = contents + ? (contents as { text?: unknown }).text + : undefined; + const text = typeof textValue === "string" ? textValue : null; + if (!text) return null; + + const mimeValue = contents + ? (contents as { mimeType?: unknown }).mimeType + : undefined; + const mime = typeof mimeValue === "string" ? mimeValue : ""; + if (!mime.includes(RESOURCE_MIME_TYPE.split(";")[0])) { + // Resource doesn't look like an MCP App profile — skip rather than + // mount arbitrary HTML. + return null; + } + + const meta = (contents as { _meta?: Record })._meta; + const ui = (meta?.ui as Record) ?? {}; + const permissions = + (ui.permissions as Record>) ?? + undefined; + const csp = (ui.csp as Record | undefined) ?? undefined; + + return { + resource: { uri, html: text, csp, permissions }, + tool, + }; + }, + enabled: !!installation, + staleTime: 5 * 60 * 1000, + }); +} diff --git a/apps/mobile/src/features/mcp/sandbox/useMobileAppBridge.ts b/apps/mobile/src/features/mcp/sandbox/useMobileAppBridge.ts new file mode 100644 index 000000000..916d68707 --- /dev/null +++ b/apps/mobile/src/features/mcp/sandbox/useMobileAppBridge.ts @@ -0,0 +1,335 @@ +import { + AppBridge, + type McpUiDisplayMode, + type McpUiHostCapabilities, + type McpUiHostContext, +} from "@modelcontextprotocol/ext-apps/app-bridge"; +import type { + CallToolResult, + ReadResourceResult, + Tool, +} from "@modelcontextprotocol/sdk/types.js"; +import { useCallback, useEffect, useRef } from "react"; +import { Platform } from "react-native"; +import type { EdgeInsets } from "react-native-safe-area-context"; +import type WebView from "react-native-webview"; +import { logger } from "@/lib/logger"; +import type { ThemeColors } from "@/lib/theme"; +import { buildMcpHostStyles } from "./mcpAppTheme"; +import { WebViewTransport } from "./webViewTransport"; + +const log = logger.scope("mobile-mcp-app-bridge"); + +export type Phase = + | "loading" + | "proxy-ready" + | "resource-sent" + | "initialized" + | "error"; + +interface UiResource { + uri: string; + html: string; + /** Opaque `McpUiResourceCsp` shape — passed through to AppBridge unchanged. */ + csp?: Record; + permissions?: Record>; +} + +interface UseMobileAppBridgeArgs { + webViewRef: { current: WebView | null }; + uiResource: UiResource | null | undefined; + serverName: string; + toolDefinition?: Tool | null; + toolInput?: Record | null; + /** Already-completed tool result, used when remounting after the original + * result event was missed. */ + existingToolResult?: CallToolResult | null; + themeColors: ThemeColors; + isDarkMode: boolean; + displayMode: McpUiDisplayMode; + containerWidth: number; + safeAreaInsets: EdgeInsets; + onPhaseChange?: (phase: Phase) => void; + onSizeChange?: (height: number) => void; + onDisplayModeChange?: (mode: McpUiDisplayMode) => void; + /** Called when the app requests a tool call via `serverTools`. Round-trip + * through the mobile MCP service. */ + proxyToolCall: (args: { + serverName: string; + toolName: string; + args?: Record; + }) => Promise; + proxyResourceRead: (args: { + serverName: string; + uri: string; + }) => Promise; + openLink: (args: { url: string }) => Promise; + /** Called when the app sends a `ui/message` (e.g. pre-fill chat input). */ + onAppMessage?: (text: string) => void; +} + +interface UseMobileAppBridgeReturn { + /** Call from the WebView's `onMessage` to feed incoming JSON-RPC. */ + handleWebViewMessage: (payload: string) => void; + /** Buffer a callback until the app finishes initializing. */ + sendWhenReady: (fn: (bridge: AppBridge) => void) => void; +} + +const HOST_INFO = { name: "posthog-code-mobile", version: "1.0.0" }; + +const HOST_CAPABILITIES: McpUiHostCapabilities = { + openLinks: {}, + serverTools: {}, + serverResources: {}, + logging: {}, + message: { text: {} }, + sandbox: {}, +}; + +function buildInitialContext(args: UseMobileAppBridgeArgs): McpUiHostContext { + const hostStyles = buildMcpHostStyles(args.themeColors, args.isDarkMode); + return { + theme: args.isDarkMode ? "dark" : "light", + styles: { variables: hostStyles.variables, css: hostStyles.css }, + availableDisplayModes: ["inline", "fullscreen"], + displayMode: args.displayMode, + containerDimensions: { + width: args.containerWidth, + // Inline default; the WebView re-sizes after onsizechange fires. + height: 320, + }, + locale: "en-US", + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + userAgent: `posthog-code-mobile/${Platform.OS}`, + platform: "mobile", + deviceCapabilities: { touch: true, hover: false }, + safeAreaInsets: { + top: args.safeAreaInsets.top, + right: args.safeAreaInsets.right, + bottom: args.safeAreaInsets.bottom, + left: args.safeAreaInsets.left, + }, + ...(args.toolDefinition ? { toolInfo: { tool: args.toolDefinition } } : {}), + }; +} + +/** + * Manages a single `AppBridge` lifecycle bound to a WebView. Mirror of + * desktop's `useAppBridge`, with the iframe `PostMessageTransport` swapped + * for our `WebViewTransport` and DOM-only host context replaced with + * React Native equivalents. + */ +export function useMobileAppBridge( + args: UseMobileAppBridgeArgs, +): UseMobileAppBridgeReturn { + const bridgeRef = useRef(null); + const transportRef = useRef(null); + const initializedRef = useRef(false); + const pendingRef = useRef void>>([]); + + // Mutable mirror of props so handlers always read latest values. + const latest = useRef(args); + latest.current = args; + + // Build/destroy bridge when the UI resource identity changes. + const { webViewRef, uiResource: uiResourceProp } = args; + useEffect(() => { + if (!uiResourceProp) return; + // Snapshot the resource at effect time so the callback always uses the + // value we keyed the effect on, even if `args` mutates mid-flight. + const uiResource = uiResourceProp; + + let cleanedUp = false; + + const setup = async () => { + try { + const transport = new WebViewTransport(webViewRef); + + // Wait for the proxy to signal it's ready by capturing the + // sandbox-proxy-ready notification on the first message. + const ready = new Promise((resolve, reject) => { + let resolved = false; + const previousOnError = transport.onerror; + const previousOnMessage = transport.onmessage; + transport.onmessage = (msg) => { + const m = msg as { method?: string }; + if ( + !resolved && + m.method === "ui/notifications/sandbox-proxy-ready" + ) { + resolved = true; + transport.onmessage = previousOnMessage; + resolve(); + return; + } + previousOnMessage?.(msg); + }; + transport.onerror = (err) => { + if (!resolved) reject(err); + previousOnError?.(err); + }; + }); + + await transport.start(); + transportRef.current = transport; + + if (cleanedUp) return; + await ready; + if (cleanedUp) return; + + latest.current.onPhaseChange?.("proxy-ready"); + + const hostContext = buildInitialContext(latest.current); + const bridge = new AppBridge(null, HOST_INFO, HOST_CAPABILITIES, { + hostContext, + }); + + bridge.oncalltool = async (params) => + latest.current.proxyToolCall({ + serverName: latest.current.serverName, + toolName: params.name, + args: params.arguments, + }); + + bridge.onreadresource = async (params) => + latest.current.proxyResourceRead({ + serverName: latest.current.serverName, + uri: params.uri, + }); + + bridge.onopenlink = async (params) => { + await latest.current.openLink({ url: params.url }); + return {}; + }; + + bridge.onmessage = async (params) => { + const text = params.content + .filter( + (block): block is { type: "text"; text: string } => + block.type === "text", + ) + .map((block) => block.text) + .join("\n"); + if (text) latest.current.onAppMessage?.(text); + return {}; + }; + + bridge.onrequestdisplaymode = async (params) => { + if (params.mode === "inline" || params.mode === "fullscreen") { + latest.current.onDisplayModeChange?.(params.mode); + return { mode: params.mode }; + } + return { mode: latest.current.displayMode }; + }; + + bridge.onsizechange = (params) => { + if (typeof params.height === "number" && params.height > 0) { + latest.current.onSizeChange?.(params.height); + } + }; + + bridge.onloggingmessage = (params) => { + log.info("App log", { + server: latest.current.serverName, + level: params.level, + data: params.data, + }); + }; + + bridge.oninitialized = () => { + if (cleanedUp) return; + initializedRef.current = true; + latest.current.onPhaseChange?.("initialized"); + + if (latest.current.toolInput) { + bridge.sendToolInput({ arguments: latest.current.toolInput }); + } + if (latest.current.existingToolResult) { + bridge.sendToolResult(latest.current.existingToolResult); + } + + for (const fn of pendingRef.current) fn(bridge); + pendingRef.current = []; + }; + + await bridge.connect(transport); + bridgeRef.current = bridge; + + await bridge.sendSandboxResourceReady({ + html: uiResource.html, + csp: uiResource.csp, + permissions: uiResource.permissions, + }); + + if (!cleanedUp) latest.current.onPhaseChange?.("resource-sent"); + } catch (err) { + log.error("Failed to initialize mobile MCP bridge", err); + if (!cleanedUp) latest.current.onPhaseChange?.("error"); + } + }; + + void setup(); + + return () => { + cleanedUp = true; + const bridge = bridgeRef.current; + const transport = transportRef.current; + bridgeRef.current = null; + transportRef.current = null; + initializedRef.current = false; + pendingRef.current = []; + if (bridge) { + bridge.teardownResource({}).catch(() => {}); + bridge.close().catch(() => {}); + } + if (transport) { + transport.close().catch(() => {}); + } + }; + // Re-run when the resource object identity changes (React Query gives a + // stable reference per cache key). Everything else flows through + // `latest.current` inside the handlers. + }, [uiResourceProp, webViewRef]); + + // Host context changes (theme, display mode, container size, safe areas). + useEffect(() => { + if (!initializedRef.current || !bridgeRef.current) return; + const bridge = bridgeRef.current; + const hostStyles = buildMcpHostStyles(args.themeColors, args.isDarkMode); + bridge.sendHostContextChange({ + theme: args.isDarkMode ? "dark" : "light", + styles: { variables: hostStyles.variables, css: hostStyles.css }, + displayMode: args.displayMode, + containerDimensions: { + width: args.containerWidth, + height: 320, + }, + safeAreaInsets: { + top: args.safeAreaInsets.top, + right: args.safeAreaInsets.right, + bottom: args.safeAreaInsets.bottom, + left: args.safeAreaInsets.left, + }, + }); + }, [ + args.isDarkMode, + args.displayMode, + args.containerWidth, + args.themeColors, + args.safeAreaInsets, + ]); + + const handleWebViewMessage = useCallback((payload: string) => { + transportRef.current?.acceptIncoming(payload); + }, []); + + const sendWhenReady = useCallback((fn: (bridge: AppBridge) => void) => { + if (initializedRef.current && bridgeRef.current) { + fn(bridgeRef.current); + } else { + pendingRef.current.push(fn); + } + }, []); + + return { handleWebViewMessage, sendWhenReady }; +} diff --git a/apps/mobile/src/features/mcp/sandbox/webViewTransport.test.ts b/apps/mobile/src/features/mcp/sandbox/webViewTransport.test.ts new file mode 100644 index 000000000..8f7971a21 --- /dev/null +++ b/apps/mobile/src/features/mcp/sandbox/webViewTransport.test.ts @@ -0,0 +1,111 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { WebViewTransport } from "./webViewTransport"; + +type FakeWebView = { + injectJavaScript: ReturnType; +}; + +function makeRef(): { current: FakeWebView } { + return { + current: { + injectJavaScript: vi.fn(), + }, + }; +} + +describe("WebViewTransport", () => { + let ref: { current: FakeWebView }; + let transport: WebViewTransport; + + beforeEach(() => { + ref = makeRef(); + // Cast through unknown — the real type expects a WebView instance, but + // we only ever read `injectJavaScript` so the duck-typed fake suffices. + transport = new WebViewTransport( + ref as unknown as { + current: import("react-native-webview").default | null; + }, + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("injects send() payloads as a __mcpReceive call", async () => { + await transport.start(); + await transport.send({ jsonrpc: "2.0", id: 1, method: "ping" }); + const snippet = ref.current.injectJavaScript.mock.calls[0][0]; + expect(snippet).toContain("window.__mcpReceive"); + expect(snippet).toContain('"method":"ping"'); + }); + + it("escapes embedded in payloads", async () => { + await transport.start(); + await transport.send({ + jsonrpc: "2.0", + method: "ui/notifications/log", + params: { html: "" }, + }); + const snippet = ref.current.injectJavaScript.mock.calls[0][0]; + expect(snippet).not.toContain(""); + expect(snippet).toContain("<\\/script>"); + }); + + it("dispatches incoming messages to onmessage once started", async () => { + const received: unknown[] = []; + transport.onmessage = (msg) => { + received.push(msg); + }; + await transport.start(); + transport.acceptIncoming( + JSON.stringify({ + jsonrpc: "2.0", + method: "ui/notifications/sandbox-proxy-ready", + }), + ); + expect(received).toHaveLength(1); + expect((received[0] as { method: string }).method).toBe( + "ui/notifications/sandbox-proxy-ready", + ); + }); + + it("ignores incoming messages before start()", () => { + const received: unknown[] = []; + transport.onmessage = (msg) => received.push(msg); + transport.acceptIncoming(JSON.stringify({ jsonrpc: "2.0", method: "x" })); + expect(received).toHaveLength(0); + }); + + it("calls onerror on malformed JSON", async () => { + const errors: Error[] = []; + transport.onerror = (err) => errors.push(err); + await transport.start(); + transport.acceptIncoming("not-json{"); + expect(errors).toHaveLength(1); + }); + + it("send() after close throws", async () => { + await transport.start(); + await transport.close(); + await expect( + transport.send({ jsonrpc: "2.0", method: "x" }), + ).rejects.toThrow(/closed/i); + }); + + it("close() fires onclose exactly once", async () => { + const onclose = vi.fn(); + transport.onclose = onclose; + await transport.close(); + await transport.close(); + expect(onclose).toHaveBeenCalledTimes(1); + }); + + it("send() is a no-op when the WebView ref is null", async () => { + ref.current = null as unknown as FakeWebView; + await transport.start(); + await expect( + transport.send({ jsonrpc: "2.0", method: "x" }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/apps/mobile/src/features/mcp/sandbox/webViewTransport.ts b/apps/mobile/src/features/mcp/sandbox/webViewTransport.ts new file mode 100644 index 000000000..9940e3a75 --- /dev/null +++ b/apps/mobile/src/features/mcp/sandbox/webViewTransport.ts @@ -0,0 +1,83 @@ +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; +import type WebView from "react-native-webview"; + +/** + * MCP `Transport` implementation that bridges JSON-RPC messages between the + * RN host and a `react-native-webview`-hosted sandbox proxy. + * + * Inbound (WebView → RN): the caller hands us messages via `acceptIncoming` + * (typically called from the WebView's `onMessage` prop). + * Outbound (RN → WebView): `send` injects a tiny JS snippet that invokes the + * sandbox proxy's `window.__mcpReceive` entry point. + * + * The transport never validates origin (there isn't a meaningful one inside a + * WebView) — the host MUST only inject HTML it trusts via `WebView`'s + * `source`. Since the sandbox proxy HTML is hard-coded in + * `sandboxProxyHtml.ts` and the inner iframe's content comes from a UI + * resource the user has already chosen to install, that boundary is fine. + */ +export class WebViewTransport implements Transport { + private webViewRef: { current: WebView | null }; + private started = false; + private closed = false; + + onmessage?: (message: JSONRPCMessage) => void; + onerror?: (error: Error) => void; + onclose?: () => void; + // Required by Transport; we honour it but the protocol version is not + // meaningful at the WebView boundary. + setProtocolVersion?: (version: string) => void; + + constructor(webViewRef: { current: WebView | null }) { + this.webViewRef = webViewRef; + } + + async start(): Promise { + this.started = true; + } + + /** + * Forward a JSON-RPC message from the host to the WebView. Idempotent and + * safe to call before `start()` — `injectJavaScript` will just no-op until + * the WebView is mounted. + */ + async send(message: JSONRPCMessage): Promise { + if (this.closed) throw new Error("Transport closed"); + const webView = this.webViewRef.current; + if (!webView) return; + const json = JSON.stringify(message); + // The escape pass below is the standard way to embed an already-JSON + // string inside another script payload — without it, a literal `` + // sequence in the data could prematurely end the injected snippet. + const escaped = json.replace(/<\/script>/gi, "<\\/script>"); + const snippet = `void (window.__mcpReceive && window.__mcpReceive(${escaped}));`; + webView.injectJavaScript(snippet); + } + + /** + * Called from the WebView's `onMessage` handler with the raw JSON payload + * the sandbox proxy posted. Parses, validates shape, and dispatches. + */ + acceptIncoming(payload: string): void { + if (this.closed) return; + if (!this.started) return; + let message: unknown; + try { + message = JSON.parse(payload); + } catch (err) { + this.onerror?.( + err instanceof Error ? err : new Error("Invalid JSON from WebView"), + ); + return; + } + if (!message || typeof message !== "object") return; + this.onmessage?.(message as JSONRPCMessage); + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + this.onclose?.(); + } +} diff --git a/apps/mobile/src/features/mcp/service.ts b/apps/mobile/src/features/mcp/service.ts new file mode 100644 index 000000000..199c08c20 --- /dev/null +++ b/apps/mobile/src/features/mcp/service.ts @@ -0,0 +1,184 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import type { + CallToolResult, + ReadResourceResult, + Tool, +} from "@modelcontextprotocol/sdk/types.js"; +import { AppState, type AppStateStatus } from "react-native"; +import { useAuthStore } from "@/features/auth"; +import { logger } from "@/lib/logger"; + +const log = logger.scope("mcp-service"); + +interface ServerConnection { + installationId: string; + serverName: string; + proxyUrl: string; + client: Client; + transport: StreamableHTTPClientTransport; +} + +const CLIENT_INFO = { name: "posthog-code-mobile", version: "1.0.0" }; + +/** + * Mobile-side service that owns MCP `Client` connections per installation. + * + * Each installation gets a lazy `StreamableHTTPClientTransport` pointed at the + * cloud-hosted proxy URL the API returned. Auth is injected per-request via + * the user's PostHog access token in the `Authorization` header — the cloud + * proxy strips it and forwards a fresh server-side credential to the MCP + * server, so the mobile token never reaches the upstream. + * + * Connections are kept alive across screens (one per server). They're torn + * down when the app backgrounds for more than a few seconds so we don't + * accumulate sockets, and re-opened on demand. + */ +class McpConnectionManager { + private connections = new Map(); + private pendingConnects = new Map>(); + private appStateSubscription: { remove(): void } | null = null; + + registerAppStateListener(): void { + if (this.appStateSubscription) return; + this.appStateSubscription = AppState.addEventListener( + "change", + (nextState) => { + if (nextState !== "active") { + // Background — drop all connections to avoid stale-socket churn. + // Next request re-opens them lazily. + void this.closeAll(); + } + }, + ); + } + + /** Returns a connected MCP `Client` for the given installation, creating + * one on first use. Concurrent callers share the same pending promise. */ + async getClient(args: { + installationId: string; + serverName: string; + proxyUrl: string; + }): Promise { + const existing = this.connections.get(args.installationId); + if (existing) return existing.client; + + const pending = this.pendingConnects.get(args.installationId); + if (pending) { + const connection = await pending; + return connection.client; + } + + const promise = this.connect(args); + this.pendingConnects.set(args.installationId, promise); + try { + const connection = await promise; + this.connections.set(args.installationId, connection); + return connection.client; + } finally { + this.pendingConnects.delete(args.installationId); + } + } + + private async connect(args: { + installationId: string; + serverName: string; + proxyUrl: string; + }): Promise { + const { oauthAccessToken } = useAuthStore.getState(); + if (!oauthAccessToken) { + throw new Error("Not authenticated"); + } + + const url = new URL(args.proxyUrl); + const transport = new StreamableHTTPClientTransport(url, { + requestInit: { + headers: { + Authorization: `Bearer ${oauthAccessToken}`, + }, + }, + }); + + const client = new Client(CLIENT_INFO, { capabilities: {} }); + await client.connect(transport); + log.info("MCP client connected", { + installationId: args.installationId, + serverName: args.serverName, + }); + + return { + installationId: args.installationId, + serverName: args.serverName, + proxyUrl: args.proxyUrl, + client, + transport, + }; + } + + async callTool(args: { + installationId: string; + serverName: string; + proxyUrl: string; + toolName: string; + arguments?: Record; + }): Promise { + const client = await this.getClient(args); + const result = await client.callTool({ + name: args.toolName, + arguments: args.arguments ?? {}, + }); + return result as CallToolResult; + } + + async readResource(args: { + installationId: string; + serverName: string; + proxyUrl: string; + uri: string; + }): Promise { + const client = await this.getClient(args); + return (await client.readResource({ uri: args.uri })) as ReadResourceResult; + } + + async getTool(args: { + installationId: string; + serverName: string; + proxyUrl: string; + toolName: string; + }): Promise { + const client = await this.getClient(args); + const { tools } = await client.listTools(); + return tools.find((t) => t.name === args.toolName) ?? null; + } + + /** Close a single connection (e.g., after uninstall). */ + async close(installationId: string): Promise { + const connection = this.connections.get(installationId); + if (!connection) return; + this.connections.delete(installationId); + try { + await connection.client.close(); + } catch (err) { + log.warn("Failed to close MCP client", { installationId, err }); + } + } + + async closeAll(): Promise { + const ids = [...this.connections.keys()]; + await Promise.allSettled(ids.map((id) => this.close(id))); + } +} + +let manager: McpConnectionManager | null = null; + +export function getMcpConnectionManager(): McpConnectionManager { + if (!manager) { + manager = new McpConnectionManager(); + manager.registerAppStateListener(); + } + return manager; +} + +// Exported for tests. +export { McpConnectionManager }; +export type { AppStateStatus }; diff --git a/apps/mobile/src/features/mcp/types.ts b/apps/mobile/src/features/mcp/types.ts new file mode 100644 index 000000000..b8cdb4c43 --- /dev/null +++ b/apps/mobile/src/features/mcp/types.ts @@ -0,0 +1,115 @@ +// Shared types for MCP server installations and marketplace templates. +// Mirrors the PostHog cloud REST schema (see `apps/code/src/renderer/api/generated.ts`). + +export type McpAuthType = "api_key" | "oauth" | "none"; + +export type McpApprovalState = "approved" | "needs_approval" | "do_not_use"; + +export type McpInstallSource = "posthog" | "posthog-code" | "posthog-mobile"; + +/** Server-side marketplace template — one entry per recommended server. */ +export interface McpRecommendedServer { + id: string; + name: string; + url: string; + docs_url?: string; + description?: string; + auth_type?: McpAuthType; + icon_key?: string; + category?: string; + /** Some templates expose a `transport_type` ("stdio" | "streamable_http"); when + * absent, treat as HTTP. Stdio servers can't run on mobile; we badge them. */ + transport_type?: "stdio" | "streamable_http"; +} + +/** Server-side record of one user's installation of a server. */ +export interface McpServerInstallation { + id: string; + template_id: string | null; + name: string; + icon_key: string; + display_name?: string; + url?: string; + description?: string; + auth_type?: McpAuthType; + is_enabled?: boolean; + needs_reauth: boolean; + pending_oauth: boolean; + /** Cloud-hosted proxy URL the client should hit to talk to the MCP server. + * Desktop substitutes a local loopback; mobile uses whatever the API returns. */ + proxy_url: string; + tool_count: number; + transport_type?: "stdio" | "streamable_http"; + created_at: string; + updated_at: string | null; +} + +export interface McpInstallationTool { + id: string; + tool_name: string; + display_name: string; + description: string; + input_schema: unknown; + approval_state?: McpApprovalState; + last_seen_at: string; + removed_at: string | null; + created_at: string; + updated_at: string | null; +} + +export interface McpOAuthRedirectResponse { + redirect_url: string; +} + +export type McpInstallResponse = + | McpServerInstallation + | McpOAuthRedirectResponse; + +export function isOAuthRedirect( + response: McpInstallResponse, +): response is McpOAuthRedirectResponse { + return ( + typeof (response as McpOAuthRedirectResponse).redirect_url === "string" + ); +} + +export interface InstallCustomMcpServerOptions { + name: string; + url: string; + auth_type: McpAuthType; + api_key?: string; + description?: string; + client_id?: string; + client_secret?: string; + install_source?: McpInstallSource; + posthog_code_callback_url?: string; +} + +export interface InstallMcpTemplateOptions { + template_id: string; + api_key?: string; + install_source?: McpInstallSource; + posthog_code_callback_url?: string; +} + +export interface UpdateMcpServerInstallationOptions { + display_name?: string; + description?: string; + is_enabled?: boolean; +} + +export interface McpUiResource { + uri: string; + html: string; + /** Opaque CSP descriptor handed straight to AppBridge (`McpUiResourceCsp`). */ + csp?: Record; + permissions?: Record>; +} + +/** Returns true if the template/installation requires stdio transport, which + * the mobile app can't host. UI uses this to render a "Desktop only" badge. */ +export function isStdioServer( + s: Pick, +): boolean { + return s.transport_type === "stdio"; +} diff --git a/apps/mobile/src/features/mcp/utils/mcpToolName.test.ts b/apps/mobile/src/features/mcp/utils/mcpToolName.test.ts new file mode 100644 index 000000000..f9982e865 --- /dev/null +++ b/apps/mobile/src/features/mcp/utils/mcpToolName.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { isMcpToolName, parseMcpToolName } from "./mcpToolName"; + +describe("isMcpToolName", () => { + it("accepts a well-formed MCP tool name", () => { + expect(isMcpToolName("mcp__github__create_issue")).toBe(true); + }); + + it("accepts tool names with extra underscores in the tool segment", () => { + expect(isMcpToolName("mcp__github__list_pull_requests")).toBe(true); + }); + + it("rejects non-MCP tool names", () => { + expect(isMcpToolName("read_file")).toBe(false); + expect(isMcpToolName("Bash")).toBe(false); + expect(isMcpToolName("")).toBe(false); + expect(isMcpToolName(null)).toBe(false); + expect(isMcpToolName(undefined)).toBe(false); + }); + + it("rejects malformed prefixes", () => { + expect(isMcpToolName("mcp_github__tool")).toBe(false); + expect(isMcpToolName("mcp__github")).toBe(false); // no second separator + expect(isMcpToolName("mcp__")).toBe(false); + }); +}); + +describe("parseMcpToolName", () => { + it("splits server and tool", () => { + expect(parseMcpToolName("mcp__linear__create_issue")).toEqual({ + serverName: "linear", + toolName: "create_issue", + }); + }); + + it("keeps double-underscore tool names intact on the tool side", () => { + expect(parseMcpToolName("mcp__db__select__count")).toEqual({ + serverName: "db", + toolName: "select__count", + }); + }); + + it("returns null for invalid names", () => { + expect(parseMcpToolName("read_file")).toBeNull(); + expect(parseMcpToolName("mcp__only-server")).toBeNull(); + expect(parseMcpToolName(null)).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/mcp/utils/mcpToolName.ts b/apps/mobile/src/features/mcp/utils/mcpToolName.ts new file mode 100644 index 000000000..f2e0273eb --- /dev/null +++ b/apps/mobile/src/features/mcp/utils/mcpToolName.ts @@ -0,0 +1,35 @@ +// Helpers for detecting + parsing MCP tool names that arrive from the agent. +// +// Cloud agents prefix MCP tool calls with `mcp____` in the raw +// tool name (mobile sees this on `_meta.claudeCode.toolName`). PostHog's own +// MCP plugin already has its own dedicated renderer (`isPostHogExecTool`); we +// pick up everything else. + +const MCP_PREFIX = "mcp__"; + +/** Returns true for any tool name following the MCP naming convention. */ +export function isMcpToolName(toolName: string | undefined | null): boolean { + if (!toolName) return false; + if (!toolName.startsWith(MCP_PREFIX)) return false; + const rest = toolName.slice(MCP_PREFIX.length); + return rest.includes("__"); +} + +export interface ParsedMcpToolName { + serverName: string; + toolName: string; +} + +/** Split `mcp____` into its parts, or `null` if it doesn't match. */ +export function parseMcpToolName( + raw: string | undefined | null, +): ParsedMcpToolName | null { + if (!raw || !raw.startsWith(MCP_PREFIX)) return null; + const rest = raw.slice(MCP_PREFIX.length); + const splitIdx = rest.indexOf("__"); + if (splitIdx <= 0) return null; + return { + serverName: rest.slice(0, splitIdx), + toolName: rest.slice(splitIdx + 2), + }; +} diff --git a/apps/mobile/src/features/navigation/components/NavDrawer.tsx b/apps/mobile/src/features/navigation/components/NavDrawer.tsx index 2b57d0fd1..c9daf7487 100644 --- a/apps/mobile/src/features/navigation/components/NavDrawer.tsx +++ b/apps/mobile/src/features/navigation/components/NavDrawer.tsx @@ -1,6 +1,13 @@ import { Text } from "@components/text"; import { usePathname, useRouter } from "expo-router"; -import { Clock, GearSix, ListBullets, Plus, Tray } from "phosphor-react-native"; +import { + Clock, + GearSix, + ListBullets, + Plus, + PuzzlePiece, + Tray, +} from "phosphor-react-native"; import { memo, type ReactNode, useEffect } from "react"; import { Dimensions, @@ -94,6 +101,11 @@ const NavDrawerContent = memo(function NavDrawerContent({ if (pathname === "/settings") return; router.push("/settings"); }; + const handleMcpServers = () => { + close(); + if (pathname === "/mcp-servers") return; + router.push("/mcp-servers"); + }; const handleHome = () => navigateTo("/tasks"); const handleTaskPress = (taskId: string) => { @@ -107,6 +119,7 @@ const NavDrawerContent = memo(function NavDrawerContent({ const isOnInbox = pathname === "/inbox"; const isOnAutomations = pathname === "/automations"; const isOnSettings = pathname === "/settings"; + const isOnMcpServers = pathname === "/mcp-servers"; return ( + + } + label="MCP servers" + active={isOnMcpServers} + onPress={handleMcpServers} + /> diff --git a/apps/mobile/svg.d.ts b/apps/mobile/svg.d.ts new file mode 100644 index 000000000..f976e9e51 --- /dev/null +++ b/apps/mobile/svg.d.ts @@ -0,0 +1,8 @@ +// Lets us `import Logo from "./logo.svg"` and have it typed as a React +// component, courtesy of react-native-svg-transformer at build time. +declare module "*.svg" { + import type React from "react"; + import type { SvgProps } from "react-native-svg"; + const content: React.FC; + export default content; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 305195b5b..10ca2b915 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -261,7 +261,7 @@ importers: version: 17.2.3 drizzle-orm: specifier: ^0.45.1 - version: 0.45.1(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.8.0)(bun-types@1.3.13) + version: 0.45.1(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.8.0)(bun-types@1.3.14) electron-log: specifier: ^5.4.3 version: 5.4.3 @@ -524,6 +524,12 @@ importers: '@expo/ui': specifier: 0.2.0-beta.9 version: 0.2.0-beta.9(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) + '@modelcontextprotocol/ext-apps': + specifier: ^1.2.2 + version: 1.2.2(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(zod@4.3.6) + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(zod@4.3.6) '@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)) @@ -672,6 +678,9 @@ importers: '@vitejs/plugin-react': specifier: ^4.7.0 version: 4.7.0(vite@6.4.1(@types/node@25.2.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + react-native-svg-transformer: + specifier: ^1.5.3 + version: 1.5.3(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))(typescript@5.9.3) react-test-renderer: specifier: ^19.1.0 version: 19.1.0(react@19.1.0) @@ -759,7 +768,7 @@ importers: version: link:../shared '@types/bun': specifier: latest - version: 1.3.13 + version: 1.3.14 '@types/tar': specifier: ^6.1.13 version: 6.1.13 @@ -4880,6 +4889,80 @@ packages: typescript: optional: true + '@svgr/babel-plugin-add-jsx-attribute@8.0.0': + resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-remove-jsx-attribute@8.0.0': + resolution: {integrity: sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-remove-jsx-empty-expression@8.0.0': + resolution: {integrity: sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-replace-jsx-attribute-value@8.0.0': + resolution: {integrity: sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-svg-dynamic-title@8.0.0': + resolution: {integrity: sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-svg-em-dimensions@8.0.0': + resolution: {integrity: sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-transform-react-native-svg@8.1.0': + resolution: {integrity: sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-transform-svg-component@8.0.0': + resolution: {integrity: sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==} + engines: {node: '>=12'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-preset@8.1.0': + resolution: {integrity: sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/core@8.1.0': + resolution: {integrity: sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==} + engines: {node: '>=14'} + + '@svgr/hast-util-to-babel-ast@8.0.0': + resolution: {integrity: sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==} + engines: {node: '>=14'} + + '@svgr/plugin-jsx@8.1.0': + resolution: {integrity: sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==} + engines: {node: '>=14'} + peerDependencies: + '@svgr/core': '*' + + '@svgr/plugin-svgo@8.1.0': + resolution: {integrity: sha512-Ywtl837OGO9pTLIN/onoWLmDQ4zFUycI1g76vuKGEz6evR/ZTJlJuz3G/fIkb6OVBJ2g0o6CGJzaEjfmEo3AHA==} + engines: {node: '>=14'} + peerDependencies: + '@svgr/core': '*' + '@szmarczak/http-timer@4.0.6': resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} @@ -5259,8 +5342,8 @@ packages: '@types/better-sqlite3@7.6.13': resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} - '@types/bun@1.3.13': - resolution: {integrity: sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw==} + '@types/bun@1.3.14': + resolution: {integrity: sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==} '@types/cacheable-request@6.0.3': resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} @@ -6041,8 +6124,8 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} - bun-types@1.3.13: - resolution: {integrity: sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA==} + bun-types@1.3.14: + resolution: {integrity: sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==} bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} @@ -6411,6 +6494,15 @@ packages: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} + cosmiconfig@8.3.6: + resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + cosmiconfig@9.0.1: resolution: {integrity: sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==} engines: {node: '>=14'} @@ -6455,6 +6547,14 @@ packages: resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==} engines: {node: '>=8.0.0'} + css-tree@2.2.1: + resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + css-tree@2.3.1: + resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css-what@6.2.2: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} @@ -6467,6 +6567,10 @@ packages: engines: {node: '>=4'} hasBin: true + csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + cssstyle@4.6.0: resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} engines: {node: '>=18'} @@ -6655,6 +6759,9 @@ packages: domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dot-case@3.0.4: + resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} + dot-prop@10.1.0: resolution: {integrity: sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q==} engines: {node: '>=20'} @@ -8748,6 +8855,9 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lower-case@2.0.2: + resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} + lowercase-keys@2.0.0: resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} engines: {node: '>=8'} @@ -8881,6 +8991,12 @@ packages: mdn-data@2.0.14: resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} + mdn-data@2.0.28: + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + + mdn-data@2.0.30: + resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} + mdurl@2.0.0: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} @@ -9294,6 +9410,9 @@ packages: nice-try@1.0.5: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} + no-case@3.0.4: + resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + node-abi@3.87.0: resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==} engines: {node: '>=10'} @@ -9656,6 +9775,9 @@ packages: path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-dirname@1.0.2: + resolution: {integrity: sha512-ALzNPpyNq9AqXMBjeymIjFDAkAFH06mHJH/cSBHAgU0s4vfpBn6b2nf8tiRLvagKD8RbTpq2FKTBg7cl9l3c7Q==} + path-exists@3.0.0: resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} engines: {node: '>=4'} @@ -9705,6 +9827,10 @@ packages: resolution: {integrity: sha512-dUnb5dXUf+kzhC/W/F4e5/SkluXIFf5VUHolW1Eg1irn1hGWjPGdsRcvYJ1nD6lhk8Ir7VM0bHJKsYTx8Jx9OQ==} engines: {node: '>=4'} + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} @@ -10249,6 +10375,12 @@ packages: react: '*' react-native: '*' + react-native-svg-transformer@1.5.3: + resolution: {integrity: sha512-M4uFg5pUt35OMgjD4rWWbwd6PmxV96W7r/gQTTa+iZA5B+jO6aURhzAZGLHSrg1Kb91cKG0Rildy9q1WJvYstg==} + peerDependencies: + react-native: '>=0.59.0' + react-native-svg: '>=12.0.0' + react-native-svg@15.15.2: resolution: {integrity: sha512-lpaSwA2i+eLvcEdDZyGgMEInQW99K06zjJqfMFblE0yxI0SCN5E4x6in46f0IYi6i3w2t2aaq3oOnyYBe+bo4w==} peerDependencies: @@ -10603,6 +10735,10 @@ packages: resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==} engines: {node: '>=11.0.0'} + sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + engines: {node: '>=11.0.0'} + saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} @@ -10790,6 +10926,9 @@ packages: resolution: {integrity: sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==} engines: {node: '>= 18'} + snake-case@3.0.4: + resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} + socks-proxy-agent@7.0.0: resolution: {integrity: sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==} engines: {node: '>= 10'} @@ -11051,6 +11190,14 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svg-parser@2.0.4: + resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==} + + svgo@3.3.3: + resolution: {integrity: sha512-+wn7I4p7YgJhHs38k2TNjy1vCfPIfLIJWR5MnCStsN8WuuTcBnRKcMHQLMM2ijxGZmDoZwNv8ipl5aTTen62ng==} + engines: {node: '>=14.0.0'} + hasBin: true + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -15129,6 +15276,14 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) + '@modelcontextprotocol/ext-apps@1.2.2(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(zod@4.3.6)': + dependencies: + '@modelcontextprotocol/sdk': 1.29.0(zod@4.3.6) + zod: 4.3.6 + optionalDependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + '@modelcontextprotocol/sdk@1.27.1(zod@4.3.6)': dependencies: '@hono/node-server': 1.19.9(hono@4.11.7) @@ -16879,6 +17034,85 @@ snapshots: transitivePeerDependencies: - supports-color + '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + + '@svgr/babel-plugin-remove-jsx-attribute@8.0.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + + '@svgr/babel-plugin-remove-jsx-empty-expression@8.0.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + + '@svgr/babel-plugin-replace-jsx-attribute-value@8.0.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + + '@svgr/babel-plugin-svg-dynamic-title@8.0.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + + '@svgr/babel-plugin-svg-em-dimensions@8.0.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + + '@svgr/babel-plugin-transform-react-native-svg@8.1.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + + '@svgr/babel-plugin-transform-svg-component@8.0.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + + '@svgr/babel-preset@8.1.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@svgr/babel-plugin-add-jsx-attribute': 8.0.0(@babel/core@7.29.0) + '@svgr/babel-plugin-remove-jsx-attribute': 8.0.0(@babel/core@7.29.0) + '@svgr/babel-plugin-remove-jsx-empty-expression': 8.0.0(@babel/core@7.29.0) + '@svgr/babel-plugin-replace-jsx-attribute-value': 8.0.0(@babel/core@7.29.0) + '@svgr/babel-plugin-svg-dynamic-title': 8.0.0(@babel/core@7.29.0) + '@svgr/babel-plugin-svg-em-dimensions': 8.0.0(@babel/core@7.29.0) + '@svgr/babel-plugin-transform-react-native-svg': 8.1.0(@babel/core@7.29.0) + '@svgr/babel-plugin-transform-svg-component': 8.0.0(@babel/core@7.29.0) + + '@svgr/core@8.1.0(typescript@5.9.3)': + dependencies: + '@babel/core': 7.29.0 + '@svgr/babel-preset': 8.1.0(@babel/core@7.29.0) + camelcase: 6.3.0 + cosmiconfig: 8.3.6(typescript@5.9.3) + snake-case: 3.0.4 + transitivePeerDependencies: + - supports-color + - typescript + + '@svgr/hast-util-to-babel-ast@8.0.0': + dependencies: + '@babel/types': 7.29.0 + entities: 4.5.0 + + '@svgr/plugin-jsx@8.1.0(@svgr/core@8.1.0(typescript@5.9.3))': + dependencies: + '@babel/core': 7.29.0 + '@svgr/babel-preset': 8.1.0(@babel/core@7.29.0) + '@svgr/core': 8.1.0(typescript@5.9.3) + '@svgr/hast-util-to-babel-ast': 8.0.0 + svg-parser: 2.0.4 + transitivePeerDependencies: + - supports-color + + '@svgr/plugin-svgo@8.1.0(@svgr/core@8.1.0(typescript@5.9.3))(typescript@5.9.3)': + dependencies: + '@svgr/core': 8.1.0(typescript@5.9.3) + cosmiconfig: 8.3.6(typescript@5.9.3) + deepmerge: 4.3.1 + svgo: 3.3.3 + transitivePeerDependencies: + - typescript + '@szmarczak/http-timer@4.0.6': dependencies: defer-to-connect: 2.0.1 @@ -17267,9 +17501,9 @@ snapshots: dependencies: '@types/node': 24.12.0 - '@types/bun@1.3.13': + '@types/bun@1.3.14': dependencies: - bun-types: 1.3.13 + bun-types: 1.3.14 '@types/cacheable-request@6.0.3': dependencies: @@ -18227,7 +18461,7 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 - bun-types@1.3.13: + bun-types@1.3.14: dependencies: '@types/node': 24.12.0 @@ -18615,6 +18849,15 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 + cosmiconfig@8.3.6(typescript@5.9.3): + dependencies: + import-fresh: 3.3.1 + js-yaml: 4.1.1 + parse-json: 5.2.0 + path-type: 4.0.0 + optionalDependencies: + typescript: 5.9.3 + cosmiconfig@9.0.1(typescript@5.9.3): dependencies: env-paths: 2.2.1 @@ -18669,12 +18912,26 @@ snapshots: mdn-data: 2.0.14 source-map: 0.6.1 + css-tree@2.2.1: + dependencies: + mdn-data: 2.0.28 + source-map-js: 1.2.1 + + css-tree@2.3.1: + dependencies: + mdn-data: 2.0.30 + source-map-js: 1.2.1 + css-what@6.2.2: {} css.escape@1.5.1: {} cssesc@3.0.0: {} + csso@5.0.5: + dependencies: + css-tree: 2.2.1 + cssstyle@4.6.0: dependencies: '@asamuzakjp/css-color': 3.2.0 @@ -18820,6 +19077,11 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 + dot-case@3.0.4: + dependencies: + no-case: 3.0.4 + tslib: 2.8.1 + dot-prop@10.1.0: dependencies: type-fest: 5.4.3 @@ -18841,12 +19103,12 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.8.0)(bun-types@1.3.13): + drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.8.0)(bun-types@1.3.14): optionalDependencies: '@opentelemetry/api': 1.9.0 '@types/better-sqlite3': 7.6.13 better-sqlite3: 12.8.0 - bun-types: 1.3.13 + bun-types: 1.3.14 ds-store@0.1.6: dependencies: @@ -21097,6 +21359,10 @@ snapshots: loupe@3.2.1: {} + lower-case@2.0.2: + dependencies: + tslib: 2.8.1 + lowercase-keys@2.0.0: {} lru-cache@10.4.3: {} @@ -21362,6 +21628,10 @@ snapshots: mdn-data@2.0.14: {} + mdn-data@2.0.28: {} + + mdn-data@2.0.30: {} + mdurl@2.0.0: {} media-typer@1.1.0: {} @@ -22013,6 +22283,11 @@ snapshots: nice-try@1.0.5: {} + no-case@3.0.4: + dependencies: + lower-case: 2.0.2 + tslib: 2.8.1 + node-abi@3.87.0: dependencies: semver: 7.7.3 @@ -22410,6 +22685,8 @@ snapshots: path-browserify@1.0.1: {} + path-dirname@1.0.2: {} + path-exists@3.0.0: {} path-exists@4.0.0: {} @@ -22447,6 +22724,8 @@ snapshots: dependencies: pify: 2.3.0 + path-type@4.0.0: {} + pathe@1.1.2: {} pathe@2.0.3: {} @@ -23081,6 +23360,18 @@ snapshots: react-native-is-edge-to-edge: 1.2.1(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) warn-once: 0.1.1 + react-native-svg-transformer@1.5.3(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))(typescript@5.9.3): + dependencies: + '@svgr/core': 8.1.0(typescript@5.9.3) + '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.9.3)) + '@svgr/plugin-svgo': 8.1.0(@svgr/core@8.1.0(typescript@5.9.3))(typescript@5.9.3) + path-dirname: 1.0.2 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(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) + transitivePeerDependencies: + - supports-color + - typescript + 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): dependencies: css-select: 5.2.2 @@ -23549,6 +23840,8 @@ snapshots: sax@1.4.4: {} + sax@1.6.0: {} + saxes@6.0.0: dependencies: xmlchars: 2.2.0 @@ -23813,6 +24106,11 @@ snapshots: smol-toml@1.6.0: {} + snake-case@3.0.4: + dependencies: + dot-case: 3.0.4 + tslib: 2.8.1 + socks-proxy-agent@7.0.0: dependencies: agent-base: 6.0.2 @@ -24071,6 +24369,18 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svg-parser@2.0.4: {} + + svgo@3.3.3: + dependencies: + commander: 7.2.0 + css-select: 5.2.2 + css-tree: 2.3.1 + css-what: 6.2.2 + csso: 5.0.5 + picocolors: 1.1.1 + sax: 1.6.0 + symbol-tree@3.2.4: {} tabbable@6.4.0: {} From 6e502566543610873323b8545ab4b19bb51a59a4 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Thu, 14 May 2026 10:07:20 -0400 Subject: [PATCH 55/94] Automations list UX --- apps/mobile/src/app/(tabs)/_layout.tsx | 6 ++ apps/mobile/src/app/(tabs)/automations.tsx | 35 ++++-------- .../tasks/components/AutomationList.tsx | 10 +++- .../components/FloatingAutomationsHeader.tsx | 57 +++++++++++++++++++ .../FloatingNewAutomationButton.tsx | 43 ++++++++++++++ 5 files changed, 126 insertions(+), 25 deletions(-) create mode 100644 apps/mobile/src/features/tasks/components/FloatingAutomationsHeader.tsx create mode 100644 apps/mobile/src/features/tasks/components/FloatingNewAutomationButton.tsx diff --git a/apps/mobile/src/app/(tabs)/_layout.tsx b/apps/mobile/src/app/(tabs)/_layout.tsx index c982fd50a..3eac16df9 100644 --- a/apps/mobile/src/app/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(tabs)/_layout.tsx @@ -6,6 +6,7 @@ import { useNavDrawerStore } from "@/features/navigation/stores/navDrawerStore"; import { useThemeColors } from "@/lib/theme"; const HOME_ROUTE = "/tasks"; +const TAB_ROUTES = new Set(["/", "/tasks", "/inbox", "/automations"]); export default function TabsLayout() { const themeColors = useThemeColors(); @@ -25,6 +26,11 @@ export default function TabsLayout() { store.close(); return true; } + // Only intercept when we're actually on a tab destination. Modals + // pushed on top of the tabs (e.g. /automation, /task) keep this + // handler mounted; without the guard we'd redirect to /tasks instead + // of letting the modal dismiss naturally. + if (!TAB_ROUTES.has(pathname)) return false; if (pathname === HOME_ROUTE) return false; router.replace(HOME_ROUTE); return true; diff --git a/apps/mobile/src/app/(tabs)/automations.tsx b/apps/mobile/src/app/(tabs)/automations.tsx index 718de7d80..4721c6ab2 100644 --- a/apps/mobile/src/app/(tabs)/automations.tsx +++ b/apps/mobile/src/app/(tabs)/automations.tsx @@ -1,10 +1,10 @@ -import { Text } from "@components/text"; import { useFocusEffect, useRouter } from "expo-router"; import { useCallback, useRef } from "react"; -import { InteractionManager, Pressable, View } from "react-native"; +import { InteractionManager, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { MenuButton } from "@/features/navigation/components/MenuButton"; import { AutomationList } from "@/features/tasks/components/AutomationList"; +import { FloatingAutomationsHeader } from "@/features/tasks/components/FloatingAutomationsHeader"; +import { FloatingNewAutomationButton } from "@/features/tasks/components/FloatingNewAutomationButton"; export default function AutomationsScreen() { const router = useRouter(); @@ -38,32 +38,21 @@ export default function AutomationsScreen() { [router], ); + // Matches FloatingTasksHeader: top inset + 6 (top pad) + 40 (button) + 8 + // (bottom pad) plus a small visual buffer so rows don't hug the fade. + const headerHeight = insets.top + 64; + return ( - - - - - Automations - - - - New automation - - - - - + + + + ); } diff --git a/apps/mobile/src/features/tasks/components/AutomationList.tsx b/apps/mobile/src/features/tasks/components/AutomationList.tsx index c1ed2df82..ddce0baee 100644 --- a/apps/mobile/src/features/tasks/components/AutomationList.tsx +++ b/apps/mobile/src/features/tasks/components/AutomationList.tsx @@ -15,6 +15,8 @@ import { AutomationItem } from "./AutomationItem"; interface AutomationListProps { onAutomationPress?: (automationId: string) => void; onCreateAutomation?: () => void; + /** Top inset so the list can scroll behind a floating header. */ + contentInsetTop?: number; } function EmptyAutomationState({ @@ -37,7 +39,7 @@ function EmptyAutomationState({ style={{ backgroundColor: themeColors.accent[9] }} > - Create automation + New automation )} @@ -48,6 +50,7 @@ function EmptyAutomationState({ export function AutomationList({ onAutomationPress, onCreateAutomation, + contentInsetTop = 0, }: AutomationListProps) { const { automations, isLoading, error, refetch } = useAutomations(); const { allTasks: automationTasks } = useTasks({ @@ -116,7 +119,10 @@ export function AutomationList({ tintColor={themeColors.accent[9]} /> } - contentContainerStyle={{ paddingBottom: 100 }} + contentContainerStyle={{ + paddingTop: contentInsetTop, + paddingBottom: 100, + }} /> ); } diff --git a/apps/mobile/src/features/tasks/components/FloatingAutomationsHeader.tsx b/apps/mobile/src/features/tasks/components/FloatingAutomationsHeader.tsx new file mode 100644 index 000000000..39e6aa08c --- /dev/null +++ b/apps/mobile/src/features/tasks/components/FloatingAutomationsHeader.tsx @@ -0,0 +1,57 @@ +import { Text } from "@components/text"; +import { LinearGradient } from "expo-linear-gradient"; +import { View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { MenuButton } from "@/features/navigation/components/MenuButton"; +import { toRgba, useThemeColors } from "@/lib/theme"; + +/** + * Floating header for the Automations list — mirrors FloatingTasksHeader so + * the two tabs feel like siblings. Hamburger on the left, centered title, + * gradient fade so the list content disappears gracefully behind it. + */ +export function FloatingAutomationsHeader() { + const insets = useSafeAreaInsets(); + const themeColors = useThemeColors(); + + const fadeHeight = insets.top + 88; + + return ( + + + + + + + + + Automations + + + + {/* Spacer mirroring the MenuButton width so the title stays + optically centered. */} + + + + ); +} diff --git a/apps/mobile/src/features/tasks/components/FloatingNewAutomationButton.tsx b/apps/mobile/src/features/tasks/components/FloatingNewAutomationButton.tsx new file mode 100644 index 000000000..3c5afb5b2 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/FloatingNewAutomationButton.tsx @@ -0,0 +1,43 @@ +import { Plus } from "phosphor-react-native"; +import { Pressable } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/components/text"; +import { useThemeColors } from "@/lib/theme"; + +interface FloatingNewAutomationButtonProps { + onPress: () => void; +} + +/** + * Pill-shaped FAB anchored to the bottom-right corner — mirrors + * FloatingNewTaskButton so the two tabs feel like siblings. + */ +export function FloatingNewAutomationButton({ + onPress, +}: FloatingNewAutomationButtonProps) { + const insets = useSafeAreaInsets(); + const themeColors = useThemeColors(); + + return ( + + + + New automation + + + ); +} From 974e9cd091d86cc2482658b13b371b7bf4506249 Mon Sep 17 00:00:00 2001 From: Annika <14750837+annikaschmid@users.noreply.github.com> Date: Thu, 14 May 2026 10:20:27 -0400 Subject: [PATCH 56/94] fix(code): preserve repo selection in task input Normalize repository matching across the shared GitHub repo picker so prefilled task flows keep the existing repo selected even when casing or .git suffixes differ. Reset the picker open/search state for prefilled task flows so the dropdown stays closed until the field is focused, and cover the lookup behavior with a focused regression test. --- .../components/detail/ReportDetailPane.tsx | 3 +- .../task-detail/components/TaskInput.tsx | 82 ++++++++++++++----- .../hooks/useDetectedCloudRepository.ts | 5 +- .../src/renderer/hooks/useIntegrations.ts | 65 ++++++++++----- .../src/renderer/utils/repository.test.ts | 33 ++++++++ apps/code/src/renderer/utils/repository.ts | 21 +++++ 6 files changed, 169 insertions(+), 40 deletions(-) create mode 100644 apps/code/src/renderer/utils/repository.test.ts diff --git a/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx b/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx index 4dd14652d..c52ae4a08 100644 --- a/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx +++ b/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx @@ -31,6 +31,7 @@ import { } from "@radix-ui/themes"; import { useTRPC } from "@renderer/trpc"; import { EXTERNAL_LINKS } from "@renderer/utils/links"; +import { normalizeRepositoryLookupKey } from "@renderer/utils/repository"; import { getDeeplinkProtocol } from "@shared/deeplink"; import type { ActionabilityJudgmentArtefact, @@ -84,7 +85,7 @@ function useReportRepository(reportId: string) { reportTask.task_id, )) as unknown as Task | null; if (task?.repository) { - return task.repository.toLowerCase(); + return normalizeRepositoryLookupKey(task.repository); } } diff --git a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx index d0302697d..eeb83befe 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -36,6 +36,10 @@ import { Flex, Text, Tooltip } from "@radix-ui/themes"; import { useAuthStore } from "@renderer/features/auth/stores/authStore"; import { useDraftStore } from "@renderer/features/message-editor/stores/draftStore"; import { trpcClient, useTRPC } from "@renderer/trpc/client"; +import { + findMatchingRepository, + normalizeRepositoryLookupKey, +} from "@renderer/utils/repository"; import { toast } from "@renderer/utils/toast"; import { type TaskInputReportAssociation, @@ -212,16 +216,34 @@ export function TaskInput({ loadMore: loadMoreCloudRepositories, } = useUserGithubRepositories(cloudRepoSearchQuery, isCloudRepoPickerOpen); const [selectedRepository, setSelectedRepository] = useState( - () => - initialCloudRepository?.toLowerCase() ?? - lastUsedCloudRepository?.toLowerCase() ?? - null, + () => initialCloudRepository ?? lastUsedCloudRepository ?? null, ); const selectedCloudRepository = useMemo(() => { - if (!selectedRepository) return null; - const lower = selectedRepository.toLowerCase(); - return repositories.includes(lower) ? lower : null; + return findMatchingRepository(selectedRepository, repositories); }, [selectedRepository, repositories]); + const cloudRepoPickerRepositories = useMemo(() => { + if (!isCloudRepoPickerOpen) { + return repositories; + } + + if ( + !selectedCloudRepository || + visibleCloudRepositories.some( + (repo) => + normalizeRepositoryLookupKey(repo) === + normalizeRepositoryLookupKey(selectedCloudRepository), + ) + ) { + return visibleCloudRepositories; + } + + return [selectedCloudRepository, ...visibleCloudRepositories]; + }, [ + isCloudRepoPickerOpen, + repositories, + selectedCloudRepository, + visibleCloudRepositories, + ]); const { currentBranch, branchLoading, defaultBranch } = useGitQueries(selectedDirectory); @@ -293,9 +315,8 @@ export function TaskInput({ return; } - const normalizedRepo = repo.toLowerCase(); - setSelectedRepository(normalizedRepo); - setLastUsedCloudRepository(normalizedRepo); + setSelectedRepository(repo); + setLastUsedCloudRepository(repo); }, [setLastUsedCloudRepository], ); @@ -303,9 +324,18 @@ export function TaskInput({ useEffect(() => { if (!initialCloudRepository) return; setWorkspaceModeState("cloud"); - setSelectedRepository(initialCloudRepository.toLowerCase()); + setSelectedRepository(initialCloudRepository); }, [initialCloudRepository]); + useEffect(() => { + if (!prefillRequestKey) return; + + setIsCloudRepoPickerOpen(false); + setCloudRepoSearchQuery(""); + setIsCloudBranchPickerOpen(false); + setCloudBranchSearchQuery(""); + }, [prefillRequestKey]); + const handleRefreshRepositories = useCallback(() => { void refreshRepositories().catch((error) => { toast.error("Failed to refresh repositories", { @@ -371,9 +401,23 @@ export function TaskInput({ return; } - setSelectedRepository(lastUsedCloudRepository.toLowerCase()); + setSelectedRepository(lastUsedCloudRepository); }, [lastUsedCloudRepository, selectedRepository]); + useEffect(() => { + if ( + !selectedCloudRepository || + !selectedRepository || + normalizeRepositoryLookupKey(selectedCloudRepository) !== + normalizeRepositoryLookupKey(selectedRepository) || + selectedCloudRepository === selectedRepository + ) { + return; + } + + setSelectedRepository(selectedCloudRepository); + }, [selectedCloudRepository, selectedRepository]); + useEffect(() => { // Clear `selectedRepository` only when the list has actually loaded AND the // selection is missing from it — i.e. the repo was removed from the user's @@ -391,7 +435,11 @@ export function TaskInput({ } setSelectedRepository(null); - if (lastUsedCloudRepository === selectedRepository) { + if ( + lastUsedCloudRepository && + normalizeRepositoryLookupKey(lastUsedCloudRepository) === + normalizeRepositoryLookupKey(selectedRepository) + ) { setLastUsedCloudRepository(null); } }, [ @@ -661,13 +709,9 @@ export function TaskInput({ > {workspaceMode === "cloud" ? ( { - const map: Record = {}; + const map: Record = {}; let pending = false; for (const result of results) { if (result.isPending) pending = true; if (!result.data) continue; for (const repo of result.data.repos ?? []) { - if (!(repo in map)) { - map[repo] = result.data.integrationId; + const repoKey = normalizeRepositoryLookupKey(repo); + if (!(repoKey in map)) { + map[repoKey] = { + repository: repo, + integrationId: result.data.integrationId, + }; } } } @@ -162,8 +173,10 @@ function useAllUserGithubRepositories( const installationRepos = result.data.repos ?? []; reposByInstallationId[result.data.installationId] = installationRepos; for (const repo of installationRepos) { - if (!(repo in map)) { - map[repo] = { + const repoKey = normalizeRepositoryLookupKey(repo); + if (!(repoKey in map)) { + map[repoKey] = { + repository: repo, userIntegrationId: result.data.userIntegrationId, installationId: result.data.installationId, }; @@ -223,7 +236,7 @@ export function useGithubRepositories( meta: AUTH_SCOPED_QUERY_META, })), combine: (results) => { - const map: Record = {}; + const map: Record = {}; let pending = false; let refreshing = false; let hasMoreResults = false; @@ -238,8 +251,12 @@ export function useGithubRepositories( } for (const repo of result.data.repositories ?? []) { - if (!(repo in map)) { - map[repo] = result.data.integrationId; + const repoKey = normalizeRepositoryLookupKey(repo); + if (!(repoKey in map)) { + map[repoKey] = { + repository: repo, + integrationId: result.data.integrationId, + }; } } } @@ -258,7 +275,9 @@ export function useGithubRepositories( }, []); return { - repositories: Object.keys(repositoryMap), + repositories: Object.values(repositoryMap).map( + ({ repository }) => repository, + ), isPending: queryEnabled ? isPending : false, isRefreshing: queryEnabled ? isRefreshing : false, hasMore, @@ -323,8 +342,10 @@ export function useUserGithubRepositories( } for (const repo of result.data.repositories ?? []) { - if (!(repo in map)) { - map[repo] = { + const repoKey = normalizeRepositoryLookupKey(repo); + if (!(repoKey in map)) { + map[repoKey] = { + repository: repo, userIntegrationId: result.data.userIntegrationId, installationId: result.data.installationId, }; @@ -346,7 +367,9 @@ export function useUserGithubRepositories( }, []); return { - repositories: Object.keys(repositoryMap), + repositories: Object.values(repositoryMap).map( + ({ repository }) => repository, + ), isPending: queryEnabled ? isPending : false, isRefreshing: queryEnabled ? isRefreshing : false, hasMore, @@ -511,23 +534,25 @@ export function useUserRepositoryIntegration() { } = useAllUserGithubRepositories(githubIntegrations); const repositories = useMemo( - () => Object.keys(repositoryMap), + () => Object.values(repositoryMap).map(({ repository }) => repository), [repositoryMap], ); const getUserIntegrationIdForRepo = useCallback( (repoKey: string) => - repositoryMap[repoKey?.toLowerCase()]?.userIntegrationId, + repositoryMap[normalizeRepositoryLookupKey(repoKey)]?.userIntegrationId, [repositoryMap], ); const getInstallationIdForRepo = useCallback( - (repoKey: string) => repositoryMap[repoKey?.toLowerCase()]?.installationId, + (repoKey: string) => + repositoryMap[normalizeRepositoryLookupKey(repoKey)]?.installationId, [repositoryMap], ); const isRepoInIntegration = useCallback( - (repoKey: string) => !repoKey || repoKey.toLowerCase() in repositoryMap, + (repoKey: string) => + !repoKey || normalizeRepositoryLookupKey(repoKey) in repositoryMap, [repositoryMap], ); @@ -590,17 +615,19 @@ export function useRepositoryIntegration() { useAllGithubRepositories(githubIntegrations); const repositories = useMemo( - () => Object.keys(repositoryMap), + () => Object.values(repositoryMap).map(({ repository }) => repository), [repositoryMap], ); const getIntegrationIdForRepo = useCallback( - (repoKey: string) => repositoryMap[repoKey?.toLowerCase()], + (repoKey: string) => + repositoryMap[normalizeRepositoryLookupKey(repoKey)]?.integrationId, [repositoryMap], ); const isRepoInIntegration = useCallback( - (repoKey: string) => !repoKey || repoKey.toLowerCase() in repositoryMap, + (repoKey: string) => + !repoKey || normalizeRepositoryLookupKey(repoKey) in repositoryMap, [repositoryMap], ); diff --git a/apps/code/src/renderer/utils/repository.test.ts b/apps/code/src/renderer/utils/repository.test.ts new file mode 100644 index 000000000..dec2776cb --- /dev/null +++ b/apps/code/src/renderer/utils/repository.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { + findMatchingRepository, + normalizeRepositoryLookupKey, +} from "./repository"; + +describe("normalizeRepositoryLookupKey", () => { + it("lowercases and strips a .git suffix", () => { + expect(normalizeRepositoryLookupKey(" PostHog/Code.git\n")).toBe( + "posthog/code", + ); + }); +}); + +describe("findMatchingRepository", () => { + it("matches repositories case-insensitively", () => { + expect(findMatchingRepository("posthog/code", ["PostHog/Code"])).toBe( + "PostHog/Code", + ); + }); + + it("matches repositories even when the input has a .git suffix", () => { + expect(findMatchingRepository("posthog/code.git", ["PostHog/Code"])).toBe( + "PostHog/Code", + ); + }); + + it("returns null when there is no matching repository", () => { + expect(findMatchingRepository("posthog/code", ["posthog/posthog"])).toBe( + null, + ); + }); +}); diff --git a/apps/code/src/renderer/utils/repository.ts b/apps/code/src/renderer/utils/repository.ts index 290258869..b743680cb 100644 --- a/apps/code/src/renderer/utils/repository.ts +++ b/apps/code/src/renderer/utils/repository.ts @@ -1,3 +1,5 @@ +import { normalizeRepoKey } from "@shared/utils/repo"; + export const parseRepository = ( repository: string, ): { organization: string; repoName: string } | null => { @@ -15,3 +17,22 @@ export function getTaskRepository(task: { }): string | null { return task.repository ?? null; } + +export function normalizeRepositoryLookupKey(repository: string): string { + return normalizeRepoKey(repository).toLowerCase(); +} + +export function findMatchingRepository( + repository: string | null | undefined, + repositories: string[], +): string | null { + if (!repository) return null; + + const normalizedRepository = normalizeRepositoryLookupKey(repository); + return ( + repositories.find( + (candidate) => + normalizeRepositoryLookupKey(candidate) === normalizedRepository, + ) ?? null + ); +} From 333c5d38733f709d3ab1e36f9a3e9d821a8fae62 Mon Sep 17 00:00:00 2001 From: Annika <14750837+annikaschmid@users.noreply.github.com> Date: Thu, 14 May 2026 10:21:31 -0400 Subject: [PATCH 57/94] fix(mobile): hide running automation badge Stop rendering the running chip for active automation runs while keeping success and other terminal statuses visible. Add coverage for both the status helper and badge component so the mobile task UI keeps this behavior stable. --- .../components/AutomationStatusBadge.test.tsx | 48 +++++++++++++++++++ .../components/AutomationStatusBadge.tsx | 12 +++-- .../tasks/utils/automationStatus.test.ts | 14 ++++-- .../features/tasks/utils/automationStatus.ts | 12 ++--- 4 files changed, 68 insertions(+), 18 deletions(-) create mode 100644 apps/mobile/src/features/tasks/components/AutomationStatusBadge.test.tsx diff --git a/apps/mobile/src/features/tasks/components/AutomationStatusBadge.test.tsx b/apps/mobile/src/features/tasks/components/AutomationStatusBadge.test.tsx new file mode 100644 index 000000000..3be63af42 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/AutomationStatusBadge.test.tsx @@ -0,0 +1,48 @@ +import { createElement } from "react"; +import { act, create } from "react-test-renderer"; +import { describe, expect, it } from "vitest"; +import { AutomationStatusBadge } from "./AutomationStatusBadge"; + +describe("AutomationStatusBadge", () => { + it("does not render a running chip for active automation runs", () => { + let renderer: ReturnType | null = null; + + act(() => { + renderer = create( + createElement(AutomationStatusBadge, { + enabled: true, + lastRunStatus: "running", + lastTaskRunStatus: "in_progress", + }), + ); + }); + + if (!renderer) { + throw new Error("Renderer not created"); + } + + const output = JSON.stringify(renderer.toJSON()); + + expect(output).toContain("Enabled"); + expect(output).not.toContain("Running"); + }); + + it("still renders non-running run states", () => { + let renderer: ReturnType | null = null; + + act(() => { + renderer = create( + createElement(AutomationStatusBadge, { + enabled: true, + lastRunStatus: "success", + }), + ); + }); + + if (!renderer) { + throw new Error("Renderer not created"); + } + + expect(JSON.stringify(renderer.toJSON())).toContain("Success"); + }); +}); diff --git a/apps/mobile/src/features/tasks/components/AutomationStatusBadge.tsx b/apps/mobile/src/features/tasks/components/AutomationStatusBadge.tsx index 0b0b07b03..970de00d6 100644 --- a/apps/mobile/src/features/tasks/components/AutomationStatusBadge.tsx +++ b/apps/mobile/src/features/tasks/components/AutomationStatusBadge.tsx @@ -32,11 +32,13 @@ export function AutomationStatusBadge({ {enabled ? "Enabled" : "Paused"} - - - {runStatus.label} - - + {runStatus ? ( + + + {runStatus.label} + + + ) : null} ); } diff --git a/apps/mobile/src/features/tasks/utils/automationStatus.test.ts b/apps/mobile/src/features/tasks/utils/automationStatus.test.ts index e1c5cb38d..3a3305451 100644 --- a/apps/mobile/src/features/tasks/utils/automationStatus.test.ts +++ b/apps/mobile/src/features/tasks/utils/automationStatus.test.ts @@ -13,15 +13,21 @@ describe("automationStatus", () => { }); }); - it("shows running only when the linked task run is actively in progress", () => { + it("hides the running badge while the linked task run is actively in progress", () => { expect( getAutomationStatusPresentation({ lastRunStatus: "running", lastTaskRunStatus: "in_progress", }), - ).toMatchObject({ - label: "Running", - }); + ).toBeNull(); + }); + + it("hides the running badge when only the automation-level status is available", () => { + expect( + getAutomationStatusPresentation({ + lastRunStatus: "running", + }), + ).toBeNull(); }); it("falls back to automation status when task-run detail is unavailable", () => { diff --git a/apps/mobile/src/features/tasks/utils/automationStatus.ts b/apps/mobile/src/features/tasks/utils/automationStatus.ts index f7fe78443..e5dd7c8fe 100644 --- a/apps/mobile/src/features/tasks/utils/automationStatus.ts +++ b/apps/mobile/src/features/tasks/utils/automationStatus.ts @@ -13,7 +13,7 @@ export interface AutomationStatusPresentation { export function getAutomationStatusPresentation({ lastRunStatus, lastTaskRunStatus, -}: AutomationStatusInput): AutomationStatusPresentation { +}: AutomationStatusInput): AutomationStatusPresentation | null { switch (lastTaskRunStatus) { case "not_started": case "queued": @@ -23,10 +23,7 @@ export function getAutomationStatusPresentation({ }; case "started": case "in_progress": - return { - label: "Running", - className: "bg-status-info/20 text-status-info", - }; + return null; case "completed": return { label: "Success", @@ -44,10 +41,7 @@ export function getAutomationStatusPresentation({ switch (lastRunStatus) { case "running": - return { - label: "Running", - className: "bg-status-info/20 text-status-info", - }; + return null; case "success": return { label: "Success", From 69ff33c67727c02018a98d22137523a47d56234b Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Thu, 14 May 2026 10:25:05 -0400 Subject: [PATCH 58/94] Added universal links --- apps/mobile/app.json | 14 +++ apps/mobile/src/app/(tabs)/inbox.tsx | 2 +- apps/mobile/src/app/_layout.tsx | 27 ++++-- apps/mobile/src/app/auth.tsx | 24 ++++- .../mobile/src/app/{report => inbox}/[id].tsx | 0 .../notifications/lib/notifications.ts | 55 ++++++++--- apps/mobile/src/lib/deep-links.ts | 93 +++++++++++++++++++ 7 files changed, 189 insertions(+), 26 deletions(-) rename apps/mobile/src/app/{report => inbox}/[id].tsx (100%) create mode 100644 apps/mobile/src/lib/deep-links.ts diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 2fe68fe9b..f1aa95036 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -18,6 +18,7 @@ "icon": "./assets/app.icon", "supportsTablet": true, "bundleIdentifier": "com.posthog.code.mobile", + "associatedDomains": ["applinks:code.posthog.com"], "infoPlist": { "NSMicrophoneUsageDescription": "Allow PostHog to use your microphone for voice-to-text input", "NSSpeechRecognitionUsageDescription": "Allow PostHog to transcribe your voice input on-device", @@ -39,6 +40,19 @@ "android.permission.RECORD_AUDIO", "android.permission.MODIFY_AUDIO_SETTINGS", "android.permission.CAMERA" + ], + "intentFilters": [ + { + "action": "VIEW", + "autoVerify": true, + "data": [ + { + "scheme": "https", + "host": "code.posthog.com" + } + ], + "category": ["BROWSABLE", "DEFAULT"] + } ] }, "web": { diff --git a/apps/mobile/src/app/(tabs)/inbox.tsx b/apps/mobile/src/app/(tabs)/inbox.tsx index 504da788e..b42006c21 100644 --- a/apps/mobile/src/app/(tabs)/inbox.tsx +++ b/apps/mobile/src/app/(tabs)/inbox.tsx @@ -49,7 +49,7 @@ export default function InboxScreen() { // ── List mode handlers ──────────────────────────────────────────────────── const handleReportPress = useCallback( (report: SignalReport) => { - router.push(`/report/${report.id}`); + router.push(`/inbox/${report.id}`); }, [router], ); diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 7055f6897..c314e050a 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -2,7 +2,7 @@ import "../../global.css"; import "@/lib/textDefaults"; import { QueryClientProvider } from "@tanstack/react-query"; -import { router, Stack } from "expo-router"; +import { router, Stack, usePathname } from "expo-router"; import { StatusBar } from "expo-status-bar"; import { useColorScheme } from "nativewind"; import { PostHogProvider } from "posthog-react-native"; @@ -32,8 +32,10 @@ interface RootLayoutNavProps { function RootLayoutNav({ isConnected }: RootLayoutNavProps) { const { isLoading, initializeAuth } = useAuthStore(); + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const aiChatEnabled = usePreferencesStore((s) => s.aiChatEnabled); const themeColors = useThemeColors(); + const pathname = usePathname(); useScreenTracking(); @@ -42,11 +44,23 @@ function RootLayoutNav({ isConnected }: RootLayoutNavProps) { }, [initializeAuth]); useEffect(() => { - return setupNotificationResponseListener(({ taskId }) => { - router.push(`/task/${taskId}`); + return setupNotificationResponseListener(({ path }) => { + router.push(path); }); }, []); + // Auth gate. If a deep link drops an unauthed user on a protected route + // (e.g. `posthog://task/abc` from a notification or shared link), bounce + // them to /auth with a `next` param so the sign-in flow can resume the + // originally-intended navigation. + useEffect(() => { + if (isLoading) return; + if (isAuthenticated) return; + if (!pathname || pathname === "/auth") return; + const next = pathname !== "/" ? pathname : undefined; + router.replace(next ? { pathname: "/auth", params: { next } } : "/auth"); + }, [isAuthenticated, isLoading, pathname]); + if (isLoading) { return ( @@ -100,10 +114,11 @@ function RootLayoutNav({ isConnected }: RootLayoutNavProps) { options={{ headerShown: false }} /> - {/* Report detail - modal presentation, no native header - (the in-content title block is the canonical header). */} + {/* Inbox report detail - modal presentation, no native header + (the in-content title block is the canonical header). Path mirrors + the desktop app's `posthog-code://inbox/` deep-link shape. */} diff --git a/apps/mobile/src/app/auth.tsx b/apps/mobile/src/app/auth.tsx index 013b638e8..cd0b48f5b 100644 --- a/apps/mobile/src/app/auth.tsx +++ b/apps/mobile/src/app/auth.tsx @@ -1,5 +1,5 @@ -import { router } from "expo-router"; -import { useMemo, useState } from "react"; +import { router, useLocalSearchParams } from "expo-router"; +import { useCallback, useMemo, useState } from "react"; import { ActivityIndicator, ScrollView, @@ -27,6 +27,7 @@ const DEV_REGIONS: RegionOption[] = [ export default function AuthScreen() { const themeColors = useThemeColors(); + const { next } = useLocalSearchParams<{ next?: string }>(); // Only show dev region in development builds const regions = useMemo( @@ -42,6 +43,19 @@ export default function AuthScreen() { const { loginWithOAuth, loginWithPersonalApiKey } = useAuthStore(); + // After successful sign-in, resume the originally-requested deep link if + // there was one, otherwise drop into the default tab. Guards against `next` + // pointing back at /auth (which would loop) or being a non-local URL. + const navigateAfterLogin = useCallback(() => { + const target = + typeof next === "string" && + next.startsWith("/") && + !next.startsWith("/auth") + ? next + : "/(tabs)"; + router.replace(target); + }, [next]); + const handleQrScan = async (result: QrScanResult) => { setScannerVisible(false); setDevToken(result.apiKey); @@ -54,7 +68,7 @@ export default function AuthScreen() { projectId: result.projectId, region: selectedRegion, }); - router.replace("/(tabs)"); + navigateAfterLogin(); } catch (err) { setError(err instanceof Error ? err.message : "Failed to sign in"); } finally { @@ -75,7 +89,7 @@ export default function AuthScreen() { projectId: projectIdNum, region: selectedRegion, }); - router.replace("/(tabs)"); + navigateAfterLogin(); } catch (err) { setError(err instanceof Error ? err.message : "Failed to sign in"); } finally { @@ -90,7 +104,7 @@ export default function AuthScreen() { try { await loginWithOAuth(selectedRegion); // Navigate to tabs on success - router.replace("/(tabs)"); + navigateAfterLogin(); } catch (err) { const message = err instanceof Error ? err.message : "Failed to authenticate"; diff --git a/apps/mobile/src/app/report/[id].tsx b/apps/mobile/src/app/inbox/[id].tsx similarity index 100% rename from apps/mobile/src/app/report/[id].tsx rename to apps/mobile/src/app/inbox/[id].tsx diff --git a/apps/mobile/src/features/notifications/lib/notifications.ts b/apps/mobile/src/features/notifications/lib/notifications.ts index bb390133a..780dc5196 100644 --- a/apps/mobile/src/features/notifications/lib/notifications.ts +++ b/apps/mobile/src/features/notifications/lib/notifications.ts @@ -2,16 +2,33 @@ import Constants from "expo-constants"; import * as Device from "expo-device"; import * as Notifications from "expo-notifications"; import { Platform } from "react-native"; +import { externalUrlToAppPath, paths } from "@/lib/deep-links"; import { logger } from "@/lib/logger"; const log = logger.scope("notifications"); +/** + * Shape of `content.data` we expect on incoming notifications. + * + * Two forms are accepted (in priority order): + * 1. `{ url: "posthog://task/abc" }` or `{ url: "/task/abc" }` — generic + * deep link. Preferred for new notification types. + * 2. `{ taskId, taskRunId? }` — legacy task-specific shape kept for + * backwards compatibility with already-queued server notifications. + */ export interface NotificationData { taskId: string; taskRunId: string; } -export type NotificationResponseHandler = (data: NotificationData) => void; +export interface NotificationTapPayload { + /** App-relative path to navigate to (e.g. "/task/abc"). */ + path: string; +} + +export type NotificationResponseHandler = ( + payload: NotificationTapPayload, +) => void; let handlerConfigured = false; @@ -101,20 +118,30 @@ export async function presentLocalNotification(args: { } } -function extractNotificationData( +function extractTapPayload( response: Notifications.NotificationResponse, -): NotificationData | null { +): NotificationTapPayload | null { const data = response.notification.request.content.data as - | Partial + | { url?: unknown; taskId?: unknown; taskRunId?: unknown } | undefined; - if ( - !data || - typeof data.taskId !== "string" || - typeof data.taskRunId !== "string" - ) { + if (!data) return null; + + if (typeof data.url === "string" && data.url.length > 0) { + // Already-shaped app path → use as-is. External URL → translate to one. + if (data.url.startsWith("/")) return { path: data.url }; + const path = externalUrlToAppPath(data.url); + if (path) return { path }; + log.warn("Notification url did not match a known scheme", { + url: data.url, + }); return null; } - return { taskId: data.taskId, taskRunId: data.taskRunId }; + + if (typeof data.taskId === "string") { + return { path: paths.task(data.taskId) }; + } + + return null; } /** @@ -130,8 +157,8 @@ export function setupNotificationResponseListener( Notifications.getLastNotificationResponseAsync() .then((response) => { if (!response) return; - const data = extractNotificationData(response); - if (data) onTap(data); + const payload = extractTapPayload(response); + if (payload) onTap(payload); }) .catch((err) => { log.warn("Failed to read last notification response", { error: err }); @@ -139,8 +166,8 @@ export function setupNotificationResponseListener( const subscription = Notifications.addNotificationResponseReceivedListener( (response) => { - const data = extractNotificationData(response); - if (data) onTap(data); + const payload = extractTapPayload(response); + if (payload) onTap(payload); }, ); diff --git a/apps/mobile/src/lib/deep-links.ts b/apps/mobile/src/lib/deep-links.ts new file mode 100644 index 000000000..50be15f6a --- /dev/null +++ b/apps/mobile/src/lib/deep-links.ts @@ -0,0 +1,93 @@ +/** + * Deep-link URL construction for the mobile app. + * + * Path shape mirrors the desktop app (apps/code/src/shared/deeplink.ts and + * the registered handlers in main/services/*-link/) so a single URL can route + * to either client: + * posthog://task/ + * posthog://task//run/ + * posthog://inbox/ + * + * Mobile uses the `posthog://` custom scheme (registered in app.json) and + * https://code.posthog.com as the universal-link host. Both share the same + * path shape, so a `code.posthog.com/task/X` URL opens the same screen as + * `posthog://task/X`. + * + * For in-app navigation, prefer the `paths.*` helpers — they return the + * router-relative path that `router.push()` expects. For external/shareable + * links (push notifications, Slack messages, copy-link buttons), use + * `universalUrl()` or `customSchemeUrl()`. + */ + +export const MOBILE_SCHEME = "posthog"; +export const UNIVERSAL_LINK_HOST = "code.posthog.com"; +export const UNIVERSAL_LINK_PREFIX = `https://${UNIVERSAL_LINK_HOST}`; + +/** + * Router-relative paths used inside the app with `router.push()` / + * `router.replace()`. These are also the path shape that expo-router maps + * incoming deep links to. + */ +export const paths = { + tasksTab: "/(tabs)/tasks" as const, + inboxTab: "/(tabs)/inbox" as const, + automationsTab: "/(tabs)/automations" as const, + settings: "/settings" as const, + newTask: "/task" as const, + task: (taskId: string) => `/task/${taskId}` as const, + inboxReport: (reportId: string) => `/inbox/${reportId}` as const, + automation: (automationId: string) => `/automation/${automationId}` as const, + newAutomation: "/automation/create" as const, + automationTemplates: "/automation" as const, +} as const; + +/** A path is the part after the host: starts with `/`, no scheme. */ +type AppPath = string; + +/** Build a shareable `posthog://...` URL for an in-app path. */ +export function customSchemeUrl(path: AppPath): string { + const trimmed = path.replace(/^\/+/, ""); + return `${MOBILE_SCHEME}://${trimmed}`; +} + +/** Build a shareable `https://code.posthog.com/...` URL for an in-app path. */ +export function universalUrl(path: AppPath): string { + const normalized = path.startsWith("/") ? path : `/${path}`; + return `${UNIVERSAL_LINK_PREFIX}${normalized}`; +} + +/** + * Convert an incoming external URL (custom scheme or universal link) to the + * router-relative path expo-router uses. Returns null if the URL doesn't + * belong to us. + * + * Used by the auth gate to round-trip the originally-requested URL through + * the sign-in flow. + */ +export function externalUrlToAppPath(url: string): AppPath | null { + try { + const parsed = new URL(url); + + if (parsed.protocol === `${MOBILE_SCHEME}:`) { + // posthog://task/abc → /task/abc + const host = parsed.hostname; + if (!host) return null; + const rest = parsed.pathname || ""; + const search = parsed.search || ""; + return `/${host}${rest}${search}`; + } + + if ( + (parsed.protocol === "https:" || parsed.protocol === "http:") && + parsed.hostname === UNIVERSAL_LINK_HOST + ) { + // https://code.posthog.com/task/abc → /task/abc + const path = parsed.pathname || "/"; + return `${path}${parsed.search || ""}`; + } + + return null; + } catch { + return null; + } +} From 421c7697b57f8a794fcde214b2839051f5765a5f Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Thu, 14 May 2026 10:37:32 -0400 Subject: [PATCH 59/94] Remove AI chat --- apps/mobile/src/app/(tabs)/_layout.tsx | 3 +- apps/mobile/src/app/(tabs)/index.tsx | 54 -- apps/mobile/src/app/_layout.tsx | 10 - apps/mobile/src/app/auth.tsx | 2 +- apps/mobile/src/app/chat/[id].tsx | 132 ----- apps/mobile/src/app/chat/index.tsx | 75 --- apps/mobile/src/app/settings/index.tsx | 17 - .../features/chat/components/AgentMessage.tsx | 8 +- .../src/features/chat/components/Composer.tsx | 237 -------- .../chat/components/FailureMessage.tsx | 17 - .../features/chat/components/MessagesList.tsx | 128 ----- .../chat/components/VisualizationArtifact.tsx | 259 --------- apps/mobile/src/features/chat/index.ts | 15 +- .../src/features/chat/stores/chatStore.ts | 505 ------------------ apps/mobile/src/features/chat/types.ts | 177 ------ apps/mobile/src/features/conversations/api.ts | 40 -- .../components/ConversationItem.tsx | 113 ---- .../components/ConversationList.tsx | 86 --- .../conversations/hooks/useConversations.ts | 40 -- .../src/features/conversations/index.ts | 9 - .../conversations/stores/conversationStore.ts | 11 - .../src/features/conversations/types.ts | 63 --- .../preferences/stores/preferencesStore.ts | 5 - 23 files changed, 13 insertions(+), 1993 deletions(-) delete mode 100644 apps/mobile/src/app/(tabs)/index.tsx delete mode 100644 apps/mobile/src/app/chat/[id].tsx delete mode 100644 apps/mobile/src/app/chat/index.tsx delete mode 100644 apps/mobile/src/features/chat/components/Composer.tsx delete mode 100644 apps/mobile/src/features/chat/components/FailureMessage.tsx delete mode 100644 apps/mobile/src/features/chat/components/MessagesList.tsx delete mode 100644 apps/mobile/src/features/chat/components/VisualizationArtifact.tsx delete mode 100644 apps/mobile/src/features/chat/stores/chatStore.ts delete mode 100644 apps/mobile/src/features/chat/types.ts delete mode 100644 apps/mobile/src/features/conversations/api.ts delete mode 100644 apps/mobile/src/features/conversations/components/ConversationItem.tsx delete mode 100644 apps/mobile/src/features/conversations/components/ConversationList.tsx delete mode 100644 apps/mobile/src/features/conversations/hooks/useConversations.ts delete mode 100644 apps/mobile/src/features/conversations/index.ts delete mode 100644 apps/mobile/src/features/conversations/stores/conversationStore.ts delete mode 100644 apps/mobile/src/features/conversations/types.ts diff --git a/apps/mobile/src/app/(tabs)/_layout.tsx b/apps/mobile/src/app/(tabs)/_layout.tsx index 3eac16df9..3965ca495 100644 --- a/apps/mobile/src/app/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(tabs)/_layout.tsx @@ -6,7 +6,7 @@ import { useNavDrawerStore } from "@/features/navigation/stores/navDrawerStore"; import { useThemeColors } from "@/lib/theme"; const HOME_ROUTE = "/tasks"; -const TAB_ROUTES = new Set(["/", "/tasks", "/inbox", "/automations"]); +const TAB_ROUTES = new Set(["/tasks", "/inbox", "/automations"]); export default function TabsLayout() { const themeColors = useThemeColors(); @@ -50,7 +50,6 @@ export default function TabsLayout() { - diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx deleted file mode 100644 index 14bbc93af..000000000 --- a/apps/mobile/src/app/(tabs)/index.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { Text } from "@components/text"; -import { Redirect, useRouter } from "expo-router"; -import { Pressable, View } from "react-native"; -import { - type ConversationDetail, - ConversationList, -} from "@/features/conversations"; -import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; - -export default function ConversationsScreen() { - const router = useRouter(); - const aiChatEnabled = usePreferencesStore((s) => s.aiChatEnabled); - - if (!aiChatEnabled) { - return ; - } - - const handleConversationPress = (conversation: ConversationDetail) => { - router.push(`/chat/${conversation.id}`); - }; - - const handleNewChat = () => { - router.push("/chat"); - }; - - return ( - - {/* Header */} - - - - - Conversations - - - Your PostHog AI chats - - - - - New chat - - - - - - {/* Conversation List */} - - - ); -} diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index c314e050a..a3a5eab65 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -33,7 +33,6 @@ interface RootLayoutNavProps { function RootLayoutNav({ isConnected }: RootLayoutNavProps) { const { isLoading, initializeAuth } = useAuthStore(); const isAuthenticated = useAuthStore((s) => s.isAuthenticated); - const aiChatEnabled = usePreferencesStore((s) => s.aiChatEnabled); const themeColors = useThemeColors(); const pathname = usePathname(); @@ -83,15 +82,6 @@ function RootLayoutNav({ isConnected }: RootLayoutNavProps) { - {/* Chat routes - only registered when AI chat feature is enabled. - Screens use a FloatingBackButton instead of the native header. */} - {aiChatEnabled && ( - <> - - - - )} - {/* Tinder-style inbox review */} diff --git a/apps/mobile/src/app/auth.tsx b/apps/mobile/src/app/auth.tsx index cd0b48f5b..4f5e79f14 100644 --- a/apps/mobile/src/app/auth.tsx +++ b/apps/mobile/src/app/auth.tsx @@ -52,7 +52,7 @@ export default function AuthScreen() { next.startsWith("/") && !next.startsWith("/auth") ? next - : "/(tabs)"; + : "/(tabs)/tasks"; router.replace(target); }, [next]); diff --git a/apps/mobile/src/app/chat/[id].tsx b/apps/mobile/src/app/chat/[id].tsx deleted file mode 100644 index 8d1e060b0..000000000 --- a/apps/mobile/src/app/chat/[id].tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { Text } from "@components/text"; -import { useLocalSearchParams, useRouter } from "expo-router"; -import { useCallback, useEffect, useState } from "react"; -import { ActivityIndicator, 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 { FloatingBackButton } from "@/components/FloatingBackButton"; -import { Composer, MessagesList, useChatStore } from "@/features/chat"; -import { useThemeColors } from "@/lib/theme"; - -export default function ChatDetailScreen() { - const { id } = useLocalSearchParams<{ id: string }>(); - const router = useRouter(); - const insets = useSafeAreaInsets(); - const themeColors = useThemeColors(); - const [loadError, setLoadError] = useState(null); - - const { - thread, - streamingActive, - conversationLoading, - askMax, - stopGeneration, - loadConversation, - resetThread, - } = useChatStore(); - - useEffect(() => { - if (!id) return; - - setLoadError(null); - loadConversation(id).catch((err) => { - console.error("Failed to load conversation:", err); - setLoadError("Failed to load conversation"); - }); - - return () => { - // Reset when leaving the screen - resetThread(); - }; - }, [id, loadConversation, resetThread]); - - const handleSend = useCallback( - async (message: string) => { - await askMax(message, id); - }, - [askMax, id], - ); - - const handleOpenTask = useCallback( - (taskId: string) => { - router.push(`/task/${taskId}`); - }, - [router], - ); - - const { height } = useReanimatedKeyboardAnimation(); - - // useReanimatedKeyboardAnimation returns negative height values - // e.g., -300 when keyboard is open, 0 when closed - const contentPosition = useAnimatedStyle(() => { - return { - transform: [{ translateY: height.value }], - }; - }, []); - - const inputContainerStyle = useAnimatedStyle(() => { - return { - marginBottom: height.value < 0 ? 12 : insets.bottom, - }; - }, [insets.bottom]); - - if (loadError) { - return ( - - - {loadError} - router.back()} - className="rounded-lg bg-gray-3 px-4 py-2" - > - Go back - - - ); - } - - if (conversationLoading && thread.length === 0) { - return ( - - - - Loading conversation... - - ); - } - - return ( - - - {streamingActive && ( - - - Stop - - - )} - - - {/* Fixed input at bottom */} - - - - - ); -} diff --git a/apps/mobile/src/app/chat/index.tsx b/apps/mobile/src/app/chat/index.tsx deleted file mode 100644 index 9e974edd0..000000000 --- a/apps/mobile/src/app/chat/index.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { Text } from "@components/text"; -import { useCallback } from "react"; -import { Pressable } 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 { FloatingBackButton } from "@/components/FloatingBackButton"; -import { Composer, MessagesList, useChatStore } from "@/features/chat"; - -export default function NewChatScreen() { - const insets = useSafeAreaInsets(); - const { thread, streamingActive, askMax, stopGeneration, resetThread } = - useChatStore(); - - const handleSend = useCallback( - async (message: string) => { - await askMax(message); - }, - [askMax], - ); - - const { height } = useReanimatedKeyboardAnimation(); - - // useReanimatedKeyboardAnimation returns negative height values - // e.g., -300 when keyboard is open, 0 when closed - const contentPosition = useAnimatedStyle(() => { - return { - transform: [{ translateY: height.value }], - }; - }, []); - - const inputContainerStyle = useAnimatedStyle(() => { - return { - marginBottom: height.value < 0 ? 12 : insets.bottom, - }; - }, [insets.bottom]); - - return ( - - - {/* Top-right Stop / New action that used to live in the header. */} - {(streamingActive || thread.length > 0) && ( - - - {streamingActive ? "Stop" : "New"} - - - )} - - - {/* Fixed input at bottom */} - - - - - ); -} diff --git a/apps/mobile/src/app/settings/index.tsx b/apps/mobile/src/app/settings/index.tsx index 91df70f6d..f8e9cceaa 100644 --- a/apps/mobile/src/app/settings/index.tsx +++ b/apps/mobile/src/app/settings/index.tsx @@ -83,8 +83,6 @@ 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 pushNotificationsEnabled = usePreferencesStore( @@ -264,21 +262,6 @@ export default function SettingsScreen() { /> - {/* Labs */} - - - } - /> - - {/* Integrations */} ; + type: "tool_call"; +} + interface AgentMessageProps { content: string; isLoading?: boolean; diff --git a/apps/mobile/src/features/chat/components/Composer.tsx b/apps/mobile/src/features/chat/components/Composer.tsx deleted file mode 100644 index 54e0fe10c..000000000 --- a/apps/mobile/src/features/chat/components/Composer.tsx +++ /dev/null @@ -1,237 +0,0 @@ -import { GlassContainer, GlassView } from "expo-glass-effect"; -import * as Haptics from "expo-haptics"; -import { ArrowUp, Microphone, Stop } from "phosphor-react-native"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { - ActivityIndicator, - Animated, - Easing, - Keyboard, - Platform, - TextInput, - TouchableOpacity, - View, -} from "react-native"; -import { useThemeColors } from "@/lib/theme"; -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(""); - - const appendTranscript = useCallback((transcript: string) => { - setMessage((prev) => (prev ? `${prev} ${transcript}` : transcript)); - }, []); - - const { status, startRecording, stopRecording, cancelRecording } = - useVoiceRecording({ onTranscript: appendTranscript }); - - const isRecording = status === "recording"; - const isTranscribing = status === "transcribing"; - - const handleSend = () => { - const trimmed = message.trim(); - if (!trimmed || disabled) return; - setMessage(""); - Keyboard.dismiss(); - onSend(trimmed); - }; - - const handleMicPress = async () => { - if (isRecording) { - await stopRecording(); - } else if (!isTranscribing) { - await startRecording(); - } - }; - - const handleMicLongPress = async () => { - if (isRecording) { - await cancelRecording(); - } - }; - - 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 ( - - {/* */} - - {/* Input field with pulsing border when it's the user's turn */} - - - - - - - - {/* Mic / Send / Stop button */} - - - {isTranscribing ? ( - - ) : canSend ? ( - - ) : isRecording || showStop ? ( - - ) : ( - - )} - - - - - ); - } -} diff --git a/apps/mobile/src/features/chat/components/FailureMessage.tsx b/apps/mobile/src/features/chat/components/FailureMessage.tsx deleted file mode 100644 index bd8a12c38..000000000 --- a/apps/mobile/src/features/chat/components/FailureMessage.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Text, View } from "react-native"; - -interface FailureMessageProps { - content?: string; -} - -export function FailureMessage({ content }: FailureMessageProps) { - return ( - - - - {content || "Something went wrong. Please try again."} - - - - ); -} diff --git a/apps/mobile/src/features/chat/components/MessagesList.tsx b/apps/mobile/src/features/chat/components/MessagesList.tsx deleted file mode 100644 index b2497044f..000000000 --- a/apps/mobile/src/features/chat/components/MessagesList.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { useEffect, useRef } from "react"; -import { - FlatList, - type StyleProp, - Text, - View, - type ViewStyle, -} from "react-native"; -import { - AssistantMessageType, - isArtifactMessage, - isAssistantMessage, - isHumanMessage, - isVisualizationArtifactContent, - type ThreadMessage, -} from "../types"; -import { AgentMessage } from "./AgentMessage"; -import { FailureMessage } from "./FailureMessage"; -import { HumanMessage } from "./HumanMessage"; -import { VisualizationArtifact } from "./VisualizationArtifact"; - -interface MessagesListProps { - messages: ThreadMessage[]; - contentContainerStyle?: StyleProp; - onOpenTask?: (taskId: string) => void; -} - -function MessageItem({ - item, - hasHumanMessageAfter, - onOpenTask, -}: { - item: ThreadMessage; - hasHumanMessageAfter: boolean; - onOpenTask?: (taskId: string) => void; -}) { - if (isHumanMessage(item)) { - return ; - } - - if (isAssistantMessage(item)) { - return ( - - ); - } - - if (item.type === AssistantMessageType.Failure) { - return ; - } - - if (isArtifactMessage(item) && isVisualizationArtifactContent(item.content)) { - return ; - } - - return null; -} - -export function MessagesList({ - messages, - contentContainerStyle, - onOpenTask, -}: MessagesListProps) { - const flatListRef = useRef(null); - - useEffect(() => { - if (messages.length > 0 && flatListRef.current) { - flatListRef.current.scrollToOffset({ offset: 0, animated: true }); - } - }, [messages.length]); - - const reversedMessages = [...messages].reverse(); - - return ( - { - // Use the message ID if available, otherwise create a stable key - // based on the original index in the non-reversed array - if (item.id) { - return item.id; - } - // For messages without IDs, use the original index (messages.length - 1 - index) - // combined with message type to create a more stable key - const originalIndex = messages.length - 1 - index; - return `msg-${originalIndex}-${item.type}`; - }} - inverted - renderItem={({ item, index }) => { - // List is inverted, so index 0 is the last message. Check if any message before this index (after in original order) is human. - const hasHumanMessageAfter = reversedMessages - .slice(0, index) - .some((m) => isHumanMessage(m)); - return ( - - ); - }} - contentContainerStyle={contentContainerStyle} - keyboardDismissMode="interactive" - keyboardShouldPersistTaps="handled" - showsVerticalScrollIndicator={false} - ListEmptyComponent={ - - - Start a conversation - - - Ask PostHog AI anything about your product data - - - } - /> - ); -} diff --git a/apps/mobile/src/features/chat/components/VisualizationArtifact.tsx b/apps/mobile/src/features/chat/components/VisualizationArtifact.tsx deleted file mode 100644 index ed08731bf..000000000 --- a/apps/mobile/src/features/chat/components/VisualizationArtifact.tsx +++ /dev/null @@ -1,259 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ActivityIndicator, Text, View } from "react-native"; -import { WebView, type WebViewMessageEvent } from "react-native-webview"; -import { useAuthStore } from "@/features/auth"; -import { useThemeColors } from "@/lib/theme"; -import type { - ArtifactMessage, - MessageStatus, - VisualizationArtifactContent, -} from "../types"; - -interface VisualizationArtifactProps { - message: ArtifactMessage & { status?: MessageStatus }; - content: VisualizationArtifactContent; -} - -export function VisualizationArtifact({ - message, - content, -}: VisualizationArtifactProps) { - const webViewRef = useRef(null); - const themeColors = useThemeColors(); - const [isLoading, setIsLoading] = useState(true); - const [hasError, setHasError] = useState(false); - const [errorMessage, setErrorMessage] = useState(null); - // biome-ignore lint/suspicious/noExplicitAny: Query results structure varies - const [queryResults, setQueryResults] = useState | null>( - content.cachedResults || null, - ); - - const cloudRegion = useAuthStore((state) => state.cloudRegion); - const projectId = useAuthStore((state) => state.projectId); - const accessToken = useAuthStore((state) => state.oauthAccessToken); - const { getCloudUrlFromRegion } = useAuthStore.getState(); - - const cloudUrl = cloudRegion ? getCloudUrlFromRegion(cloudRegion) : null; - const renderQueryUrl = cloudUrl ? `${cloudUrl}/render_query` : null; - - // Fetch query results from the API if not already cached - useEffect(() => { - if (queryResults || !cloudUrl || !projectId || !accessToken) { - return; - } - - const fetchQueryResults = async () => { - try { - const response = await fetch( - `${cloudUrl}/api/projects/${projectId}/query/`, - { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ query: content.query }), - }, - ); - - if (!response.ok) { - const errorText = await response.text(); - console.error("Query API error:", response.status, errorText); - setHasError(true); - setErrorMessage(`Query failed: ${response.status}`); - setIsLoading(false); - return; - } - - const data = await response.json(); - setQueryResults(data); - } catch (error) { - console.error("Failed to fetch query results:", error); - setHasError(true); - setErrorMessage("Failed to fetch data"); - setIsLoading(false); - } - }; - - fetchQueryResults(); - }, [cloudUrl, projectId, accessToken, content.query, queryResults]); - - // Build the query wrapped in InsightVizNode if needed - const wrappedQuery = useMemo(() => { - // If the query is already an InsightVizNode, use it directly - if (content.query.kind === "InsightVizNode") { - return content.query; - } - // Otherwise wrap it in InsightVizNode - return { - kind: "InsightVizNode", - source: content.query, - }; - }, [content.query]); - - // Build the payload to send to the WebView - const payload = useMemo( - () => ({ - query: wrappedQuery, - cachedResults: queryResults, - }), - [wrappedQuery, queryResults], - ); - - // JavaScript to inject that sends the payload via postMessage - const injectedJavaScript = useMemo(() => { - if (!renderQueryUrl || !queryResults) return ""; - const targetOrigin = new URL(renderQueryUrl).origin; - return ` - (function() { - // Force dark theme - document.body.setAttribute('theme', 'dark'); - - const payload = ${JSON.stringify(payload)}; - const targetOrigin = "${targetOrigin}"; - - function send() { - // Ensure dark theme persists - document.body.setAttribute('theme', 'dark'); - window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'ready' })); - window.postMessage(payload, targetOrigin); - } - - // Send immediately and also on load - if (document.readyState === 'complete') { - send(); - } else { - window.addEventListener('load', send); - } - - // Also try after a short delay to ensure the page is fully ready - setTimeout(send, 500); - setTimeout(send, 1500); - })(); - true; - `; - }, [payload, renderQueryUrl, queryResults]); - - const handleMessage = useCallback((event: WebViewMessageEvent) => { - try { - const data = JSON.parse(event.nativeEvent.data); - if (data.type === "ready") { - setIsLoading(false); - } - } catch { - // Ignore parse errors - } - }, []); - - const handleError = useCallback(() => { - setHasError(true); - setErrorMessage("Failed to load visualization"); - setIsLoading(false); - }, []); - - const handleLoadEnd = useCallback(() => { - // Give a bit more time for the chart to render - setTimeout(() => setIsLoading(false), 1000); - }, []); - - if (message.status !== "completed") { - return null; - } - - if (!renderQueryUrl || !projectId || !accessToken) { - return ( - - - - Unable to load visualization: Not authenticated - - - - ); - } - - if (hasError) { - return ( - - - - {errorMessage || "Failed to load visualization"} - - - - ); - } - - // Show loading while fetching query results - if (!queryResults) { - return ( - - - {content.name && ( - - - {content.name} - - - )} - - - - Fetching data... - - - - - ); - } - - return ( - - - {/* Header with title */} - {content.name && ( - - - {content.name} - - - )} - - {/* WebView container */} - - {isLoading && ( - - - - Loading visualization... - - - )} - - - - {/* Artifact name */} - {content.name && ( - - - {content.name} - - - )} - - - ); -} diff --git a/apps/mobile/src/features/chat/index.ts b/apps/mobile/src/features/chat/index.ts index cc4d7c8d1..8db47fa60 100644 --- a/apps/mobile/src/features/chat/index.ts +++ b/apps/mobile/src/features/chat/index.ts @@ -1,25 +1,18 @@ -// Chat feature - Core messaging functionality +// Shared chat-style UI primitives reused by tasks and inbox. The standalone +// PostHog AI conversations feature has been removed, but these components +// (markdown rendering, tool-call display, agent/human bubbles, voice input) +// are still used elsewhere. // Components export { AgentMessage } from "./components/AgentMessage"; -export { Composer } from "./components/Composer"; -export { FailureMessage } from "./components/FailureMessage"; export { HumanMessage } from "./components/HumanMessage"; -export { MessagesList } from "./components/MessagesList"; export type { ToolKind, ToolMessageProps, ToolStatus, } from "./components/ToolMessage"; export { deriveToolKind, ToolMessage } from "./components/ToolMessage"; -export { VisualizationArtifact } from "./components/VisualizationArtifact"; // Hooks export { usePeriodicRerender } from "./hooks/usePeriodicRerender"; export { useVoiceRecording } from "./hooks/useVoiceRecording"; - -// Store -export { useChatStore } from "./stores/chatStore"; - -// Types -export * from "./types"; diff --git a/apps/mobile/src/features/chat/stores/chatStore.ts b/apps/mobile/src/features/chat/stores/chatStore.ts deleted file mode 100644 index 74419ec02..000000000 --- a/apps/mobile/src/features/chat/stores/chatStore.ts +++ /dev/null @@ -1,505 +0,0 @@ -import { fetch } from "expo/fetch"; -import * as Crypto from "expo-crypto"; -import { create } from "zustand"; -import { useAuthStore } from "@/features/auth"; -import { logger } from "@/lib/logger"; -import { - AssistantEventType, - type AssistantGenerationStatusEvent, - AssistantGenerationStatusType, - AssistantMessageType, - type Conversation, - ConversationStatus, - isArtifactMessage, - isAssistantMessage, - isHumanMessage, - type RootAssistantMessage, - type ThreadMessage, -} from "../types"; - -// Generate a unique temporary ID for streaming messages -let tempIdCounter = 0; -function generateTempId(): string { - return `temp-${Date.now()}-${tempIdCounter++}`; -} - -const FAILURE_MESSAGE: ThreadMessage = { - type: AssistantMessageType.Failure, - content: - "Oops! It looks like I'm having trouble answering this. Could you please try again?", - status: "completed", -}; - -interface ChatState { - // Conversation state - conversation: Conversation | null; - thread: ThreadMessage[]; - - // Loading state - streamingActive: boolean; - conversationLoading: boolean; - - // Controller for aborting requests - abortController: AbortController | null; - - // Actions - askMax: (prompt: string, conversationId?: string) => Promise; - stopGeneration: () => void; - resetThread: () => void; - setConversation: (conversation: Conversation | null) => void; - loadConversation: (conversationId: string) => Promise; -} - -export const useChatStore = create((set, get) => ({ - conversation: { - id: Crypto.randomUUID(), - title: "New chat", - status: ConversationStatus.Idle, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }, - thread: [], - streamingActive: false, - conversationLoading: false, - abortController: null, - - askMax: async (prompt: string, conversationId?: string) => { - const authState = useAuthStore.getState(); - - if ( - !authState.isAuthenticated || - !authState.oauthAccessToken || - !authState.cloudRegion || - !authState.projectId - ) { - logger.error("Not authenticated"); - return; - } - - const state = get(); - - // Add human message immediately with a temp ID - const humanMessage: ThreadMessage = { - type: AssistantMessageType.Human, - content: prompt, - status: "completed", - id: generateTempId(), - }; - - // Add a loading assistant message placeholder with a temp ID for streaming - const loadingAssistantMessage: ThreadMessage = { - type: AssistantMessageType.Assistant, - content: "", - status: "loading", - id: generateTempId(), - }; - - set({ - thread: [...state.thread, humanMessage, loadingAssistantMessage], - streamingActive: true, - conversationLoading: true, - }); - - const abortController = new AbortController(); - set({ abortController }); - - try { - const cloudUrl = authState.getCloudUrlFromRegion(authState.cloudRegion); - const traceId = Crypto.randomUUID(); - - // Include conversation ID - prefer explicit param over store state, fallback to new UUID - const effectiveConversationId = - conversationId ?? get().conversation?.id ?? Crypto.randomUUID(); - - const requestBody: Record = { - content: prompt, - trace_id: traceId, - conversation: effectiveConversationId, - }; - const response = await fetch( - `${cloudUrl}/api/environments/${authState.projectId}/conversations/`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${authState.oauthAccessToken}`, - }, - body: JSON.stringify(requestBody), - signal: abortController.signal, - }, - ); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const reader = response.body?.getReader(); - if (!reader) { - throw new Error("No response body reader"); - } - - const decoder = new TextDecoder(); - let buffer = ""; - - while (true) { - const { done, value } = await reader.read(); - - if (done) { - break; - } - - buffer += decoder.decode(value, { stream: true }); - - // Parse SSE events from buffer - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; // Keep incomplete line in buffer - - for (const line of lines) { - if (line.startsWith("event:")) { - // Store event type for next data line - const _eventType = line.slice(6).trim(); - // Handle in conjunction with data line below - continue; - } - - if (line.startsWith("data:")) { - const data = line.slice(5).trim(); - if (!data) continue; - - // Try to extract event type from previous lines or parse event-data format - await processSSEEvent(data, set, get); - } - } - } - } catch (error) { - const errorMessage = (error as Error).message || ""; - const isAborted = - (error as Error).name === "AbortError" || - errorMessage.includes("canceled") || - errorMessage.includes("cancelled") || - errorMessage.includes("aborted"); - - if (isAborted) { - logger.debug("Request cancelled"); - } else { - logger.error("Stream error:", error); - const currentThread = get().thread; - const lastMessage = currentThread[currentThread.length - 1]; - - if (lastMessage?.status === "loading") { - set({ - thread: [ - ...currentThread.slice(0, -1), - { ...FAILURE_MESSAGE, id: Crypto.randomUUID() }, - ], - }); - } else { - set({ - thread: [ - ...currentThread, - { ...FAILURE_MESSAGE, id: Crypto.randomUUID() }, - ], - }); - } - } - } finally { - // Update conversation status - const currentConversation = get().conversation; - if (currentConversation) { - set({ - conversation: { - ...currentConversation, - status: ConversationStatus.Idle, - }, - }); - } - - set({ - streamingActive: false, - conversationLoading: false, - abortController: null, - }); - } - }, - - stopGeneration: () => { - const { abortController } = get(); - if (abortController) { - abortController.abort(); - } - set({ - streamingActive: false, - conversationLoading: false, - abortController: null, - }); - }, - - resetThread: () => { - get().stopGeneration(); - set({ - conversation: { - id: Crypto.randomUUID(), - title: "New chat", - status: ConversationStatus.Idle, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }, - thread: [], - }); - }, - - setConversation: (conversation: Conversation | null) => { - set({ conversation }); - }, - - loadConversation: async (conversationId: string) => { - const authState = useAuthStore.getState(); - - if ( - !authState.isAuthenticated || - !authState.oauthAccessToken || - !authState.cloudRegion || - !authState.projectId - ) { - logger.error("Not authenticated"); - return; - } - - set({ conversationLoading: true }); - - try { - const cloudUrl = authState.getCloudUrlFromRegion(authState.cloudRegion); - const response = await fetch( - `${cloudUrl}/api/environments/${authState.projectId}/conversations/${conversationId}/`, - { - headers: { - Authorization: `Bearer ${authState.oauthAccessToken}`, - }, - }, - ); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const data = await response.json(); - - // Convert messages to ThreadMessage format - // For ToolCallMessage, we keep its own status property - const thread: ThreadMessage[] = (data.messages || []).map( - (msg: RootAssistantMessage) => { - if (msg.type === AssistantMessageType.ToolCall) { - // ToolCallMessage has its own status, but ThreadMessage needs a MessageStatus - return { - ...msg, - status: "completed" as const, - } as ThreadMessage; - } - return { - ...msg, - status: "completed" as const, - }; - }, - ); - - set({ - conversation: { - id: data.id, - title: data.title || "Conversation", - status: data.status || ConversationStatus.Idle, - created_at: data.created_at || new Date().toISOString(), - updated_at: data.updated_at || new Date().toISOString(), - }, - thread, - }); - } catch (error) { - logger.error("Failed to load conversation:", error); - throw error; - } finally { - set({ conversationLoading: false }); - } - }, -})); - -// SSE Event processor -async function processSSEEvent( - rawData: string, - set: (partial: Partial) => void, - get: () => ChatState, -): Promise { - // The SSE format from PostHog is: event: \ndata: - // We need to parse both the event type and data - - let eventType: string | null = null; - let data: string = rawData; - - // Check if this is a combined event+data format - if (rawData.includes("\n")) { - const parts = rawData.split("\n"); - for (const part of parts) { - if (part.startsWith("event:")) { - eventType = part.slice(6).trim(); - } else if (part.startsWith("data:")) { - data = part.slice(5).trim(); - } - } - } - - // Try to parse as JSON - let parsed: unknown; - try { - parsed = JSON.parse(data); - } catch { - return; // Not valid JSON, skip - } - - if (!parsed || typeof parsed !== "object") { - return; - } - - const parsedObj = parsed as Record; - - // Detect event type from the data structure if not explicitly provided - if (!eventType) { - if ( - "status" in parsedObj && - "id" in parsedObj && - "created_at" in parsedObj - ) { - eventType = AssistantEventType.Conversation; - } else if ("type" in parsedObj) { - const type = parsedObj.type as string; - if ( - type === AssistantGenerationStatusType.Acknowledged || - type === AssistantGenerationStatusType.GenerationError - ) { - eventType = AssistantEventType.Status; - } else { - // Handle all message types including artifacts - eventType = AssistantEventType.Message; - } - } - } - - const state = get(); - - switch (eventType) { - case AssistantEventType.Conversation: { - const conversation = parsedObj as unknown as Conversation; - set({ - conversation: { - ...conversation, - title: conversation.title || "New chat", - }, - }); - break; - } - - case AssistantEventType.Message: { - const message = parsedObj as unknown as RootAssistantMessage; - // A message is "loading" if it has no ID or has a temp- prefix - const isLoadingMessage = !message.id || message.id.startsWith("temp-"); - const messageStatus = isLoadingMessage ? "loading" : "completed"; - const threadMessage = { - ...message, - status: messageStatus, - } as ThreadMessage; - - if (isHumanMessage(message)) { - // Find and replace the provisional human message (the one we added with a temp- ID) - const thread = state.thread; - const lastHumanIndex = [...thread] - .map((m, i) => [m, i] as const) - .reverse() - .find(([m]) => isHumanMessage(m))?.[1]; - - if (lastHumanIndex !== undefined) { - set({ - thread: [ - ...thread.slice(0, lastHumanIndex), - threadMessage, - ...thread.slice(lastHumanIndex + 1), - ], - }); - } else { - set({ thread: [...thread, threadMessage] }); - } - } else if ( - isAssistantMessage(message) || - isArtifactMessage(message) || - message.type === AssistantMessageType.Failure - ) { - // Check if a message with the same ID already exists - const existingMessageIndex = message.id - ? state.thread.findIndex((msg) => msg.id === message.id) - : -1; - - if (existingMessageIndex >= 0) { - // When streaming a message with an already-present ID, we simply replace it - // (primarily when streaming in-progress messages with a temp- ID) - set({ - thread: [ - ...state.thread.slice(0, existingMessageIndex), - threadMessage, - ...state.thread.slice(existingMessageIndex + 1), - ], - }); - } else if (isLoadingMessage) { - // When a new temp message is streamed for the first time, we need to replace - // the loading placeholder (if any), or append it - const lastMessage = state.thread[state.thread.length - 1]; - if (lastMessage?.status === "loading") { - // Replace the loading placeholder with the streaming message - set({ - thread: [...state.thread.slice(0, -1), threadMessage], - }); - } else { - // No loading placeholder, append the message - set({ thread: [...state.thread, threadMessage] }); - } - } else { - // When we get the completed messages at the end of a generation, - // we replace from the last completed message to arrive at the final state - const lastCompletedMessageIndex = state.thread.findLastIndex( - (msg) => msg.status === "completed", - ); - const replaceIndex = lastCompletedMessageIndex + 1; - - if (replaceIndex < state.thread.length) { - // Replace the message at replaceIndex - set({ - thread: [ - ...state.thread.slice(0, replaceIndex), - threadMessage, - ...state.thread.slice(replaceIndex + 1), - ], - }); - } else { - // No message to replace, just add - set({ thread: [...state.thread, threadMessage] }); - } - } - } - break; - } - - case AssistantEventType.Status: { - const statusEvent = - parsedObj as unknown as AssistantGenerationStatusEvent; - if (statusEvent.type === AssistantGenerationStatusType.GenerationError) { - const thread = state.thread; - const lastMessage = thread[thread.length - 1]; - - if (lastMessage?.status === "loading") { - set({ - thread: [ - ...thread.slice(0, -1), - { ...lastMessage, status: "error" }, - ], - }); - } - } - break; - } - } -} diff --git a/apps/mobile/src/features/chat/types.ts b/apps/mobile/src/features/chat/types.ts deleted file mode 100644 index 6b2bf21b4..000000000 --- a/apps/mobile/src/features/chat/types.ts +++ /dev/null @@ -1,177 +0,0 @@ -// Simplified types for PostHog AI conversation in mobile app -// Based on posthog/frontend/src/queries/schema/schema-assistant-messages.ts - -export enum AssistantMessageType { - Human = "human", - Assistant = "ai", - Artifact = "ai/artifact", - Failure = "ai/failure", - ToolCall = "ai/tool_call", -} - -/** Source of artifact - determines which model to fetch from */ -export enum ArtifactSource { - /** Artifact created by the agent (stored in AgentArtifact) */ - Artifact = "artifact", - /** Reference to a saved insight (stored in Insight model) */ - Insight = "insight", - /** Legacy visualization message converted to artifact (content stored inline in state) */ - State = "state", -} - -/** Type of artifact content */ -export enum ArtifactContentType { - /** Visualization artifact (chart, graph, etc.) */ - Visualization = "visualization", - /** Notebook */ - Notebook = "notebook", -} - -export enum AssistantEventType { - Status = "status", - Message = "message", - Conversation = "conversation", -} - -export enum AssistantGenerationStatusType { - Acknowledged = "ack", - GenerationError = "generation_error", -} - -export interface BaseAssistantMessage { - id?: string; -} - -export interface HumanMessage extends BaseAssistantMessage { - type: AssistantMessageType.Human; - content: string; -} - -export interface AssistantToolCall { - id: string; - name: string; - args: Record; - type: "tool_call"; -} - -export interface AssistantMessage extends BaseAssistantMessage { - type: AssistantMessageType.Assistant; - content: string; - tool_calls?: AssistantToolCall[]; - meta?: { - thinking?: Array<{ type: string; thinking: string }>; - }; -} - -export interface FailureMessage extends BaseAssistantMessage { - type: AssistantMessageType.Failure; - content?: string; -} - -export interface VisualizationArtifactContent { - content_type: ArtifactContentType.Visualization; - // biome-ignore lint/suspicious/noExplicitAny: Query can be any insight query type - query: Record; - name?: string | null; - description?: string | null; - // Cached results from the query execution - // biome-ignore lint/suspicious/noExplicitAny: Results structure varies by query type - cachedResults?: Record; -} - -export interface NotebookArtifactContent { - content_type: ArtifactContentType.Notebook; -} - -export type ArtifactContent = - | VisualizationArtifactContent - | NotebookArtifactContent; - -export interface ArtifactMessage extends BaseAssistantMessage { - type: AssistantMessageType.Artifact; - /** The ID of the artifact (short_id for both drafts and saved insights) */ - artifact_id: string; - /** Source of artifact - determines which model to fetch from */ - source: ArtifactSource; - /** Content of artifact */ - content: ArtifactContent; -} - -export type ToolCallStatus = "pending" | "running" | "completed" | "error"; - -export interface ToolCallMessage extends BaseAssistantMessage { - type: AssistantMessageType.ToolCall; - toolName: string; - toolCallId: string; - status: ToolCallStatus; - args?: Record; - result?: unknown; -} - -export type RootAssistantMessage = - | HumanMessage - | AssistantMessage - | ArtifactMessage - | FailureMessage - | FailureMessage - | ToolCallMessage; - -export type MessageStatus = "loading" | "completed" | "error"; - -export type ThreadMessage = RootAssistantMessage & { - status: MessageStatus; -}; - -export interface Conversation { - id: string; - title?: string | null; - created_at: string; - updated_at: string; - status: ConversationStatus; -} - -export enum ConversationStatus { - Idle = "idle", - InProgress = "in_progress", -} - -export interface AssistantGenerationStatusEvent { - type: AssistantGenerationStatusType; -} - -// Helper type guards -export function isHumanMessage( - message: RootAssistantMessage, -): message is HumanMessage { - return message.type === AssistantMessageType.Human; -} - -export function isAssistantMessage( - message: RootAssistantMessage, -): message is AssistantMessage { - return message.type === AssistantMessageType.Assistant; -} - -export function isFailureMessage( - message: RootAssistantMessage, -): message is FailureMessage { - return message.type === AssistantMessageType.Failure; -} - -export function isArtifactMessage( - message: RootAssistantMessage, -): message is ArtifactMessage { - return message.type === AssistantMessageType.Artifact; -} - -export function isVisualizationArtifactContent( - content: ArtifactContent, -): content is VisualizationArtifactContent { - return content.content_type === ArtifactContentType.Visualization; -} - -export function isToolCallMessage( - message: RootAssistantMessage, -): message is ToolCallMessage { - return message.type === AssistantMessageType.ToolCall; -} diff --git a/apps/mobile/src/features/conversations/api.ts b/apps/mobile/src/features/conversations/api.ts deleted file mode 100644 index 044ecd78d..000000000 --- a/apps/mobile/src/features/conversations/api.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { fetch } from "expo/fetch"; -import { getBaseUrl, getHeaders, getProjectId } from "@/lib/api"; -import type { ConversationDetail } from "./types"; - -export async function getConversations(): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - const headers = getHeaders(); - - const response = await fetch( - `${baseUrl}/api/environments/${projectId}/conversations/`, - { headers }, - ); - - if (!response.ok) { - throw new Error(`Failed to fetch conversations: ${response.statusText}`); - } - - const data = await response.json(); - return data.results ?? []; -} - -export async function getConversation( - conversationId: string, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - const headers = getHeaders(); - - const response = await fetch( - `${baseUrl}/api/environments/${projectId}/conversations/${conversationId}/`, - { headers }, - ); - - if (!response.ok) { - throw new Error(`Failed to fetch conversation: ${response.statusText}`); - } - - return await response.json(); -} diff --git a/apps/mobile/src/features/conversations/components/ConversationItem.tsx b/apps/mobile/src/features/conversations/components/ConversationItem.tsx deleted file mode 100644 index 4b36cd1dc..000000000 --- a/apps/mobile/src/features/conversations/components/ConversationItem.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { Text } from "@components/text"; -import { differenceInHours, format, formatDistanceToNow } from "date-fns"; -import { memo } from "react"; -import { Pressable, View } from "react-native"; -import { - AssistantMessageType, - type ConversationDetail, - ConversationStatus, - ConversationType, -} from "../types"; - -interface ConversationItemProps { - conversation: ConversationDetail; - onPress: (conversation: ConversationDetail) => void; -} - -const statusColorMap: Record = { - [ConversationStatus.Idle]: { bg: "bg-gray-5/20", text: "text-gray-9" }, - [ConversationStatus.InProgress]: { - bg: "bg-status-info/20", - text: "text-status-info", - }, -}; - -const typeDisplayMap: Record = { - [ConversationType.Chat]: "Chat", - [ConversationType.DeepResearch]: "Deep research", -}; - -function ConversationItemComponent({ - conversation, - onPress, -}: ConversationItemProps) { - const updatedAt = conversation.updated_at - ? new Date(conversation.updated_at) - : conversation.created_at - ? new Date(conversation.created_at) - : new Date(); - - const hoursSinceUpdated = differenceInHours(new Date(), updatedAt); - const timeDisplay = - hoursSinceUpdated < 24 - ? formatDistanceToNow(updatedAt, { addSuffix: true }) - : format(updatedAt, "MMM d"); - - const statusColors = - statusColorMap[conversation.status] || - statusColorMap[ConversationStatus.Idle]; - - // Get preview from first human message - const firstHumanMessage = conversation.messages?.find( - (m) => m.type === AssistantMessageType.Human, - ); - const preview = firstHumanMessage?.content || "No messages"; - - const messageCount = conversation.messages?.length || 0; - - return ( - onPress(conversation)} - className="border-gray-6 border-b px-3 py-3 active:bg-gray-3" - > - - {/* Type badge */} - - - {typeDisplayMap[conversation.type] || conversation.type} - - - - {/* Status Badge */} - {conversation.status === ConversationStatus.InProgress && ( - - In progress - - )} - - {/* Message count */} - - {messageCount} {messageCount === 1 ? "message" : "messages"} - - - - {/* Title */} - - {conversation.title || "Untitled conversation"} - - - {/* Preview */} - - {preview} - - - {/* Bottom row: agent mode + time */} - - - {conversation.agent_mode || "General"} - - {timeDisplay} - - - ); -} - -export const ConversationItem = memo(ConversationItemComponent); diff --git a/apps/mobile/src/features/conversations/components/ConversationList.tsx b/apps/mobile/src/features/conversations/components/ConversationList.tsx deleted file mode 100644 index 62435da6e..000000000 --- a/apps/mobile/src/features/conversations/components/ConversationList.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { Text } from "@components/text"; -import { - ActivityIndicator, - FlatList, - Pressable, - RefreshControl, - View, -} from "react-native"; -import { useThemeColors } from "@/lib/theme"; -import { useConversations } from "../hooks/useConversations"; -import type { ConversationDetail } from "../types"; -import { ConversationItem } from "./ConversationItem"; - -interface ConversationListProps { - onConversationPress?: (conversation: ConversationDetail) => void; -} - -export function ConversationList({ - onConversationPress, -}: ConversationListProps) { - const { conversations, isLoading, error, refetch } = useConversations(); - const themeColors = useThemeColors(); - - const handleConversationPress = (conversation: ConversationDetail) => { - onConversationPress?.(conversation); - }; - - const handleRetry = () => { - void refetch(); - }; - - if (error) { - return ( - - {error} - - Retry - - - ); - } - - if (isLoading && conversations.length === 0) { - return ( - - - Loading conversations... - - ); - } - - if (conversations.length === 0) { - return ( - - No conversations yet - - Start chatting with PostHog AI to see your conversations here - - - ); - } - - return ( - item.id} - renderItem={({ item }) => ( - - )} - refreshControl={ - - } - contentContainerStyle={{ paddingBottom: 100 }} - /> - ); -} diff --git a/apps/mobile/src/features/conversations/hooks/useConversations.ts b/apps/mobile/src/features/conversations/hooks/useConversations.ts deleted file mode 100644 index c80663c58..000000000 --- a/apps/mobile/src/features/conversations/hooks/useConversations.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { useAuthStore } from "@/features/auth"; -import { getConversation, getConversations } from "../api"; -import { sortConversationsByDate } from "../stores/conversationStore"; - -export const conversationKeys = { - all: ["conversations"] as const, - lists: () => [...conversationKeys.all, "list"] as const, - list: () => [...conversationKeys.lists()] as const, - details: () => [...conversationKeys.all, "detail"] as const, - detail: (id: string) => [...conversationKeys.details(), id] as const, -}; - -export function useConversations() { - const { projectId, oauthAccessToken } = useAuthStore(); - - const query = useQuery({ - queryKey: conversationKeys.list(), - queryFn: getConversations, - enabled: !!projectId && !!oauthAccessToken, - select: sortConversationsByDate, - }); - - return { - conversations: query.data ?? [], - isLoading: query.isLoading, - error: query.error?.message ?? null, - refetch: query.refetch, - }; -} - -export function useConversation(conversationId: string) { - const { projectId, oauthAccessToken } = useAuthStore(); - - return useQuery({ - queryKey: conversationKeys.detail(conversationId), - queryFn: () => getConversation(conversationId), - enabled: !!projectId && !!oauthAccessToken && !!conversationId, - }); -} diff --git a/apps/mobile/src/features/conversations/index.ts b/apps/mobile/src/features/conversations/index.ts deleted file mode 100644 index 923cc3b72..000000000 --- a/apps/mobile/src/features/conversations/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { getConversation, getConversations } from "./api"; -export { ConversationItem } from "./components/ConversationItem"; -export { ConversationList } from "./components/ConversationList"; -export { - conversationKeys, - useConversation, - useConversations, -} from "./hooks/useConversations"; -export * from "./types"; diff --git a/apps/mobile/src/features/conversations/stores/conversationStore.ts b/apps/mobile/src/features/conversations/stores/conversationStore.ts deleted file mode 100644 index ec0dc7ce0..000000000 --- a/apps/mobile/src/features/conversations/stores/conversationStore.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { ConversationDetail } from "../types"; - -export function sortConversationsByDate( - conversations: ConversationDetail[], -): ConversationDetail[] { - return [...conversations].sort((a, b) => { - const dateA = a.updated_at || a.created_at || ""; - const dateB = b.updated_at || b.created_at || ""; - return dateB.localeCompare(dateA); - }); -} diff --git a/apps/mobile/src/features/conversations/types.ts b/apps/mobile/src/features/conversations/types.ts deleted file mode 100644 index e09d4dfb0..000000000 --- a/apps/mobile/src/features/conversations/types.ts +++ /dev/null @@ -1,63 +0,0 @@ -export interface UserBasicType { - uuid: string; - distinct_id: string; - first_name: string; - last_name?: string; - email: string; -} - -export enum ConversationStatus { - Idle = "idle", - InProgress = "in_progress", -} - -export enum ConversationType { - Chat = "chat", - DeepResearch = "deep_research", -} - -export interface Conversation { - id: string; - user: UserBasicType; - status: ConversationStatus; - title: string | null; - created_at: string | null; - updated_at: string | null; - type: ConversationType; - has_unsupported_content?: boolean; - agent_mode?: string | null; -} - -export enum AssistantMessageType { - Human = "human", - Assistant = "ai", - ToolCall = "tool", - Failure = "ai/failure", -} - -export interface HumanMessage { - type: AssistantMessageType.Human; - content: string; - id?: string; -} - -export interface AssistantMessage { - type: AssistantMessageType.Assistant; - content: string; - id?: string; -} - -export interface FailureMessage { - type: AssistantMessageType.Failure; - content: string; - id?: string; -} - -export type RootAssistantMessage = - | HumanMessage - | AssistantMessage - | FailureMessage; - -export interface ConversationDetail extends Conversation { - messages: RootAssistantMessage[]; -} diff --git a/apps/mobile/src/features/preferences/stores/preferencesStore.ts b/apps/mobile/src/features/preferences/stores/preferencesStore.ts index 64d307ba3..7be276e1a 100644 --- a/apps/mobile/src/features/preferences/stores/preferencesStore.ts +++ b/apps/mobile/src/features/preferences/stores/preferencesStore.ts @@ -15,8 +15,6 @@ export type CompletionSound = export type InitialTaskMode = "plan" | "last_used"; interface PreferencesState { - aiChatEnabled: boolean; - setAiChatEnabled: (enabled: boolean) => void; pingsEnabled: boolean; setPingsEnabled: (enabled: boolean) => void; pushNotificationsEnabled: boolean; @@ -41,8 +39,6 @@ interface PreferencesState { export const usePreferencesStore = create()( persist( (set) => ({ - aiChatEnabled: false, - setAiChatEnabled: (enabled) => set({ aiChatEnabled: enabled }), pingsEnabled: true, setPingsEnabled: (enabled) => set({ pingsEnabled: enabled }), pushNotificationsEnabled: true, @@ -70,7 +66,6 @@ export const usePreferencesStore = create()( name: "posthog-preferences", storage: createJSONStorage(() => AsyncStorage), partialize: (state) => ({ - aiChatEnabled: state.aiChatEnabled, pingsEnabled: state.pingsEnabled, pushNotificationsEnabled: state.pushNotificationsEnabled, theme: state.theme, From 3ca028db367ad24f92e10de67afb7ddd5ab9d0aa Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 14 May 2026 11:01:55 -0400 Subject: [PATCH 60/94] feat(mobile): add watch app (#2148) --- apps/mobile/.gitignore | 2 +- apps/mobile/README.md | 48 + apps/mobile/app.json | 1 + .../native/ios/WatchTaskControlModule.m | 17 + .../native/ios/WatchTaskControlModule.swift | 208 +++ apps/mobile/native/watch/ExtensionInfo.plist | 36 + apps/mobile/native/watch/HapticsPolicy.swift | 44 + apps/mobile/native/watch/Info.plist | 30 + .../watch/PostHogCodeWatch.entitlements | 5 + .../native/watch/PostHogCodeWatchApp.swift | 13 + .../PostHogCodeWatchExtension.entitlements | 5 + apps/mobile/native/watch/TaskViews.swift | 1322 +++++++++++++++++ .../mobile/native/watch/WatchTaskModels.swift | 230 +++ apps/mobile/native/watch/WatchTaskStore.swift | 180 +++ apps/mobile/plugins/withWatchApp.js | 317 ++++ apps/mobile/src/app/_layout.tsx | 17 +- apps/mobile/src/app/task/[id].tsx | 36 +- .../features/tasks/components/TaskList.tsx | 18 +- .../tasks/composer/TaskChatComposer.tsx | 9 +- apps/mobile/src/features/tasks/index.ts | 11 + .../features/tasks/stores/taskSessionStore.ts | 545 ++++++- .../src/features/tasks/stores/taskStore.ts | 23 +- apps/mobile/src/features/tasks/types.ts | 285 +++- .../tasks/utils/watchTaskControl.test.ts | 79 + .../features/tasks/utils/watchTaskControl.ts | 850 +++++++++++ .../features/tasks/watchTaskControlBridge.ts | 79 + 26 files changed, 4397 insertions(+), 13 deletions(-) create mode 100644 apps/mobile/native/ios/WatchTaskControlModule.m create mode 100644 apps/mobile/native/ios/WatchTaskControlModule.swift create mode 100644 apps/mobile/native/watch/ExtensionInfo.plist create mode 100644 apps/mobile/native/watch/HapticsPolicy.swift create mode 100644 apps/mobile/native/watch/Info.plist create mode 100644 apps/mobile/native/watch/PostHogCodeWatch.entitlements create mode 100644 apps/mobile/native/watch/PostHogCodeWatchApp.swift create mode 100644 apps/mobile/native/watch/PostHogCodeWatchExtension.entitlements create mode 100644 apps/mobile/native/watch/TaskViews.swift create mode 100644 apps/mobile/native/watch/WatchTaskModels.swift create mode 100644 apps/mobile/native/watch/WatchTaskStore.swift create mode 100644 apps/mobile/plugins/withWatchApp.js create mode 100644 apps/mobile/src/features/tasks/utils/watchTaskControl.test.ts create mode 100644 apps/mobile/src/features/tasks/utils/watchTaskControl.ts create mode 100644 apps/mobile/src/features/tasks/watchTaskControlBridge.ts diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore index d914c328f..4b0a6ea62 100644 --- a/apps/mobile/.gitignore +++ b/apps/mobile/.gitignore @@ -37,5 +37,5 @@ yarn-error.* *.tsbuildinfo # generated native folders -/ios +/ios/* /android diff --git a/apps/mobile/README.md b/apps/mobile/README.md index 33a62ed8a..fa2443804 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -203,6 +203,54 @@ pnpm format The `--clean` flag removes existing `ios/` and `android/` directories before regenerating. +## Apple Watch companion + +The watchOS companion is a native SwiftUI target generated during Expo prebuild by the local config plugin at `plugins/withWatchApp.js`. + +Canonical native source lives outside generated iOS output: + +- `native/watch/` — SwiftUI watch app source, Info.plists, and entitlements +- `native/ios/` — iPhone WatchConnectivity bridge + +Generated output lives under `ios/`, including `ios/watch/`, `ios/PostHogCode/WatchTaskControlModule.*`, and `PostHogCode.xcodeproj/project.pbxproj`. + +### Watch architecture + +- iPhone remains the authenticated relay for the paired watch. +- Mobile derives compact task snapshots from task/session state and sends them through WatchConnectivity. +- Desktop-started local tasks work through the shared PostHog task run log/status backend, then iPhone relays to the watch. +- Watch actions send compact commands back to iPhone, which routes them through existing mobile commands (`permission_response`, `cancel`, retry/resume, and handoff URLs). +- Direct watch-to-Mac WatchConnectivity is not supported by Apple; Mac handoff uses `posthog-code://task/{taskId}/run/{taskRunId}`. + +### Rebuilding native watch targets + +```bash +cd apps/mobile +pnpm prebuild +# or, when regenerating native projects: +pnpm prebuild:clean +``` + +The `./plugins/withWatchApp` plugin copies native sources from `native/`, recreates the watch app/extension targets, and embeds them in the iOS app. If generated iOS files or Xcode targets drift, update `native/` and rerun prebuild instead of editing generated project files manually. + +### Running in simulators + +1. Open `ios/PostHogCode.xcworkspace` in Xcode. +2. Select the iOS app scheme with a paired iPhone + Apple Watch simulator destination. +3. Build/run the iOS app; Xcode should install the embedded watch app. +4. Sign in on iPhone and open or start a PostHog Code task. +5. Open the watch app and verify the mission overview, checklist, timeline, approvals, and blocker cards. + +### Verification checklist + +- Cloud task from phone/mac updates progress on watch. +- Desktop/local task shows a `Local` badge and receives progress through persisted task run logs. +- Approval card actions reach the existing permission response path. +- Stop maps to the existing cancel command; retry maps to resume/retry from iPhone. +- Open on iPhone uses `posthog://task/{taskId}`; Open on Mac uses `posthog-code://task/{taskId}/run/{taskRunId}`. +- Haptics fire once for approval needed, completion, failure/stale blockers, and action acceptance — not on every polling update. +- Intermittent connectivity shows cached mission state rather than raw errors/logs. + ## Build Profiles Defined in `eas.json`: diff --git a/apps/mobile/app.json b/apps/mobile/app.json index f1aa95036..99f81d5c0 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -72,6 +72,7 @@ "cameraPermission": "Allow PostHog to use your camera to scan QR codes for sign-in" } ], + "./plugins/withWatchApp", [ "expo-font", { diff --git a/apps/mobile/native/ios/WatchTaskControlModule.m b/apps/mobile/native/ios/WatchTaskControlModule.m new file mode 100644 index 000000000..7ab668aa2 --- /dev/null +++ b/apps/mobile/native/ios/WatchTaskControlModule.m @@ -0,0 +1,17 @@ +#import +#import + +@interface RCT_EXTERN_MODULE(WatchTaskControlModule, RCTEventEmitter) + +RCT_EXTERN_METHOD(isSupported:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(publishEnvelope:(NSDictionary *)envelope + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(sendUrgentUpdate:(NSDictionary *)envelope + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +@end diff --git a/apps/mobile/native/ios/WatchTaskControlModule.swift b/apps/mobile/native/ios/WatchTaskControlModule.swift new file mode 100644 index 000000000..fdafeb752 --- /dev/null +++ b/apps/mobile/native/ios/WatchTaskControlModule.swift @@ -0,0 +1,208 @@ +import Foundation +import React +import WatchConnectivity + +@objc(WatchTaskControlModule) +final class WatchTaskControlModule: RCTEventEmitter, WCSessionDelegate { + private var hasListeners = false + private var latestEnvelope: [String: Any]? + private var pendingCommands: [[String: Any]] = [] + + override init() { + super.init() + activateSessionIfAvailable() + } + + override static func requiresMainQueueSetup() -> Bool { + return false + } + + override func supportedEvents() -> [String]! { + return ["WatchTaskControlCommand"] + } + + override func startObserving() { + DispatchQueue.main.async { [weak self] in + self?.hasListeners = true + self?.flushPendingCommands() + } + } + + override func stopObserving() { + DispatchQueue.main.async { [weak self] in + self?.hasListeners = false + } + } + + @objc + func isSupported(_ resolve: RCTPromiseResolveBlock, rejecter reject: RCTPromiseRejectBlock) { + resolve(WCSession.isSupported()) + } + + @objc + func publishEnvelope( + _ envelope: NSDictionary, + resolver resolve: RCTPromiseResolveBlock, + rejecter reject: RCTPromiseRejectBlock + ) { + guard WCSession.isSupported() else { + resolve(false) + return + } + + let payload = sanitizeDictionary(envelope) + latestEnvelope = payload + let session = WCSession.default + activateSessionIfAvailable() + + do { + try session.updateApplicationContext(["type": "task_envelope", "payload": payload]) + resolve(true) + } catch { + reject( + "watch_context_failed", + "Failed to update watch application context: \(error.localizedDescription)", + error + ) + } + } + + @objc + func sendUrgentUpdate( + _ envelope: NSDictionary, + resolver resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock + ) { + guard WCSession.isSupported() else { + resolve(false) + return + } + + let payload = sanitizeDictionary(envelope) + latestEnvelope = payload + activateSessionIfAvailable() + + let message: [String: Any] = ["type": "task_envelope", "payload": payload] + do { + try WCSession.default.updateApplicationContext(message) + } catch { + reject( + "watch_context_failed", + "Failed to update watch application context: \(error.localizedDescription)", + error + ) + return + } + + if WCSession.default.isReachable { + WCSession.default.sendMessage(message, replyHandler: nil, errorHandler: nil) + } + resolve(true) + } + + private func activateSessionIfAvailable() { + guard WCSession.isSupported() else { return } + let session = WCSession.default + session.delegate = self + if session.activationState == .notActivated { + session.activate() + } + } + + private func emitCommand(_ command: [String: Any]) { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + guard self.hasListeners else { + self.pendingCommands.append(command) + return + } + self.sendEvent(withName: "WatchTaskControlCommand", body: command) + } + } + + private func flushPendingCommands() { + guard hasListeners, !pendingCommands.isEmpty else { return } + let commands = pendingCommands + pendingCommands.removeAll() + for command in commands { + sendEvent(withName: "WatchTaskControlCommand", body: command) + } + } + + private func sanitizeDictionary(_ dictionary: NSDictionary) -> [String: Any] { + var result: [String: Any] = [:] + for (key, value) in dictionary { + guard let key = key as? String, let sanitized = sanitizeValue(value) else { continue } + result[key] = sanitized + } + return result + } + + private func sanitizeArray(_ array: NSArray) -> [Any] { + array.compactMap { sanitizeValue($0) } + } + + private func sanitizeValue(_ value: Any) -> Any? { + if let dictionary = value as? NSDictionary { + return sanitizeDictionary(dictionary) + } + if let array = value as? NSArray { + return sanitizeArray(array) + } + if value is NSNull { + return nil + } + return value + } + + func session( + _ session: WCSession, + activationDidCompleteWith activationState: WCSessionActivationState, + error: Error? + ) { + if activationState == .activated, let latestEnvelope { + try? session.updateApplicationContext(["type": "task_envelope", "payload": latestEnvelope]) + } + } + + func sessionDidBecomeInactive(_ session: WCSession) {} + + func sessionDidDeactivate(_ session: WCSession) { + session.activate() + } + + func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { + handleMessage(message, replyHandler: nil) + } + + func session( + _ session: WCSession, + didReceiveMessage message: [String: Any], + replyHandler: @escaping ([String: Any]) -> Void + ) { + handleMessage(message, replyHandler: replyHandler) + } + + func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { + handleMessage(applicationContext, replyHandler: nil) + } + + private func handleMessage(_ message: [String: Any], replyHandler: (([String: Any]) -> Void)?) { + guard let type = message["type"] as? String else { + replyHandler?(["ok": false, "error": "missing_type"]) + return + } + + if type == "task_command" { + if let command = message["payload"] as? [String: Any] { + emitCommand(command) + replyHandler?(["ok": true]) + } else { + replyHandler?(["ok": false, "error": "missing_payload"]) + } + return + } + + replyHandler?(["ok": false, "error": "unknown_type"]) + } +} diff --git a/apps/mobile/native/watch/ExtensionInfo.plist b/apps/mobile/native/watch/ExtensionInfo.plist new file mode 100644 index 000000000..a9dfad322 --- /dev/null +++ b/apps/mobile/native/watch/ExtensionInfo.plist @@ -0,0 +1,36 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + PostHog Code + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionAttributes + + WKAppBundleIdentifier + com.posthog.code.mobile.watchkitapp + + NSExtensionPointIdentifier + com.apple.watchkit + + WKExtensionDelegateClassName + $(PRODUCT_MODULE_NAME).ExtensionDelegate + + diff --git a/apps/mobile/native/watch/HapticsPolicy.swift b/apps/mobile/native/watch/HapticsPolicy.swift new file mode 100644 index 000000000..2d8ab6c5c --- /dev/null +++ b/apps/mobile/native/watch/HapticsPolicy.swift @@ -0,0 +1,44 @@ +import Foundation +import WatchKit + +@MainActor +final class HapticsPolicy { + private var lastApprovalId: String? + private var lastTerminalTaskId: String? + private var lastFailureTaskId: String? + private var lastApprovalHapticAt: Date = .distantPast + private var lastActionHapticAt: Date = .distantPast + + private let approvalCooldown: TimeInterval = 20 + private let actionCooldown: TimeInterval = 0.6 + + func apply(snapshot: WatchTaskSnapshot) { + let now = Date() + + if let approval = snapshot.approval, + approval.id != lastApprovalId, + now.timeIntervalSince(lastApprovalHapticAt) > approvalCooldown { + lastApprovalId = approval.id + lastApprovalHapticAt = now + WKInterfaceDevice.current().play(.notification) + } + + if snapshot.status == "completed", snapshot.id != lastTerminalTaskId { + lastTerminalTaskId = snapshot.id + WKInterfaceDevice.current().play(.success) + } + + if (snapshot.status == "failed" || snapshot.status == "blocked" || snapshot.status == "stale"), + snapshot.id != lastFailureTaskId { + lastFailureTaskId = snapshot.id + WKInterfaceDevice.current().play(.failure) + } + } + + func actionAccepted() { + let now = Date() + guard now.timeIntervalSince(lastActionHapticAt) > actionCooldown else { return } + lastActionHapticAt = now + WKInterfaceDevice.current().play(.click) + } +} diff --git a/apps/mobile/native/watch/Info.plist b/apps/mobile/native/watch/Info.plist new file mode 100644 index 000000000..f7651b812 --- /dev/null +++ b/apps/mobile/native/watch/Info.plist @@ -0,0 +1,30 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + PostHog Code + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + WKCompanionAppBundleIdentifier + com.posthog.code.mobile + WKWatchKitApp + + WKWatchOnly + + + diff --git a/apps/mobile/native/watch/PostHogCodeWatch.entitlements b/apps/mobile/native/watch/PostHogCodeWatch.entitlements new file mode 100644 index 000000000..0c67376eb --- /dev/null +++ b/apps/mobile/native/watch/PostHogCodeWatch.entitlements @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/mobile/native/watch/PostHogCodeWatchApp.swift b/apps/mobile/native/watch/PostHogCodeWatchApp.swift new file mode 100644 index 000000000..5f148f7b4 --- /dev/null +++ b/apps/mobile/native/watch/PostHogCodeWatchApp.swift @@ -0,0 +1,13 @@ +import SwiftUI + +@main +struct PostHogCodeWatchApp: App { + @StateObject private var store = WatchTaskStore() + + var body: some Scene { + WindowGroup { + WatchHomeView() + .environmentObject(store) + } + } +} diff --git a/apps/mobile/native/watch/PostHogCodeWatchExtension.entitlements b/apps/mobile/native/watch/PostHogCodeWatchExtension.entitlements new file mode 100644 index 000000000..0c67376eb --- /dev/null +++ b/apps/mobile/native/watch/PostHogCodeWatchExtension.entitlements @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/mobile/native/watch/TaskViews.swift b/apps/mobile/native/watch/TaskViews.swift new file mode 100644 index 000000000..94f00c33b --- /dev/null +++ b/apps/mobile/native/watch/TaskViews.swift @@ -0,0 +1,1322 @@ +import SwiftUI +import WatchKit + +private let accent = Color.orange + +func formatElapsed(_ seconds: Int) -> String { + if seconds >= 3600 { return "\(seconds / 3600)h \((seconds % 3600) / 60)m" } + if seconds >= 60 { return "\(seconds / 60)m \(seconds % 60)s" } + return "\(seconds)s" +} + +func statusColor(_ status: String) -> Color { + switch status { + case "completed": return .green + case "failed": return .red + case "blocked", "waiting_for_approval", "stale": return .orange + case "running": return .secondary + case "connecting": return .blue + default: return .secondary + } +} + +func shortTime(_ milliseconds: TimeInterval?) -> String { + guard let milliseconds else { return "" } + let date = Date(timeIntervalSince1970: TimeInterval(milliseconds) / 1000) + let hours = Date().timeIntervalSince(date) / 3600 + if hours < 24 { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: date, relativeTo: Date()) + } + let formatter = DateFormatter() + formatter.setLocalizedDateFormatFromTemplate("MMM d") + return formatter.string(from: date) +} + +func taskRepositoryLabel(_ task: WatchTaskSnapshot) -> String { + let repo = task.repository?.trimmingCharacters(in: .whitespacesAndNewlines) + return repo?.isEmpty == false ? repo! : "No repository" +} + +struct TaskRepositorySection: Identifiable { + let id: String + let tasks: [WatchTaskSnapshot] +} + +func groupTasksByRepository(_ tasks: [WatchTaskSnapshot]) -> [TaskRepositorySection] { + let grouped = Dictionary(grouping: tasks, by: taskRepositoryLabel) + return grouped.map { label, tasks in + TaskRepositorySection( + id: label, + tasks: tasks.sorted { ($0.updatedAt ?? $0.generatedAt) > ($1.updatedAt ?? $1.generatedAt) } + ) + } + .sorted { lhs, rhs in + if lhs.id == "No repository" { return false } + if rhs.id == "No repository" { return true } + let lhsTime = lhs.tasks.first?.updatedAt ?? lhs.tasks.first?.generatedAt ?? 0 + let rhsTime = rhs.tasks.first?.updatedAt ?? rhs.tasks.first?.generatedAt ?? 0 + return lhsTime > rhsTime + } +} + +enum WatchTaskOrganizeMode: String, CaseIterable { + case byProject + case chronological +} + +enum WatchTaskSortMode: String, CaseIterable { + case updated + case created +} + +enum WatchTaskVisibility: String, CaseIterable { + case external + case internalOnly = "internal" + case all +} + +struct TasksRootView: View { + @EnvironmentObject private var store: WatchTaskStore + @State private var selectedTaskId: String? + @AppStorage("watch_task_organize_mode") private var organizeModeRaw = WatchTaskOrganizeMode.chronological.rawValue + @AppStorage("watch_task_sort_mode") private var sortModeRaw = WatchTaskSortMode.updated.rawValue + @AppStorage("watch_task_visibility") private var visibilityRaw = WatchTaskVisibility.external.rawValue + @AppStorage("watch_task_show_archived") private var showArchived = false + + private var organizeMode: WatchTaskOrganizeMode { + WatchTaskOrganizeMode(rawValue: organizeModeRaw) ?? .chronological + } + + private var sortMode: WatchTaskSortMode { + WatchTaskSortMode(rawValue: sortModeRaw) ?? .updated + } + + private var visibility: WatchTaskVisibility { + WatchTaskVisibility(rawValue: visibilityRaw) ?? .external + } + + private var visibleTasks: [WatchTaskSnapshot] { + store.tasks + .filter { task in + switch visibility { + case .external: return task.internal != true + case .internalOnly: return task.internal == true + case .all: return true + } + } + } + + private var activeTasks: [WatchTaskSnapshot] { + visibleTasks + .filter { $0.isArchived != true } + .sorted { taskSortTimestamp($0) > taskSortTimestamp($1) } + } + + private var archivedTasks: [WatchTaskSnapshot] { + guard showArchived else { return [] } + return visibleTasks + .filter { $0.isArchived == true } + .sorted { taskSortTimestamp($0) > taskSortTimestamp($1) } + } + + var body: some View { + NavigationStack { + if store.envelope?.isAuthenticated == false { + SignedOutWatchView(state: store.connectionState) + } else if store.tasks.isEmpty { + EmptyTasksView(state: store.connectionState) + } else { + List { + Button { promptForNewTask() } label: { + Label("New Task", systemImage: "plus") + } + .tint(accent) + + NavigationLink { TaskListSettingsView() } label: { + Label("Filter & Sort", systemImage: "line.3.horizontal.decrease.circle") + } + + if activeTasks.isEmpty && archivedTasks.isEmpty { + Text(showArchived ? "No matching tasks" : "No active tasks") + .font(.caption) + .foregroundStyle(.secondary) + } else { + if organizeMode == .byProject { + ForEach(groupTasksByRepository(activeTasks)) { section in + Section(header: ProjectSectionHeader(title: section.id, count: section.tasks.count)) { + ForEach(section.tasks) { task in + taskLink(task) + } + } + } + } else { + ForEach(activeTasks) { taskLink($0) } + } + + if !archivedTasks.isEmpty { + Section(header: ArchivedSectionHeader(count: archivedTasks.count)) { + ForEach(archivedTasks) { taskLink($0) } + } + } + } + } + .navigationTitle("Tasks") + .navigationDestination(item: $selectedTaskId) { id in + let task = store.tasks.first(where: { $0.id == id }) + TaskOverviewView(task: task) + } + .refreshable { + store.requestSnapshot() + } + } + } + } + + private func taskSortTimestamp(_ task: WatchTaskSnapshot) -> TimeInterval { + if sortMode == .created { return task.createdAt ?? task.generatedAt } + return task.updatedAt ?? task.generatedAt + } + + private func taskLink(_ task: WatchTaskSnapshot) -> some View { + Button { + selectedTaskId = task.id + } label: { + TaskRow(task: task, isActive: store.envelope?.activeTaskId == task.id) + } + .opacity(task.isArchived == true ? 0.45 : 1) + .contextMenu { + if task.isArchived == true { + Button("Restore") { sendArchiveCommand(task, type: "restore") } + } else { + Button("Archive", role: .destructive) { sendArchiveCommand(task, type: "archive") } + } + } + } + + private func sendArchiveCommand(_ task: WatchTaskSnapshot, type: String) { + store.send(command: WatchTaskCommand(id: UUID().uuidString, type: type, taskId: task.taskId, taskRunId: task.taskRunId, toolCallId: nil, optionId: nil, displayText: nil, answers: nil, customInput: nil, url: nil)) + } + + private func promptForNewTask() { + presentNewTaskInput { prompt in + store.send(command: WatchTaskCommand( + id: UUID().uuidString, + type: "create_task", + taskId: "new", + taskRunId: nil, + toolCallId: nil, + optionId: nil, + displayText: prompt, + answers: nil, + customInput: prompt, + url: nil + )) + } + } +} + +struct ConnectivityIndicator: View { + let state: String + + var body: some View { + HStack(spacing: 4) { + Circle() + .fill(state == "Live" || state == "Connected" || state == "Cached" ? .green : .orange) + .frame(width: 6, height: 6) + Text(state) + .font(.caption2) + .foregroundStyle(.secondary) + } + } +} + +struct SignedOutWatchView: View { + let state: String + + var body: some View { + VStack(spacing: 10) { + ConnectivityIndicator(state: state) + Image(systemName: "iphone.and.arrow.forward") + .font(.title2) + .foregroundStyle(accent) + Text("Sign in on iPhone").font(.headline) + Text("Open the PostHog app on your iPhone and log in to sync tasks to your watch.") + .font(.caption2) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding() + } +} + +struct EmptyTasksView: View { + @EnvironmentObject private var store: WatchTaskStore + let state: String + + var body: some View { + VStack(spacing: 10) { + ConnectivityIndicator(state: state) + Text("✨").font(.title2) + Text("No tasks yet").font(.headline) + Text("Create your first task to get PostHog working.") + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(0) + .multilineTextAlignment(.center) + Button(action: promptForNewTask) { + Label("New Task", systemImage: "plus") + } + .buttonStyle(.borderedProminent) + .tint(accent) + } + .padding() + } + + private func promptForNewTask() { + presentNewTaskInput { prompt in + store.send(command: WatchTaskCommand( + id: UUID().uuidString, + type: "create_task", + taskId: "new", + taskRunId: nil, + toolCallId: nil, + optionId: nil, + displayText: prompt, + answers: nil, + customInput: prompt, + url: nil + )) + } + } +} + +private let newTaskInputSuggestions = [ + "Create or update my CLAUDE.md file", + "Search for a TODO comment and fix it", + "Recommend areas to improve our tests", +] + +func presentNewTaskInput(includeSuggestions: Bool = true, _ completion: @escaping (String) -> Void) { + WKExtension.shared().visibleInterfaceController?.presentTextInputController( + withSuggestions: includeSuggestions ? newTaskInputSuggestions : nil, + allowedInputMode: .plain + ) { results in + let firstNonEmpty = results? + .compactMap { $0 as? String } + .first(where: { + $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + }) + guard let prompt = firstNonEmpty else { return } + completion(prompt) + } +} + +struct ProjectSectionHeader: View { + let title: String + let count: Int + + var body: some View { + HStack(spacing: 4) { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: 9, weight: .semibold)) + Text(title) + .font(.footnote) + .truncationMode(.middle) + .lineLimit(1) + Spacer() + Text("\(count)") + .font(.footnote) + .foregroundStyle(.secondary) + } + .listRowInsets(EdgeInsets()) + .foregroundStyle(.secondary) + } +} + +struct ArchivedSectionHeader: View { + let count: Int + + var body: some View { + HStack(spacing: 4) { + Image(systemName: "archivebox") + .font(.system(size: 9, weight: .semibold)) + Text("Archived") + .font(.system(size: 10, weight: .semibold, design: .rounded)) + Text("\(count)") + .font(.system(size: 9, weight: .medium, design: .rounded)) + .foregroundStyle(.secondary) + } + .foregroundStyle(.secondary) + } +} + +struct TaskRow: View { + let task: WatchTaskSnapshot + let isActive: Bool + + var body: some View { + HStack(alignment: .top, spacing: 8) { + TaskStatusDot(task: task) + .padding(.top, 2) + VStack(alignment: .leading, spacing: 3) { + HStack(alignment: .firstTextBaseline) { + Text(task.title).font(.caption).lineLimit(1) + Spacer(minLength: 4) + Text(shortTime(task.createdAt)) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } + if let subtitle = task.subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + HStack(spacing: 4) { + if task.isArchived == true { Text("Archived") } + if task.progress.total > 0 { Text("\(task.progress.completed)/\(task.progress.total)") } + } + .font(.system(size: 9)) + .foregroundStyle(.secondary) + } + } + } +} + +struct TaskStatusDot: View { + let task: WatchTaskSnapshot + + var body: some View { + ZStack { + Circle() + .stroke(statusColor(task.status).opacity(0.35), lineWidth: 2) + .frame(width: 14, height: 14) + if task.status == "running" || task.status == "connecting" { + ProgressView() + .progressViewStyle(.circular) + .tint(statusColor(task.status)) + .frame(width: 14, height: 14) + } else if task.status == "completed" { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 14)) + .foregroundStyle(statusColor(task.status)) + } else if task.status == "failed" || task.status == "blocked" { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 14)) + .foregroundStyle(statusColor(task.status)) + } else if task.status == "waiting_for_approval" { + Image(systemName: "exclamationmark.circle.fill") + .font(.system(size: 14)) + .foregroundStyle(statusColor(task.status)) + } else { + Circle() + .fill(statusColor(task.status)) + .frame(width: 7, height: 7) + } + } + } +} + +struct TaskListSettingsView: View { + @AppStorage("watch_task_organize_mode") private var organizeModeRaw = WatchTaskOrganizeMode.chronological.rawValue + @AppStorage("watch_task_sort_mode") private var sortModeRaw = WatchTaskSortMode.updated.rawValue + @AppStorage("watch_task_visibility") private var visibilityRaw = WatchTaskVisibility.external.rawValue + @AppStorage("watch_task_show_archived") private var showArchived = false + + var body: some View { + Form { + Picker("Group by", selection: $organizeModeRaw) { + Text("Project").tag(WatchTaskOrganizeMode.byProject.rawValue) + Text("None").tag(WatchTaskOrganizeMode.chronological.rawValue) + } + Picker("Sort by", selection: $sortModeRaw) { + Text("Updated").tag(WatchTaskSortMode.updated.rawValue) + Text("Created").tag(WatchTaskSortMode.created.rawValue) + } + Picker("Visibility", selection: $visibilityRaw) { + Text("External").tag(WatchTaskVisibility.external.rawValue) + Text("Internal").tag(WatchTaskVisibility.internalOnly.rawValue) + Text("All").tag(WatchTaskVisibility.all.rawValue) + } + Toggle("Show archived", isOn: $showArchived) + } + .navigationTitle("Filter") + } +} + +struct TaskOverviewView: View { + @EnvironmentObject private var store: WatchTaskStore + let task: WatchTaskSnapshot? + + private var currentTask: WatchTaskSnapshot? { + guard let task else { return nil } + return store.tasks.first(where: { $0.id == task.id }) ?? task + } + + var body: some View { + ScrollView { + if let task = currentTask { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + Text(task.title) + .font(.headline) + .lineLimit(3) + Text(task.statusText) + .font(.caption) + .foregroundStyle(statusColor(task.status)) + } + + CurrentTaskCard(task: task) + + VStack(spacing: 6) { + Button("Open on iPhone") { openOnPhone(task) } + Button { promptForTask(task) } label: { + Label("Tap to Speak", systemImage: "mic") + } + .buttonStyle(.borderedProminent) + .tint(accent) + if task.allowedActions.contains("stop") { + Button("Stop Agent") { stopAgent(task) }.tint(.red) + } + } + + if let approval = task.approval { ApprovalCard(task: task, approval: approval) } + if let blocker = task.blocker, task.approval == nil { BlockerCard(task: task, blocker: blocker) } + + if let status = store.lastCommandStatus { + Text(status).font(.caption2).foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } else { + ContentUnavailableView( + "Task unavailable", + systemImage: "exclamationmark.triangle", + description: Text("Pull to refresh tasks.") + ) + } + } + .navigationTitle("Task") + } + + private func openOnPhone(_ task: WatchTaskSnapshot) { + send(type: "open_phone", task: task, url: task.handoff.phoneUrl, prompt: nil) + } + + private func stopAgent(_ task: WatchTaskSnapshot) { + send(type: "stop", task: task, url: nil, prompt: nil) + } + + private func promptForTask(_ task: WatchTaskSnapshot) { + presentNewTaskInput(includeSuggestions: false) { prompt in + send(type: "open_task_prompt", task: task, url: task.handoff.phoneUrl, prompt: prompt) + } + } + + private func send(type: String, task: WatchTaskSnapshot, url: String?, prompt: String?) { + store.send(command: WatchTaskCommand( + id: UUID().uuidString, + type: type, + taskId: task.taskId, + taskRunId: task.taskRunId, + toolCallId: nil, + optionId: nil, + displayText: prompt, + answers: nil, + customInput: prompt, + url: url + )) + } +} + +struct TaskHeader: View { + let task: WatchTaskSnapshot + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top) { + if task.progress.total > 0 { + ZStack { + ProgressView(value: task.progress.fraction) + .progressViewStyle(.circular) + .tint(statusColor(task.status)) + Text("\(Int(task.progress.fraction * 100))") + .font(.system(size: 10, weight: .bold, design: .rounded)) + } + } + VStack(alignment: .leading, spacing: 2) { + if let slug = task.slug { + Text(slug).font(.caption2).foregroundStyle(.secondary) + } + Text(task.title).font(.headline).lineLimit(2) + Text(task.statusText).font(.caption).foregroundStyle(statusColor(task.status)) + } + } + HStack(spacing: 6) { + Chip(text: task.environment == "local" ? "Local" : task.environment.capitalized) + Chip(text: formatElapsed(task.elapsedSeconds)) + if task.isStale { Chip(text: "Stale", color: .orange) } + } + if task.progress.total > 0 { + Text("\(task.progress.completed)/\(task.progress.total) done") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } +} + +struct Chip: View { + let text: String + var color: Color = .secondary + + var body: some View { + Text(text) + .font(.caption2) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(color.opacity(0.18), in: Capsule()) + } +} + +struct CurrentTaskCard: View { + let task: WatchTaskSnapshot + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Current").font(.caption2).foregroundStyle(.secondary) + Text(task.currentTask ?? "Waiting for next step").font(.caption).lineLimit(3) + } + .padding(10) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12)) + } +} + +struct ChecklistView: View { + let task: WatchTaskSnapshot + + var body: some View { + List(task.checklist) { item in + HStack(alignment: .top, spacing: 8) { + Image(systemName: icon(for: item.status)) + .foregroundStyle(statusColor(item.status == "running" ? "running" : item.status)) + VStack(alignment: .leading, spacing: 2) { + Text(item.title).font(.caption) + if let subtitle = item.subtitle { + Text(subtitle).font(.caption2).foregroundStyle(.secondary).lineLimit(2) + } + } + } + } + .navigationTitle("Checklist") + } + + private func icon(for status: String) -> String { + switch status { + case "completed": return "checkmark.circle.fill" + case "running": return "arrow.triangle.2.circlepath.circle.fill" + case "failed": return "xmark.octagon.fill" + default: return "circle" + } + } +} + +struct TimelineView: View { + let task: WatchTaskSnapshot + + var body: some View { + List(task.timeline) { item in + VStack(alignment: .leading, spacing: 3) { + Text(item.title).font(.caption) + if let detail = item.detail { + Text(detail).font(.caption2).foregroundStyle(.secondary).lineLimit(3) + } + } + } + .navigationTitle("Timeline") + } +} + +struct ApprovalCard: View { + @EnvironmentObject private var store: WatchTaskStore + let task: WatchTaskSnapshot + let approval: WatchTaskApproval + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Label(approval.risk == "destructive" ? "High-risk approval" : "Approval needed", systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.orange) + Text(approval.summary).font(.caption).lineLimit(4) + if let detail = approval.detail { Text(detail).font(.caption2).foregroundStyle(.secondary).lineLimit(2) } + ForEach(approval.options) { option in + Button(option.title) { sendApproval(option) } + .buttonStyle(.borderedProminent) + .tint(option.role == "reject" ? .red : accent) + } + if approval.diffAvailable == true { + Button("View Diff") { send(type: "view_diff", url: task.handoff.phoneUrl) } + } + } + .padding(10) + .background(.orange.opacity(0.15), in: RoundedRectangle(cornerRadius: 12)) + } + + private func sendApproval(_ option: WatchTaskApprovalOption) { + store.send(command: WatchTaskCommand( + id: UUID().uuidString, + type: "approval_response", + taskId: task.taskId, + taskRunId: task.taskRunId, + toolCallId: approval.toolCallId, + optionId: option.id, + displayText: option.title, + answers: nil, + customInput: nil, + url: nil + )) + } + + private func send(type: String, url: String?) { + store.send(command: WatchTaskCommand(id: UUID().uuidString, type: type, taskId: task.taskId, taskRunId: task.taskRunId, toolCallId: nil, optionId: nil, displayText: nil, answers: nil, customInput: nil, url: url)) + } +} + +struct BlockerCard: View { + @EnvironmentObject private var store: WatchTaskStore + let task: WatchTaskSnapshot + let blocker: WatchTaskBlocker + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Label(blocker.title, systemImage: blocker.kind == "stale" ? "wifi.slash" : "xmark.octagon.fill") + .font(.caption) + .foregroundStyle(blocker.kind == "stale" ? .orange : .red) + if let detail = blocker.detail { Text(detail).font(.caption2).foregroundStyle(.secondary).lineLimit(4) } + HStack { + Button("Retry") { send(type: "retry") } + Button("Stop") { send(type: "stop") }.tint(.red) + } + } + .padding(10) + .background(.red.opacity(0.12), in: RoundedRectangle(cornerRadius: 12)) + } + + private func send(type: String) { + store.send(command: WatchTaskCommand(id: UUID().uuidString, type: type, taskId: task.taskId, taskRunId: task.taskRunId, toolCallId: nil, optionId: nil, displayText: nil, answers: nil, customInput: nil, url: nil)) + } +} + +struct HandoffButtons: View { + @EnvironmentObject private var store: WatchTaskStore + let task: WatchTaskSnapshot + + var body: some View { + VStack(spacing: 6) { + Button("Open on iPhone") { send(type: "open_phone", url: task.handoff.phoneUrl) } + Button("Send Demo Prompt") { sendDemoPrompt() } + if task.allowedActions.contains("stop") { Button("Stop Agent") { send(type: "stop", url: nil) }.tint(.red) } + } + } + + private func send(type: String, url: String?) { + store.send(command: WatchTaskCommand(id: UUID().uuidString, type: type, taskId: task.taskId, taskRunId: task.taskRunId, toolCallId: nil, optionId: nil, displayText: nil, answers: nil, customInput: nil, url: url)) + } + + private func sendDemoPrompt() { + store.send(command: WatchTaskCommand(id: UUID().uuidString, type: "send_prompt", taskId: task.taskId, taskRunId: task.taskRunId, toolCallId: nil, optionId: nil, displayText: "Demo prompt from Apple Watch", answers: nil, customInput: nil, url: nil)) + } +} + +struct WatchHomeView: View { + @EnvironmentObject private var store: WatchTaskStore + + var body: some View { + NavigationStack { + List { +// Button { store.requestSnapshot() } label: { +// Label("Refresh", systemImage: "arrow.clockwise") +// .font(.caption2) +// } +// .buttonStyle(.plain) + NavigationLink { TasksRootView() } label: { + Label { + VStack(alignment: .leading, spacing: 2) { + Text("Tasks") + Text("\(store.tasks.count) synced") + .font(.caption2) + .foregroundStyle(.secondary) + } + } icon: { + Image(systemName: "checklist") + } + } + NavigationLink { InboxListView() } label: { + Label { + VStack(alignment: .leading, spacing: 2) { + Text("Inbox") + Text("\((store.envelope?.inboxReports ?? []).count) reports") + .font(.caption2) + .foregroundStyle(.secondary) + } + } icon: { + Image(systemName: "tray") + } + } + NavigationLink { AutomationsListView() } label: { + Label { + VStack(alignment: .leading, spacing: 2) { + Text("Automations") + Text("\((store.envelope?.automations ?? []).count) synced") + .font(.caption2) + .foregroundStyle(.secondary) + } + } icon: { + Image(systemName: "clock.arrow.circlepath") + } + } + } + .navigationTitle("PostHog Code") + .refreshable { + store.requestSnapshot() + } + } + } +} + +enum WatchInboxSortMode: String, CaseIterable { + case priority + case updated + case strongest + + var label: String { + switch self { + case .priority: return "Priority" + case .updated: return "Updated" + case .strongest: return "Strongest" + } + } +} + +enum WatchInboxStatusFilter: String, CaseIterable { + case active + case ready + case needsInput + case all + + var label: String { + switch self { + case .active: return "Active" + case .ready: return "Ready" + case .needsInput: return "Needs input" + case .all: return "All" + } + } +} + +func inboxStatusColor(_ status: String) -> Color { + switch status { + case "ready": return .green + case "pending_input", "in_progress": return .orange + case "candidate": return .blue + case "failed": return .red + default: return .secondary + } +} + +func priorityRank(_ priority: String?) -> Int { + switch priority { + case "P0": return 0 + case "P1": return 1 + case "P2": return 2 + case "P3": return 3 + case "P4": return 4 + default: return 9 + } +} + +func sourceProductLabel(_ source: String) -> String { + source + .split(separator: "_") + .map { $0.prefix(1).uppercased() + $0.dropFirst() } + .joined(separator: " ") +} + +struct InboxListView: View { + @EnvironmentObject private var store: WatchTaskStore + @AppStorage("watch_inbox_sort_mode") private var sortModeRaw = WatchInboxSortMode.priority.rawValue + @AppStorage("watch_inbox_status_filter") private var statusFilterRaw = WatchInboxStatusFilter.active.rawValue + @AppStorage("watch_inbox_source_filter") private var sourceFilter = "all" + @AppStorage("watch_inbox_reviewer_filter") private var reviewerFilter = "all" + + private var sortMode: WatchInboxSortMode { + WatchInboxSortMode(rawValue: sortModeRaw) ?? .priority + } + + private var statusFilter: WatchInboxStatusFilter { + WatchInboxStatusFilter(rawValue: statusFilterRaw) ?? .active + } + + private var reports: [WatchInboxReportSnapshot] { + (store.envelope?.inboxReports ?? []) + .filter(matchesStatus) + .filter { sourceFilter == "all" || $0.sourceProducts.contains(sourceFilter) } + .filter { report in + if reviewerFilter == "all" { return true } + if reviewerFilter == "me" { return report.isSuggestedReviewer == true } + return report.suggestedReviewerUuids.contains(reviewerFilter) + } + .sorted(by: sortReports) + } + + var body: some View { + if store.envelope?.isAuthenticated == false { + SignedOutWatchView(state: store.connectionState) + } else { + List { + NavigationLink { InboxFilterView() } label: { + Label("Filter & Sort", systemImage: "line.3.horizontal.decrease.circle") + } + if reports.isEmpty { + Text("No matching reports") + .font(.caption) + .foregroundStyle(.secondary) + } else { + ForEach(reports) { report in + NavigationLink { InboxReportDetailView(report: report) } label: { + InboxReportRow(report: report) + } + } + } + } + .navigationTitle("Inbox") + .refreshable { + store.requestSnapshot() + } + } + } + + private func matchesStatus(_ report: WatchInboxReportSnapshot) -> Bool { + switch statusFilter { + case .active: + return ["ready", "pending_input", "in_progress", "failed", "candidate", "potential"].contains(report.status) + case .ready: + return report.status == "ready" + case .needsInput: + return report.status == "pending_input" || report.actionability == "requires_human_input" + case .all: + return true + } + } + + private func sortReports(_ lhs: WatchInboxReportSnapshot, _ rhs: WatchInboxReportSnapshot) -> Bool { + if lhs.isSuggestedReviewer == true && rhs.isSuggestedReviewer != true { return true } + if lhs.isSuggestedReviewer != true && rhs.isSuggestedReviewer == true { return false } + switch sortMode { + case .priority: + let lhsRank = priorityRank(lhs.priority) + let rhsRank = priorityRank(rhs.priority) + if lhsRank != rhsRank { return lhsRank < rhsRank } + return (lhs.updatedAt ?? 0) > (rhs.updatedAt ?? 0) + case .updated: + return (lhs.updatedAt ?? 0) > (rhs.updatedAt ?? 0) + case .strongest: + return lhs.totalWeight > rhs.totalWeight + } + } +} + +struct InboxReportRow: View { + let report: WatchInboxReportSnapshot + + var body: some View { + HStack(alignment: .top, spacing: 8) { + Circle() + .fill(inboxStatusColor(report.status)) + .frame(width: 9, height: 9) + .padding(.top, 4) + VStack(alignment: .leading, spacing: 3) { + HStack(alignment: .firstTextBaseline) { + Text(report.title).font(.caption).lineLimit(2) + Spacer(minLength: 4) + Text(shortTime(report.updatedAt)) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } + HStack(spacing: 4) { + Text(report.statusText) + if let priority = report.priority { Text(priority) } + if let actionability = report.actionabilityText { Text(actionability) } + if report.isSuggestedReviewer == true { Text("For you") } + } + .font(.system(size: 9)) + .foregroundStyle(.secondary) + HStack(spacing: 4) { + Image(systemName: "bolt.fill") + .font(.system(size: 8)) + Text("\(report.signalCount)") + if let firstSource = report.sourceProducts.first { Text(sourceProductLabel(firstSource)) } + } + .font(.system(size: 9)) + .foregroundStyle(.secondary) + } + } + } +} + +struct InboxFilterView: View { + @EnvironmentObject private var store: WatchTaskStore + @AppStorage("watch_inbox_sort_mode") private var sortModeRaw = WatchInboxSortMode.priority.rawValue + @AppStorage("watch_inbox_status_filter") private var statusFilterRaw = WatchInboxStatusFilter.active.rawValue + @AppStorage("watch_inbox_source_filter") private var sourceFilter = "all" + @AppStorage("watch_inbox_reviewer_filter") private var reviewerFilter = "all" + + private var sources: [String] { + Array(Set((store.envelope?.inboxReports ?? []).flatMap { $0.sourceProducts })).sorted() + } + + var body: some View { + Form { + Picker("Sort by", selection: $sortModeRaw) { + ForEach(WatchInboxSortMode.allCases, id: \.rawValue) { mode in + Text(mode.label).tag(mode.rawValue) + } + } + Picker("Status", selection: $statusFilterRaw) { + ForEach(WatchInboxStatusFilter.allCases, id: \.rawValue) { filter in + Text(filter.label).tag(filter.rawValue) + } + } + Picker("Source", selection: $sourceFilter) { + Text("All").tag("all") + ForEach(sources, id: \.self) { source in + Text(sourceProductLabel(source)).tag(source) + } + } + Picker("Reviewer", selection: $reviewerFilter) { + Text("All").tag("all") + Text("Me").tag("me") + ForEach(store.envelope?.inboxReviewers ?? []) { reviewer in + Text(reviewer.isMe == true ? "\(reviewer.name) (Me)" : reviewer.name) + .tag(reviewer.uuid) + } + } + } + .navigationTitle("Filter") + } +} + +struct InboxReportDetailView: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var store: WatchTaskStore + let report: WatchInboxReportSnapshot + + private var reportIds: String { + (store.envelope?.inboxReports ?? []) + .map(\.id) + .sorted() + .joined(separator: ",") + } + + private var primaryActionTitle: String? { + if report.allowedActions.contains("implement_as_task") { return "Implement as task" } + if report.allowedActions.contains("start_task") { return "Start task" } + return nil + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Chip(text: report.statusText, color: inboxStatusColor(report.status)) + if let priority = report.priority { Chip(text: priority, color: .orange) } + if report.isSuggestedReviewer == true { Chip(text: "For you", color: .orange) } + } + Text(report.title).font(.headline).lineLimit(3) + HStack(spacing: 4) { + Image(systemName: "bolt.fill") + .font(.system(size: 10)) + Text("\(report.signalCount) signal\(report.signalCount == 1 ? "" : "s")") + if let updatedAt = report.updatedAt { Text("· \(shortTime(updatedAt))") } + } + .font(.caption2) + .foregroundStyle(.secondary) + } + + if report.alreadyAddressed == true { + Label("May already be addressed", systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.orange) + } + + if let summary = report.summary, !summary.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Text("Summary").font(.caption2).foregroundStyle(.secondary) + Text(summary).font(.caption).lineLimit(10) + } + .padding(10) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12)) + } + + if !report.suggestedReviewers.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text("Suggested reviewers").font(.caption2).foregroundStyle(.secondary) + ForEach(report.suggestedReviewers) { reviewer in + HStack(spacing: 6) { + Image(systemName: reviewer.isMe == true ? "eye.fill" : "person.crop.circle") + .foregroundStyle(reviewer.isMe == true ? .orange : .secondary) + Text(reviewer.isMe == true ? "\(reviewer.name) (Me)" : reviewer.name) + .font(.caption) + .lineLimit(1) + } + } + } + } + + if !report.sourceProducts.isEmpty { + HStack(spacing: 4) { + ForEach(report.sourceProducts.prefix(3), id: \.self) { source in + Chip(text: sourceProductLabel(source)) + } + } + } + + VStack(spacing: 6) { + if let primaryActionTitle { + Button(primaryActionTitle) { + send(type: "start_report_task") + dismiss() + } + .buttonStyle(.borderedProminent) + .tint(accent) + } + if report.allowedActions.contains("dismiss") { + Button("Dismiss") { + sendDismiss() + dismiss() + } + .tint(.red) + } + Button("Open on iPhone") { send(type: "open_report") } + if let status = store.lastCommandStatus { + Text(status).font(.caption2).foregroundStyle(.secondary) + } + } + } + .padding(.vertical, 4) + } + .navigationTitle("Report") + .onChange(of: reportIds) { ids in + if !ids.split(separator: ",").contains(Substring(report.id)) { + dismiss() + } + } + } + + private func send(type: String) { + store.send(command: WatchTaskCommand( + id: UUID().uuidString, + type: type, + taskId: report.id, + url: report.handoff.phoneUrl, + reportId: report.id + )) + } + + private func sendDismiss() { + store.send(command: WatchTaskCommand( + id: UUID().uuidString, + type: "dismiss_report", + taskId: report.id, + optionId: "other", + displayText: "Dismiss report", + url: report.handoff.phoneUrl, + reportId: report.id + )) + } +} + +func automationStatusColor(_ automation: WatchAutomationSnapshot) -> Color { + if automation.statusText == "Running" || automation.statusText == "Queued" { return .blue } + if automation.statusText == "Failed" { return .red } + if automation.statusText == "Success" { return .green } + return automation.enabled ? .orange : .secondary +} + +struct AutomationsListView: View { + @EnvironmentObject private var store: WatchTaskStore + + private var automations: [WatchAutomationSnapshot] { + (store.envelope?.automations ?? []) + .sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) } + } + + var body: some View { + List { + Button { send(type: "new_automation", automationId: "new") } label: { + Label("New Automation", systemImage: "plus") + } + .tint(accent) + + if automations.isEmpty { + Text("No automations yet") + .font(.caption) + .foregroundStyle(.secondary) + } else { + ForEach(automations) { automation in + NavigationLink { AutomationDetailView(automation: automation) } label: { + AutomationRow(automation: automation) + } + } + } + } + .navigationTitle("Automations") + .toolbar { + Button { store.requestSnapshot() } label: { + Image(systemName: "arrow.clockwise") + .font(.system(size: 12, weight: .semibold)) + } + .buttonStyle(.plain) + .foregroundStyle(accent) + .accessibilityLabel("Refresh Automations") + } + } + + private func send(type: String, automationId: String) { + store.send(command: WatchTaskCommand( + id: UUID().uuidString, + type: type, + taskId: automationId, + automationId: automationId + )) + } +} + +struct AutomationRow: View { + let automation: WatchAutomationSnapshot + + var body: some View { + HStack(alignment: .top, spacing: 8) { + Circle() + .fill(automationStatusColor(automation)) + .frame(width: 9, height: 9) + .padding(.top, 4) + VStack(alignment: .leading, spacing: 3) { + HStack(alignment: .firstTextBaseline) { + Text(automation.name).font(.caption).lineLimit(1) + Spacer(minLength: 4) + Text(shortTime(automation.lastRunAt)) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } + HStack(spacing: 4) { + Text(automation.enabled ? "Enabled" : "Paused") + Text(automation.statusText) + } + .font(.system(size: 9)) + .foregroundStyle(.secondary) + Text(automation.secondaryLabel) + .font(.system(size: 9)) + .foregroundStyle(.secondary) + .lineLimit(1) + Text(automation.scheduleSummary) + .font(.system(size: 9)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } +} + +struct AutomationDetailView: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var store: WatchTaskStore + let automation: WatchAutomationSnapshot + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Chip(text: automation.enabled ? "Enabled" : "Paused", color: automation.enabled ? .orange : .secondary) + Chip(text: automation.statusText, color: automationStatusColor(automation)) + } + Text(automation.name).font(.headline).lineLimit(3) + Text(automation.scheduleSummary) + .font(.caption2) + .foregroundStyle(.secondary) + } + + VStack(alignment: .leading, spacing: 4) { + Text(automation.repository?.isEmpty == false ? "Repository" : "Context") + .font(.caption2) + .foregroundStyle(.secondary) + Text(automation.secondaryLabel) + .font(.caption) + .lineLimit(3) + } + .padding(10) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12)) + + VStack(alignment: .leading, spacing: 4) { + Text("Prompt").font(.caption2).foregroundStyle(.secondary) + Text(automation.prompt).font(.caption).lineLimit(8) + } + .padding(10) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12)) + + if let lastTaskId = automation.lastTaskId, !lastTaskId.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Text("Last task").font(.caption2).foregroundStyle(.secondary) + Text(lastTaskId).font(.caption).lineLimit(1) + } + } + + if let lastError = automation.lastError, !lastError.isEmpty { + Label(lastError, systemImage: "xmark.octagon.fill") + .font(.caption) + .foregroundStyle(.red) + .lineLimit(4) + } + + VStack(spacing: 6) { + Button("Run now") { sendAndDismiss(type: "run_automation") } + .buttonStyle(.borderedProminent) + .tint(accent) + if automation.allowedActions.contains("pause") { + Button("Pause") { sendAndDismiss(type: "pause_automation") } + .tint(.orange) + } + if automation.allowedActions.contains("resume") { + Button("Resume") { sendAndDismiss(type: "resume_automation") } + .tint(accent) + } + Button("Open on iPhone") { send(type: "open_automation") } + if let status = store.lastCommandStatus { + Text(status).font(.caption2).foregroundStyle(.secondary) + } + } + } + .padding(.vertical, 4) + } + .navigationTitle("Automation") + } + + private func send(type: String) { + store.send(command: WatchTaskCommand( + id: UUID().uuidString, + type: type, + taskId: automation.id, + url: automation.handoff.phoneUrl, + automationId: automation.id + )) + } + + private func sendAndDismiss(type: String) { + send(type: type) + dismiss() + } +} diff --git a/apps/mobile/native/watch/WatchTaskModels.swift b/apps/mobile/native/watch/WatchTaskModels.swift new file mode 100644 index 000000000..e9d862753 --- /dev/null +++ b/apps/mobile/native/watch/WatchTaskModels.swift @@ -0,0 +1,230 @@ +import Foundation + +struct WatchTaskEnvelope: Codable { + let schemaVersion: Int + let generatedAt: TimeInterval + let isAuthenticated: Bool? + let activeTaskId: String? + let tasks: [WatchTaskSnapshot] + let inboxReports: [WatchInboxReportSnapshot]? + let inboxReviewers: [WatchInboxReviewer]? + let automations: [WatchAutomationSnapshot]? + + enum CodingKeys: String, CodingKey { + case schemaVersion + case generatedAt + case isAuthenticated + case activeTaskId = "activeTaskId" + case tasks = "tasks" + case inboxReports = "inboxReports" + case inboxReviewers = "inboxReviewers" + case automations = "automations" + } +} + +struct WatchAutomationSnapshot: Codable, Identifiable { + let schemaVersion: Int + let id: String + let name: String + let prompt: String + let repository: String? + let templateName: String? + let secondaryLabel: String + let scheduleSummary: String + let cronExpression: String + let timezone: String? + let enabled: Bool + let statusText: String + let lastRunStatus: String? + let lastTaskRunStatus: String? + let lastRunAt: TimeInterval? + let lastTaskId: String? + let lastError: String? + let createdAt: TimeInterval? + let updatedAt: TimeInterval? + let allowedActions: [String] + let handoff: WatchTaskHandoff +} + +struct WatchInboxReviewer: Codable, Identifiable { + var id: String { uuid } + let uuid: String + let name: String + let email: String? + let githubLogin: String? + let isMe: Bool? +} + +struct WatchInboxSuggestedReviewer: Codable, Identifiable { + var id: String { uuid ?? githubLogin } + let uuid: String? + let name: String + let githubLogin: String + let isMe: Bool? +} + +struct WatchInboxReportSnapshot: Codable, Identifiable { + let schemaVersion: Int + let id: String + let title: String + let summary: String? + let status: String + let statusText: String + let priority: String? + let actionability: String? + let actionabilityText: String? + let alreadyAddressed: Bool? + let isSuggestedReviewer: Bool? + let suggestedReviewerUuids: [String] + let suggestedReviewers: [WatchInboxSuggestedReviewer] + let sourceProducts: [String] + let signalCount: Int + let totalWeight: Double + let createdAt: TimeInterval? + let updatedAt: TimeInterval? + let implementationPrUrl: String? + let repository: String? + let allowedActions: [String] + let handoff: WatchTaskHandoff +} + +struct WatchTaskSnapshot: Codable, Identifiable { + let schemaVersion: Int + let id: String + let generatedAt: TimeInterval + let source: String + let taskId: String + let taskRunId: String? + let taskNumber: Int? + let slug: String? + let title: String + let subtitle: String? + let repository: String? + let branch: String? + let `internal`: Bool? + let isArchived: Bool? + let environment: String + let status: String + let statusText: String + let currentTask: String? + let createdAt: TimeInterval? + let startedAt: TimeInterval? + let updatedAt: TimeInterval? + let completedAt: TimeInterval? + let elapsedSeconds: Int + let progress: WatchTaskProgress + let checklist: [WatchTaskChecklistItem] + let timeline: [WatchTaskTimelineItem] + let approval: WatchTaskApproval? + let blocker: WatchTaskBlocker? + let lastError: String? + let isStale: Bool + let staleReason: String? + let allowedActions: [String] + let handoff: WatchTaskHandoff +} + +struct WatchTaskProgress: Codable { + let completed: Int + let running: Int + let pending: Int + let failed: Int + let total: Int + let fraction: Double +} + +struct WatchTaskChecklistItem: Codable, Identifiable { + let id: String + let title: String + let subtitle: String? + let status: String + let priority: String? + let depth: Int? + let kind: String? + let updatedAt: TimeInterval? +} + +struct WatchTaskTimelineItem: Codable, Identifiable { + let id: String + let title: String + let detail: String? + let kind: String + let timestamp: TimeInterval +} + +struct WatchTaskApproval: Codable, Identifiable { + let id: String + let toolCallId: String + let title: String + let summary: String + let detail: String? + let risk: String + let requestedAt: TimeInterval + let options: [WatchTaskApprovalOption] + let diffAvailable: Bool? +} + +struct WatchTaskApprovalOption: Codable, Identifiable { + let id: String + let title: String + let role: String + let destructive: Bool? +} + +struct WatchTaskBlocker: Codable { + let title: String + let detail: String? + let kind: String +} + +struct WatchTaskHandoff: Codable { + let phoneUrl: String + let macUrl: String? + let webUrl: String? +} + +struct WatchTaskCommand: Codable { + let id: String + let type: String + let taskId: String + let taskRunId: String? + let toolCallId: String? + let optionId: String? + let displayText: String? + let answers: [String: String]? + let customInput: String? + let url: String? + let automationId: String? + let reportId: String? + let dismissalReason: String? + + init( + id: String, + type: String, + taskId: String, + taskRunId: String? = nil, + toolCallId: String? = nil, + optionId: String? = nil, + displayText: String? = nil, + answers: [String: String]? = nil, + customInput: String? = nil, + url: String? = nil, + automationId: String? = nil, + reportId: String? = nil, + dismissalReason: String? = nil + ) { + self.id = id + self.type = type + self.taskId = taskId + self.taskRunId = taskRunId + self.toolCallId = toolCallId + self.optionId = optionId + self.displayText = displayText + self.answers = answers + self.customInput = customInput + self.url = url + self.automationId = automationId + self.reportId = reportId + self.dismissalReason = dismissalReason + } +} diff --git a/apps/mobile/native/watch/WatchTaskStore.swift b/apps/mobile/native/watch/WatchTaskStore.swift new file mode 100644 index 000000000..dd7098383 --- /dev/null +++ b/apps/mobile/native/watch/WatchTaskStore.swift @@ -0,0 +1,180 @@ +import Foundation +import SwiftUI +import WatchConnectivity + +@MainActor +final class WatchTaskStore: NSObject, ObservableObject { + @Published var envelope: WatchTaskEnvelope? + @Published var connectionState: String = "Waiting for iPhone" + @Published var lastCommandStatus: String? + @Published var lastEnvelopeStatus: String? + + let haptics = HapticsPolicy() + private let cacheKey = "watch_task_envelope" + + var tasks: [WatchTaskSnapshot] { envelope?.tasks ?? [] } + + var activeTask: WatchTaskSnapshot? { + guard let envelope else { return tasks.first } + if let activeId = envelope.activeTaskId { + return envelope.tasks.first { $0.id == activeId } ?? envelope.tasks.first + } + return envelope.tasks.first + } + + override init() { + super.init() + loadCachedEnvelope() + activateSession() + requestSnapshot() + } + + func requestSnapshot() { + lastCommandStatus = "Requesting latest tasks…" + send(command: WatchTaskCommand( + id: UUID().uuidString, + type: "request_snapshot", + taskId: "snapshot", + taskRunId: nil, + toolCallId: nil, + optionId: nil, + displayText: "Request latest task snapshot", + answers: nil, + customInput: nil, + url: nil + )) + } + + func send(command: WatchTaskCommand) { + lastCommandStatus = "Sending…" + guard WCSession.isSupported() else { + lastCommandStatus = "iPhone relay unavailable" + return + } + + let payload = encodeDictionary(command) + let message: [String: Any] = ["type": "task_command", "payload": payload] + let session = WCSession.default + activateSession() + + if session.isReachable { + session.sendMessage(message, replyHandler: { [weak self] reply in + Task { @MainActor in + self?.haptics.actionAccepted() + self?.lastCommandStatus = (reply["ok"] as? Bool) == true ? "Sent" : "Failed" + } + }, errorHandler: { [weak self] _ in + Task { @MainActor in + self?.lastCommandStatus = "Failed" + } + }) + } else { + do { + try session.updateApplicationContext(message) + haptics.actionAccepted() + lastCommandStatus = "Queued for iPhone" + } catch { + lastCommandStatus = "Failed" + } + } + } + + private func activateSession() { + guard WCSession.isSupported() else { + connectionState = "WatchConnectivity unavailable" + return + } + WCSession.default.delegate = self + WCSession.default.activate() + } + + private func handleEnvelopePayload(_ payload: Any?) { + guard let payload else { + lastEnvelopeStatus = "Envelope missing payload" + return + } + do { + let data = try JSONSerialization.data(withJSONObject: payload) + let envelope = try JSONDecoder().decode(WatchTaskEnvelope.self, from: data) + self.envelope = envelope + self.connectionState = "Live" + self.lastEnvelopeStatus = "iPhone update received at \(Date().formatted(date: .omitted, time: .standard))" + if let activeId = envelope.activeTaskId, + let active = envelope.tasks.first(where: { $0.id == activeId }) { + haptics.apply(snapshot: active) + } else if let first = envelope.tasks.first { + haptics.apply(snapshot: first) + } + cache(data: data) + } catch { + connectionState = "Snapshot decode failed" + lastEnvelopeStatus = error.localizedDescription + } + } + + private func encodeDictionary(_ value: T) -> [String: Any] { + guard + let data = try? JSONEncoder().encode(value), + let object = try? JSONSerialization.jsonObject(with: data), + let dictionary = object as? [String: Any] + else { return [:] } + return dictionary + } + + private func cache(data: Data) { + UserDefaults.standard.set(data, forKey: cacheKey) + } + + private func loadCachedEnvelope() { + guard let data = UserDefaults.standard.data(forKey: cacheKey) else { return } + envelope = try? JSONDecoder().decode(WatchTaskEnvelope.self, from: data) + if envelope != nil { connectionState = "Cached" } + } +} + +extension WatchTaskStore: WCSessionDelegate { + nonisolated func session( + _ session: WCSession, + activationDidCompleteWith activationState: WCSessionActivationState, + error: Error? + ) { + Task { @MainActor in + self.connectionState = activationState == .activated ? "Connected" : "Disconnected" + if let payload = session.receivedApplicationContext["payload"] { + self.handleEnvelopePayload(payload) + } + if activationState == .activated { + self.requestSnapshot() + } + } + } + + nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { + Task { @MainActor in + guard applicationContext["type"] as? String == "task_envelope" else { return } + self.handleEnvelopePayload(applicationContext["payload"]) + } + } + + nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { + Task { @MainActor in + guard message["type"] as? String == "task_envelope" else { return } + self.handleEnvelopePayload(message["payload"]) + } + } + + nonisolated func session( + _ session: WCSession, + didReceiveMessage message: [String: Any], + replyHandler: @escaping ([String: Any]) -> Void + ) { + Task { @MainActor in + guard message["type"] as? String == "task_envelope" else { + replyHandler(["ok": false, "error": "unknown_type"]) + return + } + self.handleEnvelopePayload(message["payload"]) + replyHandler(["ok": true]) + } + } +} diff --git a/apps/mobile/plugins/withWatchApp.js b/apps/mobile/plugins/withWatchApp.js new file mode 100644 index 000000000..14911d923 --- /dev/null +++ b/apps/mobile/plugins/withWatchApp.js @@ -0,0 +1,317 @@ +const { withDangerousMod, withXcodeProject } = require("@expo/config-plugins"); +const fs = require("node:fs"); +const path = require("node:path"); + +const WATCH_APP_TARGET = "PostHogCodeWatch"; +const WATCH_EXTENSION_TARGET = "PostHogCodeWatchExtension"; +const WATCH_SOURCE_DIR = "watch"; +const WATCH_BUNDLE_ID = "com.posthog.code.mobile.watchkitapp"; +const WATCH_EXTENSION_BUNDLE_ID = `${WATCH_BUNDLE_ID}.watchkitextension`; + +function copyNativeSupportFiles(sourceDir, destinationDir) { + if (!fs.existsSync(sourceDir)) { + throw new Error(`Missing native source directory: ${sourceDir}`); + } + + fs.mkdirSync(destinationDir, { recursive: true }); + for (const entry of fs.readdirSync(sourceDir)) { + const source = path.join(sourceDir, entry); + const destination = path.join(destinationDir, entry); + if (!fs.statSync(source).isFile()) continue; + if (entry.endsWith(".swift") || entry.endsWith(".m")) { + fs.rmSync(destination, { force: true }); + continue; + } + fs.copyFileSync(source, destination); + } +} + +function ensureWatchScaffold(iosRoot) { + const mobileRoot = path.resolve(__dirname, ".."); + const nativeRoot = path.join(mobileRoot, "native"); + + copyNativeSupportFiles( + path.join(nativeRoot, "watch"), + path.join(iosRoot, WATCH_SOURCE_DIR), + ); + copyNativeSupportFiles( + path.join(nativeRoot, "ios"), + path.join(iosRoot, "PostHogCode"), + ); +} + +function quote(value) { + return `"${value}"`; +} + +function ensureBuildPhases(project, targetUuid) { + const target = project.hash.project.objects.PBXNativeTarget[targetUuid]; + const phaseNames = new Set( + (target.buildPhases ?? []).map((phase) => phase.comment), + ); + if (!phaseNames.has("Sources")) { + project.addBuildPhase([], "PBXSourcesBuildPhase", "Sources", targetUuid); + } + if (!phaseNames.has("Frameworks")) { + project.addBuildPhase( + [], + "PBXFrameworksBuildPhase", + "Frameworks", + targetUuid, + ); + } + if (!phaseNames.has("Resources")) { + project.addBuildPhase( + [], + "PBXResourcesBuildPhase", + "Resources", + targetUuid, + ); + } +} + +function targetUuid(project, targetName) { + const targets = project.hash.project.objects.PBXNativeTarget; + for (const [key, target] of Object.entries(targets)) { + if (key.endsWith("_comment")) continue; + const normalizedName = String(target.name ?? "").replace(/^"|"$/g, ""); + const normalizedProductName = String(target.productName ?? "").replace( + /^"|"$/g, + "", + ); + if (normalizedName === targetName || normalizedProductName === targetName) { + return key; + } + } + return undefined; +} + +function hasTarget(project, targetName) { + return !!targetUuid(project, targetName); +} + +function ensureDependencySections(project) { + const objects = project.hash.project.objects; + objects.PBXContainerItemProxy ??= {}; + objects.PBXTargetDependency ??= {}; +} + +function hasTargetDependency(project, target, dependencyTarget) { + const nativeTarget = project.hash.project.objects.PBXNativeTarget[target]; + const dependencies = nativeTarget?.dependencies ?? []; + const targetDependencies = + project.hash.project.objects.PBXTargetDependency ?? {}; + + return dependencies.some((dependency) => { + const targetDependency = targetDependencies[dependency.value]; + return targetDependency?.target === dependencyTarget; + }); +} + +function ensureTargetDependency(project, target, dependencyTarget) { + ensureDependencySections(project); + if (!hasTargetDependency(project, target, dependencyTarget)) { + project.addTargetDependency(target, [dependencyTarget]); + } +} + +function groupKeyByName(project, groupName) { + const groups = project.hash.project.objects.PBXGroup; + for (const [key, value] of Object.entries(groups)) { + if (key.endsWith("_comment")) continue; + if (groups[`${key}_comment`] === groupName || value.name === groupName) { + return key; + } + } + return undefined; +} + +function ensureSource(project, filePath, target, groupName = WATCH_SOURCE_DIR) { + const existing = project.hasFile(filePath); + if (existing) return; + project.addSourceFile( + filePath, + { target }, + groupKeyByName(project, groupName), + ); +} + +function ensureWatchSource(project, fileName, target) { + const watchGroupKey = groupKeyByName(project, WATCH_SOURCE_DIR); + project.removeSourceFile( + `${WATCH_SOURCE_DIR}/${fileName}`, + { target }, + watchGroupKey, + ); + ensureSource(project, fileName, target, WATCH_SOURCE_DIR); + setFileReferencePath(project, fileName, `../native/watch/${fileName}`); +} + +function setFileReferencePath(project, fileName, filePath) { + const fileReferences = project.hash.project.objects.PBXFileReference; + for (const [key, ref] of Object.entries(fileReferences)) { + if (key.endsWith("_comment")) continue; + const comment = fileReferences[`${key}_comment`]; + if ( + comment === fileName || + ref.name === fileName || + ref.path === fileName + ) { + ref.name = fileName; + ref.path = filePath; + ref.sourceTree = "SOURCE_ROOT"; + } + } +} + +function ensureResource( + project, + filePath, + target, + groupName = WATCH_SOURCE_DIR, +) { + const resources = project.pbxResourcesBuildPhaseObj(target)?.files ?? []; + if ( + resources.some((file) => file.comment?.startsWith(path.basename(filePath))) + ) { + return; + } + + project.addResourceFile( + filePath, + { target }, + groupKeyByName(project, groupName), + ); +} + +function updateTargetBuildSettings(project, targetUuid, settings) { + const target = project.hash.project.objects.PBXNativeTarget[targetUuid]; + const configListId = target?.buildConfigurationList; + const configList = + project.hash.project.objects.XCConfigurationList[configListId]; + if (!configList?.buildConfigurations) return; + + for (const item of configList.buildConfigurations) { + const config = + project.hash.project.objects.XCBuildConfiguration[item.value]; + if (!config?.buildSettings) continue; + Object.assign(config.buildSettings, settings); + } +} + +function addWatchTargets(project) { + if (!project.pbxGroupByName(WATCH_SOURCE_DIR)) { + const group = project.addPbxGroup([], WATCH_SOURCE_DIR, WATCH_SOURCE_DIR); + const mainGroupId = project.getFirstProject().firstProject.mainGroup; + const mainGroup = project.hash.project.objects.PBXGroup[mainGroupId]; + mainGroup.children.push({ value: group.uuid, comment: WATCH_SOURCE_DIR }); + } + + const watchApp = hasTarget(project, WATCH_APP_TARGET) + ? { uuid: targetUuid(project, WATCH_APP_TARGET) } + : project.addTarget( + WATCH_APP_TARGET, + "watch2_app", + WATCH_SOURCE_DIR, + WATCH_BUNDLE_ID, + ); + const watchExtension = hasTarget(project, WATCH_EXTENSION_TARGET) + ? { uuid: targetUuid(project, WATCH_EXTENSION_TARGET) } + : project.addTarget( + WATCH_EXTENSION_TARGET, + "watch2_extension", + WATCH_SOURCE_DIR, + WATCH_EXTENSION_BUNDLE_ID, + ); + + ensureBuildPhases(project, watchApp.uuid); + ensureBuildPhases(project, watchExtension.uuid); + + const hostTargetUuid = project.getFirstTarget().uuid; + ensureTargetDependency(project, hostTargetUuid, watchApp.uuid); + ensureTargetDependency(project, watchApp.uuid, watchExtension.uuid); + + ensureSource( + project, + "PostHogCode/WatchTaskControlModule.swift", + hostTargetUuid, + "PostHogCode", + ); + setFileReferencePath( + project, + "WatchTaskControlModule.swift", + "../native/ios/WatchTaskControlModule.swift", + ); + ensureSource( + project, + "PostHogCode/WatchTaskControlModule.m", + hostTargetUuid, + "PostHogCode", + ); + setFileReferencePath( + project, + "WatchTaskControlModule.m", + "../native/ios/WatchTaskControlModule.m", + ); + + ensureWatchSource(project, "PostHogCodeWatchApp.swift", watchExtension.uuid); + ensureWatchSource(project, "WatchTaskStore.swift", watchExtension.uuid); + ensureWatchSource(project, "WatchTaskModels.swift", watchExtension.uuid); + ensureWatchSource(project, "TaskViews.swift", watchExtension.uuid); + ensureWatchSource(project, "HapticsPolicy.swift", watchExtension.uuid); + + ensureResource( + project, + "PostHogCode/posthog.icon", + watchApp.uuid, + "PostHogCode", + ); + + updateTargetBuildSettings(project, watchApp.uuid, { + ASSETCATALOG_COMPILER_APPICON_NAME: "posthog", + CODE_SIGN_ENTITLEMENTS: `${WATCH_SOURCE_DIR}/PostHogCodeWatch.entitlements`, + CURRENT_PROJECT_VERSION: "1", + INFOPLIST_FILE: `${WATCH_SOURCE_DIR}/Info.plist`, + LD_RUNPATH_SEARCH_PATHS: quote("$(inherited) @executable_path/Frameworks"), + MARKETING_VERSION: "1.0", + PRODUCT_BUNDLE_IDENTIFIER: WATCH_BUNDLE_ID, + PRODUCT_NAME: WATCH_APP_TARGET, + SDKROOT: "watchos", + SKIP_INSTALL: "YES", + SWIFT_VERSION: "5.0", + TARGETED_DEVICE_FAMILY: "4", + WATCHOS_DEPLOYMENT_TARGET: "10.0", + }); + + updateTargetBuildSettings(project, watchExtension.uuid, { + CODE_SIGN_ENTITLEMENTS: `${WATCH_SOURCE_DIR}/PostHogCodeWatchExtension.entitlements`, + CURRENT_PROJECT_VERSION: "1", + INFOPLIST_FILE: `${WATCH_SOURCE_DIR}/ExtensionInfo.plist`, + LD_RUNPATH_SEARCH_PATHS: quote("$(inherited) @executable_path/Frameworks"), + MARKETING_VERSION: "1.0", + PRODUCT_BUNDLE_IDENTIFIER: WATCH_EXTENSION_BUNDLE_ID, + PRODUCT_NAME: WATCH_EXTENSION_TARGET, + SDKROOT: "watchos", + SKIP_INSTALL: "YES", + SWIFT_VERSION: "5.0", + TARGETED_DEVICE_FAMILY: "4", + WATCHOS_DEPLOYMENT_TARGET: "10.0", + }); + + return project; +} + +module.exports = function withWatchApp(config) { + config = withDangerousMod(config, [ + "ios", + (config) => { + ensureWatchScaffold(config.modRequest.platformProjectRoot); + return config; + }, + ]); + + return withXcodeProject(config, (config) => { + config.modResults = addWatchTargets(config.modResults); + return config; + }); +}; diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index a3a5eab65..de6e3e881 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -17,6 +17,7 @@ import { import { useAuthStore } from "@/features/auth"; import { setupNotificationResponseListener } from "@/features/notifications/lib/notifications"; import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; +import { useTaskSessionStore } from "@/features/tasks/stores/taskSessionStore"; import { useNetworkStatus } from "@/hooks/useNetworkStatus"; import { POSTHOG_API_KEY, @@ -32,7 +33,10 @@ interface RootLayoutNavProps { function RootLayoutNav({ isConnected }: RootLayoutNavProps) { const { isLoading, initializeAuth } = useAuthStore(); - const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const _aiChatEnabled = usePreferencesStore((s) => s.aiChatEnabled); + const publishWatchSnapshot = useTaskSessionStore( + (s) => s.publishWatchSnapshot, + ); const themeColors = useThemeColors(); const pathname = usePathname(); @@ -43,8 +47,13 @@ function RootLayoutNav({ isConnected }: RootLayoutNavProps) { }, [initializeAuth]); useEffect(() => { - return setupNotificationResponseListener(({ path }) => { - router.push(path); + if (isLoading) return; + publishWatchSnapshot({ urgent: true }); + }, [isLoading, publishWatchSnapshot]); + + useEffect(() => { + return setupNotificationResponseListener(({ taskId }) => { + router.push(`/task/${taskId}`); }); }, []); @@ -58,7 +67,7 @@ function RootLayoutNav({ isConnected }: RootLayoutNavProps) { if (!pathname || pathname === "/auth") return; const next = pathname !== "/" ? pathname : undefined; router.replace(next ? { pathname: "/auth", params: { next } } : "/auth"); - }, [isAuthenticated, isLoading, pathname]); + }, [isLoading, pathname]); if (isLoading) { return ( diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 1b942e9c8..e6c781705 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -42,15 +42,21 @@ import { useThemeColors } from "@/lib/theme"; const log = logger.scope("task-detail"); +function getFirstParam(value?: string | string[]): string | undefined { + return Array.isArray(value) ? value[0] : value; +} + export default function TaskDetailScreen() { const { id: taskId, fromAutomation, automationName, + prompt: initialPrompt, } = useLocalSearchParams<{ id: string; fromAutomation?: string; automationName?: string; + prompt?: string; }>(); const router = useRouter(); const queryClient = useQueryClient(); @@ -70,13 +76,18 @@ export default function TaskDetailScreen() { setConfigOption, getSessionForTask, setFocusedTaskId, + setActiveWatchTask, } = useTaskSessionStore(); useEffect(() => { if (!taskId) return; setFocusedTaskId(taskId); - return () => setFocusedTaskId(null); - }, [taskId, setFocusedTaskId]); + setActiveWatchTask(taskId); + return () => { + setFocusedTaskId(null); + setActiveWatchTask(undefined); + }; + }, [taskId, setFocusedTaskId, setActiveWatchTask]); const session = taskId ? getSessionForTask(taskId) : undefined; @@ -86,7 +97,15 @@ export default function TaskDetailScreen() { const composerConfig = useTaskStore((s) => taskId ? s.composerConfigByTaskId[taskId] : undefined, ); + const pendingPrompt = useTaskStore((s) => + taskId ? s.pendingPromptByTaskId[taskId] : undefined, + ); const setComposerConfig = useTaskStore((s) => s.setComposerConfig); + const setPendingPrompt = useTaskStore((s) => s.setPendingPrompt); + const consumePendingPrompt = useTaskStore((s) => s.consumePendingPrompt); + const [initialComposerMessage, setInitialComposerMessage] = useState< + string | undefined + >(); const composerMode: ExecutionMode = composerConfig?.mode ?? DEFAULT_EXECUTION_MODE; const composerModel = composerConfig?.model ?? DEFAULT_MODEL; @@ -112,6 +131,18 @@ export default function TaskDetailScreen() { }; }, [insets.bottom]); + useEffect(() => { + if (!taskId) return; + const prompt = getFirstParam(initialPrompt)?.trim(); + if (prompt) setPendingPrompt(taskId, prompt); + }, [taskId, initialPrompt, setPendingPrompt]); + + useEffect(() => { + if (!taskId || !pendingPrompt) return; + const prompt = consumePendingPrompt(taskId); + if (prompt) setInitialComposerMessage(prompt); + }, [taskId, pendingPrompt, consumePendingPrompt]); + useEffect(() => { if (!taskId) return; @@ -516,6 +547,7 @@ export default function TaskDetailScreen() { placeholder={ session?.terminalStatus ? "Resume this task..." : "Ask a question" } + initialMessage={initialComposerMessage} mode={composerMode} model={composerModel} reasoning={composerReasoning} diff --git a/apps/mobile/src/features/tasks/components/TaskList.tsx b/apps/mobile/src/features/tasks/components/TaskList.tsx index 43aaae1c1..9c2919558 100644 --- a/apps/mobile/src/features/tasks/components/TaskList.tsx +++ b/apps/mobile/src/features/tasks/components/TaskList.tsx @@ -1,6 +1,6 @@ import { Text } from "@components/text"; import { CaretRight, GitBranch } from "phosphor-react-native"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { ActivityIndicator, FlatList, @@ -12,6 +12,7 @@ import { useThemeColors } from "@/lib/theme"; import { useIntegrations } from "../hooks/useIntegrations"; import { useTasks } from "../hooks/useTasks"; import { useArchivedTasksStore } from "../stores/archivedTasksStore"; +import { useTaskSessionStore } from "../stores/taskSessionStore"; import { taskActivityTimestamp, useTaskStore } from "../stores/taskStore"; import type { Task } from "../types"; import { GitHubConnectionPrompt } from "./GitHubConnectionPrompt"; @@ -41,7 +42,7 @@ function CreateTaskEmptyState({ onCreateTask }: CreateTaskEmptyStateProps) { No tasks yet - Create your first task to get PostHog working. + Create a task to get started {onCreateTask && ( state.registerWatchTask, + ); + const publishWatchSnapshot = useTaskSessionStore( + (state) => state.publishWatchSnapshot, + ); const organizeMode = useTaskStore((s) => s.organizeMode); const sortMode = useTaskStore((s) => s.sortMode); const [archivedExpanded, setArchivedExpanded] = useState(false); const [scrollEnabled, setScrollEnabled] = useState(true); + useEffect(() => { + for (const task of tasks) { + registerWatchTask(task); + } + publishWatchSnapshot({ urgent: true }); + }, [tasks, registerWatchTask, publishWatchSnapshot]); + const handleTaskPress = (task: Task) => { onTaskPress?.(task.id); }; diff --git a/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx b/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx index 9a0730698..327666fea 100644 --- a/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx +++ b/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx @@ -62,6 +62,7 @@ interface TaskChatComposerProps { onStop?: () => void; disabled?: boolean; placeholder?: string; + initialMessage?: string; isUserTurn?: boolean; /** Current pill values (persisted per-task by the caller). */ mode: ExecutionMode; @@ -142,6 +143,7 @@ export function TaskChatComposer({ onStop, disabled = false, placeholder = "Ask a question", + initialMessage, isUserTurn = false, mode, model, @@ -151,10 +153,15 @@ export function TaskChatComposer({ onReasoningChange, }: TaskChatComposerProps) { const themeColors = useThemeColors(); - const [message, setMessage] = useState(""); + const [message, setMessage] = useState(() => initialMessage ?? ""); const [attachments, setAttachments] = useState([]); const [attachmentSheetOpen, setAttachmentSheetOpen] = useState(false); + useEffect(() => { + if (!initialMessage) return; + setMessage(initialMessage); + }, [initialMessage]); + const appendTranscript = useCallback((transcript: string) => { setMessage((prev) => (prev ? `${prev} ${transcript}` : transcript)); }, []); diff --git a/apps/mobile/src/features/tasks/index.ts b/apps/mobile/src/features/tasks/index.ts index 07c05346e..8ab7e6dda 100644 --- a/apps/mobile/src/features/tasks/index.ts +++ b/apps/mobile/src/features/tasks/index.ts @@ -33,3 +33,14 @@ export { convertStoredEntriesToEvents, parseSessionLogs, } from "./utils/parseSessionLogs"; +export { + createWatchTaskEnvelope, + createWatchTaskSnapshot, +} from "./utils/watchTaskControl"; +export { + isWatchTaskControlAvailable, + isWatchTaskControlSupported, + publishWatchTaskEnvelope, + sendUrgentWatchTaskUpdate, + subscribeToWatchTaskCommands, +} from "./watchTaskControlBridge"; diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts index ffd8bb498..12f1faf50 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts @@ -1,14 +1,31 @@ import * as Haptics from "expo-haptics"; -import { AppState } from "react-native"; +import { router } from "expo-router"; +import { Alert, AppState, Linking } from "react-native"; import { create } from "zustand"; +import { useAuthStore } from "@/features/auth/stores/authStore"; +import { + dismissSignalReport, + getAvailableSuggestedReviewers, + getReportRepository, + getSignalReport, + getSignalReportArtefacts, + getSignalReports, +} from "@/features/inbox/api"; +import type { DismissalReasonOptionValue } from "@/features/inbox/constants"; +import type { SuggestedReviewer } from "@/features/inbox/types"; import { presentLocalNotification } from "@/features/notifications/lib/notifications"; import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; import { logger } from "@/lib/logger"; +import { queryClient } from "@/lib/queryClient"; import { CloudCommandError, getTask, + getTaskAutomations, + getTasks, + runTaskAutomation, runTaskInCloud, sendCloudCommand, + updateTaskAutomation, } from "../api"; import { buildCloudPromptBlocks } from "../composer/attachments/buildCloudPrompt"; import { serializeCloudPrompt } from "../composer/attachments/cloudPrompt"; @@ -25,9 +42,25 @@ import { type SessionNotificationAttachment, type StoredLogEntry, type Task, + type WatchAutomationSnapshot, + type WatchInboxReportSnapshot, + type WatchInboxReviewer, + type WatchTaskCommand, } from "../types"; import { convertStoredEntriesToEvents } from "../utils/parseSessionLogs"; import { playMeepSound } from "../utils/sounds"; +import { + createWatchAutomationSnapshot, + createWatchInboxReportSnapshot, + createWatchInboxReviewers, + createWatchTaskEnvelope, +} from "../utils/watchTaskControl"; +import { + publishWatchTaskEnvelope, + sendUrgentWatchTaskUpdate, + subscribeToWatchTaskCommands, +} from "../watchTaskControlBridge"; +import { useArchivedTasksStore } from "./archivedTasksStore"; import { useAttachmentEchoStore } from "./attachmentEchoStore"; const log = logger.scope("task-session-store"); @@ -248,8 +281,15 @@ export interface TaskSession { interface TaskSessionStore { sessions: Record; focusedTaskId: string | null; + watchTasks: Record; + watchInboxReports: WatchInboxReportSnapshot[]; + watchInboxReviewers: WatchInboxReviewer[]; + watchAutomations: WatchAutomationSnapshot[]; + activeWatchTaskId?: string; setFocusedTaskId: (taskId: string | null) => void; + setActiveWatchTask: (taskId?: string) => void; + registerWatchTask: (task: Task) => void; connectToTask: (task: Task) => Promise; disconnectFromTask: (taskId: string) => void; @@ -269,12 +309,15 @@ interface TaskSessionStore { }, ) => Promise; cancelPrompt: (taskId: string) => Promise; + retryTaskFromWatch: (taskId: string, taskRunId?: string) => Promise; + handleWatchCommand: (command: WatchTaskCommand) => Promise; setConfigOption: ( taskId: string, configId: string, value: string, ) => Promise; getSessionForTask: (taskId: string) => TaskSession | undefined; + publishWatchSnapshot: (options?: { urgent?: boolean }) => void; _handleCloudUpdate: ( taskRunId: string, @@ -292,6 +335,254 @@ interface TaskSessionStore { const watchHandles = new Map(); const connectAttempts = new Set(); +let watchPublishTimeout: ReturnType | null = null; +let watchPublishUrgent = false; +let watchCommandUnsubscribe: (() => void) | null = null; +const WATCH_SNAPSHOT_DEBOUNCE_MS = 250; + +type WatchCurrentUser = { id: number; uuid?: string }; + +async function fetchCurrentUserForWatch(): Promise { + const cached = queryClient.getQueryData(["user", "me"]); + if (typeof cached?.id === "number") return cached; + + return queryClient.fetchQuery({ + queryKey: ["user", "me"], + staleTime: 5 * 60 * 1000, + queryFn: async () => { + const { cloudRegion, oauthAccessToken, getCloudUrlFromRegion } = + useAuthStore.getState(); + if (!cloudRegion || !oauthAccessToken) { + throw new Error("Missing auth state for Watch task refresh"); + } + + const response = await fetch( + `${getCloudUrlFromRegion(cloudRegion)}/api/users/@me/`, + { headers: { Authorization: `Bearer ${oauthAccessToken}` } }, + ); + if (!response.ok) { + throw new Error(`Failed to fetch current user: ${response.statusText}`); + } + return (await response.json()) as WatchCurrentUser; + }, + }); +} + +type SignalReportArtefact = Awaited< + ReturnType +>["results"][number]; + +function extractSuggestedReviewers( + artefacts: SignalReportArtefact[], +): SuggestedReviewer[] { + const artefact = artefacts.find( + (item) => item.type === "suggested_reviewers", + ); + return Array.isArray(artefact?.content) + ? (artefact.content as SuggestedReviewer[]) + : []; +} + +function extractReportRepository( + artefacts: SignalReportArtefact[], +): string | null { + const artefact = artefacts.find((item) => item.type === "repo_selection"); + if (!artefact) return null; + let parsed: unknown = artefact.content; + if (typeof parsed === "string") { + const raw = parsed; + try { + parsed = JSON.parse(raw); + } catch { + return raw.toLowerCase(); + } + } + if (typeof parsed === "object" && parsed !== null) { + const repo = + (parsed as Record).repository ?? + (parsed as Record).repo; + if (typeof repo === "string") return repo.toLowerCase(); + } + return null; +} + +async function fetchCurrentWatchInbox(currentUser: WatchCurrentUser): Promise<{ + reports: WatchInboxReportSnapshot[]; + reviewers: WatchInboxReviewer[]; +}> { + const [reportsResponse, availableReviewers] = await Promise.all([ + getSignalReports({ + limit: 50, + status: "ready,pending_input,in_progress,failed,candidate,potential", + ordering: "status,-is_suggested_reviewer,priority", + }), + getAvailableSuggestedReviewers(), + ]); + + const reviewers = createWatchInboxReviewers( + availableReviewers.results, + currentUser.uuid, + ); + + const reports = await Promise.all( + reportsResponse.results.map(async (report) => { + const artefacts = await getSignalReportArtefacts(report.id); + return createWatchInboxReportSnapshot(report, { + reviewers: extractSuggestedReviewers(artefacts.results), + repository: extractReportRepository(artefacts.results), + currentUserUuid: currentUser.uuid, + }); + }), + ); + + return { reports, reviewers }; +} + +async function fetchCurrentWatchAutomations(): Promise< + WatchAutomationSnapshot[] +> { + const [automations, automationTasks] = await Promise.all([ + getTaskAutomations(), + getTasks({ originProduct: "automation" }), + ]); + const taskStatusById = new Map( + automationTasks.map((task) => [task.id, task.latest_run?.status ?? null]), + ); + return automations.map((automation) => + createWatchAutomationSnapshot( + automation, + automation.last_task_id + ? (taskStatusById.get(automation.last_task_id) ?? null) + : null, + ), + ); +} + +async function refreshCurrentWatchTasks(): Promise { + const currentUser = await fetchCurrentUserForWatch(); + const [tasks, inbox, automations] = await Promise.all([ + getTasks({ + createdBy: currentUser.id, + }), + fetchCurrentWatchInbox(currentUser).catch((error) => { + log.warn("Failed to refresh Watch inbox", { error }); + return { reports: [], reviewers: [] }; + }), + fetchCurrentWatchAutomations().catch((error) => { + log.warn("Failed to refresh Watch automations", { error }); + return []; + }), + ]); + useTaskSessionStore.setState({ + watchTasks: Object.fromEntries(tasks.map((task) => [task.id, task])), + watchInboxReports: inbox.reports, + watchInboxReviewers: inbox.reviewers, + watchAutomations: automations, + }); +} + +function buildWatchTaskEnvelopeFromStore() { + const state = useTaskSessionStore.getState(); + const sessionsByTaskId: Record = {}; + for (const session of Object.values(state.sessions)) { + sessionsByTaskId[session.taskId] = session; + } + + const archivedTasks = useArchivedTasksStore.getState().archivedTasks; + const visibleTasks = Object.values(state.watchTasks); + const activeWatchTaskId = state.activeWatchTaskId; + const activeWatchTask = activeWatchTaskId + ? state.watchTasks[activeWatchTaskId] + : undefined; + const visibleActiveTaskId = + activeWatchTaskId && + !(activeWatchTaskId in archivedTasks) && + !isTerminalStatus(activeWatchTask?.latest_run?.status) + ? activeWatchTaskId + : undefined; + + return createWatchTaskEnvelope( + visibleTasks, + sessionsByTaskId, + visibleActiveTaskId, + { + archivedTaskIds: new Set(Object.keys(archivedTasks)), + isAuthenticated: useAuthStore.getState().isAuthenticated, + inboxReports: state.watchInboxReports, + inboxReviewers: state.watchInboxReviewers, + automations: state.watchAutomations, + }, + ); +} + +function publishCurrentWatchTaskEnvelope(urgent: boolean) { + const envelope = buildWatchTaskEnvelopeFromStore(); + const publish = urgent + ? sendUrgentWatchTaskUpdate(envelope) + : publishWatchTaskEnvelope(envelope); + publish.catch((error) => { + log.warn("Failed to publish watch task envelope", { error }); + }); +} + +function scheduleWatchTaskPublish(options: { urgent?: boolean } = {}) { + if (options.urgent) { + if (watchPublishTimeout) { + clearTimeout(watchPublishTimeout); + watchPublishTimeout = null; + } + watchPublishUrgent = false; + publishCurrentWatchTaskEnvelope(true); + return; + } + + if (watchPublishTimeout) return; + + watchPublishTimeout = setTimeout(() => { + watchPublishTimeout = null; + const urgent = watchPublishUrgent; + watchPublishUrgent = false; + publishCurrentWatchTaskEnvelope(urgent); + }, WATCH_SNAPSHOT_DEBOUNCE_MS); +} + +function ensureWatchCommandSubscription() { + if (watchCommandUnsubscribe) return; + watchCommandUnsubscribe = subscribeToWatchTaskCommands((command) => { + useTaskSessionStore + .getState() + .handleWatchCommand(command) + .catch((error) => { + log.warn("Failed to handle watch task command", { error }); + }); + }); +} + +let archivedWatchTaskIds = Object.keys( + useArchivedTasksStore.getState().archivedTasks, +) + .sort() + .join(","); + +useArchivedTasksStore.subscribe((state) => { + const nextArchivedWatchTaskIds = Object.keys(state.archivedTasks) + .sort() + .join(","); + if (nextArchivedWatchTaskIds === archivedWatchTaskIds) return; + archivedWatchTaskIds = nextArchivedWatchTaskIds; + scheduleWatchTaskPublish({ urgent: true }); +}); + +function macTaskUrl(command: { + taskId: string; + taskRunId?: string; + url?: string; +}) { + if (command.url) return command.url; + const runPath = command.taskRunId ? `/run/${command.taskRunId}` : ""; + return `posthog-code://task/${command.taskId}${runPath}`; +} + function mapTerminalStatus( status: string | undefined | null, ): "completed" | "failed" | undefined { @@ -300,13 +591,62 @@ function mapTerminalStatus( return undefined; } +async function sendWatchPromptToTask( + taskId: string, + prompt: string, +): Promise { + const store = useTaskSessionStore.getState(); + const task = store.watchTasks[taskId] ?? (await getTask(taskId)); + + if (isTerminalStatus(task.latest_run?.status)) { + store.disconnectFromTask(taskId); + const updatedTask = await runTaskInCloud(taskId, { + resumeFromRunId: task.latest_run?.id, + pendingUserMessage: prompt, + }); + store.registerWatchTask(updatedTask); + await store.connectToTask(updatedTask); + scheduleWatchTaskPublish({ urgent: true }); + return; + } + + if (!store.getSessionForTask(taskId)) { + await store.connectToTask(task); + } + await store.sendPrompt(taskId, prompt); + scheduleWatchTaskPublish({ urgent: true }); +} + export const useTaskSessionStore = create((set, get) => ({ sessions: {}, focusedTaskId: null, + watchTasks: {}, + watchInboxReports: [], + watchInboxReviewers: [], + watchAutomations: [], setFocusedTaskId: (taskId) => set({ focusedTaskId: taskId }), + setActiveWatchTask: (taskId) => { + ensureWatchCommandSubscription(); + set({ activeWatchTaskId: taskId }); + scheduleWatchTaskPublish({ urgent: true }); + }, + + registerWatchTask: (task) => { + ensureWatchCommandSubscription(); + set((state) => ({ + watchTasks: { + ...state.watchTasks, + [task.id]: task, + }, + })); + scheduleWatchTaskPublish(); + }, + connectToTask: async (task: Task) => { + ensureWatchCommandSubscription(); + get().registerWatchTask(task); const taskId = task.id; const latestRunId = task.latest_run?.id; @@ -375,6 +715,7 @@ export const useTaskSessionStore = create((set, get) => ({ const { [session.taskRunId]: _, ...rest } = state.sessions; return { sessions: rest }; }); + scheduleWatchTaskPublish(); log.debug("Disconnected from task", { taskId }); }, @@ -660,9 +1001,12 @@ export const useTaskSessionStore = create((set, get) => ({ [session.taskRunId]: { ...state.sessions[session.taskRunId], isPromptPending: false, + awaitingPing: false, + awaitingAgentOutput: false, }, }, })); + scheduleWatchTaskPublish({ urgent: true }); return true; } catch (error) { log.error("Failed to send cancel request", error); @@ -670,10 +1014,178 @@ export const useTaskSessionStore = create((set, get) => ({ } }, + retryTaskFromWatch: async (taskId: string, taskRunId?: string) => { + const currentTask = get().watchTasks[taskId] ?? (await getTask(taskId)); + const resumeFromRunId = taskRunId ?? currentTask.latest_run?.id; + + get().disconnectFromTask(taskId); + const updatedTask = await runTaskInCloud(taskId, { resumeFromRunId }); + + get().registerWatchTask(updatedTask); + await get().connectToTask(updatedTask); + scheduleWatchTaskPublish({ urgent: true }); + }, + + handleWatchCommand: async (command: WatchTaskCommand) => { + switch (command.type) { + case "approval_response": + await get().sendPermissionResponse(command.taskId, { + toolCallId: command.toolCallId, + optionId: command.optionId, + answers: command.answers, + customInput: command.customInput, + displayText: command.displayText, + }); + break; + case "stop": + await get().cancelPrompt(command.taskId); + break; + case "retry": + await get().retryTaskFromWatch(command.taskId, command.taskRunId); + break; + case "send_prompt": + await get().sendPrompt(command.taskId, command.displayText); + break; + case "debug_ping": + log.info("Received debug ping from watch", { command }); + Alert.alert( + "Watch ping", + command.displayText ?? "Ping from Apple Watch", + ); + break; + case "debug_request_snapshot": + case "request_snapshot": { + log.info("Watch requested task snapshot", { command }); + if (useAuthStore.getState().isAuthenticated) { + try { + await refreshCurrentWatchTasks(); + } catch (error) { + log.warn("Failed to refresh Watch tasks", { error }); + } + } + scheduleWatchTaskPublish({ urgent: true }); + break; + } + case "open_phone": + case "view_diff": + await Linking.openURL( + command.url ?? `posthog://task/${command.taskId}`, + ); + break; + case "open_task_prompt": { + const taskId = command.taskId; + const prompt = (command.customInput ?? command.displayText)?.trim(); + + if (prompt) { + try { + await sendWatchPromptToTask(taskId, prompt); + } catch (error) { + log.error("Failed to send watch prompt", { error }); + Alert.alert( + "Failed to send", + "Your message from Apple Watch could not be delivered.", + ); + } + } + + if (get().focusedTaskId !== taskId) { + router.push(`/task/${taskId}`); + } + break; + } + case "open_report": + await Linking.openURL( + command.url ?? + `posthog://report/${command.reportId ?? command.taskId}`, + ); + break; + case "dismiss_report": { + const reportId = command.reportId ?? command.taskId; + await dismissSignalReport(reportId, { + reason: (command.optionId ?? "other") as DismissalReasonOptionValue, + note: command.customInput, + }); + await refreshCurrentWatchTasks(); + scheduleWatchTaskPublish({ urgent: true }); + break; + } + case "start_report_task": { + const reportId = command.reportId ?? command.taskId; + const report = await getSignalReport(reportId); + if (!report) throw new Error("Signal report not found"); + const repository = await getReportRepository(reportId).catch( + () => null, + ); + const prompt = `Act on this signal report. Investigate the root cause, implement the fix, and open a PR if appropriate.\n\n${report.summary ?? ""}`; + const params = new URLSearchParams({ + prompt, + signalReport: reportId, + }); + if (repository) params.set("repo", repository); + await Linking.openURL(`posthog://task?${params.toString()}`); + break; + } + case "new_automation": + router.push("/automation"); + break; + case "open_automation": + await Linking.openURL( + command.url ?? + `posthog://automation/${command.automationId ?? command.taskId}`, + ); + break; + case "run_automation": { + const automation = await runTaskAutomation( + command.automationId ?? command.taskId, + ); + if (automation.last_task_id) { + await Linking.openURL( + `posthog://task/${automation.last_task_id}?fromAutomation=1&automationName=${encodeURIComponent(automation.name)}`, + ); + } + await refreshCurrentWatchTasks(); + scheduleWatchTaskPublish({ urgent: true }); + break; + } + case "pause_automation": + case "resume_automation": + await updateTaskAutomation(command.automationId ?? command.taskId, { + enabled: command.type === "resume_automation", + }); + await refreshCurrentWatchTasks(); + scheduleWatchTaskPublish({ urgent: true }); + break; + case "open_mac": + await Linking.openURL(macTaskUrl(command)); + break; + case "archive": + useArchivedTasksStore.getState().archive(command.taskId); + scheduleWatchTaskPublish({ urgent: true }); + break; + case "restore": + useArchivedTasksStore.getState().unarchive(command.taskId); + scheduleWatchTaskPublish({ urgent: true }); + break; + case "create_task": { + const prompt = (command.customInput ?? command.displayText)?.trim(); + const path = prompt + ? `posthog://task?prompt=${encodeURIComponent(prompt)}` + : "posthog://task"; + await Linking.openURL(path); + break; + } + } + }, + getSessionForTask: (taskId: string) => { return Object.values(get().sessions).find((s) => s.taskId === taskId); }, + publishWatchSnapshot: (options) => { + ensureWatchCommandSubscription(); + scheduleWatchTaskPublish(options); + }, + _startWatcher: (taskRunId: string, taskId: string) => { if (watchHandles.has(taskRunId)) return; @@ -874,6 +1386,7 @@ export const useTaskSessionStore = create((set, get) => ({ } } } + scheduleWatchTaskPublish({ urgent: update.kind === "status" }); }, _resumeCloudRun: async ( @@ -916,6 +1429,7 @@ export const useTaskSessionStore = create((set, get) => ({ }; }); + get().registerWatchTask(updatedTask); get()._startWatcher(newRun.id, taskId); log.debug("Swapped to resume run", { taskId, @@ -925,6 +1439,33 @@ export const useTaskSessionStore = create((set, get) => ({ }, })); +ensureWatchCommandSubscription(); + +useAuthStore.subscribe((state, previousState) => { + if (state.isLoading) return; + + if (!state.isAuthenticated) { + if (previousState.isLoading || previousState.isAuthenticated) { + scheduleWatchTaskPublish({ urgent: true }); + } + return; + } + + if ( + previousState.isLoading || + !previousState.isAuthenticated || + previousState.projectId !== state.projectId + ) { + refreshCurrentWatchTasks() + .catch((error) => { + log.warn("Failed to refresh Watch tasks after auth change", { error }); + }) + .finally(() => { + scheduleWatchTaskPublish({ urgent: true }); + }); + } +}); + // When the app returns from background, iOS may have killed the SSE // connection. Nudge every active watcher to reconnect so the stream resumes // with Last-Event-ID. @@ -933,4 +1474,6 @@ AppState.addEventListener("change", (nextState) => { for (const handle of watchHandles.values()) { handle.reconnectIfDisconnected(); } + ensureWatchCommandSubscription(); + scheduleWatchTaskPublish(); }); diff --git a/apps/mobile/src/features/tasks/stores/taskStore.ts b/apps/mobile/src/features/tasks/stores/taskStore.ts index 9ce1dd8c6..ff31d9b5c 100644 --- a/apps/mobile/src/features/tasks/stores/taskStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskStore.ts @@ -30,6 +30,7 @@ interface TaskUIState { * repo pill so users don't have to re-pick the same repo every time. */ lastRepository: RepositorySelection; composerConfigByTaskId: Record; + pendingPromptByTaskId: Record; selectTask: (taskId: string | null) => void; setOrganizeMode: (mode: OrganizeMode) => void; @@ -41,11 +42,13 @@ interface TaskUIState { taskId: string, config: Partial, ) => void; + setPendingPrompt: (taskId: string, prompt: string) => void; + consumePendingPrompt: (taskId: string) => string | undefined; } export const useTaskStore = create()( persist( - (set) => ({ + (set, get) => ({ selectedTaskId: null, organizeMode: "by-project", sortMode: "updated", @@ -53,6 +56,7 @@ export const useTaskStore = create()( filter: "", lastRepository: EMPTY_REPOSITORY_SELECTION, composerConfigByTaskId: {}, + pendingPromptByTaskId: {}, selectTask: (selectedTaskId) => set({ selectedTaskId }), setOrganizeMode: (organizeMode) => set({ organizeMode }), @@ -70,6 +74,23 @@ export const useTaskStore = create()( }, }, })), + setPendingPrompt: (taskId, prompt) => + set((state) => ({ + pendingPromptByTaskId: { + ...state.pendingPromptByTaskId, + [taskId]: prompt, + }, + })), + consumePendingPrompt: (taskId) => { + const prompt = get().pendingPromptByTaskId[taskId]; + if (!prompt) return undefined; + set((state) => { + const remaining = { ...state.pendingPromptByTaskId }; + delete remaining[taskId]; + return { pendingPromptByTaskId: remaining }; + }); + return prompt; + }, }), { name: "posthog-task-ui", diff --git a/apps/mobile/src/features/tasks/types.ts b/apps/mobile/src/features/tasks/types.ts index 4ccd6c3f1..39df13e78 100644 --- a/apps/mobile/src/features/tasks/types.ts +++ b/apps/mobile/src/features/tasks/types.ts @@ -116,10 +116,293 @@ export interface SessionNotification { export interface PlanEntry { content: string; - status: "pending" | "in_progress" | "completed"; + status: "pending" | "in_progress" | "completed" | "failed"; priority: string; } +export type WatchTaskEnvironment = "cloud" | "local" | "unknown"; + +export type WatchTaskStatus = + | "idle" + | "connecting" + | "running" + | "waiting_for_approval" + | "blocked" + | "failed" + | "completed" + | "stale"; + +export type WatchTaskChecklistStatus = + | "pending" + | "running" + | "completed" + | "failed"; + +export type WatchTaskTimelineKind = + | "started" + | "progress" + | "tool" + | "approval" + | "blocked" + | "failed" + | "completed" + | "handoff"; + +export type WatchTaskRisk = "low" | "medium" | "high" | "destructive"; + +export type WatchTaskActionType = + | "approve" + | "reject" + | "stop" + | "retry" + | "open_phone" + | "open_mac" + | "view_diff"; + +export type WatchInboxReportStatus = + | "potential" + | "candidate" + | "in_progress" + | "ready" + | "failed" + | "pending_input" + | "suppressed" + | "deleted"; + +export type WatchInboxReportPriority = "P0" | "P1" | "P2" | "P3" | "P4"; + +export type WatchInboxReportActionability = + | "immediately_actionable" + | "requires_human_input" + | "not_actionable"; + +export interface WatchInboxReviewer { + uuid: string; + name: string; + email?: string; + githubLogin?: string; + isMe?: boolean; +} + +export interface WatchInboxSuggestedReviewer { + uuid?: string; + name: string; + githubLogin: string; + isMe?: boolean; +} + +export type WatchInboxReportAction = + | "dismiss" + | "start_task" + | "implement_as_task" + | "open_phone"; + +export interface WatchAutomationSnapshot { + schemaVersion: 1; + id: string; + name: string; + prompt: string; + repository?: string | null; + templateName?: string | null; + secondaryLabel: string; + scheduleSummary: string; + cronExpression: string; + timezone?: string | null; + enabled: boolean; + statusText: string; + lastRunStatus: string | null; + lastTaskRunStatus?: TaskRunStatus | null; + lastRunAt?: number; + lastTaskId?: string | null; + lastError?: string | null; + createdAt?: number; + updatedAt?: number; + allowedActions: Array<"pause" | "resume" | "run" | "open_phone">; + handoff: WatchTaskHandoff; +} + +export interface WatchInboxReportSnapshot { + schemaVersion: 1; + id: string; + title: string; + summary?: string; + status: WatchInboxReportStatus; + statusText: string; + priority?: WatchInboxReportPriority | null; + actionability?: WatchInboxReportActionability | null; + actionabilityText?: string; + alreadyAddressed?: boolean | null; + isSuggestedReviewer?: boolean; + suggestedReviewerUuids: string[]; + suggestedReviewers: WatchInboxSuggestedReviewer[]; + sourceProducts: string[]; + signalCount: number; + totalWeight: number; + createdAt?: number; + updatedAt?: number; + implementationPrUrl?: string | null; + repository?: string | null; + allowedActions: WatchInboxReportAction[]; + handoff: WatchTaskHandoff; +} + +export interface WatchTaskProgress { + completed: number; + running: number; + pending: number; + failed: number; + total: number; + /** 0...1 progress fraction for SwiftUI ProgressView/rings. */ + fraction: number; +} + +export interface WatchTaskChecklistItem { + id: string; + title: string; + subtitle?: string; + status: WatchTaskChecklistStatus; + priority?: string; + depth?: number; + kind?: "plan" | "agent" | "tool" | "approval" | "system"; + updatedAt?: number; +} + +export interface WatchTaskTimelineItem { + id: string; + title: string; + detail?: string; + kind: WatchTaskTimelineKind; + timestamp: number; +} + +export interface WatchTaskApprovalOption { + id: string; + title: string; + role: "approve" | "reject" | "neutral"; + destructive?: boolean; +} + +export interface WatchTaskApproval { + id: string; + toolCallId: string; + title: string; + summary: string; + detail?: string; + risk: WatchTaskRisk; + requestedAt: number; + options: WatchTaskApprovalOption[]; + diffAvailable?: boolean; +} + +export interface WatchTaskBlocker { + title: string; + detail?: string; + kind: "error" | "approval" | "stale" | "offline" | "unknown"; +} + +export interface WatchTaskHandoff { + phoneUrl: string; + macUrl?: string; + webUrl?: string; +} + +export interface WatchTaskSnapshot { + schemaVersion: 1; + id: string; + generatedAt: number; + source: "mobile" | "desktop" | "cloud"; + taskId: string; + taskRunId?: string; + taskNumber?: number | null; + slug?: string; + title: string; + subtitle?: string; + repository?: string | null; + branch?: string | null; + internal?: boolean; + isArchived?: boolean; + environment: WatchTaskEnvironment; + status: WatchTaskStatus; + statusText: string; + currentTask?: string; + createdAt?: number; + startedAt?: number; + updatedAt?: number; + completedAt?: number; + elapsedSeconds: number; + progress: WatchTaskProgress; + checklist: WatchTaskChecklistItem[]; + timeline: WatchTaskTimelineItem[]; + approval?: WatchTaskApproval; + blocker?: WatchTaskBlocker; + lastError?: string | null; + isStale: boolean; + staleReason?: string; + allowedActions: WatchTaskActionType[]; + handoff: WatchTaskHandoff; +} + +export interface WatchTaskEnvelope { + schemaVersion: 1; + generatedAt: number; + isAuthenticated: boolean; + activeTaskId?: string; + tasks: WatchTaskSnapshot[]; + inboxReports?: WatchInboxReportSnapshot[]; + inboxReviewers?: WatchInboxReviewer[]; + automations?: WatchAutomationSnapshot[]; +} + +interface WatchTaskCommandBase { + id: string; + taskId: string; + taskRunId?: string; +} + +export type WatchTaskCommand = + | (WatchTaskCommandBase & { + type: "approval_response"; + toolCallId: string; + optionId: string; + displayText: string; + answers?: Record; + customInput?: string; + }) + | (WatchTaskCommandBase & { + type: "send_prompt"; + displayText: string; + }) + | (WatchTaskCommandBase & { + type: "debug_ping" | "debug_request_snapshot" | "request_snapshot"; + displayText?: string; + }) + | (WatchTaskCommandBase & { + type: + | "stop" + | "retry" + | "open_phone" + | "open_mac" + | "view_diff" + | "archive" + | "restore" + | "create_task" + | "open_task_prompt" + | "open_report" + | "dismiss_report" + | "start_report_task" + | "new_automation" + | "open_automation" + | "run_automation" + | "pause_automation" + | "resume_automation"; + url?: string; + automationId?: string; + reportId?: string; + optionId?: string; + displayText?: string; + customInput?: string; + }); + export interface AcpMessage { type: "acp_message"; direction: "client" | "agent"; diff --git a/apps/mobile/src/features/tasks/utils/watchTaskControl.test.ts b/apps/mobile/src/features/tasks/utils/watchTaskControl.test.ts new file mode 100644 index 000000000..b598f292d --- /dev/null +++ b/apps/mobile/src/features/tasks/utils/watchTaskControl.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import type { Task, TaskRunStatus } from "../types"; +import { createWatchTaskSnapshot } from "./watchTaskControl"; + +function taskWithRunStatus(status: TaskRunStatus): Task { + return { + id: "task-1", + task_number: 1, + slug: "task-1", + title: "Test task", + description: "Test task description", + created_at: "2026-05-14T00:00:00.000Z", + updated_at: "2026-05-14T00:01:00.000Z", + origin_product: "user_created", + repository: "PostHog/code", + internal: false, + latest_run: { + id: "run-1", + task: "task-1", + team: 1, + branch: null, + environment: "cloud", + status, + log_url: "", + error_message: null, + output: null, + state: {}, + created_at: "2026-05-14T00:00:00.000Z", + updated_at: "2026-05-14T00:01:00.000Z", + completed_at: status === "completed" ? "2026-05-14T00:02:00.000Z" : null, + }, + }; +} + +describe("watchTaskControl", () => { + it("does not treat non-terminal backend run status as actively running", () => { + const snapshot = createWatchTaskSnapshot(taskWithRunStatus("in_progress")); + + expect(snapshot.status).toBe("idle"); + expect(snapshot.statusText).toBe("Idle"); + }); + + it("does not keep showing running from stale tool progress after prompt stops", () => { + const snapshot = createWatchTaskSnapshot(taskWithRunStatus("in_progress"), { + taskId: "task-1", + taskRunId: "run-1", + taskTitle: "Test task", + events: [ + { + type: "session_update", + ts: Date.now(), + notification: { + update: { + sessionUpdate: "tool_call", + toolCallId: "tool-1", + title: "Long running tool", + status: "in_progress", + _meta: { claudeCode: { toolName: "Task" } }, + }, + }, + }, + ], + status: "connected", + isPromptPending: false, + awaitingAgentOutput: false, + } as never); + + expect(snapshot.progress.running).toBe(1); + expect(snapshot.status).toBe("idle"); + expect(snapshot.statusText).toBe("Idle"); + }); + + it("still maps terminal backend run status", () => { + const snapshot = createWatchTaskSnapshot(taskWithRunStatus("completed")); + + expect(snapshot.status).toBe("completed"); + expect(snapshot.statusText).toBe("Completed"); + }); +}); diff --git a/apps/mobile/src/features/tasks/utils/watchTaskControl.ts b/apps/mobile/src/features/tasks/utils/watchTaskControl.ts new file mode 100644 index 000000000..4003bf935 --- /dev/null +++ b/apps/mobile/src/features/tasks/utils/watchTaskControl.ts @@ -0,0 +1,850 @@ +import type { + AvailableSuggestedReviewer, + SignalReport, + SuggestedReviewer, +} from "@/features/inbox/types"; +import type { TaskSession } from "../stores/taskSessionStore"; +import type { + PlanEntry, + SessionEvent, + SessionNotification, + Task, + TaskAutomation, + TaskRun, + WatchAutomationSnapshot, + WatchInboxReportAction, + WatchInboxReportSnapshot, + WatchInboxReviewer, + WatchInboxSuggestedReviewer, + WatchTaskActionType, + WatchTaskApproval, + WatchTaskApprovalOption, + WatchTaskChecklistItem, + WatchTaskChecklistStatus, + WatchTaskEnvelope, + WatchTaskProgress, + WatchTaskRisk, + WatchTaskSnapshot, + WatchTaskStatus, + WatchTaskTimelineItem, +} from "../types"; +import { formatAutomationScheduleSummary } from "./automationSchedule"; +import { getAutomationStatusPresentation } from "./automationStatus"; +import { getAutomationTemplatePresentation } from "./automationTemplatePresentation"; + +const MAX_TITLE_LENGTH = 72; +const MAX_DETAIL_LENGTH = 160; +const MAX_CHECKLIST_ITEMS = 8; +const MAX_TIMELINE_ITEMS = 6; +const STALE_AFTER_MS = 30_000; + +interface TaskBuildOptions { + now?: number; + isStale?: boolean; + staleReason?: string; + isArchived?: boolean; +} + +interface WatchInboxBuildOptions { + reviewers?: SuggestedReviewer[]; + repository?: string | null; + currentUserUuid?: string; +} + +interface ToolState { + id: string; + title: string; + status: WatchTaskChecklistStatus; + args?: Record; + result?: unknown; + ts: number; + isAgent?: boolean; + parentToolCallId?: string; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function truncate(value: string | null | undefined, max = MAX_DETAIL_LENGTH) { + if (!value) return undefined; + const normalized = value.replace(/\s+/g, " ").trim(); + if (normalized.length <= max) return normalized; + return `${normalized.slice(0, Math.max(0, max - 1)).trim()}…`; +} + +function toMillis(value?: string | null): number | undefined { + if (!value) return undefined; + const timestamp = new Date(value).getTime(); + return Number.isFinite(timestamp) ? timestamp : undefined; +} + +function mapPlanStatus(status: PlanEntry["status"]): WatchTaskChecklistStatus { + if (status === "in_progress") return "running"; + if (status === "completed") return "completed"; + if (status === "failed") return "failed"; + return "pending"; +} + +function mapToolStatus( + status?: "pending" | "in_progress" | "completed" | "failed" | null, +): WatchTaskChecklistStatus { + switch (status) { + case "in_progress": + return "running"; + case "completed": + return "completed"; + case "failed": + return "failed"; + default: + return "pending"; + } +} + +function calculateProgress(items: WatchTaskChecklistItem[]): WatchTaskProgress { + const total = items.length; + const completed = items.filter((item) => item.status === "completed").length; + const running = items.filter((item) => item.status === "running").length; + const failed = items.filter((item) => item.status === "failed").length; + const pending = Math.max(0, total - completed - running - failed); + return { + completed, + running, + pending, + failed, + total, + fraction: total > 0 ? clamp(completed / total, 0, 1) : 0, + }; +} + +function extractSessionUpdate( + event: SessionEvent, +): SessionNotification["update"] | null { + if (event.type !== "session_update") return null; + return event.notification.update ?? null; +} + +function isQuestionTool(tool: ToolState): boolean { + if (tool.title.toLowerCase().includes("question")) return true; + const args = tool.args; + if (!args) return false; + if (Array.isArray(args.questions)) return true; + const input = args.input; + return !!( + input && + typeof input === "object" && + Array.isArray((input as Record).questions) + ); +} + +function extractQuestions(args?: Record): Array<{ + question: string; + header?: string; + options?: Array<{ label?: string; description?: string; optionId?: string }>; +}> { + const raw = + args?.questions ?? + (args?.input as Record | undefined)?.questions; + if (!Array.isArray(raw)) return []; + return raw.filter( + ( + item, + ): item is { + question: string; + header?: string; + options?: Array<{ + label?: string; + description?: string; + optionId?: string; + }>; + } => + !!item && + typeof item === "object" && + typeof (item as Record).question === "string", + ); +} + +function deriveToolRisk(tool: ToolState): WatchTaskRisk { + const haystack = + `${tool.title} ${JSON.stringify(tool.args ?? {})}`.toLowerCase(); + if ( + haystack.includes("delete") || + haystack.includes("rm -rf") || + haystack.includes("drop table") || + haystack.includes("destroy") + ) { + return "destructive"; + } + if ( + haystack.includes("git push") || + haystack.includes("deploy") || + haystack.includes("production") || + haystack.includes("write") || + haystack.includes("edit") + ) { + return "high"; + } + if (haystack.includes("bash") || haystack.includes("execute")) + return "medium"; + return "low"; +} + +function formatToolSummary(tool: ToolState): string { + const args = tool.args ?? {}; + if (typeof args.file_path === "string") return args.file_path; + if (typeof args.target_file === "string") return args.target_file; + if (typeof args.command === "string") return args.command; + if (typeof args.pattern === "string") return `Search for “${args.pattern}”`; + if (typeof args.prompt === "string") return args.prompt; + return tool.title; +} + +function extractApproval(tool: ToolState): WatchTaskApproval | undefined { + if (tool.status === "completed" || tool.status === "failed") return undefined; + + const questions = extractQuestions(tool.args); + if ( + questions.length > 0 || + isQuestionTool(tool) || + deriveToolRisk(tool) !== "low" + ) { + const firstQuestion = questions[0]; + const title = + truncate(firstQuestion?.header ?? tool.title, MAX_TITLE_LENGTH) ?? + "Approval needed"; + const summary = + truncate( + firstQuestion?.question ?? formatToolSummary(tool), + MAX_DETAIL_LENGTH, + ) ?? "Agent is waiting for approval."; + const risk = deriveToolRisk(tool); + const questionOptions = firstQuestion?.options ?? []; + const options: WatchTaskApprovalOption[] = + questionOptions.length > 0 + ? questionOptions.slice(0, 3).map((option, index) => { + const title = option.label ?? `Option ${index + 1}`; + const normalized = title.toLowerCase(); + return { + id: option.optionId ?? `option_${index}`, + title: truncate(title, 28) ?? title, + role: + normalized.includes("reject") || normalized.includes("no") + ? "reject" + : "approve", + destructive: risk === "destructive" && index === 0, + }; + }) + : [ + { + id: "allow", + title: risk === "destructive" ? "Approve" : "Allow", + role: "approve", + destructive: risk === "destructive", + }, + { id: "reject", title: "Reject", role: "reject" }, + ]; + + return { + id: tool.id, + toolCallId: tool.id, + title, + summary, + detail: truncate(formatToolSummary(tool), MAX_DETAIL_LENGTH), + risk, + requestedAt: tool.ts, + options, + diffAvailable: /diff|edit|write|delete|file/i.test( + `${tool.title} ${JSON.stringify(tool.args ?? {})}`, + ), + }; + } + + return undefined; +} + +function extractPlan(events: SessionEvent[]): PlanEntry[] | null { + for (let index = events.length - 1; index >= 0; index--) { + const update = extractSessionUpdate(events[index]); + if (update?.sessionUpdate === "plan" && Array.isArray(update.entries)) { + return update.entries; + } + } + return null; +} + +function collectTools(events: SessionEvent[]): ToolState[] { + const tools = new Map(); + + for (const event of events) { + const update = extractSessionUpdate(event); + if (!update) continue; + if ( + update.sessionUpdate !== "tool_call" && + update.sessionUpdate !== "tool_call_update" + ) { + continue; + } + + const id = update.toolCallId ?? `${update.title ?? "tool"}-${event.ts}`; + const existing = tools.get(id); + const next: ToolState = { + id, + title: update.title ?? existing?.title ?? "Agent action", + status: mapToolStatus(update.status) ?? existing?.status ?? "pending", + args: update.rawInput ?? existing?.args, + result: update.rawOutput ?? existing?.result, + ts: existing?.ts ?? event.ts, + isAgent: + existing?.isAgent ?? + (update._meta?.claudeCode?.toolName === "Agent" || + update._meta?.claudeCode?.toolName === "Task"), + parentToolCallId: + update._meta?.claudeCode?.parentToolCallId ?? + existing?.parentToolCallId, + }; + tools.set(id, next); + } + + return [...tools.values()].sort((a, b) => a.ts - b.ts); +} + +function buildChecklist( + plan: PlanEntry[] | null, + tools: ToolState[], +): WatchTaskChecklistItem[] { + if (plan?.length) { + return plan.slice(0, MAX_CHECKLIST_ITEMS).map((entry, index) => ({ + id: `plan-${index}`, + title: truncate(entry.content, MAX_TITLE_LENGTH) ?? "Task step", + status: mapPlanStatus(entry.status), + priority: entry.priority, + kind: "plan", + depth: 0, + })); + } + + const interestingTools = tools + .filter( + (tool) => + tool.isAgent || tool.status === "running" || tool.status === "failed", + ) + .slice(-MAX_CHECKLIST_ITEMS); + + return interestingTools.map((tool) => ({ + id: `tool-${tool.id}`, + title: truncate(tool.title, MAX_TITLE_LENGTH) ?? "Agent action", + subtitle: truncate(formatToolSummary(tool), 96), + status: tool.status, + kind: tool.isAgent ? "agent" : "tool", + depth: tool.parentToolCallId ? 1 : 0, + updatedAt: tool.ts, + })); +} + +function buildTimeline( + events: SessionEvent[], + tools: ToolState[], +): WatchTaskTimelineItem[] { + const timeline: WatchTaskTimelineItem[] = []; + const seen = new Set(); + + const push = (item: WatchTaskTimelineItem) => { + if (seen.has(item.id)) return; + seen.add(item.id); + timeline.push(item); + }; + + for (const event of events.slice(-80)) { + const update = extractSessionUpdate(event); + if (!update) continue; + switch (update.sessionUpdate) { + case "agent_message": + if (update.content?.type === "text") { + push({ + id: `message-${event.ts}-${timeline.length}`, + title: truncate(update.content.text, 64) ?? "Agent update", + kind: "progress", + timestamp: event.ts, + }); + } + break; + case "tool_call": + push({ + id: `tool-${update.toolCallId ?? event.ts}`, + title: truncate(update.title, 64) ?? "Started action", + detail: truncate( + update.rawInput + ? formatToolSummary({ + id: update.toolCallId ?? "tool", + title: update.title ?? "Tool", + status: mapToolStatus(update.status), + args: update.rawInput, + ts: event.ts, + }) + : undefined, + ), + kind: isQuestionTool({ + id: update.toolCallId ?? "tool", + title: update.title ?? "Tool", + status: mapToolStatus(update.status), + args: update.rawInput, + ts: event.ts, + }) + ? "approval" + : "tool", + timestamp: event.ts, + }); + break; + case "plan": + push({ + id: `plan-${event.ts}`, + title: "Plan updated", + detail: `${Array.isArray(update.entries) ? update.entries.length : 0} steps`, + kind: "progress", + timestamp: event.ts, + }); + break; + } + } + + for (const tool of tools + .filter( + (tool) => tool.status === "failed" || deriveToolRisk(tool) !== "low", + ) + .slice(-3)) { + push({ + id: `tool-state-${tool.id}`, + title: tool.status === "failed" ? "Action failed" : "Risky action", + detail: truncate(tool.title, MAX_DETAIL_LENGTH), + kind: tool.status === "failed" ? "failed" : "approval", + timestamp: tool.ts, + }); + } + + return timeline + .sort((a, b) => b.timestamp - a.timestamp) + .slice(0, MAX_TIMELINE_ITEMS); +} + +function getCurrentTask( + checklist: WatchTaskChecklistItem[], + tools: ToolState[], +): string | undefined { + const runningPlan = checklist.find((item) => item.status === "running"); + if (runningPlan) return runningPlan.title; + const runningTool = [...tools] + .reverse() + .find((tool) => tool.status === "running" || tool.status === "pending"); + if (runningTool) return truncate(runningTool.title, MAX_TITLE_LENGTH); + return checklist.find((item) => item.status === "pending")?.title; +} + +function deriveStatus(args: { + session?: TaskSession; + approval?: WatchTaskApproval; + progress: WatchTaskProgress; + isStale: boolean; + runStatus?: string | null; +}): WatchTaskStatus { + const { session, approval, isStale, runStatus } = args; + if (session?.terminalStatus === "failed") return "failed"; + if (session?.terminalStatus === "completed") return "completed"; + if (runStatus === "completed") return "completed"; + if (runStatus === "failed" || runStatus === "cancelled") return "failed"; + if (isStale) return "stale"; + if (approval) return "waiting_for_approval"; + if (session?.status === "connecting") return "connecting"; + if (session?.lastError) return "blocked"; + if (session?.isPromptPending) { + return "running"; + } + return "idle"; +} + +function statusText(status: WatchTaskStatus): string { + switch (status) { + case "connecting": + return "Connecting"; + case "running": + return "Running"; + case "waiting_for_approval": + return "Approval needed"; + case "blocked": + return "Blocked"; + case "failed": + return "Failed"; + case "completed": + return "Completed"; + case "stale": + return "Stale"; + default: + return "Idle"; + } +} + +function allowedActions( + status: WatchTaskStatus, + approval?: WatchTaskApproval, +): WatchTaskActionType[] { + const actions: WatchTaskActionType[] = ["open_phone", "open_mac"]; + if (approval) { + actions.unshift("reject"); + actions.unshift("approve"); + if (approval.diffAvailable) actions.splice(2, 0, "view_diff"); + } + if ( + status === "running" || + status === "waiting_for_approval" || + status === "stale" + ) { + actions.push("stop"); + } + if (status === "failed" || status === "blocked" || status === "stale") { + actions.push("retry"); + } + return [...new Set(actions)]; +} + +export function createWatchTaskSnapshot( + task: Task, + session?: TaskSession, + options: TaskBuildOptions = {}, +): WatchTaskSnapshot { + const now = options.now ?? Date.now(); + const run = task.latest_run; + const events = session?.events ?? []; + const plan = extractPlan(events); + const tools = collectTools(events); + const checklist = buildChecklist(plan, tools); + const progress = calculateProgress(checklist); + const approval = [...tools].reverse().map(extractApproval).find(Boolean); + const lastEventAt = + session?.lastEventAt ?? + toMillis(run?.updated_at) ?? + toMillis(task.updated_at) ?? + now; + const environment = run?.environment ?? "unknown"; + const isStale = + options.isStale ?? + (environment === "local" && + !!session?.isPromptPending && + lastEventAt > 0 && + now - lastEventAt > STALE_AFTER_MS); + const status = deriveStatus({ + session, + approval, + progress, + isStale, + runStatus: run?.status, + }); + const startedAt = + toMillis(run?.created_at) ?? toMillis(task.created_at) ?? now; + const completedAt = toMillis(run?.completed_at); + const elapsedSeconds = Math.max( + 0, + Math.floor(((completedAt ?? now) - startedAt) / 1000), + ); + const currentTask = getCurrentTask(checklist, tools); + const timeline = buildTimeline(events, tools); + const taskRunId = session?.taskRunId ?? run?.id; + + if (run?.status === "completed") { + timeline.unshift({ + id: `completed-${taskRunId ?? task.id}`, + title: "Run completed", + kind: "completed", + timestamp: completedAt ?? now, + }); + } else if (run?.status === "failed" || session?.terminalStatus === "failed") { + timeline.unshift({ + id: `failed-${taskRunId ?? task.id}`, + title: "Run failed", + detail: truncate(session?.lastError ?? run?.error_message ?? undefined), + kind: "failed", + timestamp: completedAt ?? now, + }); + } + + return { + schemaVersion: 1, + id: taskRunId ? `${task.id}:${taskRunId}` : task.id, + generatedAt: now, + source: + environment === "local" + ? "desktop" + : environment === "cloud" + ? "cloud" + : "mobile", + taskId: task.id, + taskRunId, + taskNumber: task.task_number, + slug: task.slug, + title: + truncate(task.title || task.description, MAX_TITLE_LENGTH) ?? + "PostHog Code task", + subtitle: truncate(task.description, 96), + repository: task.repository, + branch: run?.branch, + internal: task.internal, + isArchived: options.isArchived, + environment, + status, + statusText: statusText(status), + currentTask, + createdAt: toMillis(task.created_at), + startedAt, + updatedAt: toMillis(run?.updated_at) ?? toMillis(task.updated_at), + completedAt, + elapsedSeconds, + progress, + checklist, + timeline: timeline.slice(0, MAX_TIMELINE_ITEMS), + approval, + blocker: + status === "failed" || status === "blocked" || status === "stale" + ? { + title: + status === "stale" + ? "Desktop connection stale" + : statusText(status), + detail: truncate( + options.staleReason ?? + session?.lastError ?? + run?.error_message ?? + undefined, + ), + kind: status === "stale" ? "stale" : "error", + } + : approval + ? { + title: approval.title, + detail: approval.summary, + kind: "approval", + } + : undefined, + lastError: session?.lastError ?? run?.error_message ?? null, + isStale, + staleReason: isStale + ? (options.staleReason ?? "No recent updates from the desktop runner.") + : undefined, + allowedActions: allowedActions(status, approval), + handoff: { + phoneUrl: `posthog://task/${task.id}`, + macUrl: taskRunId + ? `posthog-code://task/${task.id}/run/${taskRunId}` + : `posthog-code://task/${task.id}`, + }, + }; +} + +function inboxStatusText(status: SignalReport["status"]): string { + switch (status) { + case "ready": + return "Ready"; + case "pending_input": + return "Needs input"; + case "in_progress": + return "Researching"; + case "candidate": + return "Queued"; + case "potential": + return "Gathering"; + case "failed": + return "Failed"; + case "suppressed": + return "Suppressed"; + case "deleted": + return "Deleted"; + default: + return status; + } +} + +function actionabilityText( + value: SignalReport["actionability"], +): string | undefined { + switch (value) { + case "immediately_actionable": + return "Actionable"; + case "requires_human_input": + return "Needs input"; + case "not_actionable": + return "Not actionable"; + default: + return undefined; + } +} + +function toWatchReviewer( + reviewer: SuggestedReviewer, + currentUserUuid?: string, +): WatchInboxSuggestedReviewer { + const uuid = reviewer.user?.uuid; + return { + uuid, + name: + reviewer.user?.first_name?.trim() || + reviewer.github_name?.trim() || + reviewer.github_login, + githubLogin: reviewer.github_login, + isMe: !!uuid && uuid === currentUserUuid, + }; +} + +export function createWatchInboxReviewers( + reviewers: AvailableSuggestedReviewer[], + currentUserUuid?: string, +): WatchInboxReviewer[] { + const seen = new Set(); + return reviewers + .filter((reviewer) => { + if (!reviewer.uuid || seen.has(reviewer.uuid)) return false; + seen.add(reviewer.uuid); + return true; + }) + .map((reviewer) => ({ + uuid: reviewer.uuid, + name: reviewer.name?.trim() || reviewer.email?.trim() || "Unknown user", + email: reviewer.email?.trim() || undefined, + githubLogin: reviewer.github_login?.trim() || undefined, + isMe: reviewer.uuid === currentUserUuid, + })) + .sort((a, b) => { + if (a.isMe && !b.isMe) return -1; + if (!a.isMe && b.isMe) return 1; + return a.name.localeCompare(b.name); + }); +} + +export function createWatchInboxReportSnapshot( + report: SignalReport, + options: WatchInboxBuildOptions = {}, +): WatchInboxReportSnapshot { + const suggestedReviewers = (options.reviewers ?? []).map((reviewer) => + toWatchReviewer(reviewer, options.currentUserUuid), + ); + const suggestedReviewerUuids = suggestedReviewers + .map((reviewer) => reviewer.uuid) + .filter((uuid): uuid is string => !!uuid); + const isAwaitingInput = + report.status === "pending_input" || + (report.status === "ready" && + report.actionability === "requires_human_input"); + const canStartTask = + isAwaitingInput || + (report.status === "ready" && + report.actionability === "immediately_actionable" && + report.already_addressed !== true); + const allowedActions: WatchInboxReportAction[] = ["dismiss"]; + if (canStartTask) { + allowedActions.push(isAwaitingInput ? "implement_as_task" : "start_task"); + } + allowedActions.push("open_phone"); + + return { + schemaVersion: 1, + id: report.id, + title: truncate(report.title, MAX_TITLE_LENGTH) ?? "Untitled signal", + summary: report.summary ?? undefined, + status: report.status, + statusText: inboxStatusText(report.status), + priority: report.priority, + actionability: report.actionability, + actionabilityText: actionabilityText(report.actionability), + alreadyAddressed: report.already_addressed, + isSuggestedReviewer: report.is_suggested_reviewer, + suggestedReviewerUuids, + suggestedReviewers, + sourceProducts: report.source_products ?? [], + signalCount: report.signal_count, + totalWeight: report.total_weight, + createdAt: toMillis(report.created_at), + updatedAt: toMillis(report.updated_at), + implementationPrUrl: report.implementation_pr_url, + repository: options.repository ?? undefined, + allowedActions, + handoff: { + phoneUrl: `posthog://report/${report.id}`, + }, + }; +} + +export function createWatchAutomationSnapshot( + automation: TaskAutomation, + lastTaskRunStatus?: TaskRun["status"] | null, +): WatchAutomationSnapshot { + const presentation = getAutomationTemplatePresentation(automation); + const runStatus = getAutomationStatusPresentation({ + lastRunStatus: automation.last_run_status, + lastTaskRunStatus, + }); + + return { + schemaVersion: 1, + id: automation.id, + name: truncate(automation.name, MAX_TITLE_LENGTH) ?? "Untitled automation", + prompt: automation.prompt, + repository: automation.repository || null, + templateName: presentation.templateName, + secondaryLabel: presentation.secondaryLabel, + scheduleSummary: formatAutomationScheduleSummary(automation), + cronExpression: automation.cron_expression, + timezone: automation.timezone ?? null, + enabled: automation.enabled, + statusText: runStatus.label, + lastRunStatus: automation.last_run_status, + lastTaskRunStatus, + lastRunAt: toMillis(automation.last_run_at), + lastTaskId: automation.last_task_id, + lastError: automation.last_error, + createdAt: toMillis(automation.created_at), + updatedAt: toMillis(automation.updated_at), + allowedActions: [ + automation.enabled ? "pause" : "resume", + "run", + "open_phone", + ], + handoff: { + phoneUrl: `posthog://automation/${automation.id}`, + }, + }; +} + +export function createWatchTaskEnvelope( + sourceTasks: Task[], + sessionsByTaskId: Record, + activeTaskId?: string, + options: TaskBuildOptions & { + archivedTaskIds?: Set; + isAuthenticated?: boolean; + inboxReports?: WatchInboxReportSnapshot[]; + inboxReviewers?: WatchInboxReviewer[]; + automations?: WatchAutomationSnapshot[]; + } = {}, +): WatchTaskEnvelope { + const now = options.now ?? Date.now(); + const taskSnapshots = sourceTasks + .map((task) => + createWatchTaskSnapshot(task, sessionsByTaskId[task.id], { + ...options, + now, + isArchived: options.archivedTaskIds?.has(task.id) ?? false, + }), + ) + .sort( + (a, b) => (b.updatedAt ?? b.generatedAt) - (a.updatedAt ?? a.generatedAt), + ); + + return { + schemaVersion: 1, + generatedAt: now, + isAuthenticated: options.isAuthenticated ?? true, + activeTaskId: taskSnapshots.find( + (snapshot) => snapshot.taskId === activeTaskId, + )?.id, + tasks: taskSnapshots, + inboxReports: options.inboxReports ?? [], + inboxReviewers: options.inboxReviewers ?? [], + automations: options.automations ?? [], + }; +} diff --git a/apps/mobile/src/features/tasks/watchTaskControlBridge.ts b/apps/mobile/src/features/tasks/watchTaskControlBridge.ts new file mode 100644 index 000000000..26da82fa3 --- /dev/null +++ b/apps/mobile/src/features/tasks/watchTaskControlBridge.ts @@ -0,0 +1,79 @@ +import { NativeEventEmitter, NativeModules, Platform } from "react-native"; +import { logger } from "@/lib/logger"; +import type { WatchTaskCommand, WatchTaskEnvelope } from "./types"; + +type NativeWatchTaskControlModule = { + isSupported: () => Promise; + publishEnvelope: (envelope: WatchTaskEnvelope) => Promise; + sendUrgentUpdate: (envelope: WatchTaskEnvelope) => Promise; +}; + +const nativeModule = NativeModules.WatchTaskControlModule as + | NativeWatchTaskControlModule + | undefined; + +const log = logger.scope("watch-task-control"); + +const isAvailable = Platform.OS === "ios" && !!nativeModule; +const emitter = + isAvailable && nativeModule + ? new NativeEventEmitter(nativeModule as never) + : null; + +export function isWatchTaskControlAvailable(): boolean { + return isAvailable; +} + +export async function isWatchTaskControlSupported(): Promise { + if (!isAvailable || !nativeModule) return false; + try { + return await nativeModule.isSupported(); + } catch { + return false; + } +} + +export async function publishWatchTaskEnvelope( + envelope: WatchTaskEnvelope, +): Promise { + if (!isAvailable || !nativeModule) { + log.warn("WatchTaskControl native module is unavailable", { + platform: Platform.OS, + hasModule: !!nativeModule, + }); + return false; + } + return nativeModule.publishEnvelope(envelope); +} + +export async function sendUrgentWatchTaskUpdate( + envelope: WatchTaskEnvelope, +): Promise { + if (!isAvailable || !nativeModule) { + log.warn("WatchTaskControl native module is unavailable", { + platform: Platform.OS, + hasModule: !!nativeModule, + }); + return false; + } + return nativeModule.sendUrgentUpdate(envelope); +} + +export function subscribeToWatchTaskCommands( + handler: (command: WatchTaskCommand) => void, +): () => void { + if (!emitter) return () => {}; + const subscription = emitter.addListener( + "WatchTaskControlCommand", + (payload: WatchTaskCommand) => { + if ( + payload && + typeof payload === "object" && + typeof payload.type === "string" + ) { + handler(payload); + } + }, + ); + return () => subscription.remove(); +} From 44690522f1e5c24b73687047ae87c7813acc4daf Mon Sep 17 00:00:00 2001 From: dylan Date: Thu, 14 May 2026 11:17:58 -0400 Subject: [PATCH 61/94] fix push notif auth --- apps/mobile/scripts/push-sim.sh | 39 ++++++++ apps/mobile/scripts/send-test-push.sh | 93 +++++++++++++++++++ .../mobile/src/features/auth/lib/constants.ts | 3 + .../notifications/stores/pushTokenStore.ts | 13 ++- apps/mobile/src/lib/api.ts | 13 ++- 5 files changed, 151 insertions(+), 10 deletions(-) create mode 100755 apps/mobile/scripts/push-sim.sh create mode 100755 apps/mobile/scripts/send-test-push.sh diff --git a/apps/mobile/scripts/push-sim.sh b/apps/mobile/scripts/push-sim.sh new file mode 100755 index 000000000..893607a3c --- /dev/null +++ b/apps/mobile/scripts/push-sim.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Send a fake push to the booted iOS simulator. No Expo, no APNs, no token. +# Requires Xcode tools (`xcrun simctl` ships with Xcode). +# +# Usage: ./scripts/push-sim.sh ["Title"] ["Body"] [taskId] [taskRunId] +# All args optional; defaults to a generic test push. + +set -euo pipefail + +BUNDLE_ID="com.posthog.code.mobile" +TITLE="${1:-PostHog Code}" +BODY="${2:-Test push — tap me}" +TASK_ID="${3:-00000000-0000-0000-0000-000000000000}" +TASK_RUN_ID="${4:-00000000-0000-0000-0000-000000000000}" + +# Verify a simulator is booted. +if ! xcrun simctl list devices booted | grep -q "Booted"; then + echo "ERROR: no booted simulator. Start one with: pnpm --filter @posthog/mobile ios" >&2 + exit 1 +fi + +PAYLOAD=$(mktemp -t push-sim.XXXXXX.json) +trap 'rm -f "$PAYLOAD"' EXIT + +cat > "$PAYLOAD" <&2 + exit 1 +fi + +if [[ "$TOKEN" != ExponentPushToken\[* ]]; then + echo "ERROR: token should look like ExponentPushToken[xxx]. Got: $TOKEN" >&2 + exit 1 +fi + +echo "Sending push…" + +# Send the push. -s silences progress, --fail-with-body returns non-zero on +# HTTP errors but still prints the body so we can see the reason. +RESP=$( + curl -sS --fail-with-body \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -H "Accept-Encoding: gzip, deflate" \ + -X POST https://exp.host/--/api/v2/push/send \ + -d "$(cat <&2 + exit 1 +fi + +echo "Accepted by Expo (id=$ID). Waiting 3s before checking delivery receipt…" +sleep 3 + +RECEIPT=$( + curl -sS \ + -H "Content-Type: application/json" \ + -X POST https://exp.host/--/api/v2/push/getReceipts \ + -d "{\"ids\":[\"$ID\"]}" +) + +echo "Receipt: $RECEIPT" + +case "$RECEIPT" in + *'"status":"ok"'*) + echo "Delivered to APNs/FCM." + ;; + *'"status":"error"'*) + echo "Delivery error — read the message field above (common: DeviceNotRegistered)." >&2 + exit 1 + ;; + *'{}'*|*'"data":{}'*) + echo "Receipt not ready yet — re-run the receipt check in a few seconds:" + echo " curl -sS -H 'Content-Type: application/json' -X POST https://exp.host/--/api/v2/push/getReceipts -d '{\"ids\":[\"$ID\"]}'" + ;; + *) + echo "Unrecognized receipt shape — check the raw response above." >&2 + ;; +esac diff --git a/apps/mobile/src/features/auth/lib/constants.ts b/apps/mobile/src/features/auth/lib/constants.ts index 2effeee46..67c956505 100644 --- a/apps/mobile/src/features/auth/lib/constants.ts +++ b/apps/mobile/src/features/auth/lib/constants.ts @@ -6,6 +6,9 @@ export const POSTHOG_DEV_CLIENT_ID = "DC5uRLVbGI02YQ82grxgnK6Qn12SXWpCqdPb60oZ"; export const OAUTH_SCOPES = [ "user:read", + // Required for POST /api/users/@me/push_tokens/ — without it the backend + // rejects push-token registration with 403 and notifications never fire. + "user:write", "project:read", "task:write", "integration:read", diff --git a/apps/mobile/src/features/notifications/stores/pushTokenStore.ts b/apps/mobile/src/features/notifications/stores/pushTokenStore.ts index 98df10b67..4f334f258 100644 --- a/apps/mobile/src/features/notifications/stores/pushTokenStore.ts +++ b/apps/mobile/src/features/notifications/stores/pushTokenStore.ts @@ -66,19 +66,18 @@ export const usePushTokenStore = create((set, get) => ({ set({ expoPushToken: token }); } - if (token === get().lastUploadedToken) { - log.debug("Push token unchanged, skipping upload"); - return; - } + if (token === get().lastUploadedToken) return; try { await registerPushToken({ token, platform: Platform.OS }); await writeSecure(LAST_UPLOADED_KEY, token); set({ lastUploadedToken: token }); - log.debug("Uploaded push token to backend"); } catch (err) { - log.debug("Push token upload failed (endpoint may not exist yet)", { - error: err, + // Surface as warn so a misconfigured OAuth scope or backend regression + // doesn't fail silently — push notifications won't work until this row + // lands on the backend. + log.warn("Push token upload failed", { + error: err instanceof Error ? err.message : String(err), }); } }, diff --git a/apps/mobile/src/lib/api.ts b/apps/mobile/src/lib/api.ts index da4df5912..1d1d2a2e0 100644 --- a/apps/mobile/src/lib/api.ts +++ b/apps/mobile/src/lib/api.ts @@ -52,17 +52,24 @@ export async function registerPushToken(args: { // Push tokens are per-user, not per-project — endpoint lives under // /api/users/@me/ alongside the other user-scoped APIs. - const response = await fetch(`${baseUrl}/api/users/@me/push_tokens/`, { + const url = `${baseUrl}/api/users/@me/push_tokens/`; + const response = await fetch(url, { method: "POST", headers, body: JSON.stringify(args), }); if (!response.ok) { - log.debug("registerPushToken non-OK response", { + const body = await response.text().catch(() => ""); + log.warn("registerPushToken failed", { + url, status: response.status, + statusText: response.statusText, + body: body.slice(0, 500), }); - return; + throw new Error( + `registerPushToken failed: ${response.status} ${response.statusText} — ${body.slice(0, 200)}`, + ); } } From 685422588339ad4a0859211c41c9146e31a4794e Mon Sep 17 00:00:00 2001 From: Annika <14750837+annikaschmid@users.noreply.github.com> Date: Thu, 14 May 2026 11:20:14 -0400 Subject: [PATCH 62/94] feat(mobile): build skill-based automation creation --- apps/mobile/src/app/_layout.tsx | 2 +- apps/mobile/src/app/auth.tsx | 3 +- apps/mobile/src/app/automation/[id].tsx | 9 +- apps/mobile/src/app/automation/create.tsx | 175 ++++++--- apps/mobile/src/app/automation/index.tsx | 18 +- .../mobile/src/features/auth/lib/constants.ts | 3 + .../features/auth/stores/authStore.test.ts | 138 +++++++ .../src/features/auth/stores/authStore.ts | 52 ++- apps/mobile/src/features/auth/types.ts | 1 + .../features/tasks/api.automations.test.ts | 36 +- .../tasks/components/AutomationForm.test.tsx | 47 ++- .../tasks/components/AutomationForm.tsx | 61 ++- .../components/AutomationSkillCard.test.tsx | 93 +++++ .../tasks/components/AutomationSkillCard.tsx | 92 +++++ .../AutomationSkillChooser.test.tsx | 167 +++++++++ .../components/AutomationSkillChooser.tsx | 123 ++++++ .../components/AutomationTemplateCard.tsx | 50 --- .../AutomationTemplateGallery.test.tsx | 93 ----- .../components/AutomationTemplateGallery.tsx | 57 --- .../CreateAutomationScreen.test.tsx | 217 +++++++++++ .../tasks/hooks/useAutomations.test.ts | 12 +- .../src/features/tasks/skills/api.test.ts | 87 +++++ apps/mobile/src/features/tasks/skills/api.ts | 48 +++ .../mobile/src/features/tasks/skills/hooks.ts | 30 ++ .../features/tasks/skills/skillTemplateIds.ts | 16 + .../mobile/src/features/tasks/skills/types.ts | 8 + .../templates/automationTemplates.test.ts | 47 --- .../tasks/templates/automationTemplates.ts | 105 ------ apps/mobile/src/features/tasks/types.ts | 25 -- .../automationTemplatePresentation.test.ts | 18 +- .../utils/automationTemplatePresentation.ts | 10 +- ...ile-skill-store-automation-chooser-plan.md | 350 ++++++++++++++++++ 32 files changed, 1682 insertions(+), 511 deletions(-) create mode 100644 apps/mobile/src/features/auth/stores/authStore.test.ts create mode 100644 apps/mobile/src/features/tasks/components/AutomationSkillCard.test.tsx create mode 100644 apps/mobile/src/features/tasks/components/AutomationSkillCard.tsx create mode 100644 apps/mobile/src/features/tasks/components/AutomationSkillChooser.test.tsx create mode 100644 apps/mobile/src/features/tasks/components/AutomationSkillChooser.tsx delete mode 100644 apps/mobile/src/features/tasks/components/AutomationTemplateCard.tsx delete mode 100644 apps/mobile/src/features/tasks/components/AutomationTemplateGallery.test.tsx delete mode 100644 apps/mobile/src/features/tasks/components/AutomationTemplateGallery.tsx create mode 100644 apps/mobile/src/features/tasks/components/CreateAutomationScreen.test.tsx create mode 100644 apps/mobile/src/features/tasks/skills/api.test.ts create mode 100644 apps/mobile/src/features/tasks/skills/api.ts create mode 100644 apps/mobile/src/features/tasks/skills/hooks.ts create mode 100644 apps/mobile/src/features/tasks/skills/skillTemplateIds.ts create mode 100644 apps/mobile/src/features/tasks/skills/types.ts delete mode 100644 apps/mobile/src/features/tasks/templates/automationTemplates.test.ts delete mode 100644 apps/mobile/src/features/tasks/templates/automationTemplates.ts create mode 100644 docs/plans/2026-05-14-001-feat-mobile-skill-store-automation-chooser-plan.md diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 7055f6897..e8c405fca 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -121,7 +121,7 @@ function RootLayoutNav({ isConnected }: RootLayoutNavProps) { options={{ presentation: "modal", headerShown: true, - title: "Choose a template", + title: "Create automation", headerStyle: { backgroundColor: themeColors.background }, headerTintColor: themeColors.gray[12], }} diff --git a/apps/mobile/src/app/auth.tsx b/apps/mobile/src/app/auth.tsx index 013b638e8..debede39d 100644 --- a/apps/mobile/src/app/auth.tsx +++ b/apps/mobile/src/app/auth.tsx @@ -196,7 +196,8 @@ export default function AuthScreen() { 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. + integration:read, conversation:write, query:read, + llm_skill:read. (null); const [generalError, setGeneralError] = useState(null); - const automationTemplate = automation - ? getAutomationTemplate(automation.template_id) - : null; const repositoryRequired = automation - ? (automationTemplate?.requiresRepository ?? - automation.repository.trim().length > 0) + ? parseSkillTemplateId(automation.template_id) !== null || + automation.repository.trim().length > 0 : true; if (error || (!automation && !isLoading)) { diff --git a/apps/mobile/src/app/automation/create.tsx b/apps/mobile/src/app/automation/create.tsx index 4bde8e90e..30c35ad85 100644 --- a/apps/mobile/src/app/automation/create.tsx +++ b/apps/mobile/src/app/automation/create.tsx @@ -1,19 +1,29 @@ import { getCalendars } from "expo-localization"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { useMemo, useState } from "react"; -import { KeyboardAvoidingView, Platform, ScrollView, View } from "react-native"; +import { + ActivityIndicator, + KeyboardAvoidingView, + Platform, + Pressable, + ScrollView, + View, +} from "react-native"; import { Text } from "@/components/text"; import { TaskAutomationValidationError } from "@/features/tasks/api"; import { AutomationForm } from "@/features/tasks/components/AutomationForm"; import { useCreateTaskAutomation } from "@/features/tasks/hooks/useAutomations"; -import { - getAutomationTemplate, - getAutomationTemplateInitialValues, -} from "@/features/tasks/templates/automationTemplates"; +import { useSkillStoreSkill } from "@/features/tasks/skills/hooks"; +import { formatSkillTemplateId } from "@/features/tasks/skills/skillTemplateIds"; import { useThemeColors } from "@/lib/theme"; export default function CreateAutomationScreen() { - const { templateId } = useLocalSearchParams<{ templateId?: string }>(); + const { skillName: skillNameParam } = useLocalSearchParams<{ + skillName?: string | string[]; + }>(); + const skillName = Array.isArray(skillNameParam) + ? skillNameParam[0] + : skillNameParam; const router = useRouter(); const themeColors = useThemeColors(); const createAutomation = useCreateTaskAutomation(); @@ -21,13 +31,19 @@ export default function CreateAutomationScreen() { () => getCalendars()[0]?.timeZone ?? "UTC", [], ); - const selectedTemplate = useMemo( - () => getAutomationTemplate(templateId), - [templateId], - ); - const templateInitialValues = useMemo( - () => getAutomationTemplateInitialValues(templateId), - [templateId], + const selectedSkill = useSkillStoreSkill(skillName ?? null); + const selectedSkillName = selectedSkill.data?.name ?? skillName ?? null; + const skillInitialValues = useMemo( + () => + selectedSkill.data && selectedSkillName + ? { + name: selectedSkill.data.name, + prompt: selectedSkill.data.body, + enabled: true, + template_id: formatSkillTemplateId(selectedSkillName), + } + : null, + [selectedSkill.data, selectedSkillName], ); const [fieldError, setFieldError] = useState<{ attr: string | null; @@ -40,7 +56,7 @@ export default function CreateAutomationScreen() { - - - {selectedTemplate?.name ?? "Custom automation"} - - - {selectedTemplate?.description ?? - "Start from scratch and write your own automation prompt."} - - + {skillName && selectedSkill.isPending ? ( + + + + Loading the selected skill... + + + ) : skillName && (selectedSkill.error || !selectedSkill.data) ? ( + + + Could not load this skill + + + {selectedSkill.error?.message ?? + "The selected skill is unavailable right now."} + + + void selectedSkill.refetch()} + className="flex-1 rounded-xl border border-gray-6 bg-gray-1 py-3" + > + + Try again + + + router.replace("/automation/create")} + className="flex-1 rounded-xl border border-gray-6 bg-gray-1 py-3" + > + + Start from scratch + + + + + ) : ( + <> + {selectedSkillName && ( + + + Skill + + + {selectedSkillName} + + + )} - { - setFieldError(null); - setGeneralError(null); + { + setFieldError(null); + setGeneralError(null); - try { - const automation = await createAutomation.mutateAsync({ - ...values, - template_id: templateInitialValues?.template_id ?? null, - }); - router.replace(`/automation/${automation.id}`); - } catch (error) { - if (error instanceof TaskAutomationValidationError) { - setFieldError({ - attr: error.attr, - message: error.message, - }); - return; - } + try { + const automation = await createAutomation.mutateAsync({ + ...values, + template_id: skillInitialValues?.template_id ?? null, + }); + router.replace(`/automation/${automation.id}`); + } catch (error) { + if (error instanceof TaskAutomationValidationError) { + setFieldError({ + attr: error.attr, + message: error.message, + }); + return; + } - setGeneralError( - "Could not create automation. Please try again.", - ); - } - }} - onCancel={() => router.back()} - /> + setGeneralError( + "Could not create automation. Please try again.", + ); + } + }} + onCancel={() => router.back()} + /> + + )} diff --git a/apps/mobile/src/app/automation/index.tsx b/apps/mobile/src/app/automation/index.tsx index fbd7b832a..6bc68e490 100644 --- a/apps/mobile/src/app/automation/index.tsx +++ b/apps/mobile/src/app/automation/index.tsx @@ -1,7 +1,7 @@ import { Stack, useRouter } from "expo-router"; import { ScrollView, View } from "react-native"; import { Text } from "@/components/text"; -import { AutomationTemplateGallery } from "@/features/tasks/components/AutomationTemplateGallery"; +import { AutomationSkillChooser } from "@/features/tasks/components/AutomationSkillChooser"; import { useThemeColors } from "@/lib/theme"; export default function AutomationTemplateScreen() { @@ -13,7 +13,7 @@ export default function AutomationTemplateScreen() { - + - Start with a template + Choose how to start - Pick a daily briefing template, tweak the prompt and schedule, - then save it as an automation. + Start from scratch, or pick a shared skill from the store and + tailor it before saving. - + router.push({ pathname: "/automation/create", - params: { templateId }, + params: { skillName }, }) } onCreateCustom={() => router.push("/automation/create")} diff --git a/apps/mobile/src/features/auth/lib/constants.ts b/apps/mobile/src/features/auth/lib/constants.ts index 2effeee46..d22399eae 100644 --- a/apps/mobile/src/features/auth/lib/constants.ts +++ b/apps/mobile/src/features/auth/lib/constants.ts @@ -11,8 +11,11 @@ export const OAUTH_SCOPES = [ "integration:read", "conversation:write", "query:read", + "llm_skill:read", ]; +export const OAUTH_SCOPE_VERSION = 1; + // Token refresh settings export const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; // 5 minutes before expiry diff --git a/apps/mobile/src/features/auth/stores/authStore.test.ts b/apps/mobile/src/features/auth/stores/authStore.test.ts new file mode 100644 index 000000000..09335ee9d --- /dev/null +++ b/apps/mobile/src/features/auth/stores/authStore.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + mockPerformOAuthFlow, + mockRefreshAccessTokenRequest, + mockGetTokens, + mockSaveTokens, + mockDeleteTokens, + mockRegisterAndUpload, + mockClearPushToken, + mockQueryClientClear, +} = vi.hoisted(() => ({ + mockPerformOAuthFlow: vi.fn(), + mockRefreshAccessTokenRequest: vi.fn(), + mockGetTokens: vi.fn(), + mockSaveTokens: vi.fn(), + mockDeleteTokens: vi.fn(), + mockRegisterAndUpload: vi.fn(), + mockClearPushToken: vi.fn(), + mockQueryClientClear: vi.fn(), +})); + +vi.mock("@react-native-async-storage/async-storage", () => ({ + default: { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + }, +})); + +vi.mock("../lib/oauth", () => ({ + performOAuthFlow: mockPerformOAuthFlow, + refreshAccessToken: mockRefreshAccessTokenRequest, +})); + +vi.mock("../lib/secureStorage", () => ({ + getTokens: mockGetTokens, + saveTokens: mockSaveTokens, + deleteTokens: mockDeleteTokens, +})); + +vi.mock("@/features/notifications/stores/pushTokenStore", () => ({ + usePushTokenStore: { + getState: () => ({ + registerAndUpload: mockRegisterAndUpload, + clear: mockClearPushToken, + }), + }, +})); + +vi.mock("@/features/preferences/stores/preferencesStore", () => ({ + usePreferencesStore: { + getState: () => ({ + pushNotificationsEnabled: false, + }), + }, +})); + +vi.mock("@/lib/queryClient", () => ({ + queryClient: { + clear: mockQueryClientClear, + }, +})); + +import { OAUTH_SCOPE_VERSION } from "../lib/constants"; +import { useAuthStore } from "./authStore"; + +describe("authStore", () => { + beforeEach(() => { + mockPerformOAuthFlow.mockReset(); + mockRefreshAccessTokenRequest.mockReset(); + mockGetTokens.mockReset(); + mockSaveTokens.mockReset(); + mockDeleteTokens.mockReset(); + mockRegisterAndUpload.mockReset(); + mockClearPushToken.mockReset(); + mockQueryClientClear.mockReset(); + + useAuthStore.setState({ + oauthAccessToken: null, + oauthRefreshToken: null, + tokenExpiry: null, + cloudRegion: null, + projectId: null, + isAuthenticated: false, + isLoading: true, + }); + }); + + it("stores the current OAuth scope version on login", async () => { + mockPerformOAuthFlow.mockResolvedValueOnce({ + success: true, + data: { + access_token: "access-token", + refresh_token: "refresh-token", + expires_in: 3600, + token_type: "Bearer", + scope: "user:read", + scoped_teams: [42], + }, + }); + + await useAuthStore.getState().loginWithOAuth("us"); + + expect(mockSaveTokens).toHaveBeenCalledWith( + expect.objectContaining({ + accessToken: "access-token", + refreshToken: "refresh-token", + scopedTeams: [42], + scopeVersion: OAUTH_SCOPE_VERSION, + }), + ); + }); + + it("forces reauthentication when persisted tokens use an older scope version", async () => { + mockGetTokens.mockResolvedValueOnce({ + accessToken: "old-token", + refreshToken: "old-refresh", + expiresAt: Date.now() + 60_000, + cloudRegion: "us", + scopedTeams: [42], + scopeVersion: OAUTH_SCOPE_VERSION - 1, + }); + + const initialized = await useAuthStore.getState().initializeAuth(); + + expect(initialized).toBe(false); + expect(mockDeleteTokens).toHaveBeenCalledOnce(); + expect(mockQueryClientClear).toHaveBeenCalledOnce(); + expect(useAuthStore.getState()).toMatchObject({ + oauthAccessToken: null, + oauthRefreshToken: null, + projectId: null, + isAuthenticated: false, + isLoading: false, + }); + }); +}); diff --git a/apps/mobile/src/features/auth/stores/authStore.ts b/apps/mobile/src/features/auth/stores/authStore.ts index 2162ab1fc..8dda7fb3c 100644 --- a/apps/mobile/src/features/auth/stores/authStore.ts +++ b/apps/mobile/src/features/auth/stores/authStore.ts @@ -7,6 +7,7 @@ import { logger } from "@/lib/logger"; import { queryClient } from "@/lib/queryClient"; import { getCloudUrlFromRegion, + OAUTH_SCOPE_VERSION, OAUTH_SCOPES, TOKEN_REFRESH_BUFFER_MS, } from "../lib/constants"; @@ -45,6 +46,19 @@ interface AuthState { let refreshTimeoutId: ReturnType | null = null; +function buildStoredTokens(args: { + accessToken: string; + refreshToken: string; + expiresAt: number; + cloudRegion: CloudRegion; + scopedTeams?: number[]; +}): StoredTokens { + return { + ...args, + scopeVersion: OAUTH_SCOPE_VERSION, + }; +} + function maybeRegisterPushToken(): void { if (!usePreferencesStore.getState().pushNotificationsEnabled) return; usePushTokenStore @@ -90,13 +104,13 @@ export const useAuthStore = create()( throw new Error("No team found in OAuth scopes"); } - const storedTokens: StoredTokens = { + const storedTokens = buildStoredTokens({ accessToken: tokenResponse.access_token, refreshToken: tokenResponse.refresh_token, expiresAt, cloudRegion: region, scopedTeams: tokenResponse.scoped_teams, - }; + }); // Save tokens securely await saveTokens(storedTokens); @@ -128,13 +142,13 @@ export const useAuthStore = create()( throw new Error("Valid project ID is required"); } - const storedTokens: StoredTokens = { + const storedTokens = buildStoredTokens({ accessToken: trimmed, refreshToken: "", expiresAt: Number.MAX_SAFE_INTEGER, cloudRegion: region, scopedTeams: [projectId], - }; + }); await saveTokens(storedTokens); @@ -165,13 +179,13 @@ export const useAuthStore = create()( const expiresAt = Date.now() + tokenResponse.expires_in * 1000; const projectId = tokenResponse.scoped_teams?.[0] || state.projectId; - const storedTokens: StoredTokens = { + const storedTokens = buildStoredTokens({ accessToken: tokenResponse.access_token, refreshToken: tokenResponse.refresh_token, expiresAt, cloudRegion: state.cloudRegion, scopedTeams: tokenResponse.scoped_teams, - }; + }); // Save tokens securely await saveTokens(storedTokens); @@ -230,6 +244,21 @@ export const useAuthStore = create()( return false; } + if (tokens.scopeVersion !== OAUTH_SCOPE_VERSION) { + await deleteTokens(); + queryClient.clear(); + set({ + oauthAccessToken: null, + oauthRefreshToken: null, + tokenExpiry: null, + cloudRegion: null, + projectId: null, + isLoading: false, + isAuthenticated: false, + }); + return false; + } + const now = Date.now(); const isExpired = tokens.expiresAt <= now; @@ -247,7 +276,16 @@ export const useAuthStore = create()( } catch (error) { logger.error("Failed to refresh expired token:", error); await deleteTokens(); - set({ isLoading: false, isAuthenticated: false }); + queryClient.clear(); + set({ + oauthAccessToken: null, + oauthRefreshToken: null, + tokenExpiry: null, + cloudRegion: null, + projectId: null, + isLoading: false, + isAuthenticated: false, + }); return false; } } diff --git a/apps/mobile/src/features/auth/types.ts b/apps/mobile/src/features/auth/types.ts index be5172035..66a9ccd49 100644 --- a/apps/mobile/src/features/auth/types.ts +++ b/apps/mobile/src/features/auth/types.ts @@ -21,4 +21,5 @@ export interface StoredTokens { expiresAt: number; cloudRegion: CloudRegion; scopedTeams?: number[]; + scopeVersion?: number; } diff --git a/apps/mobile/src/features/tasks/api.automations.test.ts b/apps/mobile/src/features/tasks/api.automations.test.ts index 923296fd1..ea9a06834 100644 --- a/apps/mobile/src/features/tasks/api.automations.test.ts +++ b/apps/mobile/src/features/tasks/api.automations.test.ts @@ -35,7 +35,7 @@ const automationPayload = { github_integration: 7, cron_expression: "0 9 * * *", timezone: "Europe/London", - template_id: "developer-morning-brief", + template_id: "llm-skill:shared-daily-brief", enabled: true, last_run_at: null, last_run_status: null, @@ -88,7 +88,7 @@ describe("task automation api", () => { cron_expression: "0 9 * * *", timezone: "Europe/London", enabled: true, - template_id: "developer-morning-brief", + template_id: "llm-skill:shared-daily-brief", }); expect(mockFetch).toHaveBeenCalledWith( @@ -103,36 +103,34 @@ describe("task automation api", () => { cron_expression: "0 9 * * *", timezone: "Europe/London", enabled: true, - template_id: "developer-morning-brief", + template_id: "llm-skill:shared-daily-brief", }), }), ); }); - it("serializes repo-optional template creation payloads with an empty repository", async () => { + it("serializes skill-backed automation payloads with a prefixed template id", async () => { mockFetch.mockResolvedValueOnce( new Response( JSON.stringify({ ...automationPayload, id: "automation-2", - name: "PM pulse", - repository: "", - github_integration: null, - template_id: "pm-product-pulse", + name: "Shared daily brief", + template_id: "llm-skill:shared-daily-brief", }), { status: 200 }, ), ); await createTaskAutomation({ - name: "PM pulse", + name: "Shared daily brief", prompt: "Summarize feature usage for my product areas.", - repository: "", - github_integration: null, + repository: "posthog/posthog", + github_integration: 7, cron_expression: "0 8 * * 1-5", timezone: "America/New_York", enabled: true, - template_id: "pm-product-pulse", + template_id: "llm-skill:shared-daily-brief", }); expect(mockFetch).toHaveBeenCalledWith( @@ -140,14 +138,14 @@ describe("task automation api", () => { expect.objectContaining({ method: "POST", body: JSON.stringify({ - name: "PM pulse", + name: "Shared daily brief", prompt: "Summarize feature usage for my product areas.", - repository: "", - github_integration: null, + repository: "posthog/posthog", + github_integration: 7, cron_expression: "0 8 * * 1-5", timezone: "America/New_York", enabled: true, - template_id: "pm-product-pulse", + template_id: "llm-skill:shared-daily-brief", }), }), ); @@ -193,7 +191,7 @@ describe("task automation api", () => { }); }); - it("surfaces repo-optional template validation failures without losing backend attr info", async () => { + it("surfaces skill-backed validation failures without losing backend attr info", async () => { mockFetch.mockResolvedValueOnce( new Response( JSON.stringify({ @@ -208,14 +206,14 @@ describe("task automation api", () => { await expect( createTaskAutomation({ - name: "PM pulse", + name: "Shared daily brief", prompt: "Summarize feature usage for my product areas.", repository: "", github_integration: null, cron_expression: "0 8 * * 1-5", timezone: "America/New_York", enabled: true, - template_id: "pm-product-pulse", + template_id: "llm-skill:shared-daily-brief", }), ).rejects.toMatchObject({ attr: "repository", diff --git a/apps/mobile/src/features/tasks/components/AutomationForm.test.tsx b/apps/mobile/src/features/tasks/components/AutomationForm.test.tsx index adb601362..addeaff02 100644 --- a/apps/mobile/src/features/tasks/components/AutomationForm.test.tsx +++ b/apps/mobile/src/features/tasks/components/AutomationForm.test.tsx @@ -1,4 +1,5 @@ import { createElement } from "react"; +import { TextInput } from "react-native"; import { act, create } from "react-test-renderer"; import { describe, expect, it, vi } from "vitest"; @@ -43,10 +44,15 @@ vi.mock("./ScheduleEditor", () => ({ createElement("ScheduleEditor", props), })); +vi.mock("@/features/chat/components/MarkdownText", () => ({ + MarkdownText: (props: Record) => + createElement("MarkdownText", props), +})); + import { AutomationForm } from "./AutomationForm"; describe("AutomationForm", () => { - it("submits repo-optional templates without repository context", async () => { + it("submits successfully when repository selection is optional", async () => { mockUseIntegrations.mockReturnValue({ error: null, hasGithubIntegration: null, @@ -141,6 +147,45 @@ describe("AutomationForm", () => { ); }); + it("renders markdown preview when the prompt starts in preview mode", () => { + mockUseIntegrations.mockReturnValue({ + error: null, + hasGithubIntegration: null, + repositoryOptions: [], + repositoryWarning: null, + isLoading: false, + refetch: vi.fn(), + }); + let renderer: ReturnType | null = null; + + act(() => { + renderer = create( + createElement(AutomationForm, { + initialValues: { + name: "Daily brief", + prompt: "## Summary\n- Check PRs", + timezone: "UTC", + enabled: true, + }, + initialPromptMode: "preview", + isSubmitting: false, + submitLabel: "Create automation", + repositoryRequired: false, + onSubmit: vi.fn(), + }), + ); + }); + + if (!renderer) { + throw new Error("Renderer not created"); + } + + expect(renderer.root.findAllByType(TextInput)).toHaveLength(1); + expect(renderer.root.findByType("MarkdownText").props.content).toBe( + "## Summary\n- Check PRs", + ); + }); + it("requires repository selection for repo-backed submissions", async () => { mockUseIntegrations.mockReturnValue({ error: null, diff --git a/apps/mobile/src/features/tasks/components/AutomationForm.tsx b/apps/mobile/src/features/tasks/components/AutomationForm.tsx index 3477c87bb..aa74db7fb 100644 --- a/apps/mobile/src/features/tasks/components/AutomationForm.tsx +++ b/apps/mobile/src/features/tasks/components/AutomationForm.tsx @@ -7,6 +7,7 @@ import { TextInput, View, } from "react-native"; +import { MarkdownText } from "@/features/chat/components/MarkdownText"; import { useThemeColors } from "@/lib/theme"; import { useIntegrations } from "../hooks/useIntegrations"; import type { @@ -45,6 +46,7 @@ interface AutomationFormProps { onSubmit: (values: CreateTaskAutomationOptions) => Promise | void; onCancel?: () => void; repositoryRequired?: boolean; + initialPromptMode?: "edit" | "preview"; } export function AutomationForm({ @@ -56,6 +58,7 @@ export function AutomationForm({ onSubmit, onCancel, repositoryRequired = true, + initialPromptMode = "edit", }: AutomationFormProps) { const themeColors = useThemeColors(); const { @@ -87,6 +90,9 @@ export function AutomationForm({ !!initialValues?.name?.trim(), ); const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false); + const [promptMode, setPromptMode] = useState<"edit" | "preview">( + initialPromptMode, + ); useEffect(() => { if (hasEditedName) { @@ -232,15 +238,52 @@ export function AutomationForm({ > Prompt - + + {(["edit", "preview"] as const).map((mode) => { + const active = promptMode === mode; + + return ( + setPromptMode(mode)} + className={`rounded-lg border px-3 py-2 ${ + active + ? "border-accent-9 bg-accent-3" + : "border-gray-5 bg-background" + }`} + > + + {mode === "edit" ? "Edit" : "Preview"} + + + ); + })} + + {promptMode === "edit" ? ( + + ) : ( + + {prompt.trim() ? ( + + ) : ( + + Nothing to preview yet. + + )} + + )} {validationErrors.prompt && ( {validationErrors.prompt} diff --git a/apps/mobile/src/features/tasks/components/AutomationSkillCard.test.tsx b/apps/mobile/src/features/tasks/components/AutomationSkillCard.test.tsx new file mode 100644 index 000000000..dfb3ec087 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/AutomationSkillCard.test.tsx @@ -0,0 +1,93 @@ +import { createElement } from "react"; +import { act, create } from "react-test-renderer"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/theme", () => ({ + useThemeColors: () => ({ + accent: { + 11: "#ff5500", + }, + }), +})); + +vi.mock("phosphor-react-native", () => ({ + CaretDown: (props: Record) => + createElement("CaretDown", props), + CaretUp: (props: Record) => createElement("CaretUp", props), +})); + +import { AutomationSkillCard } from "./AutomationSkillCard"; + +describe("AutomationSkillCard", () => { + it("collapses long descriptions by default and expands on demand", () => { + const onPress = vi.fn(); + let renderer: ReturnType | null = null; + + act(() => { + renderer = create( + createElement(AutomationSkillCard, { + skill: { + name: "shared-daily-brief", + description: + "A longer description that should overflow two lines in the card preview when measured by the native text layout callback.", + }, + onPress, + }), + ); + }); + + if (!renderer) { + throw new Error("Renderer not created"); + } + + const descriptionText = + "A longer description that should overflow two lines in the card preview when measured by the native text layout callback."; + const visibleDescriptionNode = renderer.root.findAll( + (node) => + node.props.numberOfLines === 2 && + node.props.children === descriptionText, + )[0]; + const measurementNode = renderer.root.findAll( + (node) => + typeof node.props.onTextLayout === "function" && + node.props.children === descriptionText, + )[0]; + + if (!visibleDescriptionNode || !measurementNode) { + throw new Error("Description node not found"); + } + + expect(visibleDescriptionNode.props.numberOfLines).toBe(2); + + act(() => { + measurementNode.props.onTextLayout({ + nativeEvent: { + lines: [{}, {}, {}], + }, + }); + }); + + const toggle = renderer.root.findAll( + (node) => + typeof node.props.onPress === "function" && + node.props.children?.[1]?.props?.children === "Show more", + )[0]; + + act(() => { + toggle.props.onPress({ stopPropagation: vi.fn() }); + }); + + const updatedDescriptionNode = renderer.root.findAll( + (node) => + node.props.children === descriptionText && + "numberOfLines" in node.props && + node.props.children === descriptionText, + )[0]; + + expect(updatedDescriptionNode?.props.numberOfLines).toBeUndefined(); + expect( + renderer.root.findAll((node) => node.props.children === "Show less") + .length, + ).toBeGreaterThan(0); + }); +}); diff --git a/apps/mobile/src/features/tasks/components/AutomationSkillCard.tsx b/apps/mobile/src/features/tasks/components/AutomationSkillCard.tsx new file mode 100644 index 000000000..dace34492 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/AutomationSkillCard.tsx @@ -0,0 +1,92 @@ +import { CaretDown, CaretUp } from "phosphor-react-native"; +import { useState } from "react"; +import { + type NativeSyntheticEvent, + Pressable, + type TextLayoutEventData, + View, +} from "react-native"; +import { Text } from "@/components/text"; +import { useThemeColors } from "@/lib/theme"; +import type { SkillStoreListEntry } from "../skills/types"; + +interface AutomationSkillCardProps { + skill: SkillStoreListEntry; + onPress: (skillName: string) => void; +} + +export function AutomationSkillCard({ + skill, + onPress, +}: AutomationSkillCardProps) { + const themeColors = useThemeColors(); + const [isExpanded, setIsExpanded] = useState(false); + const [hasMeasuredOverflow, setHasMeasuredOverflow] = useState(false); + const [isOverflowing, setIsOverflowing] = useState(false); + const description = + skill.description ?? "Shared automation starter from your team."; + + function handleTextLayout( + event: NativeSyntheticEvent, + ): void { + if (hasMeasuredOverflow) { + return; + } + + setIsOverflowing(event.nativeEvent.lines.length > 2); + setHasMeasuredOverflow(true); + } + + return ( + onPress(skill.name)} + className="rounded-xl border border-gray-6 bg-gray-1 px-4 py-4 active:opacity-80" + > + + {skill.name} + + + + {description} + + {!hasMeasuredOverflow && ( + + + {description} + + + )} + + {isOverflowing && ( + { + event.stopPropagation(); + setIsExpanded((value) => !value); + }} + hitSlop={6} + className="mt-1 flex-row items-center gap-1 self-start py-1 active:opacity-60" + > + {isExpanded ? ( + + ) : ( + + )} + + {isExpanded ? "Show less" : "Show more"} + + + )} + + ); +} diff --git a/apps/mobile/src/features/tasks/components/AutomationSkillChooser.test.tsx b/apps/mobile/src/features/tasks/components/AutomationSkillChooser.test.tsx new file mode 100644 index 000000000..e9cd9bc0f --- /dev/null +++ b/apps/mobile/src/features/tasks/components/AutomationSkillChooser.test.tsx @@ -0,0 +1,167 @@ +import { createElement } from "react"; +import { act, create } from "react-test-renderer"; +import { describe, expect, it, vi } from "vitest"; + +const { mockUseSkillStoreSkills } = vi.hoisted(() => ({ + mockUseSkillStoreSkills: vi.fn(), +})); + +vi.mock("@/lib/theme", () => ({ + useThemeColors: () => ({ + gray: { + 9: "#666666", + }, + accent: { + 9: "#ff5500", + }, + }), +})); + +vi.mock("../skills/hooks", () => ({ + useSkillStoreSkills: mockUseSkillStoreSkills, +})); + +vi.mock("./AutomationSkillCard", () => ({ + AutomationSkillCard: ({ + skill, + onPress, + }: { + skill: { name: string }; + onPress: (skillName: string) => void; + }) => + createElement( + "AutomationSkillCard", + { + onPress: () => onPress(skill.name), + title: skill.name, + }, + skill.name, + ), +})); + +import { AutomationSkillChooser } from "./AutomationSkillChooser"; + +describe("AutomationSkillChooser", () => { + it("renders start from scratch before skill-store entries", () => { + mockUseSkillStoreSkills.mockReturnValue({ + data: [ + { name: "shared-daily-brief", description: "Briefing starter" }, + { name: "shared-pr-triage", description: "PR triage starter" }, + ], + isLoading: false, + error: null, + refetch: vi.fn(), + }); + + let renderer: ReturnType | null = null; + act(() => { + renderer = create( + createElement(AutomationSkillChooser, { + onCreateCustom: vi.fn(), + onSelectSkill: vi.fn(), + }), + ); + }); + + if (!renderer) { + throw new Error("Renderer not created"); + } + + const labels = renderer.root + .findAll((node) => typeof node.props.children === "string") + .map((node) => node.props.children); + + expect(labels).toContain("Start from scratch"); + expect(labels).toContain("shared-daily-brief"); + expect(labels).toContain("shared-pr-triage"); + expect(labels.indexOf("Start from scratch")).toBeLessThan( + labels.indexOf("shared-daily-brief"), + ); + }); + + it("routes scratch and skill selections through the expected callbacks", () => { + mockUseSkillStoreSkills.mockReturnValue({ + data: [{ name: "shared-daily-brief", description: "Briefing starter" }], + isLoading: false, + error: null, + refetch: vi.fn(), + }); + + const onCreateCustom = vi.fn(); + const onSelectSkill = vi.fn(); + let renderer: ReturnType | null = null; + act(() => { + renderer = create( + createElement(AutomationSkillChooser, { + onCreateCustom, + onSelectSkill, + }), + ); + }); + + if (!renderer) { + throw new Error("Renderer not created"); + } + + const buttons = renderer.root.findAll( + (node) => typeof node.props.onPress === "function", + ); + const skillCard = renderer.root.findByType("AutomationSkillCard"); + + buttons[0]?.props.onPress(); + skillCard.props.onPress(); + + expect(onCreateCustom).toHaveBeenCalledOnce(); + expect(onSelectSkill).toHaveBeenCalledWith("shared-daily-brief"); + }); + + it("filters the skill list by search text and shows a no-match state", () => { + mockUseSkillStoreSkills.mockReturnValue({ + data: [ + { name: "shared-daily-brief", description: "Morning update" }, + { name: "shared-pr-triage", description: "Pull request queue" }, + ], + isLoading: false, + error: null, + refetch: vi.fn(), + }); + + let renderer: ReturnType | null = null; + act(() => { + renderer = create( + createElement(AutomationSkillChooser, { + onCreateCustom: vi.fn(), + onSelectSkill: vi.fn(), + }), + ); + }); + + if (!renderer) { + throw new Error("Renderer not created"); + } + + const searchInput = renderer.root.findByProps({ + placeholder: "Search skills", + }); + + act(() => { + searchInput.props.onChangeText("triage"); + }); + + expect(renderer.root.findAllByType("AutomationSkillCard")).toHaveLength(1); + expect(renderer.root.findByType("AutomationSkillCard").props.title).toBe( + "shared-pr-triage", + ); + + act(() => { + searchInput.props.onChangeText("missing"); + }); + + expect(renderer.root.findAllByType("AutomationSkillCard")).toHaveLength(0); + expect( + renderer.root.findAll( + (node) => node.props.children === "No matching skills", + ).length, + ).toBeGreaterThan(0); + }); +}); diff --git a/apps/mobile/src/features/tasks/components/AutomationSkillChooser.tsx b/apps/mobile/src/features/tasks/components/AutomationSkillChooser.tsx new file mode 100644 index 000000000..48990823a --- /dev/null +++ b/apps/mobile/src/features/tasks/components/AutomationSkillChooser.tsx @@ -0,0 +1,123 @@ +import { useMemo, useState } from "react"; +import { ActivityIndicator, Pressable, TextInput, View } from "react-native"; +import { Text } from "@/components/text"; +import { useThemeColors } from "@/lib/theme"; +import { useSkillStoreSkills } from "../skills/hooks"; +import { AutomationSkillCard } from "./AutomationSkillCard"; + +interface AutomationSkillChooserProps { + onCreateCustom: () => void; + onSelectSkill: (skillName: string) => void; +} + +export function AutomationSkillChooser({ + onCreateCustom, + onSelectSkill, +}: AutomationSkillChooserProps) { + const themeColors = useThemeColors(); + const { data, isLoading, error, refetch } = useSkillStoreSkills(); + const skills = data ?? []; + const [search, setSearch] = useState(""); + const filteredSkills = useMemo(() => { + const query = search.trim().toLowerCase(); + + if (!query) { + return skills; + } + + return skills.filter( + (skill) => + skill.name.toLowerCase().includes(query) || + skill.description?.toLowerCase().includes(query), + ); + }, [search, skills]); + + return ( + + + + Start from scratch + + + Create a custom automation prompt and schedule it yourself. + + + + + + Skill store + + + Shared team skills you can use as automation starters. + + + + {isLoading ? ( + + + Loading skills... + + ) : error ? ( + + + Skills unavailable + + {error.message} + void refetch()} + className="mt-4 self-start rounded-lg border border-gray-6 bg-gray-2 px-3 py-2" + > + Try again + + + ) : skills.length === 0 ? ( + + + No skills available + + + You can still start from scratch above and create a custom + automation. + + + ) : ( + <> + + + {filteredSkills.length === 0 ? ( + + + No matching skills + + + {`No skills match "${search.trim()}" yet.`} + + + ) : ( + filteredSkills.map((skill) => ( + + )) + )} + + )} + + ); +} diff --git a/apps/mobile/src/features/tasks/components/AutomationTemplateCard.tsx b/apps/mobile/src/features/tasks/components/AutomationTemplateCard.tsx deleted file mode 100644 index 2f6cb5cac..000000000 --- a/apps/mobile/src/features/tasks/components/AutomationTemplateCard.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { Pressable, View } from "react-native"; -import { Text } from "@/components/text"; -import type { AutomationTemplate } from "../types"; - -interface AutomationTemplateCardProps { - template: AutomationTemplate; - onPress: (templateId: string) => void; -} - -export function AutomationTemplateCard({ - template, - onPress, -}: AutomationTemplateCardProps) { - return ( - onPress(template.id)} - className={`rounded-xl border px-4 py-4 active:opacity-80 ${ - template.hero - ? "border-accent-6 bg-accent-2" - : "border-gray-6 bg-gray-1" - }`} - > - - - {template.audienceLabel} - - - {template.categoryLabel} - - {template.hero && ( - - Recommended - - )} - - - - {template.name} - - {template.description} - - - {template.requiresRepository - ? "Requires repository access" - : "No repository required"} - - - ); -} diff --git a/apps/mobile/src/features/tasks/components/AutomationTemplateGallery.test.tsx b/apps/mobile/src/features/tasks/components/AutomationTemplateGallery.test.tsx deleted file mode 100644 index b2225d749..000000000 --- a/apps/mobile/src/features/tasks/components/AutomationTemplateGallery.test.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { createElement } from "react"; -import { act, create } from "react-test-renderer"; -import { describe, expect, it, vi } from "vitest"; - -vi.mock("./AutomationTemplateCard", () => ({ - AutomationTemplateCard: ({ - template, - onPress, - }: { - template: { id: string; name: string }; - onPress: (templateId: string) => void; - }) => - createElement( - "AutomationTemplateCard", - { - onPress: () => onPress(template.id), - title: template.name, - }, - template.name, - ), -})); - -import { AutomationTemplateGallery } from "./AutomationTemplateGallery"; - -describe("AutomationTemplateGallery", () => { - it("renders the developer template first and includes the launch set", () => { - let renderer: ReturnType | null = null; - act(() => { - renderer = create( - createElement(AutomationTemplateGallery, { - onSelectTemplate: vi.fn(), - onCreateCustom: vi.fn(), - }), - ); - }); - - if (!renderer) { - throw new Error("Renderer not created"); - } - - const labels = renderer.root - .findAll( - (node) => - typeof node.props.children === "string" && - /brief|pulse|opener/i.test(node.props.children), - ) - .map((node) => node.props.children); - - expect(labels).toContain("Developer morning briefing"); - expect(labels).toContain("PM product pulse"); - expect(labels).toContain("Executive day opener"); - expect( - renderer.root.findAll( - (node) => node.props.children === "Start from scratch", - ).length, - ).toBeGreaterThan(0); - expect(labels.indexOf("Developer morning briefing")).toBeLessThan( - labels.indexOf("PM product pulse"), - ); - }); - - it("routes template and custom selections through the expected callbacks", () => { - const onSelectTemplate = vi.fn(); - let renderer: ReturnType | null = null; - act(() => { - renderer = create( - createElement(AutomationTemplateGallery, { - onSelectTemplate, - onCreateCustom: vi.fn(), - }), - ); - }); - - if (!renderer) { - throw new Error("Renderer not created"); - } - - const buttons = renderer.root.findAll( - (node) => - typeof node.props.onPress === "function" && - (node.type === "AutomationTemplateCard" || - node.props.children === "Start from scratch"), - ); - - const developerButton = buttons.find( - (node) => node.props.title === "Developer morning briefing", - ); - - developerButton?.props.onPress(); - - expect(onSelectTemplate).toHaveBeenCalledWith("developer-morning-brief"); - }); -}); diff --git a/apps/mobile/src/features/tasks/components/AutomationTemplateGallery.tsx b/apps/mobile/src/features/tasks/components/AutomationTemplateGallery.tsx deleted file mode 100644 index ca41c2ced..000000000 --- a/apps/mobile/src/features/tasks/components/AutomationTemplateGallery.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Pressable, View } from "react-native"; -import { Text } from "@/components/text"; -import { - AUTOMATION_TEMPLATES, - getAutomationTemplates, -} from "../templates/automationTemplates"; -import type { AutomationTemplate } from "../types"; -import { AutomationTemplateCard } from "./AutomationTemplateCard"; - -interface AutomationTemplateGalleryProps { - templates?: ReadonlyArray; - onSelectTemplate: (templateId: string) => void; - onCreateCustom: () => void; -} - -export function AutomationTemplateGallery({ - templates = AUTOMATION_TEMPLATES, - onSelectTemplate, - onCreateCustom, -}: AutomationTemplateGalleryProps) { - const launchTemplates = - templates.length > 0 ? templates : getAutomationTemplates(); - - return ( - - - - Launch templates - - - Choose a starter workflow and tweak it before saving. - - - - {launchTemplates.map((template) => ( - - ))} - - - - Start from scratch - - - Create a custom automation without using one of the launch templates. - - - - ); -} diff --git a/apps/mobile/src/features/tasks/components/CreateAutomationScreen.test.tsx b/apps/mobile/src/features/tasks/components/CreateAutomationScreen.test.tsx new file mode 100644 index 000000000..29934cada --- /dev/null +++ b/apps/mobile/src/features/tasks/components/CreateAutomationScreen.test.tsx @@ -0,0 +1,217 @@ +import { createElement } from "react"; +import { act, create } from "react-test-renderer"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + mockReplace, + mockBack, + mockMutateAsync, + mockUseCreateTaskAutomation, + mockUseSkillStoreSkill, + routeParams, +} = vi.hoisted(() => ({ + mockReplace: vi.fn(), + mockBack: vi.fn(), + mockMutateAsync: vi.fn(), + mockUseCreateTaskAutomation: vi.fn(), + mockUseSkillStoreSkill: vi.fn(), + routeParams: {} as { skillName?: string | string[] }, +})); + +vi.mock("expo-router", () => ({ + Stack: { + Screen: (props: Record) => + createElement("StackScreen", props), + }, + useLocalSearchParams: () => routeParams, + useRouter: () => ({ + replace: mockReplace, + back: mockBack, + }), +})); + +vi.mock("expo-localization", () => ({ + getCalendars: () => [{ timeZone: "UTC" }], +})); + +vi.mock("@/lib/theme", () => ({ + useThemeColors: () => ({ + background: "#ffffff", + gray: { + 11: "#666666", + 12: "#111111", + }, + accent: { + 9: "#ff5500", + }, + }), +})); + +vi.mock("@/features/tasks/hooks/useAutomations", () => ({ + useCreateTaskAutomation: mockUseCreateTaskAutomation, +})); + +vi.mock("@/features/tasks/skills/hooks", () => ({ + useSkillStoreSkill: mockUseSkillStoreSkill, +})); + +vi.mock("@/features/tasks/components/AutomationForm", () => ({ + AutomationForm: (props: Record) => + createElement("AutomationForm", props), +})); + +vi.mock("@/features/tasks/api", () => ({ + TaskAutomationValidationError: class TaskAutomationValidationError extends Error { + code: string; + attr: string | null; + + constructor( + message: string, + code = "invalid_input", + attr: string | null = null, + ) { + super(message); + this.code = code; + this.attr = attr; + } + }, +})); + +import CreateAutomationScreen from "@/app/automation/create"; + +describe("CreateAutomationScreen", () => { + beforeEach(() => { + mockReplace.mockReset(); + mockBack.mockReset(); + mockMutateAsync.mockReset(); + mockUseCreateTaskAutomation.mockReset(); + mockUseSkillStoreSkill.mockReset(); + routeParams.skillName = undefined; + + mockUseCreateTaskAutomation.mockReturnValue({ + isPending: false, + mutateAsync: mockMutateAsync, + }); + }); + + it("seeds the form from the selected skill and saves a prefixed template id", async () => { + routeParams.skillName = "shared-daily-brief"; + mockUseSkillStoreSkill.mockReturnValue({ + data: { + name: "shared-daily-brief", + description: "Shared briefing starter", + body: "Summarize the most important work for today.", + }, + isPending: false, + error: null, + refetch: vi.fn(), + }); + mockMutateAsync.mockResolvedValueOnce({ id: "automation-1" }); + + let renderer: ReturnType | null = null; + act(() => { + renderer = create(createElement(CreateAutomationScreen)); + }); + + if (!renderer) { + throw new Error("Renderer not created"); + } + + const stackScreen = renderer.root.findByType("StackScreen"); + expect(stackScreen.props.options.headerTitle).toBe("Create automation"); + expect( + renderer.root.findAll( + (node) => node.props.children === "shared-daily-brief", + ).length, + ).toBeGreaterThan(0); + expect( + renderer.root.findAll( + (node) => node.props.children === "Shared briefing starter", + ).length, + ).toBe(0); + + const form = renderer.root.findByType("AutomationForm"); + expect(form.props.initialValues).toMatchObject({ + name: "shared-daily-brief", + prompt: "Summarize the most important work for today.", + timezone: "UTC", + enabled: true, + }); + expect(form.props.initialPromptMode).toBe("preview"); + + await act(async () => { + await form.props.onSubmit({ + name: "shared-daily-brief", + prompt: "Summarize the most important work for today.", + repository: "posthog/posthog", + github_integration: 7, + cron_expression: "0 9 * * 1-5", + timezone: "UTC", + enabled: true, + }); + }); + + expect(mockMutateAsync).toHaveBeenCalledWith({ + name: "shared-daily-brief", + prompt: "Summarize the most important work for today.", + repository: "posthog/posthog", + github_integration: 7, + cron_expression: "0 9 * * 1-5", + timezone: "UTC", + enabled: true, + template_id: "llm-skill:shared-daily-brief", + }); + expect(mockReplace).toHaveBeenCalledWith("/automation/automation-1"); + }); + + it("keeps scratch creation untemplated when no skill is selected", async () => { + mockUseSkillStoreSkill.mockReturnValue({ + data: undefined, + isPending: false, + error: null, + refetch: vi.fn(), + }); + mockMutateAsync.mockResolvedValueOnce({ id: "automation-2" }); + + let renderer: ReturnType | null = null; + act(() => { + renderer = create(createElement(CreateAutomationScreen)); + }); + + if (!renderer) { + throw new Error("Renderer not created"); + } + + const form = renderer.root.findByType("AutomationForm"); + expect(form.props.initialValues).toMatchObject({ + name: undefined, + prompt: undefined, + timezone: "UTC", + enabled: true, + }); + expect(form.props.initialPromptMode).toBe("edit"); + + await act(async () => { + await form.props.onSubmit({ + name: "Custom automation", + prompt: "Check the repo every morning.", + repository: "posthog/posthog", + github_integration: 7, + cron_expression: "0 9 * * 1-5", + timezone: "UTC", + enabled: true, + }); + }); + + expect(mockMutateAsync).toHaveBeenCalledWith({ + name: "Custom automation", + prompt: "Check the repo every morning.", + repository: "posthog/posthog", + github_integration: 7, + cron_expression: "0 9 * * 1-5", + timezone: "UTC", + enabled: true, + template_id: null, + }); + }); +}); diff --git a/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts b/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts index 1354450af..93c4cf1dc 100644 --- a/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts +++ b/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts @@ -112,7 +112,7 @@ const automationPayload = { github_integration: 7, cron_expression: "0 9 * * *", timezone: "Europe/London", - template_id: "developer-morning-brief", + template_id: "llm-skill:shared-daily-brief", enabled: true, last_run_at: null, last_run_status: null, @@ -200,7 +200,7 @@ describe("useAutomations", () => { github_integration: 7, cron_expression: "0 9 * * *", timezone: "Europe/London", - template_id: "developer-morning-brief", + template_id: "llm-skill:shared-daily-brief", }); }); @@ -211,7 +211,7 @@ describe("useAutomations", () => { github_integration: 7, cron_expression: "0 9 * * *", timezone: "Europe/London", - template_id: "developer-morning-brief", + template_id: "llm-skill:shared-daily-brief", }); expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: automationKeys.lists(), @@ -254,7 +254,7 @@ describe("useAutomations", () => { ).toMatchObject({ enabled: false, cron_expression: "30 14 * * *", - template_id: "developer-morning-brief", + template_id: "llm-skill:shared-daily-brief", }); unmount(); }); @@ -273,13 +273,13 @@ describe("useAutomations", () => { await expect( result.current.mutateAsync({ - name: "PM pulse", + name: "Shared daily brief", prompt: "Summarize feature usage for my product areas.", repository: "", github_integration: null, cron_expression: "0 8 * * 1-5", timezone: "America/New_York", - template_id: "pm-product-pulse", + template_id: "llm-skill:shared-daily-brief", }), ).rejects.toThrow("Repository is still required for this template."); diff --git a/apps/mobile/src/features/tasks/skills/api.test.ts b/apps/mobile/src/features/tasks/skills/api.test.ts new file mode 100644 index 000000000..6c1338ae5 --- /dev/null +++ b/apps/mobile/src/features/tasks/skills/api.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockFetch } = vi.hoisted(() => ({ + mockFetch: vi.fn(), +})); + +vi.mock("expo/fetch", () => ({ + fetch: mockFetch, +})); + +vi.mock("@/lib/api", () => ({ + getBaseUrl: () => "https://app.posthog.test", + getHeaders: () => ({ + Authorization: "Bearer token", + "Content-Type": "application/json", + }), + getProjectId: () => 42, +})); + +import { getSkillStoreSkill, getSkillStoreSkills } from "./api"; + +describe("skill store api", () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + + it("parses paginated skill-list responses", async () => { + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + results: [ + { + name: "shared-daily-brief", + description: "Shared morning briefing starter", + }, + ], + }), + { status: 200 }, + ), + ); + + const skills = await getSkillStoreSkills(); + + expect(skills).toEqual([ + { + name: "shared-daily-brief", + description: "Shared morning briefing starter", + }, + ]); + expect(mockFetch).toHaveBeenCalledWith( + "https://app.posthog.test/api/environments/42/llm_skills/", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer token", + }), + }), + ); + }); + + it("encodes skill names for detail requests and returns the full body", async () => { + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + name: "shared/brief today", + description: "Shared briefing", + body: "Summarize what matters this morning.", + }), + { status: 200 }, + ), + ); + + const skill = await getSkillStoreSkill("shared/brief today"); + + expect(skill).toMatchObject({ + name: "shared/brief today", + body: "Summarize what matters this morning.", + }); + expect(mockFetch).toHaveBeenCalledWith( + "https://app.posthog.test/api/environments/42/llm_skills/name/shared%2Fbrief%20today/", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer token", + }), + }), + ); + }); +}); diff --git a/apps/mobile/src/features/tasks/skills/api.ts b/apps/mobile/src/features/tasks/skills/api.ts new file mode 100644 index 000000000..0fc2e1b02 --- /dev/null +++ b/apps/mobile/src/features/tasks/skills/api.ts @@ -0,0 +1,48 @@ +import { fetch } from "expo/fetch"; +import { getBaseUrl, getHeaders, getProjectId } from "@/lib/api"; +import type { SkillStoreListEntry, SkillStoreSkill } from "./types"; + +function skillStoreBaseUrl(): string { + const baseUrl = getBaseUrl(); + const projectId = getProjectId(); + return `${baseUrl}/api/environments/${projectId}/llm_skills`; +} + +async function readJsonOrThrow( + response: Response, + errorPrefix: string, +): Promise { + if (!response.ok) { + const data = (await response.json().catch(() => ({}))) as { + detail?: string; + }; + throw new Error(data.detail ?? `${errorPrefix}: ${response.statusText}`); + } + + return (await response.json()) as T; +} + +export async function getSkillStoreSkills(): Promise { + const response = await fetch(`${skillStoreBaseUrl()}/`, { + headers: getHeaders(), + }); + + const data = await readJsonOrThrow< + SkillStoreListEntry[] | { results?: SkillStoreListEntry[] } + >(response, "Failed to fetch skills"); + + return Array.isArray(data) ? data : (data.results ?? []); +} + +export async function getSkillStoreSkill( + skillName: string, +): Promise { + const response = await fetch( + `${skillStoreBaseUrl()}/name/${encodeURIComponent(skillName)}/`, + { + headers: getHeaders(), + }, + ); + + return readJsonOrThrow(response, "Failed to fetch skill"); +} diff --git a/apps/mobile/src/features/tasks/skills/hooks.ts b/apps/mobile/src/features/tasks/skills/hooks.ts new file mode 100644 index 000000000..c78a85cde --- /dev/null +++ b/apps/mobile/src/features/tasks/skills/hooks.ts @@ -0,0 +1,30 @@ +import { useQuery } from "@tanstack/react-query"; +import { getSkillStoreSkill, getSkillStoreSkills } from "./api"; + +const skillStoreKeys = { + all: ["skill-store"] as const, + lists: () => [...skillStoreKeys.all, "list"] as const, + list: () => [...skillStoreKeys.lists(), "all"] as const, + details: () => [...skillStoreKeys.all, "detail"] as const, + detail: (skillName: string) => + [...skillStoreKeys.details(), skillName] as const, +}; + +export function useSkillStoreSkills() { + return useQuery({ + queryKey: skillStoreKeys.list(), + queryFn: getSkillStoreSkills, + staleTime: 5 * 60 * 1000, + }); +} + +export function useSkillStoreSkill(skillName: string | null) { + return useQuery({ + queryKey: skillStoreKeys.detail(skillName ?? ""), + queryFn: () => getSkillStoreSkill(skillName as string), + enabled: !!skillName, + staleTime: 5 * 60 * 1000, + }); +} + +export const SKILL_STORE_QUERY_KEYS = skillStoreKeys; diff --git a/apps/mobile/src/features/tasks/skills/skillTemplateIds.ts b/apps/mobile/src/features/tasks/skills/skillTemplateIds.ts new file mode 100644 index 000000000..51231a393 --- /dev/null +++ b/apps/mobile/src/features/tasks/skills/skillTemplateIds.ts @@ -0,0 +1,16 @@ +export const SKILL_TEMPLATE_ID_PREFIX = "llm-skill:"; + +export function formatSkillTemplateId(skillName: string): string { + return `${SKILL_TEMPLATE_ID_PREFIX}${skillName.trim()}`; +} + +export function parseSkillTemplateId( + templateId: string | null | undefined, +): string | null { + if (!templateId?.startsWith(SKILL_TEMPLATE_ID_PREFIX)) { + return null; + } + + const skillName = templateId.slice(SKILL_TEMPLATE_ID_PREFIX.length).trim(); + return skillName || null; +} diff --git a/apps/mobile/src/features/tasks/skills/types.ts b/apps/mobile/src/features/tasks/skills/types.ts new file mode 100644 index 000000000..7db9836bb --- /dev/null +++ b/apps/mobile/src/features/tasks/skills/types.ts @@ -0,0 +1,8 @@ +export interface SkillStoreListEntry { + name: string; + description: string | null; +} + +export interface SkillStoreSkill extends SkillStoreListEntry { + body: string; +} diff --git a/apps/mobile/src/features/tasks/templates/automationTemplates.test.ts b/apps/mobile/src/features/tasks/templates/automationTemplates.test.ts deleted file mode 100644 index 1d80f6975..000000000 --- a/apps/mobile/src/features/tasks/templates/automationTemplates.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - AUTOMATION_TEMPLATES, - getAutomationTemplate, - getAutomationTemplateInitialValues, - requiresAutomationTemplateRepository, -} from "./automationTemplates"; - -describe("automationTemplates", () => { - it("returns the developer template first and includes all launch templates", () => { - expect(AUTOMATION_TEMPLATES.map((template) => template.id)).toEqual([ - "developer-morning-brief", - "pm-product-pulse", - "executive-day-opener", - ]); - expect(AUTOMATION_TEMPLATES[0]?.hero).toBe(true); - }); - - it("derives initial editor values from a selected template", () => { - expect( - getAutomationTemplateInitialValues("developer-morning-brief"), - ).toEqual({ - name: "Developer morning briefing", - prompt: expect.stringContaining("Create my developer morning briefing."), - cron_expression: "0 9 * * 1-5", - enabled: true, - template_id: "developer-morning-brief", - }); - }); - - it("handles unknown template ids without throwing", () => { - expect(getAutomationTemplate("unknown-template")).toBeNull(); - expect(getAutomationTemplateInitialValues("unknown-template")).toBeNull(); - }); - - it("exposes repository requirements per template", () => { - expect( - requiresAutomationTemplateRepository("developer-morning-brief"), - ).toBe(true); - expect(requiresAutomationTemplateRepository("pm-product-pulse")).toBe( - false, - ); - expect(requiresAutomationTemplateRepository("executive-day-opener")).toBe( - false, - ); - }); -}); diff --git a/apps/mobile/src/features/tasks/templates/automationTemplates.ts b/apps/mobile/src/features/tasks/templates/automationTemplates.ts deleted file mode 100644 index d3494a4db..000000000 --- a/apps/mobile/src/features/tasks/templates/automationTemplates.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type { - AutomationTemplate, - AutomationTemplateInitialValues, -} from "../types"; -import { - buildCronExpression, - createDefaultScheduleDraft, -} from "../utils/automationSchedule"; - -function buildWeekdayCron(hour: string, minute: string): string { - return buildCronExpression({ - ...createDefaultScheduleDraft(), - mode: "weekdays", - hour, - minute, - }); -} - -export const AUTOMATION_TEMPLATES: ReadonlyArray = [ - { - id: "developer-morning-brief", - name: "Developer morning briefing", - description: - "Review open PRs, review requests, CI failures, and active work before you start coding.", - audience: "developer", - audienceLabel: "Developer", - categoryLabel: "Daily briefing", - prompt: - "Create my developer morning briefing. Summarize my open pull requests, outstanding review requests, CI failures, and work in progress in my selected repository. Call out blockers first, then list the most important items I should tackle this morning.", - suggestedName: "Developer morning briefing", - cron_expression: buildWeekdayCron("09", "00"), - enabled: true, - requiresRepository: true, - hero: true, - }, - { - id: "pm-product-pulse", - name: "PM product pulse", - description: - "Get a concise update on feature usage, product health, and the biggest signals worth following up on.", - audience: "pm", - audienceLabel: "PM", - categoryLabel: "Daily briefing", - prompt: - "Create my PM product pulse. Summarize the most important usage trends, product quality signals, and feature areas I should pay attention to today. Lead with notable changes, regressions, or unusual user behavior, then suggest the follow-ups that matter most.", - suggestedName: "PM product pulse", - cron_expression: buildWeekdayCron("09", "30"), - enabled: true, - requiresRepository: false, - }, - { - id: "executive-day-opener", - name: "Executive day opener", - description: - "Start the day with meetings, priorities, and the highest-level updates that need attention.", - audience: "executive", - audienceLabel: "Executive", - categoryLabel: "Daily briefing", - prompt: - "Create my executive day opener. Summarize today's meetings, the highest-priority follow-ups, and the top company or product signals that need my attention. Keep it short, scannable, and focused on decisions or risks.", - suggestedName: "Executive day opener", - cron_expression: buildWeekdayCron("07", "30"), - enabled: true, - requiresRepository: false, - }, -] as const; - -export function getAutomationTemplates(): ReadonlyArray { - return AUTOMATION_TEMPLATES; -} - -export function getAutomationTemplate( - templateId: string | null | undefined, -): AutomationTemplate | null { - if (!templateId) { - return null; - } - - return ( - AUTOMATION_TEMPLATES.find((template) => template.id === templateId) ?? null - ); -} - -export function getAutomationTemplateInitialValues( - templateId: string | null | undefined, -): AutomationTemplateInitialValues | null { - const template = getAutomationTemplate(templateId); - if (!template) { - return null; - } - - return { - name: template.suggestedName, - prompt: template.prompt, - cron_expression: template.cron_expression, - enabled: template.enabled, - template_id: template.id, - }; -} - -export function requiresAutomationTemplateRepository( - templateId: string | null | undefined, -): boolean { - return getAutomationTemplate(templateId)?.requiresRepository ?? false; -} diff --git a/apps/mobile/src/features/tasks/types.ts b/apps/mobile/src/features/tasks/types.ts index 4ccd6c3f1..1400c43fd 100644 --- a/apps/mobile/src/features/tasks/types.ts +++ b/apps/mobile/src/features/tasks/types.ts @@ -283,31 +283,6 @@ export interface RepositorySelection { repository: string | null; } -export type AutomationTemplateAudience = "developer" | "pm" | "executive"; - -export interface AutomationTemplate { - id: string; - name: string; - description: string; - audience: AutomationTemplateAudience; - audienceLabel: string; - categoryLabel: string; - prompt: string; - suggestedName: string; - cron_expression: string; - enabled: boolean; - requiresRepository: boolean; - hero?: boolean; -} - -export interface AutomationTemplateInitialValues { - name: string; - prompt: string; - cron_expression: string; - enabled: boolean; - template_id: string; -} - export interface CreateTaskOptions { description: string; title?: string; diff --git a/apps/mobile/src/features/tasks/utils/automationTemplatePresentation.test.ts b/apps/mobile/src/features/tasks/utils/automationTemplatePresentation.test.ts index f60d88512..f3e44f2f4 100644 --- a/apps/mobile/src/features/tasks/utils/automationTemplatePresentation.test.ts +++ b/apps/mobile/src/features/tasks/utils/automationTemplatePresentation.test.ts @@ -2,31 +2,31 @@ import { describe, expect, it } from "vitest"; import { getAutomationTemplatePresentation } from "./automationTemplatePresentation"; describe("automationTemplatePresentation", () => { - it("prefers repository context when one exists", () => { + it("prefers repository context when one exists for skill-backed automations", () => { expect( getAutomationTemplatePresentation({ repository: "posthog/posthog", - template_id: "developer-morning-brief", + template_id: "llm-skill:shared-daily-brief", }), ).toMatchObject({ - templateName: "Developer morning briefing", + templateName: "shared-daily-brief", repositoryLabel: "posthog/posthog", - contextLabel: "Developer · Daily briefing", + contextLabel: "Skill store", secondaryLabel: "posthog/posthog", }); }); - it("falls back to template context for repo-optional automations", () => { + it("falls back to skill-store context when no repository is present", () => { expect( getAutomationTemplatePresentation({ repository: "", - template_id: "pm-product-pulse", + template_id: "llm-skill:shared-daily-brief", }), ).toMatchObject({ - templateName: "PM product pulse", + templateName: "shared-daily-brief", repositoryLabel: null, - contextLabel: "PM · Daily briefing", - secondaryLabel: "PM · Daily briefing", + contextLabel: "Skill store", + secondaryLabel: "Skill store", }); }); diff --git a/apps/mobile/src/features/tasks/utils/automationTemplatePresentation.ts b/apps/mobile/src/features/tasks/utils/automationTemplatePresentation.ts index 6d7ba5889..d899a29b9 100644 --- a/apps/mobile/src/features/tasks/utils/automationTemplatePresentation.ts +++ b/apps/mobile/src/features/tasks/utils/automationTemplatePresentation.ts @@ -1,4 +1,4 @@ -import { getAutomationTemplate } from "../templates/automationTemplates"; +import { parseSkillTemplateId } from "../skills/skillTemplateIds"; import type { TaskAutomation } from "../types"; export interface AutomationTemplatePresentation { @@ -12,14 +12,12 @@ export function getAutomationTemplatePresentation( automation: Pick, ): AutomationTemplatePresentation { const repositoryLabel = automation.repository.trim() || null; - const template = getAutomationTemplate(automation.template_id); - const contextLabel = template - ? `${template.audienceLabel} · ${template.categoryLabel}` - : null; + const skillName = parseSkillTemplateId(automation.template_id); + const contextLabel = skillName ? "Skill store" : null; return { templateName: - template?.name ?? (automation.template_id ? "Template automation" : null), + skillName ?? (automation.template_id ? "Template automation" : null), repositoryLabel, contextLabel, secondaryLabel: repositoryLabel ?? contextLabel ?? "No repository context", diff --git a/docs/plans/2026-05-14-001-feat-mobile-skill-store-automation-chooser-plan.md b/docs/plans/2026-05-14-001-feat-mobile-skill-store-automation-chooser-plan.md new file mode 100644 index 000000000..fe56d566a --- /dev/null +++ b/docs/plans/2026-05-14-001-feat-mobile-skill-store-automation-chooser-plan.md @@ -0,0 +1,350 @@ +--- +title: feat: Replace mobile automation templates with skill store +type: feat +status: completed +date: 2026-05-14 +--- + +# feat: Replace mobile automation templates with skill store + +## Summary + +Replace the mobile automation template gallery with a scratch-first, read-only skill-store chooser. Add the auth and API plumbing needed to read PostHog skills on mobile, fetch full skill bodies only when a user selects one, and seed the existing automation editor from that skill without introducing mobile skill management or changing automation runner semantics. + +--- + +## Problem Frame + +The mobile automation flow is currently anchored to a small, local launch-template catalog. That made the first automation launch easy to ship, but it means the chooser drifts away from the shared PostHog skill store and forces mobile-specific curation for something that is already modeled as a first-class, team-shared resource elsewhere in the product. + +This follow-on work needs to swap the chooser over to the skill store without breaking the current automation editor or the backend task runner. The important constraint from research is that task automations execute the task's stored prompt text, while `template_id` is persisted metadata only. A mobile "skill-backed automation" therefore needs to treat a selected skill as a remote prompt starter, not as a runtime skill reference that the backend already resolves for us. + +--- + +## Requirements + +- R1. The mobile "New automation" chooser must show `Start from scratch` at the top and the PostHog skill-store entries below it, replacing the old local launch-template list. +- R2. Mobile must read the skill store through the existing environment-scoped PostHog APIs, using team-scoped auth and handling loading, empty, feature-disabled, and permission-failure states without blocking scratch creation. +- R3. Selecting a skill must open the existing automation create editor, not a new mobile skill-detail flow, and must prefill the editor from the selected skill's remote data. +- R4. Because task automations still execute plain prompt text, the selected skill must seed an editable automation prompt deterministically without requiring backend skill-resolution changes. +- R5. Mobile auth must expand to include `llm_skill:read`, and stale sessions that were minted before the scope expansion must be forced through a predictable reauthentication path. +- R6. Existing automation list, detail, and edit screens must remain coherent for new skill-backed automations and continue to degrade safely for unknown `template_id` values. +- R7. Test coverage must explicitly verify auth reauth behavior, skill API parsing, chooser state handling, prompt seeding, and automation presentation fallbacks. + +--- + +## Scope Boundaries + +- No mobile create/edit/archive/duplicate/version-history UI for skills. +- No backend task-automation schema changes or runner changes to resolve a skill by ID at execution time. +- No full mobile skill browser, management scene, or search-heavy marketplace experience in this iteration; the scope is the automation chooser and the create-flow handoff. +- No attempt to infer repo-optional behavior from arbitrary skill metadata in v1; skill-backed automations continue through the existing repo-backed editor contract. + +### Deferred to Follow-Up Work + +- Richer skill metadata contracts for mobile presentation, such as audience/category labels or an explicit `requires_repository` flag. +- Auto-refresh or migration flows when a skill-backed automation points at a newer skill version. +- A dedicated mobile skill-detail scene, search/filter controls, or broader skill browsing outside the automation chooser. + +--- + +## Context & Research + +### Relevant Code and Patterns + +- `apps/mobile/src/app/automation/index.tsx` is the current modal chooser and already owns the top-level "Choose a template" presentation. +- `apps/mobile/src/app/automation/create.tsx` already supports "select something in the chooser, then open the shared editor" by reading a route param and deriving `initialValues`. +- `apps/mobile/src/features/tasks/components/AutomationForm.tsx` is the right place to preserve the existing repository-selection contract; it already gates GitHub integration loading with `useIntegrations({ enabled })`. +- `apps/mobile/src/app/automation/[id].tsx` and `apps/mobile/src/features/tasks/utils/automationTemplatePresentation.ts` currently resolve `template_id` through the local template registry, so switching the chooser to remote skills requires replacing that assumption in list/detail/edit flows. +- `apps/mobile/src/features/mcp/api.ts`, `apps/mobile/src/features/mcp/hooks.ts`, and `apps/mobile/src/app/mcp-servers/index.tsx` provide the closest mobile pattern for "read a remote environment-scoped catalog, cache it with TanStack Query, and render loading/empty/error states." +- `apps/mobile/src/lib/api.ts` and `apps/mobile/src/features/auth/stores/authStore.ts` already provide team-scoped bearer auth and token persistence, but mobile currently has no scope-version guard when OAuth scopes change. +- `products/llm_analytics/backend/api/skills.py` exposes the skill-store list, get-by-name, file, and resolve endpoints under `/api/environments/{project_id}/llm_skills/...`, with `llm_skill:read` gating on the read paths. +- `products/llm_analytics/backend/api/skill_serializers.py` confirms progressive disclosure: the list payload omits `body`, while detail payloads return the full skill body and file manifest. +- `products/tasks/backend/serializers.py`, `products/tasks/backend/models.py`, and `products/tasks/backend/automation_service.py` show that `TaskAutomation.template_id` is stored as opaque metadata, while the task runner executes the task title/description fields directly. This is the key reason the mobile flow must seed prompt text from the skill instead of relying on backend skill resolution. + +### Institutional Learnings + +- No relevant `docs/solutions/` entries were present for this mobile automation + skill-store area. + +### External References + +- None. Local product and codebase patterns were sufficient for this plan. + +--- + +## Key Technical Decisions + +- Expand the mobile OAuth scope set with `llm_skill:read` and add a mobile scope-version reauth guard so app upgrades that broaden scopes do not leave existing sessions silently unable to read the skill store. +- Treat selected skills as remote prompt starters: the chooser consumes the lightweight skills list, while the create flow fetches the selected skill detail on demand and seeds the automation prompt from the skill body rather than expecting runtime backend skill lookup. +- Persist skill-backed selections in `template_id` using a reserved prefix such as `llm-skill:` so mobile can distinguish them from arbitrary unknown IDs without any backend schema change. +- Keep skill-backed automations on the existing repository-required editor contract in v1. This avoids inventing metadata semantics that the generic skill store does not guarantee and avoids colliding with the current backend repository validator. +- Remove the old local launch-template catalog entirely from the mobile automation flow. Because it has not shipped, the implementation can retire the chooser-specific catalog and UI instead of preserving it for user-facing compatibility. + +--- + +## Open Questions + +### Resolved During Planning + +- Should mobile add a separate skill-detail scene before creation? No. The selected skill should hand off directly into the existing create editor. +- Should the old local launch templates remain in the chooser beside the skill store? No. The chooser should become scratch-first plus remote skills only. +- Should this iteration change backend automation execution to resolve skills by ID? No. The runner contract stays prompt-based in this plan. +- Should v1 try to infer repo-optional behavior from arbitrary skill metadata? No. Skill-backed automations stay repo-backed until a dedicated metadata contract exists. + +### Deferred to Implementation + +- Whether the create screen should copy the skill body verbatim into the prompt field or wrap it in a very small mobile-owned framing string can be finalized during implementation after seeing the real form UX with a few representative skills. The contract stays the same either way: the stored automation prompt is derived from the selected skill body. +- Whether the best reauth UX is "clear stale tokens and send the user back to sign-in immediately" or "surface a targeted reauth explanation before redirecting" can be finalized during implementation as long as stale scope versions never keep the user in a broken semi-authenticated state. + +--- + +## Output Structure + +```text +apps/mobile/src/features/tasks/skills/ + api.ts + hooks.ts + types.ts + skillTemplateIds.ts + +apps/mobile/src/features/tasks/components/ + AutomationSkillChooser.tsx + AutomationSkillCard.tsx + +apps/mobile/src/features/auth/stores/ + authStore.test.ts +``` + +--- + +## High-Level Technical Design + +> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.* + +```mermaid +flowchart TB + A["New automation"] --> B["/automation chooser"] + B --> C["Start from scratch"] + B --> D["Fetch llm_skills list"] + D --> E["Tap skill row"] + E --> F["/automation/create?skillName=..."] + F --> G["Fetch llm_skills detail by name"] + G --> H["Seed editable prompt from skill body"] + C --> I["Open existing editor with blank defaults"] + H --> J["Submit task automation with template_id = llm-skill:"] + I --> J + J --> K["List / detail / edit resolve skill-backed IDs or other opaque template IDs for presentation"] +``` + +--- + +## Implementation Units + +### U1. Add mobile auth and skill-store client plumbing + +**Goal:** Enable the mobile app to authenticate against the skill-store read APIs reliably, including app-upgrade reauth behavior when scopes expand. + +**Requirements:** R2, R5, R7 + +**Dependencies:** None + +**Files:** +- Modify: `apps/mobile/src/features/auth/lib/constants.ts` +- Modify: `apps/mobile/src/features/auth/types.ts` +- Modify: `apps/mobile/src/features/auth/stores/authStore.ts` +- Modify: `apps/mobile/src/app/auth.tsx` +- Create: `apps/mobile/src/features/tasks/skills/types.ts` +- Create: `apps/mobile/src/features/tasks/skills/api.ts` +- Create: `apps/mobile/src/features/tasks/skills/hooks.ts` +- Test: `apps/mobile/src/features/auth/stores/authStore.test.ts` +- Test: `apps/mobile/src/features/tasks/skills/api.test.ts` + +**Approach:** +- Add `llm_skill:read` to the mobile OAuth scope set and introduce a persisted mobile scope-version marker so previously stored tokens can be invalidated or reauthed deterministically when the app expects broader scopes. +- Mirror the existing mobile MCP data-access pattern for the skills store: a small API module over `getBaseUrl()`, `getHeaders()`, and `getProjectId()`, plus TanStack Query hooks for the list and detail paths. +- Centralize skill-name encoding for detail requests because the backend skill detail route is path-based (`.../name/{skillName}/`); mobile should never interpolate raw names into URLs ad hoc. +- Keep the list/detail types scoped to what mobile actually needs: list for chooser rendering, detail for prompt seeding. +- Preserve dev sign-in ergonomics by updating the personal-API-key instructions to mention the new read scope. + +**Patterns to follow:** +- `apps/mobile/src/features/mcp/api.ts` and `apps/mobile/src/features/mcp/hooks.ts` for remote catalog fetch patterns. +- `apps/mobile/src/features/auth/stores/authStore.ts` for token persistence, refresh scheduling, and initialization. +- `apps/code/src/shared/constants/oauth.ts` as precedent for a scope-version guard when scopes change. + +**Test scenarios:** +- Happy path: a fresh OAuth session stores the current scope version and keeps the user authenticated. +- Edge case: `initializeAuth` sees stored tokens from an older scope version and forces a deterministic reauth path instead of leaving a broken session active. +- Happy path: the skills list API helper parses paginated `results` responses and returns chooser-ready rows. +- Happy path: the skill detail API helper returns the full body for a selected skill. +- Edge case: skill names containing spaces or path-sensitive characters are encoded consistently for detail requests and preserved when deriving `template_id`. +- Error path: skill list or detail requests surface 401/403/feature-disabled failures cleanly so the chooser can render a scratch-only fallback instead of crashing. + +**Verification:** +- A freshly authenticated mobile session can read the `llm_skills` endpoints, and an app update that changes required scopes does not strand old sessions in a partially authorized state. + +--- + +### U2. Replace the template chooser with a scratch-first skill chooser + +**Goal:** Swap the local launch-template gallery for a UI that foregrounds custom creation and then renders the remote skill catalog below it. + +**Requirements:** R1, R2, R7 + +**Dependencies:** U1 + +**Files:** +- Modify: `apps/mobile/src/app/automation/index.tsx` +- Create: `apps/mobile/src/features/tasks/components/AutomationSkillChooser.tsx` +- Create: `apps/mobile/src/features/tasks/components/AutomationSkillCard.tsx` +- Delete: `apps/mobile/src/features/tasks/components/AutomationTemplateGallery.tsx` +- Delete: `apps/mobile/src/features/tasks/components/AutomationTemplateCard.tsx` +- Delete: `apps/mobile/src/features/tasks/components/AutomationTemplateGallery.test.tsx` +- Test: `apps/mobile/src/features/tasks/components/AutomationSkillChooser.test.tsx` + +**Approach:** +- Keep the existing `/automation` route as the chooser entry point, but change its content model from "local launch templates first" to "scratch CTA first, skill store below." +- Render the scratch action as a persistent top card/button that always works, even when the skill-store query is loading or unavailable. +- Use the lightweight list payload for the skill rows: `name`, `description`, and optional metadata/compatibility signals when present, without assuming the generic skill store contains mobile-specific presentation fields. +- Show explicit loading, empty, and permission/error states inside the skill section rather than falling back to the retired local template catalog. + +**Patterns to follow:** +- `apps/mobile/src/app/mcp-servers/index.tsx` for remote-list loading, empty, and refresh state patterns. +- `apps/mobile/src/features/mcp/components/McpServerRow.tsx` for concise remote-catalog row presentation. +- `apps/mobile/src/app/automation/index.tsx` for modal route configuration and screen-level copy treatment. + +**Test scenarios:** +- Happy path: the chooser renders `Start from scratch` before the remote skill list. +- Happy path: skill rows render from the remote list payload and route selection through the provided callback with the chosen skill name. +- Edge case: an empty skill-store response still renders the scratch CTA and an explanatory "no skills available" state. +- Error path: a skill-store permission or feature-flag failure renders scratch-only plus an explanatory message rather than crashing or showing stale local templates. +- Integration: pull-to-refresh or manual refetch wiring refreshes the skill section without resetting the scratch CTA. + +**Verification:** +- Users can always create a custom automation immediately, and when the skill store is available they see remote skills beneath that action instead of the old local launch-template catalog. + +--- + +### U3. Adapt the create flow to seed prompts from selected skills + +**Goal:** Let the existing automation create editor consume a selected skill as a remote starter while preserving the current editable form and submission path. + +**Requirements:** R3, R4, R5, R7 + +**Dependencies:** U1, U2 + +**Files:** +- Modify: `apps/mobile/src/app/automation/create.tsx` +- Modify: `apps/mobile/src/features/tasks/components/AutomationForm.tsx` +- Create: `apps/mobile/src/features/tasks/skills/skillTemplateIds.ts` +- Test: `apps/mobile/src/features/tasks/components/AutomationForm.test.tsx` +- Test: `apps/mobile/src/features/tasks/api.automations.test.ts` +- Test: `apps/mobile/src/app/automation/create.test.tsx` + +**Approach:** +- Change the create route contract from local `templateId` lookups to a selected remote `skillName`, then fetch the full skill detail when the route loads so prompt seeding stays progressive-disclosure-friendly. +- Seed the form's initial prompt from the selected skill body and derive the stored `template_id` from a reserved `llm-skill:` prefix plus the skill name. +- Keep all `template_id` prefix parsing and formatting in one helper so route params, API lookups, and saved automation metadata cannot drift apart. +- Keep the editor repository-required for both scratch and skill-backed creation in this iteration, so the create flow stays aligned with the current task-automation backend validator. +- Add explicit loading and fetch-failure UI for the "selected skill detail is still loading" state so the user never lands on a half-populated editor. +- Preserve the current scratch path as the zero-parameter create route so no extra branching is introduced for plain custom creation. + +**Execution note:** Start with failing API/component coverage around the new `skillName -> prompt/template_id` handoff before refactoring the route and editor wiring, since the old path is currently template-ID-based. + +**Patterns to follow:** +- `apps/mobile/src/app/automation/create.tsx` for screen-level form composition and validation error handling. +- `apps/mobile/src/features/tasks/api.automations.test.ts` for payload-contract assertions. +- `apps/mobile/src/features/tasks/components/AutomationForm.tsx` for preserving the existing submit contract and repository gating. + +**Test scenarios:** +- Happy path: selecting a skill loads its detail and seeds the create form with a prompt derived from the skill body. +- Happy path: saving a skill-backed automation serializes the expected prefixed `template_id` alongside the existing task-automation fields. +- Edge case: scratch creation still opens a blank editor with no selected skill. +- Edge case: if the selected skill detail fetch fails, the screen shows a recoverable error state instead of silently falling back to an unrelated blank form. +- Error path: backend validation errors on skill-backed creates still map to the existing field/general error surfaces. +- Integration: repository selection remains required for skill-backed creation, matching the current backend contract. + +**Verification:** +- A selected skill produces an editable automation draft through the existing create form, and the saved automation payload carries enough metadata to identify the originating skill later. + +--- + +### U4. Simplify automation presentation around skill-backed metadata and safe unknown fallbacks + +**Goal:** Keep saved automations readable and editable after the chooser switch by teaching the presentation/edit flows about skill-backed IDs and by removing the old assumption that known template IDs always come from the local launch catalog. + +**Requirements:** R5, R6, R7 + +**Dependencies:** U1, U3 + +**Files:** +- Modify: `apps/mobile/src/app/automation/[id].tsx` +- Modify: `apps/mobile/src/features/tasks/utils/automationTemplatePresentation.ts` +- Modify: `apps/mobile/src/features/tasks/components/AutomationDetail.tsx` +- Modify: `apps/mobile/src/features/tasks/components/AutomationItem.tsx` +- Delete: `apps/mobile/src/features/tasks/templates/automationTemplates.ts` +- Delete: `apps/mobile/src/features/tasks/templates/automationTemplates.test.ts` +- Test: `apps/mobile/src/features/tasks/utils/automationTemplatePresentation.test.ts` +- Test: `apps/mobile/src/features/tasks/hooks/useAutomations.test.ts` + +**Approach:** +- Teach the template-presentation helper to recognize the new skill-backed `template_id` prefix, derive a stable fallback label from the stored skill name, and optionally enrich the display from cached skill-list data when available. +- Update the detail/edit flow's `repositoryRequired` derivation so it no longer assumes all known `template_id`s come from the local template registry. +- Preserve safe fallbacks for unknown IDs and blank repositories, since new skill-backed automations can still drift from the current remote catalog and `template_id` remains an opaque backend field. + +**Patterns to follow:** +- `apps/mobile/src/features/tasks/utils/automationTemplatePresentation.ts` for the current "repository first, template context second" presentation rules. +- `apps/mobile/src/app/automation/[id].tsx` for edit-mode field wiring and `template_id` preservation on update. +- `apps/mobile/src/features/tasks/components/AutomationDetail.tsx` and `AutomationItem.tsx` for list/detail metadata rendering. + +**Test scenarios:** +- Happy path: a skill-backed automation with `template_id = llm-skill:` renders a readable skill-derived label in list and detail views. +- Edge case: skill-backed automations remain editable even when the remote skill catalog is unavailable or the specific skill has been removed. +- Edge case: unknown `template_id` values and blank repositories still degrade to safe generic copy. +- Integration: edit flows preserve the original `template_id` when a user changes only prompt, schedule, or enabled state. + +**Verification:** +- Newly created skill-backed automations render coherently across list, detail, and edit surfaces, and the mobile flow no longer depends on the retired local template catalog. + +--- + +## System-Wide Impact + +- **Interaction graph:** the change touches auth bootstrap, the automation chooser, the create editor, the automation presentation helpers, and the task-automation payload contract. +- **Error propagation:** skill-store fetch failures must stay scoped to the skill section or selected-skill loading state; they must never block scratch creation or collapse the automation modal stack. +- **State lifecycle risks:** stale OAuth scopes, cached skill-list data, and prefixed `template_id` parsing all affect how the app recovers after upgrades or remote-catalog drift. +- **API surface parity:** mobile keeps using the existing task-automation POST/PATCH shape and the existing `template_id` field, while adding read-only consumption of the `llm_skills` environment APIs. +- **Integration coverage:** the important cross-layer proof points are scope expansion + reauth, list/detail split between the skills endpoints, and persistence of prefixed `template_id` values through create and edit. +- **Unchanged invariants:** the backend task runner still executes the task's stored prompt text, the existing repository-backed automation creation contract remains intact, and mobile does not gain any skill-management write paths in this iteration. + +--- + +## Risks & Dependencies + +| Risk | Mitigation | +|------|------------| +| Existing mobile sessions keep old OAuth scopes and fail every skill-store request after the app update. | Add a stored scope-version guard and make reauth deterministic during auth initialization. | +| The generic skill store does not expose mobile-friendly metadata such as audience labels or repo requirements. | Keep chooser rows minimal, rely on name/description as the authoritative list fields, and default skill-backed creation to the existing repo-required contract. | +| Removing the chooser-local template catalog leaves stale template-specific assumptions in list/detail/edit flows. | Remove the local catalog and replace those assumptions with skill-prefix parsing plus generic unknown-ID fallbacks. | +| Skill-backed automations become ambiguous later because the saved `template_id` cannot be distinguished from other opaque template IDs. | Reserve a dedicated `llm-skill:` prefix and centralize encode/decode logic in one helper. | +| Copying selected skill bodies into prompts creates long or awkward initial editor text for some skills. | Keep the seeding logic isolated so a small wrapper/tweak can be adjusted during implementation without changing the broader API or screen architecture. | + +--- + +## Documentation / Operational Notes + +- Update the dev sign-in copy in `apps/mobile/src/app/auth.tsx` so local testers know the personal API key must include `llm_skill:read` in addition to the existing task/conversation scopes. +- Coordinate the OAuth scope expansion with mobile release notes or tester guidance, since users with older persisted sessions will be forced through reauth after upgrading. + +--- + +## Sources & References + +- Prior plan: `docs/plans/2026-05-13-001-feat-mobile-automation-templates-plan.md` +- Mobile chooser: `apps/mobile/src/app/automation/index.tsx` +- Mobile create flow: `apps/mobile/src/app/automation/create.tsx` +- Mobile auth: `apps/mobile/src/features/auth/lib/constants.ts` +- Mobile auth store: `apps/mobile/src/features/auth/stores/authStore.ts` +- Remote catalog pattern: `apps/mobile/src/features/mcp/api.ts` +- Skill-store backend: `products/llm_analytics/backend/api/skills.py` +- Task automation serializer: `products/tasks/backend/serializers.py` +- Task automation runner: `products/tasks/backend/automation_service.py` From c3646f67e58715b77ec5d22f796c600b2315f723 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 14 May 2026 11:45:45 -0400 Subject: [PATCH 63/94] fix: le --- apps/mobile/plugins/withWatchApp.js | 61 ++++++++++++++++++----------- 1 file changed, 39 insertions(+), 22 deletions(-) diff --git a/apps/mobile/plugins/withWatchApp.js b/apps/mobile/plugins/withWatchApp.js index 14911d923..f190078ec 100644 --- a/apps/mobile/plugins/withWatchApp.js +++ b/apps/mobile/plugins/withWatchApp.js @@ -136,6 +136,43 @@ function ensureSource(project, filePath, target, groupName = WATCH_SOURCE_DIR) { ); } +function targetHasSource(project, target, fileName, acceptedPaths) { + const fileReferences = project.hash.project.objects.PBXFileReference; + const matchingFileReferenceIds = new Set(); + + for (const [key, ref] of Object.entries(fileReferences)) { + if (key.endsWith("_comment")) continue; + const comment = fileReferences[`${key}_comment`]; + if ( + comment === fileName || + ref.name === fileName || + acceptedPaths.includes(ref.path) + ) { + matchingFileReferenceIds.add(key); + } + } + + const buildFiles = project.hash.project.objects.PBXBuildFile; + const sourceFiles = project.pbxSourcesBuildPhaseObj(target)?.files ?? []; + return sourceFiles.some((sourceFile) => { + const buildFile = buildFiles[sourceFile.value]; + return matchingFileReferenceIds.has(buildFile?.fileRef); + }); +} + +function ensureHostSource(project, fileName, target) { + const projectPath = `PostHogCode/${fileName}`; + const nativePath = `../native/ios/${fileName}`; + if (!targetHasSource(project, target, fileName, [projectPath, nativePath])) { + project.addSourceFile( + projectPath, + { target }, + groupKeyByName(project, "PostHogCode"), + ); + } + setFileReferencePath(project, fileName, nativePath); +} + function ensureWatchSource(project, fileName, target) { const watchGroupKey = groupKeyByName(project, WATCH_SOURCE_DIR); project.removeSourceFile( @@ -231,28 +268,8 @@ function addWatchTargets(project) { ensureTargetDependency(project, hostTargetUuid, watchApp.uuid); ensureTargetDependency(project, watchApp.uuid, watchExtension.uuid); - ensureSource( - project, - "PostHogCode/WatchTaskControlModule.swift", - hostTargetUuid, - "PostHogCode", - ); - setFileReferencePath( - project, - "WatchTaskControlModule.swift", - "../native/ios/WatchTaskControlModule.swift", - ); - ensureSource( - project, - "PostHogCode/WatchTaskControlModule.m", - hostTargetUuid, - "PostHogCode", - ); - setFileReferencePath( - project, - "WatchTaskControlModule.m", - "../native/ios/WatchTaskControlModule.m", - ); + ensureHostSource(project, "WatchTaskControlModule.swift", hostTargetUuid); + ensureHostSource(project, "WatchTaskControlModule.m", hostTargetUuid); ensureWatchSource(project, "PostHogCodeWatchApp.swift", watchExtension.uuid); ensureWatchSource(project, "WatchTaskStore.swift", watchExtension.uuid); From fa36ee8435d8c7cf46ee57fe62b2264615e5093f Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 14 May 2026 11:51:44 -0400 Subject: [PATCH 64/94] fix: --- apps/mobile/src/app/_layout.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index f5818f922..aa42956d2 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -33,6 +33,7 @@ interface RootLayoutNavProps { function RootLayoutNav({ isConnected }: RootLayoutNavProps) { const { isLoading, initializeAuth } = useAuthStore(); + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const _aiChatEnabled = usePreferencesStore((s) => s.aiChatEnabled); const publishWatchSnapshot = useTaskSessionStore( (s) => s.publishWatchSnapshot, @@ -67,7 +68,7 @@ function RootLayoutNav({ isConnected }: RootLayoutNavProps) { if (!pathname || pathname === "/auth") return; const next = pathname !== "/" ? pathname : undefined; router.replace(next ? { pathname: "/auth", params: { next } } : "/auth"); - }, [isLoading, pathname]); + }, [isAuthenticated, isLoading, pathname]); if (isLoading) { return ( From 9f794ef41ad81ef1b36f800bbd7999eb503e9c5c Mon Sep 17 00:00:00 2001 From: Oliver Browne Date: Thu, 14 May 2026 10:13:09 -0400 Subject: [PATCH 65/94] tinder tweaks --- apps/mobile/src/app/(tabs)/inbox.tsx | 13 +++++---- apps/mobile/src/app/review.tsx | 11 +++++--- apps/mobile/src/app/settings/index.tsx | 12 +++++---- .../features/inbox/components/TinderView.tsx | 15 ++++++----- .../inbox/stores/dismissedReportsStore.ts | 27 +++++++++++++++---- 5 files changed, 53 insertions(+), 25 deletions(-) diff --git a/apps/mobile/src/app/(tabs)/inbox.tsx b/apps/mobile/src/app/(tabs)/inbox.tsx index b42006c21..f4f400b07 100644 --- a/apps/mobile/src/app/(tabs)/inbox.tsx +++ b/apps/mobile/src/app/(tabs)/inbox.tsx @@ -9,7 +9,10 @@ import { ReportList } from "@/features/inbox/components/ReportList"; import { ReviewerFilterSheet } from "@/features/inbox/components/ReviewerFilterSheet"; import { TinderView } from "@/features/inbox/components/TinderView"; import { useInboxReports } from "@/features/inbox/hooks/useInboxReports"; -import { useDismissedReportsStore } from "@/features/inbox/stores/dismissedReportsStore"; +import { + decidedIds, + useDismissedReportsStore, +} from "@/features/inbox/stores/dismissedReportsStore"; import { useInboxFilterStore } from "@/features/inbox/stores/inboxFilterStore"; import { useInboxStore } from "@/features/inbox/stores/inboxStore"; import type { SignalReport } from "@/features/inbox/types"; @@ -29,14 +32,14 @@ export default function InboxScreen() { ); // ── Tinder mode data ────────────────────────────────────────────────────── - const dismissedIds = useDismissedReportsStore((s) => s.dismissedIds); + const decided = useDismissedReportsStore(decidedIds); const setCurrentIndex = useInboxStore((s) => s.setCurrentIndex); const { repositoryOptions } = useIntegrations(); - // Same data as the list view, just excluding dismissed reports. + // Same data as the list view, excluding already-decided reports. const tinderReports = useMemo( - () => reports.filter((r) => !dismissedIds.includes(r.id)), - [reports, dismissedIds], + () => reports.filter((r) => !decided.includes(r.id)), + [reports, decided], ); // Reset card index when switching to tinder mode diff --git a/apps/mobile/src/app/review.tsx b/apps/mobile/src/app/review.tsx index a31d20050..03f0b20d6 100644 --- a/apps/mobile/src/app/review.tsx +++ b/apps/mobile/src/app/review.tsx @@ -5,7 +5,10 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { FloatingBackButton } from "@/components/FloatingBackButton"; import { TinderView } from "@/features/inbox/components/TinderView"; import { useInboxReports } from "@/features/inbox/hooks/useInboxReports"; -import { useDismissedReportsStore } from "@/features/inbox/stores/dismissedReportsStore"; +import { + decidedIds, + useDismissedReportsStore, +} from "@/features/inbox/stores/dismissedReportsStore"; import { useInboxStore } from "@/features/inbox/stores/inboxStore"; import { useIntegrations } from "@/features/tasks/hooks/useIntegrations"; import { useThemeColors } from "@/lib/theme"; @@ -13,15 +16,15 @@ import { useThemeColors } from "@/lib/theme"; export default function ReviewScreen() { const insets = useSafeAreaInsets(); const themeColors = useThemeColors(); - const dismissedIds = useDismissedReportsStore((s) => s.dismissedIds); + const decided = useDismissedReportsStore(decidedIds); const setCurrentIndex = useInboxStore((s) => s.setCurrentIndex); const { repositoryOptions } = useIntegrations(); const { reports, isLoading } = useInboxReports(); const tinderReports = useMemo( - () => reports.filter((r) => !dismissedIds.includes(r.id)), - [reports, dismissedIds], + () => reports.filter((r) => !decided.includes(r.id)), + [reports, decided], ); // Reset card index each time the review screen mounts diff --git a/apps/mobile/src/app/settings/index.tsx b/apps/mobile/src/app/settings/index.tsx index f8e9cceaa..070d57069 100644 --- a/apps/mobile/src/app/settings/index.tsx +++ b/apps/mobile/src/app/settings/index.tsx @@ -103,7 +103,9 @@ export default function SettingsScreen() { const setDefaultInitialTaskMode = usePreferencesStore( (s) => s.setDefaultInitialTaskMode, ); - const dismissedCount = useDismissedReportsStore((s) => s.dismissedIds.length); + const decidedCount = useDismissedReportsStore( + (s) => s.dismissedIds.length + s.acceptedIds.length, + ); const clearDismissed = useDismissedReportsStore((s) => s.clearDismissed); const [themeSheetOpen, setThemeSheetOpen] = useState(false); @@ -276,15 +278,15 @@ export default function SettingsScreen() { {/* Inbox */} 0 ? "border-gray-6 bg-gray-3 active:opacity-60" : "border-gray-4 opacity-40"}`} + className={`rounded-md border px-3 py-1.5 ${decidedCount > 0 ? "border-gray-6 bg-gray-3 active:opacity-60" : "border-gray-4 opacity-40"}`} > Clear diff --git a/apps/mobile/src/features/inbox/components/TinderView.tsx b/apps/mobile/src/features/inbox/components/TinderView.tsx index f5e930d7d..7eb2b5fd9 100644 --- a/apps/mobile/src/features/inbox/components/TinderView.tsx +++ b/apps/mobile/src/features/inbox/components/TinderView.tsx @@ -85,7 +85,9 @@ function PriorityBadge({ priority }: { priority: SignalReportPriority }) { // ─── Empty state ─── function EmptyState() { - const dismissedCount = useDismissedReportsStore((s) => s.dismissedIds.length); + const decidedCount = useDismissedReportsStore( + (s) => s.dismissedIds.length + s.acceptedIds.length, + ); const clearDismissed = useDismissedReportsStore((s) => s.clearDismissed); return ( @@ -98,13 +100,13 @@ function EmptyState() { You've reviewed all reports assigned to you. Check back later for new ones. - {dismissedCount > 0 && ( + {decidedCount > 0 && ( - Reset {dismissedCount} dismissed + Reset {decidedCount} reviewed )} @@ -131,8 +133,9 @@ export function TinderView({ // Store state const currentIndex = useInboxStore((s) => s.currentIndex); - const advanceCard = useInboxStore((s) => s.advanceCard); + const _advanceCard = useInboxStore((s) => s.advanceCard); const dismissReport = useDismissedReportsStore((s) => s.dismissReport); + const acceptReport = useDismissedReportsStore((s) => s.acceptReport); // Local state const [expandedReport, setExpandedReport] = useState( @@ -209,7 +212,7 @@ export function TinderView({ signalReportId: report.id, }); - advanceCard(); + acceptReport(report.id); showToastDone(task.id, report.title ?? "Untitled report"); } catch (e) { const message = @@ -221,7 +224,7 @@ export function TinderView({ setCreating(false); } }, - [repositoryOptions, advanceCard, showToastPending, showToastDone], + [repositoryOptions, showToastPending, showToastDone, acceptReport], ); const currentReport = diff --git a/apps/mobile/src/features/inbox/stores/dismissedReportsStore.ts b/apps/mobile/src/features/inbox/stores/dismissedReportsStore.ts index 3dad48f7e..680952945 100644 --- a/apps/mobile/src/features/inbox/stores/dismissedReportsStore.ts +++ b/apps/mobile/src/features/inbox/stores/dismissedReportsStore.ts @@ -3,40 +3,57 @@ import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; interface DismissedReportsState { + /** Report IDs swiped left (dismissed). */ dismissedIds: string[]; + /** Report IDs swiped right (accepted / task started). */ + acceptedIds: string[]; } interface DismissedReportsActions { dismissReport: (reportId: string) => void; + acceptReport: (reportId: string) => void; undismissReport: (reportId: string) => void; clearDismissed: () => void; } type DismissedReportsStore = DismissedReportsState & DismissedReportsActions; +/** All report IDs the user has acted on (swiped left or right). */ +export function decidedIds(state: DismissedReportsState): string[] { + return [...state.dismissedIds, ...state.acceptedIds]; +} + export const useDismissedReportsStore = create()( persist( (set) => ({ dismissedIds: [], + acceptedIds: [], dismissReport: (reportId) => set((state) => ({ dismissedIds: state.dismissedIds.includes(reportId) ? state.dismissedIds : [...state.dismissedIds, reportId], })), + acceptReport: (reportId) => + set((state) => ({ + acceptedIds: state.acceptedIds.includes(reportId) + ? state.acceptedIds + : [...state.acceptedIds, reportId], + })), undismissReport: (reportId) => set((state) => ({ dismissedIds: state.dismissedIds.filter((id) => id !== reportId), + acceptedIds: state.acceptedIds.filter((id) => id !== reportId), })), - clearDismissed: () => set({ dismissedIds: [] }), + clearDismissed: () => set({ dismissedIds: [], acceptedIds: [] }), }), { name: "dismissed-reports-storage", storage: createJSONStorage(() => AsyncStorage), - partialize: (state) => ({ dismissedIds: state.dismissedIds }), + partialize: (state) => ({ + dismissedIds: state.dismissedIds, + acceptedIds: state.acceptedIds, + }), }, ), ); - -export const isDismissed = (reportId: string) => - useDismissedReportsStore.getState().dismissedIds.includes(reportId); From c05fd3c2119413d29067a2b1ebf74f5efcee1a81 Mon Sep 17 00:00:00 2001 From: Oliver Browne Date: Thu, 14 May 2026 12:25:45 -0400 Subject: [PATCH 66/94] fix icons --- apps/mobile/metro.config.js | 33 +++++++++++-------- .../features/mcp/components/ServerIcon.tsx | 2 +- .../features/mcp/components/serverIcons.ts | 8 +++++ 3 files changed, 28 insertions(+), 15 deletions(-) diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js index 3217bf8bb..8afd10d43 100644 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -7,19 +7,6 @@ const monorepoRoot = path.resolve(projectRoot, "../.."); const config = getDefaultConfig(projectRoot); -// Treat .svg files as React components via react-native-svg-transformer so -// we can `import Icon from "./logo.svg"` and render it like any RN component. -const { transformer, resolver } = config; -config.transformer = { - ...transformer, - babelTransformerPath: require.resolve("react-native-svg-transformer/expo"), -}; -config.resolver = { - ...resolver, - assetExts: resolver.assetExts.filter((ext) => ext !== "svg"), - sourceExts: [...resolver.sourceExts, "svg"], -}; - // Watch monorepo root for changes config.watchFolders = [monorepoRoot]; @@ -34,4 +21,22 @@ config.resolver.extraNodeModules = { react: path.resolve(monorepoRoot, "node_modules/react"), }; -module.exports = withNativeWind(config, { input: "./global.css" }); +// Apply NativeWind first so its resolver/transformer changes are in place +// before we layer the SVG transformer on top. +const nativeWindConfig = withNativeWind(config, { input: "./global.css" }); + +// Treat .svg files as React components via react-native-svg-transformer so +// we can `import Icon from "./logo.svg"` and render it like any RN component. +// This must run AFTER withNativeWind — NativeWind overwrites the resolver +// and would clobber the assetExts/sourceExts changes if applied later. +nativeWindConfig.transformer = { + ...nativeWindConfig.transformer, + babelTransformerPath: require.resolve("react-native-svg-transformer/expo"), +}; +nativeWindConfig.resolver = { + ...nativeWindConfig.resolver, + assetExts: nativeWindConfig.resolver.assetExts.filter((ext) => ext !== "svg"), + sourceExts: [...nativeWindConfig.resolver.sourceExts, "svg"], +}; + +module.exports = nativeWindConfig; diff --git a/apps/mobile/src/features/mcp/components/ServerIcon.tsx b/apps/mobile/src/features/mcp/components/ServerIcon.tsx index 23865cb90..f0fe08ef5 100644 --- a/apps/mobile/src/features/mcp/components/ServerIcon.tsx +++ b/apps/mobile/src/features/mcp/components/ServerIcon.tsx @@ -23,7 +23,7 @@ export function ServerIcon({ iconKey, size = 32, className }: ServerIconProps) { className={`shrink-0 items-center justify-center overflow-hidden rounded-md bg-card ${className ?? ""}`} style={{ width: size, height: size }} > - {logo?.kind === "svg" ? ( + {logo?.kind === "svg" && typeof logo.component === "function" ? ( ) : logo?.kind === "png" ? ( ): ServerLogo { + if (typeof component !== "function") { + log.warn("SVG import resolved as non-component", { + type: typeof component, + }); + } return { kind: "svg", component }; } From 4bd771f63122155220c76c35ffb59224038c15b8 Mon Sep 17 00:00:00 2001 From: Annika <14750837+annikaschmid@users.noreply.github.com> Date: Thu, 14 May 2026 12:22:51 -0400 Subject: [PATCH 67/94] feat(mobile): add plan approvals and PostHog chips Add task plan approval support across the mobile app and render PostHog links as inline chips, including relative insight paths and title-plus-id labels. --- apps/mobile/src/app/_layout.tsx | 8 +- apps/mobile/src/app/task/[id].tsx | 1 + .../features/chat/components/MarkdownText.tsx | 155 ++++++++-- .../chat/components/PostHogRefChip.tsx | 30 ++ .../components/PlanApprovalCard.test.tsx | 199 ++++++++++++ .../tasks/components/PlanApprovalCard.tsx | 272 +++++++++++++++++ .../tasks/components/TaskSessionView.test.tsx | 114 +++++++ .../tasks/components/TaskSessionView.tsx | 43 ++- .../features/tasks/stores/taskSessionStore.ts | 52 +++- apps/mobile/src/features/tasks/types.ts | 14 + apps/mobile/src/lib/posthogUrl.test.ts | 90 ++++++ apps/mobile/src/lib/posthogUrl.ts | 283 ++++++++++++++++++ 12 files changed, 1234 insertions(+), 27 deletions(-) create mode 100644 apps/mobile/src/features/chat/components/PostHogRefChip.tsx create mode 100644 apps/mobile/src/features/tasks/components/PlanApprovalCard.test.tsx create mode 100644 apps/mobile/src/features/tasks/components/PlanApprovalCard.tsx create mode 100644 apps/mobile/src/features/tasks/components/TaskSessionView.test.tsx create mode 100644 apps/mobile/src/lib/posthogUrl.test.ts create mode 100644 apps/mobile/src/lib/posthogUrl.ts diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index aa42956d2..d8e508ea4 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -32,9 +32,7 @@ interface RootLayoutNavProps { } function RootLayoutNav({ isConnected }: RootLayoutNavProps) { - const { isLoading, initializeAuth } = useAuthStore(); - const isAuthenticated = useAuthStore((s) => s.isAuthenticated); - const _aiChatEnabled = usePreferencesStore((s) => s.aiChatEnabled); + const { isAuthenticated, isLoading, initializeAuth } = useAuthStore(); const publishWatchSnapshot = useTaskSessionStore( (s) => s.publishWatchSnapshot, ); @@ -53,8 +51,8 @@ function RootLayoutNav({ isConnected }: RootLayoutNavProps) { }, [isLoading, publishWatchSnapshot]); useEffect(() => { - return setupNotificationResponseListener(({ taskId }) => { - router.push(`/task/${taskId}`); + return setupNotificationResponseListener(({ path }) => { + router.push(path); }); }, []); diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index e6c781705..5fc59db11 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -507,6 +507,7 @@ export default function TaskDetailScreen() { small visual buffer at the bottom. */} ()\]]+|\/(?:insights|project|organization|settings|feature_flags|experiments|dashboard|dashboards|replay|session_replay|recordings|error_tracking|task|inbox|automation)\b[^\s<>()\]]*)/g; interface MarkdownTextProps { content: string; @@ -220,7 +226,86 @@ function openUrl(url: string) { Linking.openURL(url); } -function renderInline(text: string): React.ReactNode[] { +function splitTrailingPunctuation(text: string): { + reference: string; + trailing: string; +} { + const reference = text.replace(/[.,!?;:]+$/u, ""); + return { + reference, + trailing: text.slice(reference.length), + }; +} + +function renderPlainText( + text: string, + posthogUrlOptions: ParsePostHogUrlOptions, + keyBase: string, +): React.ReactNode[] { + const nodes: React.ReactNode[] = []; + let lastIndex = 0; + let match: RegExpExecArray | null = null; + + BARE_POSTHOG_REF_PATTERN.lastIndex = 0; + + // biome-ignore lint/suspicious/noAssignInExpressions: regex exec loop + while ((match = BARE_POSTHOG_REF_PATTERN.exec(text)) !== null) { + if (match.index > lastIndex) { + nodes.push(text.slice(lastIndex, match.index)); + } + + const candidate = match[0]; + const { reference, trailing } = splitTrailingPunctuation(candidate); + const posthogRef = parsePostHogUrl(reference, posthogUrlOptions); + + if (posthogRef) { + nodes.push( + , + ); + + if (trailing) { + nodes.push(trailing); + } + } else { + nodes.push(candidate); + } + + lastIndex = match.index + candidate.length; + } + + if (lastIndex < text.length) { + nodes.push(text.slice(lastIndex)); + } + + return nodes.length > 0 ? nodes : [text]; +} + +function formatPostHogChipLabel( + posthogRef: ReturnType, + linkText: string, + url: string, +): string { + if (!posthogRef) return linkText; + if (linkText === url) return posthogRef.defaultLabel; + + const normalizedLinkText = linkText.trim(); + if (!posthogRef.refId) return normalizedLinkText; + if (normalizedLinkText.endsWith(`(${posthogRef.refId})`)) { + return normalizedLinkText; + } + + return `${normalizedLinkText} (${posthogRef.refId})`; +} + +function renderInline( + text: string, + posthogUrlOptions: ParsePostHogUrlOptions, +): React.ReactNode[] { const nodes: React.ReactNode[] = []; // Links must come first to avoid bold/italic consuming text inside []. // Order after links: strikethrough, bold, italic, inline code. @@ -232,7 +317,13 @@ function renderInline(text: string): React.ReactNode[] { // 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)); + nodes.push( + ...renderPlainText( + text.slice(lastIndex, match.index), + posthogUrlOptions, + `plain-${match.index}`, + ), + ); } if (match[2] && match[3]) { @@ -254,16 +345,28 @@ function renderInline(text: string): React.ReactNode[] { />, ); } else { - nodes.push( - openUrl(url)} - > - {linkText} - {" ↗"} - , - ); + const posthogRef = parsePostHogUrl(url, posthogUrlOptions); + if (posthogRef) { + nodes.push( + , + ); + } else { + nodes.push( + openUrl(url)} + > + {linkText} + {" ↗"} + , + ); + } } } else if (match[4]) { // Strikethrough: ~~text~~ @@ -302,7 +405,13 @@ function renderInline(text: string): React.ReactNode[] { } if (lastIndex < text.length) { - nodes.push(text.slice(lastIndex)); + nodes.push( + ...renderPlainText( + text.slice(lastIndex), + posthogUrlOptions, + `plain-tail-${lastIndex}`, + ), + ); } return nodes.length > 0 ? nodes : [text]; @@ -310,6 +419,14 @@ function renderInline(text: string): React.ReactNode[] { export function MarkdownText({ content }: MarkdownTextProps) { const blocks = parseBlocks(content); + const cloudRegion = useAuthStore((state) => state.cloudRegion); + const posthogUrlOptions = useMemo( + () => ({ + appBaseUrl: cloudRegion ? getCloudUrlFromRegion(cloudRegion) : null, + codeBaseUrl: UNIVERSAL_LINK_PREFIX, + }), + [cloudRegion], + ); return ( @@ -360,7 +477,7 @@ export function MarkdownText({ content }: MarkdownTextProps) { : "text-[13px]" }`} > - {renderInline(block.content)} + {renderInline(block.content, posthogUrlOptions)} ); @@ -399,7 +516,7 @@ export function MarkdownText({ content }: MarkdownTextProps) { : "text-gray-12" }`} > - {renderInline(itemText)} + {renderInline(itemText, posthogUrlOptions)} ); @@ -436,7 +553,7 @@ export function MarkdownText({ content }: MarkdownTextProps) { } > - {renderInline(cell)} + {renderInline(cell, posthogUrlOptions)} ); @@ -466,7 +583,7 @@ export function MarkdownText({ content }: MarkdownTextProps) { } > - {renderInline(cell)} + {renderInline(cell, posthogUrlOptions)} ); @@ -483,7 +600,7 @@ export function MarkdownText({ content }: MarkdownTextProps) { return ( - {renderInline(block.content)} + {renderInline(block.content, posthogUrlOptions)} ); @@ -499,7 +616,7 @@ export function MarkdownText({ content }: MarkdownTextProps) { default: return ( - {renderInline(block.content)} + {renderInline(block.content, posthogUrlOptions)} ); } diff --git a/apps/mobile/src/features/chat/components/PostHogRefChip.tsx b/apps/mobile/src/features/chat/components/PostHogRefChip.tsx new file mode 100644 index 000000000..7e5360881 --- /dev/null +++ b/apps/mobile/src/features/chat/components/PostHogRefChip.tsx @@ -0,0 +1,30 @@ +import { Linking, Text } from "react-native"; +import type { PostHogRefKind } from "@/lib/posthogUrl"; + +interface PostHogRefChipProps { + href: string; + kind: PostHogRefKind; + label: string; +} + +export function PostHogRefChip({ href, kind, label }: PostHogRefChipProps) { + const destination = + kind === "docs" + ? "docs" + : kind === "code" + ? "Code" + : kind === "website" + ? "website" + : "app"; + + return ( + Linking.openURL(href)} + className="rounded-md bg-gray-3 px-1.5 py-0.5 font-mono text-[11px] text-accent-11" + accessibilityRole="link" + accessibilityLabel={`PostHog ${destination} link ${label}`} + > + {label} + + ); +} diff --git a/apps/mobile/src/features/tasks/components/PlanApprovalCard.test.tsx b/apps/mobile/src/features/tasks/components/PlanApprovalCard.test.tsx new file mode 100644 index 000000000..f22275268 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/PlanApprovalCard.test.tsx @@ -0,0 +1,199 @@ +import { createElement } from "react"; +import { TextInput } from "react-native"; +import { act, create } from "react-test-renderer"; +import { describe, expect, it, vi } from "vitest"; +import { PlanApprovalCard } from "./PlanApprovalCard"; + +vi.mock("phosphor-react-native", () => ({ + ArrowsClockwise: (props: Record) => + createElement("ArrowsClockwise", props), + ChatCircle: (props: Record) => + createElement("ChatCircle", props), + CheckCircle: (props: Record) => + createElement("CheckCircle", props), +})); + +vi.mock("@/lib/theme", () => ({ + useThemeColors: () => ({ + gray: { + 9: "#666666", + 11: "#444444", + }, + accent: { + 9: "#ff5500", + }, + status: { + success: "#00aa55", + }, + }), +})); + +vi.mock("@/features/chat", () => ({ + MarkdownText: (props: Record) => + createElement("MarkdownText", props), +})); + +function findPressableWithText( + renderer: NonNullable>, + label: string, +) { + return renderer.root.find( + (node) => + typeof node.props.onPress === "function" && + node.findAll((child) => child.props.children === label).length > 0, + ); +} + +describe("PlanApprovalCard", () => { + it("renders the plan with the markdown renderer", () => { + const plan = "# Plan\n\n1. Inspect renderer\n2. Fix markdown output"; + let renderer: ReturnType | null = null; + + act(() => { + renderer = create( + createElement(PlanApprovalCard, { + toolData: { + toolCallId: "tool-plan", + status: "pending", + }, + permission: { + requestId: "request-plan", + toolCall: { + toolCallId: "tool-plan", + title: "Ready to code?", + kind: "switch_mode", + rawInput: { plan }, + }, + options: [ + { + kind: "allow_once", + optionId: "default", + name: "Yes, and manually approve edits", + }, + ], + }, + }), + ); + }); + + if (!renderer) { + throw new Error("Renderer not created"); + } + + expect(renderer.root.findByType("MarkdownText").props.content).toBe(plan); + }); + + it("sends the selected approval option immediately", () => { + const onSendPermissionResponse = vi.fn(); + let renderer: ReturnType | null = null; + + act(() => { + renderer = create( + createElement(PlanApprovalCard, { + toolData: { + toolCallId: "tool-1", + status: "pending", + }, + permission: { + requestId: "request-1", + toolCall: { + toolCallId: "tool-1", + title: "Ready to code?", + kind: "switch_mode", + }, + options: [ + { + kind: "allow_once", + optionId: "default", + name: "Yes, and manually approve edits", + }, + ], + }, + onSendPermissionResponse, + }), + ); + }); + + if (!renderer) { + throw new Error("Renderer not created"); + } + + const approveButton = findPressableWithText( + renderer, + "Yes, and manually approve edits", + ); + + act(() => { + approveButton.props.onPress(); + }); + + expect(onSendPermissionResponse).toHaveBeenCalledWith({ + toolCallId: "tool-1", + optionId: "default", + displayText: "Yes, and manually approve edits", + }); + }); + + it("collects feedback before sending the reject option", () => { + const onSendPermissionResponse = vi.fn(); + let renderer: ReturnType | null = null; + + act(() => { + renderer = create( + createElement(PlanApprovalCard, { + toolData: { + toolCallId: "tool-2", + status: "pending", + }, + permission: { + requestId: "request-2", + toolCall: { + toolCallId: "tool-2", + title: "Ready to code?", + kind: "switch_mode", + }, + options: [ + { + kind: "reject_once", + optionId: "reject_with_feedback", + name: "No, and tell the agent what to do differently", + _meta: { customInput: true }, + }, + ], + }, + onSendPermissionResponse, + }), + ); + }); + + if (!renderer) { + throw new Error("Renderer not created"); + } + + const feedbackOption = findPressableWithText( + renderer, + "No, and tell the agent what to do differently", + ); + + act(() => { + feedbackOption.props.onPress(); + }); + + const input = renderer.root.findByType(TextInput); + act(() => { + input.props.onChangeText("Keep the rollback plan tighter."); + }); + + const sendButton = findPressableWithText(renderer, "Send feedback"); + act(() => { + sendButton.props.onPress(); + }); + + expect(onSendPermissionResponse).toHaveBeenCalledWith({ + toolCallId: "tool-2", + optionId: "reject_with_feedback", + customInput: "Keep the rollback plan tighter.", + displayText: "Keep the rollback plan tighter.", + }); + }); +}); diff --git a/apps/mobile/src/features/tasks/components/PlanApprovalCard.tsx b/apps/mobile/src/features/tasks/components/PlanApprovalCard.tsx new file mode 100644 index 000000000..6781c7cdf --- /dev/null +++ b/apps/mobile/src/features/tasks/components/PlanApprovalCard.tsx @@ -0,0 +1,272 @@ +import { + ArrowsClockwise, + ChatCircle, + CheckCircle, +} from "phosphor-react-native"; +import { useMemo, useState } from "react"; +import { Pressable, ScrollView, Text, TextInput, View } from "react-native"; +import { MarkdownText, type ToolStatus } from "@/features/chat"; +import { useThemeColors } from "@/lib/theme"; +import type { CloudPendingPermissionRequest } from "../types"; + +interface ToolData { + toolCallId: string; + status: ToolStatus; +} + +interface PermissionResponseArgs { + toolCallId: string; + optionId: string; + answers?: Record; + customInput?: string; + displayText: string; +} + +interface PlanApprovalCardProps { + toolData: ToolData; + permission?: CloudPendingPermissionRequest; + onSendPermissionResponse?: (args: PermissionResponseArgs) => void; +} + +function optionMeta(option: CloudPendingPermissionRequest["options"][number]) { + return option._meta as + | { + customInput?: boolean; + description?: string; + } + | undefined; +} + +function isRejectOption( + option?: CloudPendingPermissionRequest["options"][number], +) { + if (!option) return false; + return option.kind.startsWith("reject") || option.optionId.includes("reject"); +} + +function extractTextContent(item: unknown): string | null { + if (!item || typeof item !== "object") return null; + + const record = item as Record; + if (typeof record.text === "string") { + return record.text; + } + + if (!record.content || typeof record.content !== "object") { + return null; + } + + const content = record.content as Record; + return typeof content.text === "string" ? content.text : null; +} + +function extractPlanText( + permission?: CloudPendingPermissionRequest, +): string | null { + const rawPlan = permission?.toolCall.rawInput?.plan; + if (typeof rawPlan === "string" && rawPlan.trim().length > 0) { + return rawPlan; + } + + for (const item of permission?.toolCall.content ?? []) { + const text = extractTextContent(item); + if (text?.trim()) { + return text; + } + } + + return null; +} + +export function PlanApprovalCard({ + toolData, + permission, + onSendPermissionResponse, +}: PlanApprovalCardProps) { + const themeColors = useThemeColors(); + const [selectedCustomOptionId, setSelectedCustomOptionId] = useState< + string | null + >(null); + const [customInput, setCustomInput] = useState(""); + + const response = permission?.response; + const planText = useMemo(() => extractPlanText(permission), [permission]); + const selectedOption = useMemo( + () => + permission?.options.find( + (option) => option.optionId === response?.optionId, + ), + [permission?.options, response?.optionId], + ); + const isResolved = + !!response || + toolData.status === "completed" || + toolData.status === "error"; + + if (!permission) { + return null; + } + + const submitOption = ( + optionId: string, + displayText: string, + nextCustomInput?: string, + ) => { + if (!onSendPermissionResponse) return; + onSendPermissionResponse({ + toolCallId: toolData.toolCallId, + optionId, + displayText, + ...(nextCustomInput ? { customInput: nextCustomInput } : {}), + }); + }; + + const handleCustomSubmit = () => { + const trimmed = customInput.trim(); + if (!selectedCustomOptionId || !trimmed) return; + submitOption(selectedCustomOptionId, trimmed, trimmed); + }; + + const responseText = + response?.customInput?.trim() || + selectedOption?.name || + response?.displayText || + null; + const resolvedAsReject = isRejectOption(selectedOption); + + return ( + + + + + Implementation Plan + + + + + + Approve this plan to proceed? + + + + {planText && ( + + + + + + + + + + )} + + {isResolved ? ( + + + {resolvedAsReject ? ( + + ) : ( + + )} + + + {resolvedAsReject ? "Sent back with guidance" : "Plan approved"} + + {responseText && ( + + {responseText} + + )} + + + + ) : ( + + {permission.options.map((option) => { + const meta = optionMeta(option); + const usesCustomInput = meta?.customInput === true; + const isCustomSelected = selectedCustomOptionId === option.optionId; + + return ( + + { + if (usesCustomInput) { + setSelectedCustomOptionId((current) => + current === option.optionId ? null : option.optionId, + ); + return; + } + submitOption(option.optionId, option.name); + }} + className={`rounded-lg border px-3 py-2.5 ${ + isCustomSelected + ? "border-accent-8 bg-accent-3" + : "border-gray-6 bg-gray-3" + }`} + > + + {option.name} + + {meta?.description && ( + + {meta.description} + + )} + + + {usesCustomInput && isCustomSelected && ( + + + + + Send feedback + + + + )} + + ); + })} + + )} + + ); +} diff --git a/apps/mobile/src/features/tasks/components/TaskSessionView.test.tsx b/apps/mobile/src/features/tasks/components/TaskSessionView.test.tsx new file mode 100644 index 000000000..ad35c3aa9 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/TaskSessionView.test.tsx @@ -0,0 +1,114 @@ +import { createElement } from "react"; +import { act, create } from "react-test-renderer"; +import { describe, expect, it, vi } from "vitest"; +import { TaskSessionView } from "./TaskSessionView"; + +vi.mock("phosphor-react-native", () => ({ + ArrowDown: (props: Record) => + createElement("ArrowDown", props), + Brain: (props: Record) => createElement("Brain", props), + CaretRight: (props: Record) => + createElement("CaretRight", props), + CloudArrowDown: (props: Record) => + createElement("CloudArrowDown", props), + Robot: (props: Record) => createElement("Robot", props), +})); + +vi.mock("@/features/chat", () => ({ + AgentMessage: (props: Record) => + createElement("AgentMessage", props), + HumanMessage: (props: Record) => + createElement("HumanMessage", props), + ToolMessage: (props: Record) => + createElement("ToolMessage", props), + deriveToolKind: () => "other", +})); + +vi.mock("@/features/chat/utils/thinkingMessages", () => ({ + getRandomThinkingActivity: () => "Thinking", +})); + +vi.mock("@/lib/theme", () => ({ + useThemeColors: () => ({ + gray: { 8: "#888", 9: "#777", 11: "#555" }, + accent: { 9: "#f60" }, + status: { error: "#d00" }, + }), +})); + +vi.mock("./PlanStatusBar", () => ({ + PlanStatusBar: (props: Record) => + createElement("PlanStatusBar", props), +})); + +vi.mock("./QuestionCard", () => ({ + QuestionCard: (props: Record) => + createElement("QuestionCard", props), +})); + +vi.mock("./PlanApprovalCard", () => ({ + PlanApprovalCard: (props: Record) => + createElement("PlanApprovalCard", props), +})); + +describe("TaskSessionView", () => { + it("keeps question tools pending after the run goes idle", () => { + const events = [ + { + type: "session_update" as const, + ts: 1, + notification: { + update: { + sessionUpdate: "tool_call", + title: "Which license should I use?", + toolCallId: "question-1", + status: "pending" as const, + rawInput: { + questions: [ + { + question: "Which license should I use?", + options: [{ label: "MIT" }], + }, + ], + }, + _meta: { + claudeCode: { + toolName: "AskUserQuestion", + }, + }, + }, + }, + }, + ]; + + let renderer: ReturnType | null = null; + + act(() => { + renderer = create( + createElement(TaskSessionView, { + events, + isConnecting: false, + isThinking: true, + }), + ); + }); + + if (!renderer) { + throw new Error("Renderer not created"); + } + + act(() => { + renderer.update( + createElement(TaskSessionView, { + events, + isConnecting: false, + isThinking: false, + }), + ); + }); + + expect(renderer.root.findByType("QuestionCard").props.toolData.status).toBe( + "pending", + ); + }); +}); diff --git a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx index 4e04a0b84..1b0050333 100644 --- a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx +++ b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx @@ -23,11 +23,13 @@ import { import { getRandomThinkingActivity } from "@/features/chat/utils/thinkingMessages"; import { useThemeColors } from "@/lib/theme"; import type { + CloudPendingPermissionRequest, PlanEntry, SessionEvent, SessionNotification, SessionNotificationAttachment, } from "../types"; +import { PlanApprovalCard } from "./PlanApprovalCard"; import { PlanStatusBar } from "./PlanStatusBar"; import { QuestionCard } from "./QuestionCard"; @@ -41,6 +43,7 @@ interface PermissionResponseArgs { interface TaskSessionViewProps { events: SessionEvent[]; + pendingPermissions?: Record; isConnecting?: boolean; isThinking?: boolean; terminalStatus?: "failed" | "completed"; @@ -218,6 +221,22 @@ function hasPendingQuestionMessage(message: ParsedMessage): boolean { return message.children?.some(hasPendingQuestionMessage) ?? false; } +function isPlanApprovalTool( + toolData?: ToolData, + permission?: CloudPendingPermissionRequest, +): boolean { + if (permission?.toolCall.kind === "switch_mode") return true; + if (toolData?.rawToolName === "ExitPlanMode") return true; + return typeof toolData?.args?.plan === "string"; +} + +function isInteractivePermissionTool( + toolData?: ToolData, + permission?: CloudPendingPermissionRequest, +): boolean { + return isQuestionTool(toolData) || isPlanApprovalTool(toolData, permission); +} + // Mutable processor state persisted across renders via useRef. // Only new events (past processedIdx) are processed on each call. interface EventProcessorState { @@ -764,6 +783,7 @@ function ConnectingIndicator() { export function TaskSessionView({ events, + pendingPermissions, isConnecting, isThinking, terminalStatus, @@ -798,9 +818,14 @@ export function TaskSessionView({ const state = processorRef.current; let swept = false; for (const msg of state.toolMessages.values()) { + const permission = msg.toolData + ? pendingPermissions?.[msg.toolData.toolCallId] + : undefined; if ( msg.toolData && - (msg.toolData.status === "pending" || msg.toolData.status === "running") + (msg.toolData.status === "pending" || + msg.toolData.status === "running") && + !isInteractivePermissionTool(msg.toolData, permission) ) { msg.toolData.status = "completed"; swept = true; @@ -887,6 +912,20 @@ export function TaskSessionView({ return ; case "tool": if (!item.toolData) return null; + if ( + isPlanApprovalTool( + item.toolData, + pendingPermissions?.[item.toolData.toolCallId], + ) + ) { + return ( + + ); + } if (isQuestionTool(item.toolData)) { return ( ; + pendingPermissions?: Record; } interface TaskSessionStore { @@ -900,7 +902,37 @@ export const useTaskSessionStore = create((set, get) => ({ // The cloud command requires the requestId it generated when emitting // the permission_request SSE event — toolCallId alone is not sufficient // for routing the response back to the awaiting tool call. - const cloudRequestId = session.cloudPermissionRequestIds?.[args.toolCallId]; + const cloudRequestId = + session.cloudPermissionRequestIds?.[args.toolCallId] ?? + session.pendingPermissions?.[args.toolCallId]?.requestId; + + set((state) => { + const current = state.sessions[session.taskRunId]; + const currentPermission = current?.pendingPermissions?.[args.toolCallId]; + if (!current || !currentPermission) return state; + return { + sessions: { + ...state.sessions, + [session.taskRunId]: { + ...current, + pendingPermissions: { + ...(current.pendingPermissions ?? {}), + [args.toolCallId]: { + ...currentPermission, + response: { + optionId: args.optionId, + displayText: args.displayText, + ...(args.answers ? { answers: args.answers } : {}), + ...(args.customInput + ? { customInput: args.customInput } + : {}), + }, + }, + }, + }, + }, + }; + }); try { await sendCloudCommand(taskId, session.taskRunId, "permission_response", { @@ -943,6 +975,7 @@ export const useTaskSessionStore = create((set, get) => ({ if (!current) return state; const nextLocalEchoes = new Set(current.localUserEchoes ?? []); nextLocalEchoes.delete(args.displayText); + const currentPermission = current.pendingPermissions?.[args.toolCallId]; return { sessions: { ...state.sessions, @@ -950,6 +983,15 @@ export const useTaskSessionStore = create((set, get) => ({ ...current, events: current.events.filter((e) => e !== userEvent), localUserEchoes: nextLocalEchoes, + pendingPermissions: currentPermission + ? { + ...(current.pendingPermissions ?? {}), + [args.toolCallId]: { + ...currentPermission, + response: undefined, + }, + } + : current.pendingPermissions, isPromptPending: false, }, }, @@ -1245,6 +1287,14 @@ export const useTaskSessionStore = create((set, get) => ({ ...(current.cloudPermissionRequestIds ?? {}), [toolCallId]: update.requestId, }, + pendingPermissions: { + ...(current.pendingPermissions ?? {}), + [toolCallId]: { + requestId: update.requestId, + toolCall: update.toolCall, + options: update.options, + }, + }, }, }, }; diff --git a/apps/mobile/src/features/tasks/types.ts b/apps/mobile/src/features/tasks/types.ts index 54769f7fe..8264fa8d4 100644 --- a/apps/mobile/src/features/tasks/types.ts +++ b/apps/mobile/src/features/tasks/types.ts @@ -434,6 +434,20 @@ export interface CloudPermissionToolCall { _meta?: Record; } +export interface CloudPermissionResponseSelection { + optionId: string; + displayText: string; + customInput?: string; + answers?: Record; +} + +export interface CloudPendingPermissionRequest { + requestId: string; + toolCall: CloudPermissionToolCall; + options: CloudPermissionOption[]; + response?: CloudPermissionResponseSelection; +} + interface CloudTaskUpdateBase { taskId: string; runId: string; diff --git a/apps/mobile/src/lib/posthogUrl.test.ts b/apps/mobile/src/lib/posthogUrl.test.ts new file mode 100644 index 000000000..b652d1e46 --- /dev/null +++ b/apps/mobile/src/lib/posthogUrl.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { parsePostHogUrl } from "./posthogUrl"; + +describe("parsePostHogUrl", () => { + it("parses docs links into compact docs labels", () => { + expect(parsePostHogUrl("https://posthog.com/docs/session-replay")).toEqual({ + kind: "docs", + defaultLabel: "Docs / Session replay", + normalizedUrl: "https://posthog.com/docs/session-replay", + refId: null, + }); + }); + + it("parses PostHog Code task run links", () => { + expect( + parsePostHogUrl("https://code.posthog.com/task/task-123/run/run-456"), + ).toEqual({ + kind: "code", + defaultLabel: "Code / Task run (run-456)", + normalizedUrl: "https://code.posthog.com/task/task-123/run/run-456", + refId: "run-456", + }); + }); + + it("parses project feature flag links", () => { + expect( + parsePostHogUrl("https://us.posthog.com/project/7/feature_flags/42"), + ).toEqual({ + kind: "app", + defaultLabel: "Feature flag (42)", + normalizedUrl: "https://us.posthog.com/project/7/feature_flags/42", + refId: "42", + }); + }); + + it("parses relative insight paths using the signed-in app host", () => { + expect( + parsePostHogUrl("/insights/UiFKIsO3", { + appBaseUrl: "https://us.posthog.com", + }), + ).toEqual({ + kind: "app", + defaultLabel: "Insight (UiFKIsO3)", + normalizedUrl: "https://us.posthog.com/insights/UiFKIsO3", + refId: "UiFKIsO3", + }); + }); + + it("parses relative PostHog Code paths using the code host", () => { + expect( + parsePostHogUrl("/task/task-123/run/run-456", { + codeBaseUrl: "https://code.posthog.com", + }), + ).toEqual({ + kind: "code", + defaultLabel: "Code / Task run (run-456)", + normalizedUrl: "https://code.posthog.com/task/task-123/run/run-456", + refId: "run-456", + }); + }); + + it("uses the feature flag search query when present", () => { + expect( + parsePostHogUrl( + "https://eu.posthog.com/project/1/feature_flags?search=checkout-redesign", + ), + ).toEqual({ + kind: "app", + defaultLabel: "Feature flags / checkout-redesign", + normalizedUrl: + "https://eu.posthog.com/project/1/feature_flags?search=checkout-redesign", + refId: null, + }); + }); + + it("falls back to generic website labels for non-docs pages", () => { + expect(parsePostHogUrl("https://posthog.com/pricing")).toEqual({ + kind: "website", + defaultLabel: "PostHog / Pricing", + normalizedUrl: "https://posthog.com/pricing", + refId: null, + }); + }); + + it("ignores non-PostHog links", () => { + expect(parsePostHogUrl("https://example.com/docs/session-replay")).toBe( + null, + ); + }); +}); diff --git a/apps/mobile/src/lib/posthogUrl.ts b/apps/mobile/src/lib/posthogUrl.ts new file mode 100644 index 000000000..fe2deac21 --- /dev/null +++ b/apps/mobile/src/lib/posthogUrl.ts @@ -0,0 +1,283 @@ +export type PostHogRefKind = "app" | "code" | "docs" | "website"; + +export interface ParsedPostHogUrl { + kind: PostHogRefKind; + defaultLabel: string; + normalizedUrl: string; + refId: string | null; +} + +export interface ParsePostHogUrlOptions { + appBaseUrl?: string | null; + codeBaseUrl?: string | null; +} + +const POSTHOG_HOSTS = new Set([ + "app.posthog.com", + "code.posthog.com", + "eu.posthog.com", + "localhost", + "posthog.com", + "us.posthog.com", + "www.posthog.com", +]); + +const POSTHOG_APP_HOSTS = new Set([ + "app.posthog.com", + "eu.posthog.com", + "localhost", + "us.posthog.com", +]); + +const POSTHOG_CODE_PATH_PATTERN = /^\/(?:task|inbox|automation)(?:\/|$)/; + +function decodeSegment(segment: string): string { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +} + +function humanizeSegment(segment: string): string { + const text = decodeSegment(segment).replace(/[-_]+/g, " ").trim(); + if (!text) return ""; + return `${text.charAt(0).toUpperCase()}${text.slice(1)}`; +} + +function labelWithId(title: string, refId: string | null): string { + return refId ? `${title} (${decodeSegment(refId)})` : title; +} + +function joinLabel(prefix: string, parts: string[]): string { + const compactParts = parts.filter(Boolean); + return compactParts.length > 0 + ? `${prefix} / ${compactParts.join(" / ")}` + : prefix; +} + +function fallbackLabel(prefix: string, segments: string[]): string { + return joinLabel(prefix, segments.slice(0, 2).map(humanizeSegment)); +} + +function labelForDocs(segments: string[]): string { + return joinLabel("Docs", segments.slice(1, 3).map(humanizeSegment)); +} + +function labelForCode(segments: string[]): string { + if (segments[0] === "task") { + return segments[2] === "run" + ? labelWithId("Code / Task run", segments[3] ?? null) + : labelWithId("Code / Task", segments[1] ?? null); + } + + if (segments[0] === "inbox") { + return labelWithId("Code / Inbox", segments[1] ?? null); + } + + if (segments[0] === "automation") { + return labelWithId("Code / Automation", segments[1] ?? null); + } + + return fallbackLabel("Code", segments); +} + +function refIdForCode(segments: string[]): string | null { + if (segments[0] === "task") { + return segments[2] === "run" + ? (segments[3] ?? null) + : (segments[1] ?? null); + } + + if (segments[0] === "inbox" || segments[0] === "automation") { + return segments[1] ?? null; + } + + return null; +} + +function labelForProjectView( + parsed: URL, + projectSegments: string[], +): string | null { + const [section, refId, nestedId] = projectSegments; + + switch (section) { + case "feature_flags": { + const search = parsed.searchParams.get("search")?.trim(); + if (refId) return labelWithId("Feature flag", refId); + if (search) return `Feature flags / ${search}`; + return "Feature flags"; + } + case "experiments": + return refId ? labelWithId("Experiment", refId) : "Experiments"; + case "insights": + return refId ? labelWithId("Insight", refId) : "Insights"; + case "dashboard": + case "dashboards": + return refId ? labelWithId("Dashboard", refId) : "Dashboards"; + case "data-management": + if (refId === "events" && nestedId) { + return labelWithId("Event", nestedId); + } + return "Data management"; + case "settings": + return refId ? `Settings / ${humanizeSegment(refId)}` : "Settings"; + case "session_replay": + case "replay": + case "recordings": + return refId ? labelWithId("Replay", refId) : "Replay"; + case "error_tracking": + return refId ? labelWithId("Error", refId) : "Error tracking"; + default: + return null; + } +} + +function labelForApp(parsed: URL, segments: string[]): string { + const [section, refId] = segments; + + switch (section) { + case "insights": + return refId ? labelWithId("Insight", refId) : "Insights"; + case "dashboard": + case "dashboards": + return refId ? labelWithId("Dashboard", refId) : "Dashboards"; + case "replay": + case "recordings": + case "session_replay": + return refId ? labelWithId("Replay", refId) : "Replay"; + case "feature_flags": + return refId ? labelWithId("Feature flag", refId) : "Feature flags"; + case "experiments": + return refId ? labelWithId("Experiment", refId) : "Experiments"; + } + + if (segments[0] === "project" && segments[1]) { + const projectLabel = labelForProjectView(parsed, segments.slice(2)); + if (projectLabel) return projectLabel; + } + + return fallbackLabel("PostHog", segments); +} + +function labelForWebsite(segments: string[]): string { + if (segments[0] === "docs") { + return labelForDocs(segments); + } + + return fallbackLabel("PostHog", segments); +} + +function refIdForApp(_parsed: URL, segments: string[]): string | null { + const [section, refId] = segments; + + switch (section) { + case "insights": + case "dashboard": + case "dashboards": + case "replay": + case "recordings": + case "session_replay": + case "feature_flags": + case "experiments": + return refId ?? null; + case "project": + switch (segments[2]) { + case "feature_flags": + case "experiments": + case "insights": + case "dashboard": + case "dashboards": + case "session_replay": + case "replay": + case "recordings": + case "error_tracking": + return segments[3] ?? null; + case "data-management": + return segments[3] === "events" ? (segments[4] ?? null) : null; + default: + return null; + } + default: + return null; + } +} + +function ensureTrailingSlash(url: string): string { + return url.endsWith("/") ? url : `${url}/`; +} + +function resolvePostHogUrl( + text: string, + options: ParsePostHogUrlOptions, +): URL | null { + const trimmed = text.trim(); + + if (trimmed.startsWith("/")) { + const baseUrl = POSTHOG_CODE_PATH_PATTERN.test(trimmed) + ? options.codeBaseUrl + : options.appBaseUrl; + + if (!baseUrl) return null; + + try { + return new URL(trimmed, ensureTrailingSlash(baseUrl)); + } catch { + return null; + } + } + + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + return null; + } + + return parsed; +} + +export function parsePostHogUrl( + text: string, + options: ParsePostHogUrlOptions = {}, +): ParsedPostHogUrl | null { + const parsed = resolvePostHogUrl(text, options); + if (!parsed) return null; + + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null; + + const hostname = parsed.hostname.toLowerCase(); + if (!POSTHOG_HOSTS.has(hostname)) return null; + + const segments = parsed.pathname.split("/").filter(Boolean); + + if (hostname === "code.posthog.com") { + return { + kind: "code", + defaultLabel: labelForCode(segments), + normalizedUrl: parsed.toString(), + refId: refIdForCode(segments), + }; + } + + if (POSTHOG_APP_HOSTS.has(hostname)) { + return { + kind: "app", + defaultLabel: labelForApp(parsed, segments), + normalizedUrl: parsed.toString(), + refId: refIdForApp(parsed, segments), + }; + } + + if (hostname === "posthog.com" || hostname === "www.posthog.com") { + return { + kind: segments[0] === "docs" ? "docs" : "website", + defaultLabel: labelForWebsite(segments), + normalizedUrl: parsed.toString(), + refId: null, + }; + } + + return null; +} From 8e3afa6888fae7a2162f440a0cb6f8043be15892 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Thu, 14 May 2026 12:48:30 -0400 Subject: [PATCH 68/94] Expo build setup --- apps/mobile/app.json | 1 + apps/mobile/eas.json | 6 ++++++ apps/mobile/plugins/with-adi-registration.js | 17 +++++++++++++++++ 3 files changed, 24 insertions(+) create mode 100644 apps/mobile/plugins/with-adi-registration.js diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 99f81d5c0..bc091bfbe 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -113,6 +113,7 @@ ], "expo-localization", "expo-notifications", + "./plugins/with-adi-registration", [ "expo-speech-recognition", { diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index f4001c544..d901174e4 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -13,6 +13,12 @@ }, "production": { "autoIncrement": true + }, + "play-registration": { + "distribution": "internal", + "android": { + "buildType": "apk" + } } }, "submit": { diff --git a/apps/mobile/plugins/with-adi-registration.js b/apps/mobile/plugins/with-adi-registration.js new file mode 100644 index 000000000..5bca2b307 --- /dev/null +++ b/apps/mobile/plugins/with-adi-registration.js @@ -0,0 +1,17 @@ +const { withDangerousMod } = require("expo/config-plugins"); +const fs = require("node:fs"); +const path = require("node:path"); + +const ADI_SNIPPET = "D4HHLU7245454AAAAAAAAAAAAA"; + +module.exports = function withAdiRegistration(config) { + return withDangerousMod(config, [ + "android", + async (cfg) => { + const assetsDir = path.join(cfg.modRequest.platformProjectRoot, "app", "src", "main", "assets"); + fs.mkdirSync(assetsDir, { recursive: true }); + fs.writeFileSync(path.join(assetsDir, "adi-registration.properties"), ADI_SNIPPET); + return cfg; + }, + ]); +}; From 63473039bfdd3ee1b45f504e469617340911f266 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Thu, 14 May 2026 13:49:45 -0400 Subject: [PATCH 69/94] Png --- apps/mobile/assets/services/attio.png | Bin 5158 -> 7269 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/apps/mobile/assets/services/attio.png b/apps/mobile/assets/services/attio.png index 6065fbe8977e185e775589bc4313a877b28e2cc4..3a17902fe440c6d8d11fd477bc6f092fcee21f1b 100644 GIT binary patch literal 7269 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 literal 5158 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 Tfh0iFc3+u%&+^{`Og8=>h#V>m From 09c24fc58a360aa6e794703ba8fb0af1a5c72820 Mon Sep 17 00:00:00 2001 From: dylan Date: Thu, 14 May 2026 14:11:55 -0400 Subject: [PATCH 70/94] ugh stop breaking shit --- apps/mobile/app.json | 1 + .../plugins/withPodfileResourceBundleFix.js | 99 +++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 apps/mobile/plugins/withPodfileResourceBundleFix.js diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 99f81d5c0..6fa0318c6 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -73,6 +73,7 @@ } ], "./plugins/withWatchApp", + "./plugins/withPodfileResourceBundleFix", [ "expo-font", { diff --git a/apps/mobile/plugins/withPodfileResourceBundleFix.js b/apps/mobile/plugins/withPodfileResourceBundleFix.js new file mode 100644 index 000000000..fb2a190bd --- /dev/null +++ b/apps/mobile/plugins/withPodfileResourceBundleFix.js @@ -0,0 +1,99 @@ +// Xcode 14+ refuses to build resource-bundle targets that don't have a +// development team set. Several common Pods (react-native-svg in +// particular) ship bundles without a team and break EAS builds with: +// +// "Starting from Xcode 14, resource bundles are signed by default, +// which requires setting the development team for each resource +// bundle target." +// +// Disabling code signing for those bundle targets is the standard +// workaround — they don't actually need to be signed because the host +// app's signature covers them at runtime. +// +// Injecting this via a config plugin (instead of hand-editing Podfile) +// means `expo prebuild --clean` won't wipe the fix. + +const { withDangerousMod } = require("@expo/config-plugins"); +const fs = require("node:fs"); +const path = require("node:path"); + +const MARKER = "# CODE_SIGNING_ALLOWED resource-bundle fix"; + +const POST_INSTALL_SNIPPET = ` + ${MARKER} + installer.pods_project.targets.each do |target| + if target.respond_to?(:product_type) && target.product_type == "com.apple.product-type.bundle" + target.build_configurations.each do |config| + config.build_settings["CODE_SIGNING_ALLOWED"] = "NO" + end + end + end +`; + +const withPodfileResourceBundleFix = (config) => + withDangerousMod(config, [ + "ios", + async (cfg) => { + const podfilePath = path.join( + cfg.modRequest.platformProjectRoot, + "Podfile", + ); + const contents = fs.readFileSync(podfilePath, "utf8"); + + if (contents.includes(MARKER)) { + return cfg; + } + + // Inject the snippet at the END of the existing post_install block. + // The block opens with `post_install do |installer|` and closes at + // the matching `end` — we find the closing `end` of that block by + // tracking nesting depth. + const startMatch = contents.match(/post_install do \|installer\|/); + if (!startMatch) { + throw new Error( + "[withPodfileResourceBundleFix] could not find a post_install block to patch", + ); + } + const startIdx = startMatch.index + startMatch[0].length; + const lines = contents.slice(startIdx).split("\n"); + let depth = 1; + let endLineOffset = -1; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + // crude but sufficient for the generated Podfile shape + if ( + /\bdo\b/.test(line) || + /^(if|begin|case|class|def|module)\b/.test(line) + ) { + depth += 1; + } + if (/^end\b/.test(line)) { + depth -= 1; + if (depth === 0) { + endLineOffset = i; + break; + } + } + } + if (endLineOffset === -1) { + throw new Error( + "[withPodfileResourceBundleFix] could not find the end of the post_install block", + ); + } + + // Insert our snippet just before that closing `end`. + const insertAt = + startIdx + + lines.slice(0, endLineOffset).join("\n").length + + (endLineOffset > 0 ? 1 : 0); + const patched = + contents.slice(0, insertAt) + + POST_INSTALL_SNIPPET + + contents.slice(insertAt); + + fs.writeFileSync(podfilePath, patched, "utf8"); + return cfg; + }, + ]); + +module.exports = withPodfileResourceBundleFix; From 4d230edaa9be581bd39fbfc2d6f811df3b07ca4b Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Thu, 14 May 2026 14:26:27 -0400 Subject: [PATCH 71/94] prod eas --- apps/mobile/eas.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index d901174e4..f261e4452 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -12,7 +12,10 @@ "distribution": "internal" }, "production": { - "autoIncrement": true + "autoIncrement": true, + "android": { + "buildType": "app-bundle" + } }, "play-registration": { "distribution": "internal", From 6c49f7b3da023d17918a388b467f5b276d68d92e Mon Sep 17 00:00:00 2001 From: dylan Date: Sat, 16 May 2026 17:14:10 -0700 Subject: [PATCH 72/94] updated the readme --- apps/mobile/README.md | 79 ++++++++++++++++++++++++------------------- 1 file changed, 44 insertions(+), 35 deletions(-) diff --git a/apps/mobile/README.md b/apps/mobile/README.md index fa2443804..2113da0a8 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -7,16 +7,18 @@ React Native mobile app built with Expo and expo-router. From the **repository root**: ```bash -# Install dependencies -pnpm mobile:install +# Install dependencies (workspaces are wired up, so the root install covers mobile) +pnpm install # Build and run on iOS simulator -pnpm mobile:run:ios +pnpm --filter @posthog/mobile ios # Start the development server (after initial build) -pnpm mobile:start +pnpm --filter @posthog/mobile start ``` +> First-time iOS setup also requires the **watchOS SDK** to be installed in Xcode — see [Prerequisites](#prerequisites). + ## Tech Stack - [Expo](https://expo.dev) - Build tooling, native APIs, OTA updates @@ -100,50 +102,55 @@ src/ - Node.js 22+ - pnpm 10.23.0 - Xcode (for iOS development) +- **watchOS SDK** (iOS builds embed the Apple Watch companion; without this SDK installed, `expo run:ios` fails with `watchOS X.X must be installed in order to run the scheme`) + - Install via `xcodebuild -downloadPlatform watchOS`, or in Xcode → **Settings → Components → Platforms** → download the latest watchOS - Android Studio (for Android development) -- EAS CLI: `npm install -g eas-cli` +- EAS CLI is optional — all `eas` commands below are invoked via `npx eas`. Install globally with `npm install -g eas-cli` only if you prefer the bare command. ## Commands ### From Repository Root +All commands use `pnpm --filter @posthog/mobile

?ZHNFF@ZeF0lEO0i<9#K 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%0APV`tPdER1&Btbx4c>(1!2TxwiE14HH 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 PK_HJazNhPcin#MXg%a^p diff --git a/apps/mobile/assets/app-icon.png b/apps/mobile/assets/app-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c44991ba0846a21bd236d20787aa7df2d032de76 GIT binary patch literal 30605 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 V?N2cofU1MEPU@V28paltxAA?gr`Z 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 delta 633 zcmV-<0*3vm3z`Lx83+ad00374`G=9=7k>Z&b5ch_0Itp)=>Px#1ZP1_K>z@;j|==^ z1poj7FG)l}RCodHmS1YyKoG`12@@zav==DoJ$*<{5SNxxXlZqWtZ&d8v^VGxs-B=p zp?ztBkKnxcIfh(7kUs_|@n$BL6Gz&ewH-MzVLli{^m()M^UmxTP^nZZl}e>jJ%24F zseLK=HihhWHblQp#jcl^Lv&w#v_E}|&PM2-eu38?j?TNo{dKQjr()0h1Qtesxb_8z z!~*6aAY###ArOGTLqNpAPpmjZ7+{>m%0jpSBK$%%iyH_^$mvfYMz-oN3nKCvlx#-3 zZaY?FM}MRC$!BD=*y%stMSHNfo`1zr)u%Pt+S^_mHOYAbJO?OA<2wodrkj6Yd?&%* z^s{$8<2wn|C~*!f3Na}cA4HuB&VeOD{HFMUdupaRuq22Limw#+YV1bv6JrQ41V1Z{ zJ%#`^uA#<8xv>ifP~#eEY*1rwm3A=2bD_o#1Hb_R83F=??If-rdZu{J4SzBB9AF4( z4sbb1b0B)t&*7HA0_EfckZPVGq$`vmq$@Nq%nas)Sj9QO{>%`oFbDb+n?>aFLOkXi zn23`;mXM04$9jgP~Rm@dPSvDsZ=VJN>%6zWLBXz TfCW?300000NkvXXu0mjfsuLRz diff --git a/apps/mobile/assets/posthog.icon/Assets/posthog-logo-white.svg b/apps/mobile/assets/posthog.icon/Assets/posthog-logo-white.svg deleted file mode 100644 index 8685f2551..000000000 --- a/apps/mobile/assets/posthog.icon/Assets/posthog-logo-white.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/apps/mobile/assets/posthog.icon/Assets/posthog-logo.svg b/apps/mobile/assets/posthog.icon/Assets/posthog-logo.svg deleted file mode 100644 index 410ad78ac..000000000 --- a/apps/mobile/assets/posthog.icon/Assets/posthog-logo.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/mobile/assets/posthog.icon/icon.json b/apps/mobile/assets/posthog.icon/icon.json deleted file mode 100644 index 4f2e1e25f..000000000 --- a/apps/mobile/assets/posthog.icon/icon.json +++ /dev/null @@ -1,162 +0,0 @@ -{ - "fill": { - "linear-gradient": [ - "srgb:1.00000,1.00000,1.00000,1.00000", - "display-p3:0.90090,0.90090,0.90090,1.00000" - ], - "orientation": { - "start": { - "x": 0.5, - "y": 0 - }, - "stop": { - "x": 0.5, - "y": 0.7 - } - } - }, - "groups": [ - { - "blend-mode-specializations": [ - { - "appearance": "tinted", - "value": "normal" - } - ], - "blur-material-specializations": [ - { - "value": 0.5 - }, - { - "appearance": "tinted", - "value": 0.5 - } - ], - "hidden-specializations": [ - { - "value": false - }, - { - "appearance": "dark", - "value": true - } - ], - "layers": [ - { - "fill-specializations": [ - { - "appearance": "tinted", - "value": { - "solid": "extended-gray:0.75000,1.00000" - } - } - ], - "hidden": false, - "image-name": "posthog-logo.svg", - "name": "posthog-logo", - "position": { - "scale": 15, - "translation-in-points": [15.107999999999947, -10.528545055704626] - } - } - ], - "lighting": "individual", - "shadow-specializations": [ - { - "value": { - "kind": "layer-color", - "opacity": 0.4 - } - }, - { - "appearance": "tinted", - "value": { - "kind": "layer-color", - "opacity": 0.5 - } - } - ], - "specular": true, - "translucency-specializations": [ - { - "value": { - "enabled": true, - "value": 0.2 - } - }, - { - "appearance": "tinted", - "value": { - "enabled": true, - "value": 0.5 - } - } - ] - }, - { - "blur-material-specializations": [ - { - "value": 0.5 - }, - { - "appearance": "dark", - "value": 0.5 - } - ], - "hidden-specializations": [ - { - "value": true - }, - { - "appearance": "dark", - "value": false - } - ], - "layers": [ - { - "hidden": false, - "image-name": "posthog-logo-white.svg", - "name": "posthog-logo-white", - "position": { - "scale": 15, - "translation-in-points": [15.107999999999947, -10.528431967772121] - } - } - ], - "shadow-specializations": [ - { - "value": { - "kind": "neutral", - "opacity": 0.4 - } - }, - { - "appearance": "dark", - "value": { - "kind": "neutral", - "opacity": 0.4 - } - } - ], - "translucency-specializations": [ - { - "value": { - "enabled": true, - "value": 0.5 - } - }, - { - "appearance": "dark", - "value": { - "enabled": true, - "value": 0.5 - } - } - ] - } - ], - "supported-platforms": { - "circles": ["watchOS"], - "squares": "shared" - } -} From 331806fda06c58d485aa7c917c8b1e5b127b202c Mon Sep 17 00:00:00 2001 From: Oliver Browne Date: Wed, 13 May 2026 23:51:10 -0400 Subject: [PATCH 50/94] mobile: stream cloud runs over SSE (#2147) --- apps/mobile/src/features/tasks/api.ts | 80 +- .../tasks/components/TaskSessionView.tsx | 31 +- apps/mobile/src/features/tasks/index.ts | 1 + .../src/features/tasks/lib/cloudTaskStream.ts | 844 ++++++++++++++++ .../src/features/tasks/lib/sseParser.ts | 89 ++ .../features/tasks/stores/taskSessionStore.ts | 904 +++++++----------- apps/mobile/src/features/tasks/types.ts | 156 ++- .../features/tasks/utils/parseSessionLogs.ts | 43 + apps/mobile/src/lib/api.ts | 8 + 9 files changed, 1586 insertions(+), 570 deletions(-) create mode 100644 apps/mobile/src/features/tasks/lib/cloudTaskStream.ts create mode 100644 apps/mobile/src/features/tasks/lib/sseParser.ts diff --git a/apps/mobile/src/features/tasks/api.ts b/apps/mobile/src/features/tasks/api.ts index ac34b866c..4c11bf7b3 100644 --- a/apps/mobile/src/features/tasks/api.ts +++ b/apps/mobile/src/features/tasks/api.ts @@ -1,5 +1,10 @@ import { fetch } from "expo/fetch"; -import { getBaseUrl, getHeaders, getProjectId } from "@/lib/api"; +import { + getAccessToken, + getBaseUrl, + getHeaders, + getProjectId, +} from "@/lib/api"; import { logger } from "@/lib/logger"; import type { CreateTaskAutomationOptions, @@ -640,30 +645,87 @@ export async function sendCloudCommand( return data?.result; } -export async function fetchS3Logs(logUrl: string): Promise { +export interface SessionLogsPage { + entries: StoredLogEntry[]; + hasMore: boolean; +} + +export async function fetchSessionLogs( + taskId: string, + runId: string, + options: { limit?: number; offset?: number } = {}, +): Promise { return withRetry( async () => { - const response = await fetch(logUrl, { - signal: AbortSignal.timeout(10_000), + const baseUrl = getBaseUrl(); + const projectId = getProjectId(); + const headers = getHeaders(); + + const params = new URLSearchParams({ + limit: String(options.limit ?? 5000), + offset: String(options.offset ?? 0), }); + const response = await fetch( + `${baseUrl}/api/projects/${projectId}/tasks/${taskId}/runs/${runId}/session_logs/?${params}`, + { headers, signal: AbortSignal.timeout(10_000) }, + ); + if (!response.ok) { - if (response.status === 404) { - return ""; - } throw new HttpError( response.status, response.statusText, - "Failed to fetch logs", + "Failed to fetch session logs", ); } - return await response.text(); + const entries = (await response.json()) as StoredLogEntry[]; + return { + entries, + hasMore: response.headers.get("X-Has-More") === "true", + }; }, { shouldRetry: isRetryableError }, ); } +export interface StreamCloudTaskOptions { + lastEventId?: string | null; + startLatest?: boolean; + signal: AbortSignal; +} + +export async function streamCloudTask( + taskId: string, + runId: string, + options: StreamCloudTaskOptions, +): Promise { + const baseUrl = getBaseUrl(); + const projectId = getProjectId(); + const accessToken = getAccessToken(); + + const url = new URL( + `${baseUrl}/api/projects/${projectId}/tasks/${taskId}/runs/${runId}/stream/`, + ); + if (options.startLatest && !options.lastEventId) { + url.searchParams.set("start", "latest"); + } + + const headers: Record = { + Accept: "text/event-stream", + Authorization: `Bearer ${accessToken}`, + }; + if (options.lastEventId) { + headers["Last-Event-ID"] = options.lastEventId; + } + + return await fetch(url.toString(), { + method: "GET", + headers, + signal: options.signal, + }); +} + export async function getIntegrations(): Promise { const baseUrl = getBaseUrl(); const projectId = getProjectId(); diff --git a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx index ef4ffffb5..72b3fef9d 100644 --- a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx +++ b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx @@ -277,14 +277,26 @@ function processNewEvents( const flushAgentText = () => { if (!state.pendingAgentText) return; - const msg: ParsedMessage = { - id: `agent-${state.agentMessageCount++}`, - type: "agent", - content: state.pendingAgentText, - ts: state.pendingAgentTs, - }; - state.messages.push(msg); - state.lastAgentMsgIdx = state.messages.length - 1; + // If the last message is an in-progress agent message from a previous + // batch, append to it instead of creating a new bubble. This keeps + // streaming chunks that arrive across multiple SSE batches unified + // into a single rendered message. + if ( + state.lastAgentMsgIdx !== null && + state.messages[state.lastAgentMsgIdx]?.type === "agent" + ) { + state.messages[state.lastAgentMsgIdx].content += state.pendingAgentText; + hasItemMutation = true; + } else { + const msg: ParsedMessage = { + id: `agent-${state.agentMessageCount++}`, + type: "agent", + content: state.pendingAgentText, + ts: state.pendingAgentTs, + }; + state.messages.push(msg); + state.lastAgentMsgIdx = state.messages.length - 1; + } state.pendingAgentText = ""; state.pendingAgentTs = undefined; }; @@ -336,7 +348,7 @@ function processNewEvents( break; case "agent_complete": flushThoughtText(); - // If we already flushed an agent message from chunks, replace it + // Replace accumulated chunks with the finalized message if ( state.lastAgentMsgIdx !== null && state.messages[state.lastAgentMsgIdx]?.type === "agent" @@ -345,6 +357,7 @@ function processNewEvents( if (!state.messages[state.lastAgentMsgIdx].ts) { state.messages[state.lastAgentMsgIdx].ts = event.ts; } + hasItemMutation = true; state.pendingAgentText = ""; state.pendingAgentTs = undefined; } else { diff --git a/apps/mobile/src/features/tasks/index.ts b/apps/mobile/src/features/tasks/index.ts index 502e3375d..07c05346e 100644 --- a/apps/mobile/src/features/tasks/index.ts +++ b/apps/mobile/src/features/tasks/index.ts @@ -30,5 +30,6 @@ export * from "./types"; // Utils export { convertRawEntriesToEvents, + convertStoredEntriesToEvents, parseSessionLogs, } from "./utils/parseSessionLogs"; diff --git a/apps/mobile/src/features/tasks/lib/cloudTaskStream.ts b/apps/mobile/src/features/tasks/lib/cloudTaskStream.ts new file mode 100644 index 000000000..c8187f886 --- /dev/null +++ b/apps/mobile/src/features/tasks/lib/cloudTaskStream.ts @@ -0,0 +1,844 @@ +import { logger } from "@/lib/logger"; +import { + fetchSessionLogs, + getTaskRun, + HttpError, + streamCloudTask, +} from "../api"; +import { + type CloudTaskUpdatePayload, + isKeepaliveEvent, + isPermissionRequestEvent, + isSseErrorEvent, + isTaskRunStateEvent, + isTerminalStatus, + type StoredLogEntry, + type TaskRun, + type TaskRunStateEvent, + type TaskRunStatus, +} from "../types"; +import { type SseEvent, SseEventParser } from "./sseParser"; + +const log = logger.scope("cloud-task-stream"); + +const MAX_SSE_RECONNECT_ATTEMPTS = 5; +const SSE_RECONNECT_BASE_DELAY_MS = 2_000; +const SSE_RECONNECT_MAX_DELAY_MS = 30_000; +const EVENT_BATCH_FLUSH_MS = 16; +const EVENT_BATCH_MAX_SIZE = 50; +const SESSION_LOG_PAGE_LIMIT = 5_000; + +interface CloudTaskConnectionError { + title: string; + message: string; + retryable: boolean; + autoRetry?: boolean; +} + +class CloudTaskStreamError extends Error { + constructor( + message: string, + public readonly details: CloudTaskConnectionError, + public readonly status?: number, + ) { + super(message); + this.name = "CloudTaskStreamError"; + } +} + +function createStreamStatusError(status: number): CloudTaskStreamError { + switch (status) { + case 401: + return new CloudTaskStreamError( + "Cloud authentication expired", + { + title: "Cloud authentication expired", + message: "Please reauthenticate and retry the cloud run stream.", + retryable: true, + autoRetry: false, + }, + status, + ); + case 403: + return new CloudTaskStreamError( + "Cloud access denied", + { + title: "Cloud access denied", + message: + "You no longer have access to this cloud run. Reauthenticate and retry.", + retryable: true, + autoRetry: false, + }, + status, + ); + case 404: + return new CloudTaskStreamError( + "Cloud run not found", + { + title: "Cloud run not found", + message: + "This cloud run could not be found. It may have been deleted or moved.", + retryable: false, + autoRetry: false, + }, + status, + ); + case 406: + return new CloudTaskStreamError( + "Cloud stream unavailable", + { + title: "Cloud stream unavailable", + message: + "The backend rejected the live stream request. Restart the backend and retry.", + retryable: true, + autoRetry: false, + }, + status, + ); + default: + return new CloudTaskStreamError( + `Stream request failed with status ${status}`, + { + title: "Cloud stream failed", + message: `The cloud stream request failed with status ${status}. Retry to reconnect.`, + retryable: true, + autoRetry: true, + }, + status, + ); + } +} + +function shouldFailWatcherForFetchStatus(status: number): boolean { + return status === 401 || status === 403 || status === 404; +} + +export interface WatchCloudTaskOptions { + taskId: string; + runId: string; + onUpdate: (update: CloudTaskUpdatePayload) => void; +} + +export interface WatchCloudTaskHandle { + stop: () => void; + reconnectIfDisconnected: () => void; +} + +interface WatcherState { + taskId: string; + runId: string; + onUpdate: (update: CloudTaskUpdatePayload) => void; + stopped: boolean; + sseAbortController: AbortController | null; + reconnectTimeoutId: ReturnType | null; + batchFlushTimeoutId: ReturnType | null; + pendingLogEntries: StoredLogEntry[]; + totalEntryCount: number; + reconnectAttempts: number; + lastEventId: string | null; + lastStatus: TaskRunStatus | null; + lastStage: string | null; + lastOutput: Record | null; + lastErrorMessage: string | null; + lastBranch: string | null; + lastStatusUpdatedAt: string | null; + isBootstrapping: boolean; + hasEmittedSnapshot: boolean; + bufferedLogBatches: StoredLogEntry[][]; + failed: boolean; + needsPostBootstrapReconnect: boolean; + needsStopAfterBootstrap: boolean; +} + +export function watchCloudTask( + options: WatchCloudTaskOptions, +): WatchCloudTaskHandle { + const watcher: WatcherState = { + taskId: options.taskId, + runId: options.runId, + onUpdate: options.onUpdate, + stopped: false, + sseAbortController: null, + reconnectTimeoutId: null, + batchFlushTimeoutId: null, + pendingLogEntries: [], + totalEntryCount: 0, + reconnectAttempts: 0, + lastEventId: null, + lastStatus: null, + lastStage: null, + lastOutput: null, + lastErrorMessage: null, + lastBranch: null, + lastStatusUpdatedAt: null, + isBootstrapping: false, + hasEmittedSnapshot: false, + bufferedLogBatches: [], + failed: false, + needsPostBootstrapReconnect: false, + needsStopAfterBootstrap: false, + }; + + void bootstrapWatcher(watcher); + + return { + stop: () => stopWatcher(watcher), + reconnectIfDisconnected: () => { + if ( + watcher.stopped || + watcher.failed || + isTerminalStatus(watcher.lastStatus) + ) { + return; + } + if (watcher.sseAbortController || watcher.reconnectTimeoutId) { + return; + } + log.debug("Force reconnect after suspension", { runId: watcher.runId }); + watcher.reconnectAttempts = 0; + void connectSse(watcher, { + startLatest: !watcher.lastEventId, + }); + }, + }; +} + +function stopWatcher(watcher: WatcherState): void { + if (watcher.stopped) return; + watcher.stopped = true; + + watcher.sseAbortController?.abort(); + watcher.sseAbortController = null; + + if (watcher.reconnectTimeoutId) { + clearTimeout(watcher.reconnectTimeoutId); + watcher.reconnectTimeoutId = null; + } + + if (watcher.batchFlushTimeoutId) { + clearTimeout(watcher.batchFlushTimeoutId); + watcher.batchFlushTimeoutId = null; + } + + // Drop any unflushed batches; the consumer is gone. + watcher.pendingLogEntries = []; + watcher.bufferedLogBatches = []; +} + +async function bootstrapWatcher(watcher: WatcherState): Promise { + if (watcher.stopped) return; + + watcher.failed = false; + watcher.needsPostBootstrapReconnect = false; + watcher.needsStopAfterBootstrap = false; + + const run = await fetchTaskRunState(watcher); + if (watcher.stopped || watcher.failed) return; + + if (!run) { + failWatcher(watcher, { + title: "Failed to load cloud run", + message: "Could not fetch the cloud run state. Retry to reconnect.", + retryable: true, + }); + return; + } + + applyTaskRunState(watcher, run); + + if (isTerminalStatus(run.status)) { + const historicalEntries = await fetchAllSessionLogs(watcher); + if (watcher.stopped || watcher.failed) return; + if (!historicalEntries) { + failWatcher(watcher, { + title: "Failed to load task history", + message: + "Could not load the persisted cloud task logs. Retry to reconnect.", + retryable: true, + }); + return; + } + + watcher.totalEntryCount = historicalEntries.length; + watcher.hasEmittedSnapshot = true; + emitSnapshot(watcher, historicalEntries); + stopWatcher(watcher); + return; + } + + watcher.isBootstrapping = true; + watcher.bufferedLogBatches = []; + void connectSse(watcher, { startLatest: true }); + + const historicalEntries = await fetchAllSessionLogs(watcher); + if (watcher.stopped || watcher.failed) return; + if (!historicalEntries) { + failWatcher(watcher, { + title: "Failed to load cloud run history", + message: + "Could not load the existing cloud run logs. Retry to reconnect.", + retryable: true, + }); + return; + } + + // Flush any pending live entries into the bootstrap buffer before snapshot. + flushLogBatch(watcher); + + watcher.totalEntryCount = historicalEntries.length; + watcher.hasEmittedSnapshot = true; + emitSnapshot(watcher, historicalEntries); + + watcher.isBootstrapping = false; + drainBufferedLogBatches(watcher, historicalEntries); + + if (watcher.failed) return; + + if (watcher.needsStopAfterBootstrap || isTerminalStatus(watcher.lastStatus)) { + watcher.needsStopAfterBootstrap = false; + stopWatcher(watcher); + return; + } + + if (watcher.needsPostBootstrapReconnect) { + watcher.needsPostBootstrapReconnect = false; + scheduleReconnect(watcher, undefined, { countAttempt: false }); + } + + void verifyPostBootstrapStatus(watcher); +} + +async function verifyPostBootstrapStatus(watcher: WatcherState): Promise { + if (watcher.stopped) return; + if (isTerminalStatus(watcher.lastStatus)) return; + + const run = await fetchTaskRunState(watcher); + if (watcher.stopped || !run) return; + + if (!applyTaskRunState(watcher, run)) return; + if (isTerminalStatus(watcher.lastStatus)) return; + + emitStatus(watcher); +} + +async function connectSse( + watcher: WatcherState, + options?: { startLatest?: boolean }, +): Promise { + if (watcher.stopped) return; + + const controller = new AbortController(); + watcher.sseAbortController = controller; + + const parser = new SseEventParser(); + const decoder = new TextDecoder(); + + try { + const response = await streamCloudTask(watcher.taskId, watcher.runId, { + lastEventId: watcher.lastEventId, + startLatest: options?.startLatest, + signal: controller.signal, + }); + + if (!response.ok) { + throw createStreamStatusError(response.status); + } + + if (!response.body) { + throw new Error("Stream response did not include a body"); + } + + const reader = response.body.getReader(); + + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + if (!value) { + continue; + } + + const chunk = decoder.decode(value, { stream: true }); + const events = parser.parse(chunk); + for (const event of events) { + handleSseEvent(watcher, event); + if (watcher.failed) return; + } + } + + const trailingEvents = parser.parse(decoder.decode()); + for (const event of trailingEvents) { + handleSseEvent(watcher, event); + if (watcher.failed) return; + } + + flushLogBatch(watcher); + + if (controller.signal.aborted) { + return; + } + + await handleStreamCompletion(watcher, { reconnectIfNonTerminal: true }); + } catch (error) { + flushLogBatch(watcher); + + if (controller.signal.aborted) { + return; + } + + if ( + error instanceof CloudTaskStreamError && + error.details.autoRetry === false + ) { + failWatcher(watcher, error.details); + return; + } + + const errorMessage = + error instanceof Error ? error.message : "Unknown stream error"; + log.warn("Cloud task stream error", { + runId: watcher.runId, + error: errorMessage, + }); + await handleStreamCompletion(watcher, { + reconnectIfNonTerminal: true, + reconnectError: error, + countReconnectAttempt: true, + }); + } finally { + if (watcher.sseAbortController === controller) { + watcher.sseAbortController = null; + } + } +} + +function handleSseEvent(watcher: WatcherState, event: SseEvent): void { + if (watcher.failed || watcher.stopped) return; + + if (event.id) { + watcher.lastEventId = event.id; + } + + if (event.event === "error") { + const message = isSseErrorEvent(event.data) + ? event.data.error + : "Unknown stream error"; + throw new Error(message); + } + + if (event.event === "keepalive" || isKeepaliveEvent(event.data)) { + return; + } + + watcher.reconnectAttempts = 0; + + if (isTaskRunStateEvent(event.data)) { + if (applyTaskRunState(watcher, event.data)) { + if (!watcher.isBootstrapping && !isTerminalStatus(watcher.lastStatus)) { + emitStatus(watcher); + } + } + return; + } + + if (isPermissionRequestEvent(event.data)) { + watcher.onUpdate({ + taskId: watcher.taskId, + runId: watcher.runId, + kind: "permission_request", + requestId: event.data.requestId, + toolCall: event.data.toolCall, + options: event.data.options, + }); + return; + } + + // StoredLogEntry always has a string `type`. Anything else is a server + // event the mobile client doesn't understand yet — drop it instead of + // forwarding a malformed entry to convertStoredEntriesToEvents. + if ( + typeof event.data !== "object" || + event.data === null || + typeof (event.data as { type?: unknown }).type !== "string" + ) { + log.warn("Skipping unrecognized SSE event", { + runId: watcher.runId, + eventName: event.event, + }); + return; + } + + watcher.pendingLogEntries.push(event.data as StoredLogEntry); + if (watcher.pendingLogEntries.length >= EVENT_BATCH_MAX_SIZE) { + flushLogBatch(watcher); + return; + } + + if (!watcher.batchFlushTimeoutId) { + watcher.batchFlushTimeoutId = setTimeout(() => { + watcher.batchFlushTimeoutId = null; + flushLogBatch(watcher); + }, EVENT_BATCH_FLUSH_MS); + } +} + +function flushLogBatch(watcher: WatcherState): void { + if (watcher.pendingLogEntries.length === 0) return; + + if (watcher.batchFlushTimeoutId) { + clearTimeout(watcher.batchFlushTimeoutId); + watcher.batchFlushTimeoutId = null; + } + + const entries = watcher.pendingLogEntries; + watcher.pendingLogEntries = []; + + if (watcher.isBootstrapping) { + watcher.bufferedLogBatches.push(entries); + return; + } + + watcher.totalEntryCount += entries.length; + watcher.onUpdate({ + taskId: watcher.taskId, + runId: watcher.runId, + kind: "logs", + newEntries: entries, + totalEntryCount: watcher.totalEntryCount, + }); +} + +function drainBufferedLogBatches( + watcher: WatcherState, + historicalEntries: StoredLogEntry[], +): void { + if (watcher.bufferedLogBatches.length === 0) return; + + // Content-based dedup because SSE IDs (Redis stream IDs) don't exist in + // the S3-backed historical entries — the JSON payload is the only shared key. + const historicalCounts = new Map(); + for (const entry of historicalEntries) { + const serialized = JSON.stringify(entry); + historicalCounts.set( + serialized, + (historicalCounts.get(serialized) ?? 0) + 1, + ); + } + + for (const entries of watcher.bufferedLogBatches) { + const dedupedEntries = entries.filter((entry) => { + const serialized = JSON.stringify(entry); + const remaining = historicalCounts.get(serialized) ?? 0; + if (remaining <= 0) return true; + historicalCounts.set(serialized, remaining - 1); + return false; + }); + + if (dedupedEntries.length === 0) continue; + + watcher.totalEntryCount += dedupedEntries.length; + watcher.onUpdate({ + taskId: watcher.taskId, + runId: watcher.runId, + kind: "logs", + newEntries: dedupedEntries, + totalEntryCount: watcher.totalEntryCount, + }); + } + + watcher.bufferedLogBatches = []; +} + +function emitSnapshot(watcher: WatcherState, entries: StoredLogEntry[]): void { + watcher.onUpdate({ + taskId: watcher.taskId, + runId: watcher.runId, + kind: "snapshot", + newEntries: entries, + totalEntryCount: watcher.totalEntryCount, + status: watcher.lastStatus ?? undefined, + stage: watcher.lastStage, + output: watcher.lastOutput, + errorMessage: watcher.lastErrorMessage, + branch: watcher.lastBranch, + }); +} + +function emitStatus(watcher: WatcherState): void { + watcher.onUpdate({ + taskId: watcher.taskId, + runId: watcher.runId, + kind: "status", + status: watcher.lastStatus ?? undefined, + stage: watcher.lastStage, + output: watcher.lastOutput, + errorMessage: watcher.lastErrorMessage, + branch: watcher.lastBranch, + }); +} + +function failWatcher( + watcher: WatcherState, + error: CloudTaskConnectionError, +): void { + if (watcher.stopped) return; + + watcher.failed = true; + watcher.isBootstrapping = false; + watcher.pendingLogEntries = []; + watcher.bufferedLogBatches = []; + + if (watcher.reconnectTimeoutId) { + clearTimeout(watcher.reconnectTimeoutId); + watcher.reconnectTimeoutId = null; + } + + if (watcher.batchFlushTimeoutId) { + clearTimeout(watcher.batchFlushTimeoutId); + watcher.batchFlushTimeoutId = null; + } + + watcher.sseAbortController?.abort(); + watcher.sseAbortController = null; + + watcher.onUpdate({ + taskId: watcher.taskId, + runId: watcher.runId, + kind: "error", + errorTitle: error.title, + errorMessage: error.message, + retryable: error.retryable, + }); +} + +function scheduleReconnect( + watcher: WatcherState, + error?: unknown, + options: { countAttempt?: boolean } = {}, +): void { + if ( + watcher.stopped || + watcher.failed || + isTerminalStatus(watcher.lastStatus) + ) { + return; + } + + if (watcher.reconnectTimeoutId) { + clearTimeout(watcher.reconnectTimeoutId); + } + + const countAttempt = options.countAttempt ?? true; + if (countAttempt) { + watcher.reconnectAttempts += 1; + } else { + watcher.reconnectAttempts = 0; + } + + if (watcher.reconnectAttempts > MAX_SSE_RECONNECT_ATTEMPTS) { + const details = + error instanceof CloudTaskStreamError + ? error.details + : { + title: "Cloud stream disconnected", + message: + "Lost connection to the cloud run stream. Retry to reconnect.", + retryable: true, + }; + failWatcher(watcher, details); + return; + } + + const delay = Math.min( + SSE_RECONNECT_BASE_DELAY_MS * + 2 ** Math.max(watcher.reconnectAttempts - 1, 0), + SSE_RECONNECT_MAX_DELAY_MS, + ); + + watcher.reconnectTimeoutId = setTimeout(() => { + if (watcher.stopped) return; + watcher.reconnectTimeoutId = null; + void connectSse(watcher, { + startLatest: watcher.isBootstrapping || watcher.hasEmittedSnapshot, + }); + }, delay); +} + +async function handleStreamCompletion( + watcher: WatcherState, + options: { + reconnectIfNonTerminal: boolean; + reconnectError?: unknown; + countReconnectAttempt?: boolean; + }, +): Promise { + if (watcher.stopped) return; + + const { reconnectIfNonTerminal } = options; + const run = await fetchTaskRunState(watcher); + if (watcher.stopped || watcher.failed) return; + + if (watcher.isBootstrapping) { + if (!run) { + watcher.needsPostBootstrapReconnect = true; + return; + } + + applyTaskRunState(watcher, run); + if (isTerminalStatus(watcher.lastStatus) || !reconnectIfNonTerminal) { + watcher.needsStopAfterBootstrap = true; + } else { + watcher.needsPostBootstrapReconnect = true; + } + return; + } + + if (!run) { + scheduleReconnect( + watcher, + new CloudTaskStreamError("Failed to fetch terminal cloud run state", { + title: "Cloud run state unavailable", + message: + "Could not fetch the latest cloud run state after the stream ended. Retry to reconnect.", + retryable: true, + }), + ); + return; + } + + const stateChanged = applyTaskRunState(watcher, run); + + if (!isTerminalStatus(watcher.lastStatus) && reconnectIfNonTerminal) { + if (stateChanged) { + emitStatus(watcher); + } + log.warn("Cloud task stream ended before terminal status", { + runId: watcher.runId, + status: watcher.lastStatus, + }); + scheduleReconnect(watcher, options.reconnectError, { + countAttempt: options.countReconnectAttempt ?? false, + }); + return; + } + + emitStatus(watcher); + stopWatcher(watcher); +} + +function applyTaskRunState( + watcher: WatcherState, + run: + | Pick< + TaskRun, + | "status" + | "stage" + | "output" + | "error_message" + | "branch" + | "updated_at" + > + | TaskRunStateEvent, +): boolean { + const updatedAt = run.updated_at ?? null; + if ( + updatedAt && + watcher.lastStatusUpdatedAt && + Date.parse(updatedAt) <= Date.parse(watcher.lastStatusUpdatedAt) + ) { + return false; + } + + const nextStatus = run.status ?? watcher.lastStatus; + const nextStage = run.stage ?? null; + const nextOutput = run.output ?? null; + const nextErrorMessage = run.error_message ?? null; + const nextBranch = run.branch ?? null; + + const changed = + nextStatus !== watcher.lastStatus || + nextStage !== watcher.lastStage || + JSON.stringify(nextOutput) !== JSON.stringify(watcher.lastOutput) || + nextErrorMessage !== watcher.lastErrorMessage || + nextBranch !== watcher.lastBranch; + + watcher.lastStatus = nextStatus ?? null; + watcher.lastStage = nextStage; + watcher.lastOutput = nextOutput; + watcher.lastErrorMessage = nextErrorMessage; + watcher.lastBranch = nextBranch; + if (updatedAt) { + watcher.lastStatusUpdatedAt = updatedAt; + } + + return changed; +} + +async function fetchTaskRunState( + watcher: WatcherState, +): Promise { + try { + return await getTaskRun(watcher.taskId, watcher.runId); + } catch (error) { + if (error instanceof HttpError) { + log.warn("Cloud task status fetch failed", { + runId: watcher.runId, + status: error.status, + }); + if (shouldFailWatcherForFetchStatus(error.status)) { + failWatcher(watcher, createStreamStatusError(error.status).details); + } + return null; + } + log.warn("Cloud task status fetch error", { + runId: watcher.runId, + error, + }); + return null; + } +} + +async function fetchAllSessionLogs( + watcher: WatcherState, +): Promise { + const entries: StoredLogEntry[] = []; + let offset = 0; + + while (true) { + if (watcher.stopped || watcher.failed) return null; + try { + const page = await fetchSessionLogs(watcher.taskId, watcher.runId, { + limit: SESSION_LOG_PAGE_LIMIT, + offset, + }); + + for (const entry of page.entries) { + entries.push(entry); + } + if (!page.hasMore || page.entries.length === 0) { + return entries; + } + offset += page.entries.length; + } catch (error) { + if (error instanceof HttpError) { + log.warn("Cloud task session logs fetch failed", { + runId: watcher.runId, + status: error.status, + offset, + }); + if (shouldFailWatcherForFetchStatus(error.status)) { + failWatcher(watcher, createStreamStatusError(error.status).details); + } + return null; + } + log.warn("Cloud task session logs fetch error", { + runId: watcher.runId, + offset, + error, + }); + return null; + } + } +} diff --git a/apps/mobile/src/features/tasks/lib/sseParser.ts b/apps/mobile/src/features/tasks/lib/sseParser.ts new file mode 100644 index 000000000..4c626fd65 --- /dev/null +++ b/apps/mobile/src/features/tasks/lib/sseParser.ts @@ -0,0 +1,89 @@ +import { logger } from "@/lib/logger"; + +const log = logger.scope("sse-parser"); + +export interface SseEvent { + event?: string; + id?: string; + data: unknown; +} + +export class SseEventParser { + private buffer = ""; + private currentEventName: string | null = null; + private currentEventId: string | null = null; + private currentData: string[] = []; + + parse(chunk: string): SseEvent[] { + this.buffer += chunk; + const lines = this.buffer.split("\n"); + this.buffer = lines.pop() || ""; + + const events: SseEvent[] = []; + + for (const rawLine of lines) { + const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; + + if (line === "") { + const event = this.flushEvent(); + if (event) { + events.push(event); + } + continue; + } + + if (line.startsWith(":")) { + continue; + } + + if (line.startsWith("event:")) { + this.currentEventName = line.slice(6).trim() || null; + continue; + } + + if (line.startsWith("id:")) { + this.currentEventId = line.slice(3).trim() || null; + continue; + } + + if (line.startsWith("data:")) { + this.currentData.push(line.slice(5).trimStart()); + } + } + + return events; + } + + reset(): void { + this.buffer = ""; + this.currentEventName = null; + this.currentEventId = null; + this.currentData = []; + } + + private flushEvent(): SseEvent | null { + if (this.currentData.length === 0) { + this.currentEventName = null; + this.currentEventId = null; + return null; + } + + const rawData = this.currentData.join("\n"); + this.currentData = []; + + try { + const data = JSON.parse(rawData); + return { + event: this.currentEventName ?? undefined, + id: this.currentEventId ?? undefined, + data, + }; + } catch { + log.warn("SSE event JSON parse failure", { rawData }); + return null; + } finally { + this.currentEventName = null; + this.currentEventId = null; + } + } +} diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts index 457489963..1fe92c542 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts @@ -6,29 +6,32 @@ import { usePreferencesStore } from "@/features/preferences/stores/preferencesSt import { logger } from "@/lib/logger"; import { CloudCommandError, - fetchS3Logs, getTask, - getTaskRun, runTaskInCloud, sendCloudCommand, } from "../api"; import { buildCloudPromptBlocks } from "../composer/attachments/buildCloudPrompt"; import { serializeCloudPrompt } from "../composer/attachments/cloudPrompt"; import type { PendingAttachment } from "../composer/attachments/types"; -import type { - SessionEvent, - SessionNotification, - SessionNotificationAttachment, - StoredLogEntry, - Task, -} from "../types"; import { - convertRawEntriesToEvents, - parseSessionLogs, -} from "../utils/parseSessionLogs"; + type WatchCloudTaskHandle, + watchCloudTask, +} from "../lib/cloudTaskStream"; +import { + type CloudTaskUpdatePayload, + isTerminalStatus, + type SessionEvent, + type SessionNotification, + type SessionNotificationAttachment, + type StoredLogEntry, + type Task, +} from "../types"; +import { convertStoredEntriesToEvents } from "../utils/parseSessionLogs"; import { playMeepSound } from "../utils/sounds"; import { useAttachmentEchoStore } from "./attachmentEchoStore"; +const log = logger.scope("task-session-store"); + // Match historical `user_message_chunk` events (text-only, as the cloud // stores them) against locally-cached attachment echoes by position+text. // Echoes are written in send-order; we walk user messages in receive-order @@ -59,47 +62,6 @@ function reinjectAttachmentEchoes( } } -// 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; - type LocalNotificationKind = | "turn_complete" | "awaiting_user_input" @@ -140,6 +102,118 @@ function maybePresentLocalNotification(args: { }).catch(() => {}); } +// Session-update kinds that count as "the agent produced visible output" — +// once we've seen one of these the connecting/thinking indicator should clear. +const VISIBLE_AGENT_SESSION_UPDATES = new Set([ + "agent_message_chunk", + "agent_message", + "agent_thought_chunk", + "tool_call", + "tool_call_update", +]); + +// Notification methods that mark the end of an agent turn — clearing +// isPromptPending so the composer unblocks. +const TURN_END_METHODS = new Set([ + "_posthog/turn_complete", + "_posthog/task_complete", + "_posthog/error", + "_posthog/awaiting_user_input", +]); + +interface BatchAnalysis { + hasTurnEnd: boolean; + hasAwaitingUserInput: boolean; + hasError: boolean; + hasVisibleAgentOutput: boolean; + externalUserMessageCount: number; + agentMessageFinalized: boolean; +} + +function analyzeEntries( + entries: StoredLogEntry[], + localUserEchoes: Set, +): BatchAnalysis { + let hasTurnEnd = false; + let hasAwaitingUserInput = false; + let hasError = false; + let hasVisibleAgentOutput = false; + let externalUserMessageCount = 0; + let agentMessageFinalized = false; + + for (const entry of entries) { + const method = entry.notification?.method; + if (method && TURN_END_METHODS.has(method)) { + hasTurnEnd = true; + if (method === "_posthog/awaiting_user_input") { + hasAwaitingUserInput = true; + } + if (method === "_posthog/error") { + hasError = true; + } + } + + if ( + entry.type === "notification" && + method === "session/update" && + entry.notification?.params + ) { + const params = entry.notification.params as SessionNotification; + const sessionUpdate = params.update?.sessionUpdate; + if (sessionUpdate && VISIBLE_AGENT_SESSION_UPDATES.has(sessionUpdate)) { + hasVisibleAgentOutput = true; + } + if (sessionUpdate === "agent_message") { + agentMessageFinalized = true; + } + if (sessionUpdate === "user_message_chunk") { + const text = params.update?.content?.text; + if (text && !localUserEchoes.has(text)) { + externalUserMessageCount += 1; + } + } + } + } + + return { + hasTurnEnd, + hasAwaitingUserInput, + hasError, + hasVisibleAgentOutput, + externalUserMessageCount, + agentMessageFinalized, + }; +} + +// Strip user_message_chunk entries whose text matches a pending local echo +// (one match per echo). The echo set is mutated so each echo only cancels +// one canonical copy. +function dedupAgainstLocalEchoes( + entries: StoredLogEntry[], + localUserEchoes: Set, +): StoredLogEntry[] { + if (localUserEchoes.size === 0) return entries; + const result: StoredLogEntry[] = []; + for (const entry of entries) { + if ( + entry.type === "notification" && + entry.notification?.method === "session/update" + ) { + 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 && localUserEchoes.has(text)) { + localUserEchoes.delete(text); + continue; + } + } + } + result.push(entry); + } + return result; +} + export interface TaskSession { taskRunId: string; taskId: string; @@ -147,14 +221,11 @@ export interface TaskSession { events: SessionEvent[]; status: "connecting" | "connected" | "disconnected" | "error"; isPromptPending: boolean; - 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. + // the log). Used 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". + // Terminal backend status for this run, populated by status updates 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 @@ -162,10 +233,10 @@ export interface TaskSession { // 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. + // agent output (tool call, message, etc.) arrives. awaitingAgentOutput?: boolean; - // Timestamp of the last new event received via polling. Used to detect - // stale local sessions (desktop stopped syncing). + // Timestamp of the last new event received. Used to detect stale local + // sessions (desktop stopped syncing). lastEventAt?: number; } @@ -200,8 +271,12 @@ interface TaskSessionStore { ) => Promise; getSessionForTask: (taskId: string) => TaskSession | undefined; - _startCloudPolling: (taskRunId: string, logUrl: string) => void; - _stopCloudPolling: (taskRunId: string) => void; + _handleCloudUpdate: ( + taskRunId: string, + update: CloudTaskUpdatePayload, + ) => void; + _startWatcher: (taskRunId: string, taskId: string) => void; + _stopWatcher: (taskRunId: string) => void; _resumeCloudRun: ( taskId: string, previousRunId: string, @@ -209,19 +284,16 @@ interface TaskSessionStore { ) => Promise; } -const cloudPollers = new Map>(); +const watchHandles = 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; + +function mapTerminalStatus( + status: string | undefined | null, +): "completed" | "failed" | undefined { + if (status === "completed") return "completed"; + if (status === "failed" || status === "cancelled") return "failed"; + return undefined; +} export const useTaskSessionStore = create((set, get) => ({ sessions: {}, @@ -232,142 +304,57 @@ export const useTaskSessionStore = create((set, get) => ({ connectToTask: async (task: Task) => { const taskId = task.id; const latestRunId = task.latest_run?.id; - const latestRunLogUrl = task.latest_run?.log_url; - const _taskDescription = task.description; if (connectAttempts.has(taskId)) { - logger.debug("Connection already in progress", { taskId }); + log.debug("Connection already in progress", { taskId }); return; } const existing = get().getSessionForTask(taskId); if (existing && existing.status === "connected") { - logger.debug("Already connected to task", { taskId }); + log.debug("Already connected to task", { taskId }); return; } connectAttempts.add(taskId); try { - if (!latestRunId || !latestRunLogUrl) { - logger.debug("Task has no run yet, starting cloud run", { taskId }); - const updatedTask = await runTaskInCloud(taskId); - const newRunId = updatedTask.latest_run?.id; - const newLogUrl = updatedTask.latest_run?.log_url; + let runId = latestRunId; + let awaitingPing = false; - if (!newRunId || !newLogUrl) { - logger.error("Failed to start cloud run"); + if (!runId) { + log.debug("Task has no run yet, starting cloud run", { taskId }); + const updatedTask = await runTaskInCloud(taskId); + runId = updatedTask.latest_run?.id; + if (!runId) { + log.error("Failed to start cloud run"); return; } - - set((state) => ({ - sessions: { - ...state.sessions, - [newRunId]: { - taskRunId: newRunId, - taskId, - taskTitle: task.title, - events: [], - status: "connected", - isPromptPending: true, - logUrl: newLogUrl, - processedLineCount: 0, - awaitingPing: true, - awaitingAgentOutput: true, - }, - }, - })); - - get()._startCloudPolling(newRunId, newLogUrl); - logger.debug("Started new cloud session", { - taskId, - taskRunId: newRunId, - }); - return; + awaitingPing = true; } - logger.debug("Fetching cloud session history from S3", { - taskId, - latestRunId, - }); - const content = await fetchS3Logs(latestRunLogUrl); - const { notifications, rawEntries } = parseSessionLogs(content); - logger.debug("Loaded cloud historical logs", { - notifications: notifications.length, - rawEntries: rawEntries.length, - backendStatus: task.latest_run?.status, - }); - - const historicalEvents = convertRawEntriesToEvents( - rawEntries, - notifications, - ); - - // Re-inject locally-cached attachment metadata into historical user - // messages. Cloud logs only carry text on `user_message_chunk` events, - // so without this merge any images the user attached before navigating - // away would disappear when the session is rehydrated from S3. - reinjectAttachmentEchoes(latestRunId, historicalEvents); - - // 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: { ...state.sessions, - [latestRunId]: { - taskRunId: latestRunId, + [runId]: { + taskRunId: runId, taskId, taskTitle: task.title, - events: historicalEvents, - status: "connected", - 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" - ); - }), + events: [], + status: "connecting", + // Assume the run is working until the bootstrap snapshot tells + // us otherwise — the SSE watcher will refine these fields. + isPromptPending: true, + awaitingPing, + awaitingAgentOutput: true, }, }, })); - get()._startCloudPolling(latestRunId, latestRunLogUrl); - logger.debug("Connected to cloud session", { - taskId, - latestRunId, - backendStatus, - isTerminal, - }); + get()._startWatcher(runId, taskId); + log.debug("Started SSE watcher", { taskId, runId }); } catch (error) { - logger.error("Failed to connect to task", error); + log.error("Failed to connect to task", error); } finally { connectAttempts.delete(taskId); } @@ -377,13 +364,13 @@ export const useTaskSessionStore = create((set, get) => ({ const session = get().getSessionForTask(taskId); if (!session) return; - get()._stopCloudPolling(session.taskRunId); + get()._stopWatcher(session.taskRunId); set((state) => { const { [session.taskRunId]: _, ...rest } = state.sessions; return { sessions: rest }; }); - logger.debug("Disconnected from task", { taskId }); + log.debug("Disconnected from task", { taskId }); }, sendPrompt: async ( @@ -396,10 +383,6 @@ export const useTaskSessionStore = create((set, get) => ({ throw new Error("No active session for task"); } - // 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. - // The local echo always shows the plain prompt text in the chat. When // attachments are present we send a structured cloud-prompt blob on the // wire (`__twig_cloud_prompt_v1__:…`) so the agent receives the image @@ -461,19 +444,16 @@ export const useTaskSessionStore = create((set, get) => ({ await sendCloudCommand(taskId, session.taskRunId, "user_message", { content: wirePayload, }); - logger.debug("Sent cloud command user_message", { + log.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", { + log.warn("Transient server error sending prompt, rolling back", { status: err.status, taskId, }); @@ -497,11 +477,9 @@ export const useTaskSessionStore = create((set, get) => ({ 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", { + log.info("Sandbox inactive, creating resume run", { taskId, previousRunId: session.taskRunId, }); @@ -509,12 +487,11 @@ export const useTaskSessionStore = create((set, get) => ({ await get()._resumeCloudRun(taskId, session.taskRunId, wirePayload); return; } catch (resumeErr) { - logger.error("Failed to resume cloud run", resumeErr); + log.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; @@ -536,11 +513,6 @@ export const useTaskSessionStore = create((set, get) => ({ } }, - // 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) { @@ -586,14 +558,13 @@ export const useTaskSessionStore = create((set, get) => ({ ...(args.answers ? { answers: args.answers } : {}), ...(args.customInput ? { customInput: args.customInput } : {}), }); - logger.debug("Sent permission_response", { + log.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. + log.error("Failed to send permission_response", err); set((state) => { const current = state.sessions[session.taskRunId]; if (!current) return state; @@ -615,10 +586,6 @@ export const useTaskSessionStore = create((set, get) => ({ } }, - // Update an agent-side config option on the running cloud session - // (e.g. mode, model, effort). No-op when there is no live session — the - // caller is expected to persist the value locally so it can be replayed - // on the next resume run. setConfigOption: async (taskId, configId, value) => { const session = get().getSessionForTask(taskId); if (!session || session.terminalStatus) return; @@ -628,14 +595,14 @@ export const useTaskSessionStore = create((set, get) => ({ configId, value, }); - logger.debug("Sent set_config_option", { + log.debug("Sent set_config_option", { taskId, runId: session.taskRunId, configId, value, }); } catch (err) { - logger.warn("Failed to send set_config_option", { + log.warn("Failed to send set_config_option", { taskId, configId, error: err, @@ -650,7 +617,7 @@ export const useTaskSessionStore = create((set, get) => ({ try { await sendCloudCommand(taskId, session.taskRunId, "cancel"); - logger.debug("Sent cancel command", { + log.debug("Sent cancel command", { taskId, runId: session.taskRunId, }); @@ -666,7 +633,7 @@ export const useTaskSessionStore = create((set, get) => ({ })); return true; } catch (error) { - logger.error("Failed to send cancel request", error); + log.error("Failed to send cancel request", error); return false; } }, @@ -675,313 +642,185 @@ export const useTaskSessionStore = create((set, get) => ({ return Object.values(get().sessions).find((s) => s.taskId === taskId); }, - _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); + _startWatcher: (taskRunId: string, taskId: string) => { + if (watchHandles.has(taskRunId)) return; + + const handle = watchCloudTask({ + taskId, + runId: taskRunId, + onUpdate: (update) => get()._handleCloudUpdate(taskRunId, update), + }); + watchHandles.set(taskRunId, handle); + }, + + _stopWatcher: (taskRunId: string) => { + const handle = watchHandles.get(taskRunId); + if (handle) { + handle.stop(); + watchHandles.delete(taskRunId); + log.debug("Stopped SSE watcher", { taskRunId }); + } + }, + + _handleCloudUpdate: (taskRunId: string, update: CloudTaskUpdatePayload) => { + if (update.kind === "error") { + set((state) => { + const current = state.sessions[taskRunId]; + if (!current) return state; + return { + sessions: { + ...state.sessions, + [taskRunId]: { + ...current, + status: "error", + isPromptPending: false, + lastError: update.errorMessage, + }, + }, + }; + }); + return; + } + + if (update.kind === "permission_request") { + // Permission requests surface via `session/update` tool_call entries + // that already flow through the log stream; this dedicated payload is a + // desktop convenience and a no-op on mobile. + return; + } + + if (update.kind === "snapshot" || update.kind === "logs") { + const isSnapshot = update.kind === "snapshot"; + + // Snapshot replaces all events; drop pending echoes since the snapshot + // already includes the canonical copies. + const existing = get().sessions[taskRunId]; + const echoSet = isSnapshot + ? new Set() + : new Set(existing?.localUserEchoes ?? []); + + const dedupedEntries = isSnapshot + ? update.newEntries + : dedupAgainstLocalEchoes(update.newEntries, echoSet); + + const events = convertStoredEntriesToEvents(dedupedEntries); + // Snapshots are S3-backed and lose attachment metadata; reattach from + // the local echo store so historical user messages keep their images. + if (isSnapshot) { + reinjectAttachmentEchoes(taskRunId, events); } - pollInFlight.add(taskRunId); - pollInFlightSince.set(taskRunId, Date.now()); - try { - const session = get().sessions[taskRunId]; - if (!session) { - get()._stopCloudPolling(taskRunId); - return; + const analysis = analyzeEntries( + dedupedEntries, + isSnapshot ? new Set() : echoSet, + ); + + const wasAwaitingPing = existing?.awaitingPing ?? false; + + set((state) => { + const current = state.sessions[taskRunId]; + if (!current) return state; + + let nextIsPromptPending = current.isPromptPending; + if (analysis.externalUserMessageCount > 0) nextIsPromptPending = true; + if (analysis.hasTurnEnd || analysis.agentMessageFinalized) { + nextIsPromptPending = false; } - // 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, - ); - } - if (shouldPing) { - maybePresentLocalNotification({ - taskRunId, - kind: - run.status === "failed" ? "task_failed" : "turn_complete", - }); - } - } - } catch (statusErr) { - logger.warn("Failed to fetch task run status", { - error: statusErr, - }); + // Snapshots replay historical content — we don't mutate awaitingPing + // based on history, otherwise turn-end markers inside an existing + // run's snapshot would clear the user's pending ping before the + // status block has a chance to fire its (more specific, e.g. + // "task_failed") notification. The status block below is the + // canonical owner of awaitingPing for terminal snapshots. + let nextAwaitingPing = current.awaitingPing; + if (!isSnapshot) { + if (analysis.externalUserMessageCount > 0 && !current.awaitingPing) { + nextAwaitingPing = true; + } + if (analysis.hasTurnEnd || analysis.agentMessageFinalized) { + nextAwaitingPing = false; } } - const text = await fetchS3Logs(logUrl); - if (!text) return; + const nextAwaitingAgentOutput = + current.awaitingAgentOutput && !analysis.hasVisibleAgentOutput; - const lines = text.trim().split("\n").filter(Boolean); - const processedCount = session.processedLineCount ?? 0; + const nextEvents = isSnapshot + ? events + : events.length > 0 + ? [...current.events, ...events] + : current.events; - 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; - let receivedAwaitingUserInput = 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 { - const entry = JSON.parse(line); - const ts = entry.timestamp - ? new Date(entry.timestamp).getTime() - : Date.now(); - - // 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); - - // 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; - } - } - - batchedEvents.push({ - type: "acp_message", - direction: entry.direction ?? "agent", - ts, - message: entry.notification, - }); - - 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.notification?.method === "_posthog/awaiting_user_input" - ) { - receivedAwaitingUserInput = true; - } - } - - if ( - entry.type === "notification" && - entry.notification?.method === "session/update" && - entry.notification?.params - ) { - const params = entry.notification.params as SessionNotification; - const sessionUpdate = params?.update?.sessionUpdate; - - batchedEvents.push({ - type: "session_update", - ts, - 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; - } - } - } catch { - // Skip invalid JSON - } - } + return { + sessions: { + ...state.sessions, + [taskRunId]: { + ...current, + events: nextEvents, + status: "connected", + isPromptPending: nextIsPromptPending, + awaitingPing: nextAwaitingPing, + awaitingAgentOutput: nextAwaitingAgentOutput, + localUserEchoes: echoSet.size > 0 ? echoSet : undefined, + lastEventAt: events.length > 0 ? Date.now() : current.lastEventAt, + }, + }, + }; + }); + + // Only fire on live `logs` deltas — snapshots are historical replay + // and the status block below handles their terminal-state notification. + const shouldPingNow = + !isSnapshot && + (analysis.hasTurnEnd || analysis.agentMessageFinalized) && + (wasAwaitingPing || analysis.externalUserMessageCount > 0); + if (shouldPingNow && usePreferencesStore.getState().pingsEnabled) { + playMeepSound().catch(() => {}); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + } + if (shouldPingNow) { + const kind: LocalNotificationKind = analysis.hasError + ? "task_failed" + : analysis.hasAwaitingUserInput + ? "awaiting_user_input" + : "turn_complete"; + maybePresentLocalNotification({ taskRunId, kind }); + } + } - // 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 (update.kind === "status" || update.kind === "snapshot") { + if (isTerminalStatus(update.status)) { + const preState = get().sessions[taskRunId]; + const shouldPing = preState?.awaitingPing ?? false; + const terminal = mapTerminalStatus(update.status); + set((state) => { + const current = state.sessions[taskRunId]; + if (!current) return state; + return { + sessions: { + ...state.sessions, + [taskRunId]: { + ...current, + isPromptPending: false, + terminalStatus: terminal, + lastError: update.errorMessage ?? null, + awaitingPing: false, }, - }; + }, + }; + }); + if (shouldPing && usePreferencesStore.getState().pingsEnabled) { + playMeepSound().catch(() => {}); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + } + if (shouldPing) { + maybePresentLocalNotification({ + taskRunId, + kind: terminal === "failed" ? "task_failed" : "turn_complete", }); - if ( - shouldPingAfterBatch && - usePreferencesStore.getState().pingsEnabled - ) { - playMeepSound().catch(() => {}); - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); - } - if (shouldPingAfterBatch) { - maybePresentLocalNotification({ - taskRunId, - kind: receivedAwaitingUserInput - ? "awaiting_user_input" - : "turn_complete", - }); - } } - } catch (err) { - logger.warn("Cloud polling error", { error: err }); - } finally { - pollInFlight.delete(taskRunId); - pollInFlightSince.delete(taskRunId); } - }; - - pollS3(); - const interval = setInterval(pollS3, CLOUD_POLLING_INTERVAL_MS); - cloudPollers.set(taskRunId, interval); - }, - - _stopCloudPolling: (taskRunId: string) => { - const interval = cloudPollers.get(taskRunId); - if (interval) { - clearInterval(interval); - cloudPollers.delete(taskRunId); - pollTicks.delete(taskRunId); - logger.debug("Stopped cloud S3 polling", { taskRunId }); } }, @@ -990,9 +829,6 @@ export const useTaskSessionStore = create((set, get) => ({ 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; @@ -1003,15 +839,11 @@ export const useTaskSessionStore = create((set, get) => ({ }); 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"); + if (!newRun?.id) { + throw new Error("Resume run was created but has no id"); } - // 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); + get()._stopWatcher(previousRunId); set((state) => { const previousSession = state.sessions[previousRunId]; @@ -1023,11 +855,8 @@ export const useTaskSessionStore = create((set, get) => ({ [newRun.id]: { ...previousSession, taskRunId: newRun.id, - logUrl: newRun.log_url, - status: "connected", + status: "connecting", isPromptPending: true, - processedLineCount: 0, - processedHashes: new Set(), awaitingPing: true, awaitingAgentOutput: true, }, @@ -1035,8 +864,8 @@ export const useTaskSessionStore = create((set, get) => ({ }; }); - get()._startCloudPolling(newRun.id, newRun.log_url); - logger.debug("Swapped to resume run", { + get()._startWatcher(newRun.id, taskId); + log.debug("Swapped to resume run", { taskId, previousRunId, newRunId: newRun.id, @@ -1044,25 +873,12 @@ export const useTaskSessionStore = create((set, get) => ({ }, })); -// 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. +// When the app returns from background, iOS may have killed the SSE +// connection. Nudge every active watcher to reconnect so the stream resumes +// with Last-Event-ID. 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); - } - } + if (nextState !== "active") return; + for (const handle of watchHandles.values()) { + handle.reconnectIfDisconnected(); } }); diff --git a/apps/mobile/src/features/tasks/types.ts b/apps/mobile/src/features/tasks/types.ts index 40459b387..1400c43fd 100644 --- a/apps/mobile/src/features/tasks/types.ts +++ b/apps/mobile/src/features/tasks/types.ts @@ -32,6 +32,27 @@ export interface TaskAutomation { updated_at: string; } +export type TaskRunStatus = + | "not_started" + | "queued" + | "started" + | "in_progress" + | "completed" + | "failed" + | "cancelled"; + +export const TERMINAL_STATUSES = ["completed", "failed", "cancelled"] as const; + +export function isTerminalStatus( + status: TaskRunStatus | string | null | undefined, +): boolean { + return ( + status !== null && + status !== undefined && + TERMINAL_STATUSES.includes(status as (typeof TERMINAL_STATUSES)[number]) + ); +} + export interface TaskRun { id: string; task: string; @@ -39,14 +60,7 @@ export interface TaskRun { branch: string | null; stage?: string | null; environment?: "local" | "cloud"; - status: - | "not_started" - | "queued" - | "started" - | "in_progress" - | "completed" - | "failed" - | "cancelled"; + status: TaskRunStatus; log_url: string; error_message: string | null; output: Record | null; @@ -121,6 +135,132 @@ export interface SessionUpdateEvent { export type SessionEvent = AcpMessage | SessionUpdateEvent; +export interface CloudPermissionOption { + kind: string; + optionId: string; + name: string; + _meta?: Record; +} + +export interface CloudPermissionToolCall { + toolCallId: string; + title: string; + kind: string; + content?: unknown[]; + rawInput?: Record; + _meta?: Record; +} + +interface CloudTaskUpdateBase { + taskId: string; + runId: string; +} + +export interface CloudTaskLogsUpdate extends CloudTaskUpdateBase { + kind: "logs"; + newEntries: StoredLogEntry[]; + totalEntryCount: number; +} + +export interface CloudTaskStatusUpdate extends CloudTaskUpdateBase { + kind: "status"; + status?: TaskRunStatus; + stage?: string | null; + output?: Record | null; + errorMessage?: string | null; + branch?: string | null; +} + +export interface CloudTaskSnapshotUpdate extends CloudTaskUpdateBase { + kind: "snapshot"; + newEntries: StoredLogEntry[]; + totalEntryCount: number; + status?: TaskRunStatus; + stage?: string | null; + output?: Record | null; + errorMessage?: string | null; + branch?: string | null; +} + +export interface CloudTaskErrorUpdate extends CloudTaskUpdateBase { + kind: "error"; + errorTitle: string; + errorMessage: string; + retryable: boolean; +} + +export interface CloudTaskPermissionRequestUpdate extends CloudTaskUpdateBase { + kind: "permission_request"; + requestId: string; + toolCall: CloudPermissionToolCall; + options: CloudPermissionOption[]; +} + +export type CloudTaskUpdatePayload = + | CloudTaskLogsUpdate + | CloudTaskStatusUpdate + | CloudTaskSnapshotUpdate + | CloudTaskErrorUpdate + | CloudTaskPermissionRequestUpdate; + +export interface TaskRunStateEvent { + type: "task_run_state"; + status?: TaskRunStatus; + stage?: string | null; + output?: Record | null; + error_message?: string | null; + branch?: string | null; + updated_at?: string | null; + completed_at?: string | null; +} + +export interface PermissionRequestEventData { + type: "permission_request"; + requestId: string; + toolCall: CloudPermissionToolCall; + options: CloudPermissionOption[]; +} + +export interface SseErrorEventData { + error: string; +} + +export function isTaskRunStateEvent(data: unknown): data is TaskRunStateEvent { + return ( + typeof data === "object" && + data !== null && + (data as { type?: string }).type === "task_run_state" + ); +} + +export function isPermissionRequestEvent( + data: unknown, +): data is PermissionRequestEventData { + return ( + typeof data === "object" && + data !== null && + (data as { type?: string }).type === "permission_request" && + typeof (data as { requestId?: string }).requestId === "string" + ); +} + +export function isKeepaliveEvent(data: unknown): boolean { + return ( + typeof data === "object" && + data !== null && + (data as { type?: string }).type === "keepalive" + ); +} + +export function isSseErrorEvent(data: unknown): data is SseErrorEventData { + return ( + typeof data === "object" && + data !== null && + "error" in data && + typeof (data as SseErrorEventData).error === "string" + ); +} + export interface Integration { id: number; kind: string; diff --git a/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts b/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts index 307efca93..9cce3995d 100644 --- a/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts +++ b/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts @@ -88,3 +88,46 @@ export function convertRawEntriesToEvents( return events; } + +function inferDirection(entry: StoredLogEntry): "client" | "agent" { + if (entry.direction) return entry.direction; + const msg = entry.notification; + if (!msg) return "agent"; + const hasId = msg.id !== undefined; + const hasMethod = msg.method !== undefined; + const hasResult = msg.result !== undefined || msg.error !== undefined; + if (hasId && hasMethod) return "client"; + if (hasId && hasResult) return "agent"; + return "agent"; +} + +export function convertStoredEntriesToEvents( + entries: StoredLogEntry[], +): SessionEvent[] { + const events: SessionEvent[] = []; + for (const entry of entries) { + const ts = entry.timestamp + ? new Date(entry.timestamp).getTime() + : Date.now(); + + events.push({ + type: "acp_message", + direction: inferDirection(entry), + ts, + message: entry.notification, + }); + + if ( + entry.type === "notification" && + entry.notification?.method === "session/update" && + entry.notification?.params + ) { + events.push({ + type: "session_update", + ts, + notification: entry.notification.params as SessionNotification, + }); + } + } + return events; +} diff --git a/apps/mobile/src/lib/api.ts b/apps/mobile/src/lib/api.ts index 30d7751c3..da4df5912 100644 --- a/apps/mobile/src/lib/api.ts +++ b/apps/mobile/src/lib/api.ts @@ -19,6 +19,14 @@ export function getHeaders(): Record { }; } +export function getAccessToken(): string { + const { oauthAccessToken } = useAuthStore.getState(); + if (!oauthAccessToken) { + throw new Error("Not authenticated"); + } + return oauthAccessToken; +} + export function getBaseUrl(): string { const { cloudRegion, getCloudUrlFromRegion } = useAuthStore.getState(); if (!cloudRegion) { From 24445358c058702dc40dc1837e93a27788123fdc Mon Sep 17 00:00:00 2001 From: dylan Date: Wed, 13 May 2026 23:59:21 -0400 Subject: [PATCH 51/94] fixing the tool call ui --- .../features/tasks/components/TaskSessionView.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx index ef4ffffb5..dc8e31eda 100644 --- a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx +++ b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx @@ -825,7 +825,10 @@ export function TaskSessionView({ // In the inverted list, paddingTop becomes visual bottom spacing. // Reserve enough room so the floating activity indicator never // covers the last visible row while the agent is working. - paddingTop: (baseStyle.paddingTop ?? 0) + 28, + // 28pt was tight at default text sizes and let cards (e.g. the + // Agent loading card) peek into the indicator strip — 44pt gives + // a real buffer plus headroom for larger dynamic-type settings. + paddingTop: (baseStyle.paddingTop ?? 0) + 44, }; }, [contentContainerStyle, showActivityIndicator]); // Inverted FlatList: scrollY is the distance from the visual bottom, so @@ -965,10 +968,13 @@ export function TaskSessionView({ ) : null } /> - {/* Thinking/connecting indicators absolutely positioned above the Composer area. - Rendered outside FlatList to avoid inverted-list double-mount bugs. */} + {/* Thinking/connecting indicators pinned to the bottom of the list area. + The Composer is a sibling below TaskSessionView in flex flow, so + `bottom-0` here sits the strip right above the composer's top edge. + Solid bg so list rows scrolling under it are occluded instead of + bleeding through. */} {showActivityIndicator && ( - + {isConnecting ? ( ) : isThinking ? ( From b0e068908d0228e2188d637ae133457755eb278d Mon Sep 17 00:00:00 2001 From: Annika <14750837+annikaschmid@users.noreply.github.com> Date: Thu, 14 May 2026 00:04:15 -0400 Subject: [PATCH 52/94] feat(mobile): add automation templates Introduce a template gallery for mobile automations and move creation into a dedicated route.\n\nSupport repo-optional templates across create, edit, list, and detail flows, and add tests for template serialization and presentation behavior. --- apps/mobile/src/app/_layout.tsx | 10 + apps/mobile/src/app/automation/[id].tsx | 16 +- apps/mobile/src/app/automation/create.tsx | 114 ++++++ apps/mobile/src/app/automation/index.tsx | 96 ++--- .../features/tasks/api.automations.test.ts | 78 ++++ .../tasks/components/AutomationDetail.tsx | 32 +- .../tasks/components/AutomationForm.test.tsx | 211 +++++++++++ .../tasks/components/AutomationForm.tsx | 77 ++-- .../tasks/components/AutomationItem.tsx | 4 +- .../tasks/components/AutomationList.tsx | 35 +- .../components/AutomationTemplateCard.tsx | 50 +++ .../AutomationTemplateGallery.test.tsx | 93 +++++ .../components/AutomationTemplateGallery.tsx | 57 +++ .../tasks/hooks/useAutomations.test.ts | 43 +++ .../tasks/hooks/useIntegrations.test.ts | 27 ++ .../features/tasks/hooks/useIntegrations.ts | 33 +- .../templates/automationTemplates.test.ts | 47 +++ .../tasks/templates/automationTemplates.ts | 105 ++++++ apps/mobile/src/features/tasks/types.ts | 25 ++ .../automationTemplatePresentation.test.ts | 46 +++ .../utils/automationTemplatePresentation.ts | 27 ++ apps/mobile/src/test/setup.ts | 32 ++ ...1-feat-mobile-automation-templates-plan.md | 342 ++++++++++++++++++ 23 files changed, 1447 insertions(+), 153 deletions(-) create mode 100644 apps/mobile/src/app/automation/create.tsx create mode 100644 apps/mobile/src/features/tasks/components/AutomationForm.test.tsx create mode 100644 apps/mobile/src/features/tasks/components/AutomationTemplateCard.tsx create mode 100644 apps/mobile/src/features/tasks/components/AutomationTemplateGallery.test.tsx create mode 100644 apps/mobile/src/features/tasks/components/AutomationTemplateGallery.tsx create mode 100644 apps/mobile/src/features/tasks/templates/automationTemplates.test.ts create mode 100644 apps/mobile/src/features/tasks/templates/automationTemplates.ts create mode 100644 apps/mobile/src/features/tasks/utils/automationTemplatePresentation.test.ts create mode 100644 apps/mobile/src/features/tasks/utils/automationTemplatePresentation.ts create mode 100644 docs/plans/2026-05-13-001-feat-mobile-automation-templates-plan.md diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index f0f0d3ff0..e1cd6e7ec 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -103,6 +103,16 @@ function RootLayoutNav({ isConnected }: RootLayoutNavProps) { /> + (null); const [generalError, setGeneralError] = useState(null); + const automationTemplate = automation + ? getAutomationTemplate(automation.template_id) + : null; + const repositoryRequired = automation + ? (automationTemplate?.requiresRepository ?? + automation.repository.trim().length > 0) + : true; if (error || (!automation && !isLoading)) { return ( @@ -100,7 +108,7 @@ export default function AutomationDetailScreen() { prompt: automation.prompt, repositorySelection: { integrationId: automation.github_integration ?? null, - repository: automation.repository, + repository: automation.repository || null, }, cronExpression: automation.cron_expression, timezone: automation.timezone ?? "UTC", @@ -110,6 +118,7 @@ export default function AutomationDetailScreen() { submitLabel="Save changes" fieldError={fieldError} generalError={generalError} + repositoryRequired={repositoryRequired} onCancel={() => { setFieldError(null); setGeneralError(null); @@ -122,7 +131,10 @@ export default function AutomationDetailScreen() { try { await updateAutomation.mutateAsync({ automationId: automation.id, - updates: values, + updates: { + ...values, + template_id: automation.template_id ?? null, + }, }); setIsEditing(false); } catch (error) { diff --git a/apps/mobile/src/app/automation/create.tsx b/apps/mobile/src/app/automation/create.tsx new file mode 100644 index 000000000..4bde8e90e --- /dev/null +++ b/apps/mobile/src/app/automation/create.tsx @@ -0,0 +1,114 @@ +import { getCalendars } from "expo-localization"; +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; +import { useMemo, useState } from "react"; +import { KeyboardAvoidingView, Platform, ScrollView, View } from "react-native"; +import { Text } from "@/components/text"; +import { TaskAutomationValidationError } from "@/features/tasks/api"; +import { AutomationForm } from "@/features/tasks/components/AutomationForm"; +import { useCreateTaskAutomation } from "@/features/tasks/hooks/useAutomations"; +import { + getAutomationTemplate, + getAutomationTemplateInitialValues, +} from "@/features/tasks/templates/automationTemplates"; +import { useThemeColors } from "@/lib/theme"; + +export default function CreateAutomationScreen() { + const { templateId } = useLocalSearchParams<{ templateId?: string }>(); + const router = useRouter(); + const themeColors = useThemeColors(); + const createAutomation = useCreateTaskAutomation(); + const defaultTimezone = useMemo( + () => getCalendars()[0]?.timeZone ?? "UTC", + [], + ); + const selectedTemplate = useMemo( + () => getAutomationTemplate(templateId), + [templateId], + ); + const templateInitialValues = useMemo( + () => getAutomationTemplateInitialValues(templateId), + [templateId], + ); + const [fieldError, setFieldError] = useState<{ + attr: string | null; + message: string | null; + } | null>(null); + const [generalError, setGeneralError] = useState(null); + + return ( + <> + + + + + + + {selectedTemplate?.name ?? "Custom automation"} + + + {selectedTemplate?.description ?? + "Start from scratch and write your own automation prompt."} + + + + { + setFieldError(null); + setGeneralError(null); + + try { + const automation = await createAutomation.mutateAsync({ + ...values, + template_id: templateInitialValues?.template_id ?? null, + }); + router.replace(`/automation/${automation.id}`); + } catch (error) { + if (error instanceof TaskAutomationValidationError) { + setFieldError({ + attr: error.attr, + message: error.message, + }); + return; + } + + setGeneralError( + "Could not create automation. Please try again.", + ); + } + }} + onCancel={() => router.back()} + /> + + + + + ); +} diff --git a/apps/mobile/src/app/automation/index.tsx b/apps/mobile/src/app/automation/index.tsx index 2f9606f47..fbd7b832a 100644 --- a/apps/mobile/src/app/automation/index.tsx +++ b/apps/mobile/src/app/automation/index.tsx @@ -1,90 +1,50 @@ -import { getCalendars } from "expo-localization"; import { Stack, useRouter } from "expo-router"; -import { useMemo, useState } from "react"; -import { KeyboardAvoidingView, Platform, ScrollView, View } from "react-native"; +import { ScrollView, View } from "react-native"; import { Text } from "@/components/text"; -import { TaskAutomationValidationError } from "@/features/tasks/api"; -import { AutomationForm } from "@/features/tasks/components/AutomationForm"; -import { useCreateTaskAutomation } from "@/features/tasks/hooks/useAutomations"; +import { AutomationTemplateGallery } from "@/features/tasks/components/AutomationTemplateGallery"; import { useThemeColors } from "@/lib/theme"; -export default function NewAutomationScreen() { +export default function AutomationTemplateScreen() { const router = useRouter(); const themeColors = useThemeColors(); - const createAutomation = useCreateTaskAutomation(); - const defaultTimezone = useMemo( - () => getCalendars()[0]?.timeZone ?? "UTC", - [], - ); - const [fieldError, setFieldError] = useState<{ - attr: string | null; - message: string | null; - } | null>(null); - const [generalError, setGeneralError] = useState(null); return ( <> - - - - - - New automation - - - - { - setFieldError(null); - setGeneralError(null); - - try { - const automation = await createAutomation.mutateAsync(values); - router.replace(`/automation/${automation.id}`); - } catch (error) { - if (error instanceof TaskAutomationValidationError) { - setFieldError({ - attr: error.attr, - message: error.message, - }); - return; - } - - setGeneralError( - "Could not create automation. Please try again.", - ); - } - }} - onCancel={() => router.back()} - /> + + + + Start with a template + + + Pick a daily briefing template, tweak the prompt and schedule, + then save it as an automation. + - - + + + router.push({ + pathname: "/automation/create", + params: { templateId }, + }) + } + onCreateCustom={() => router.push("/automation/create")} + /> + + ); } diff --git a/apps/mobile/src/features/tasks/api.automations.test.ts b/apps/mobile/src/features/tasks/api.automations.test.ts index e34fd50bd..923296fd1 100644 --- a/apps/mobile/src/features/tasks/api.automations.test.ts +++ b/apps/mobile/src/features/tasks/api.automations.test.ts @@ -35,6 +35,7 @@ const automationPayload = { github_integration: 7, cron_expression: "0 9 * * *", timezone: "Europe/London", + template_id: "developer-morning-brief", enabled: true, last_run_at: null, last_run_status: null, @@ -87,6 +88,7 @@ describe("task automation api", () => { cron_expression: "0 9 * * *", timezone: "Europe/London", enabled: true, + template_id: "developer-morning-brief", }); expect(mockFetch).toHaveBeenCalledWith( @@ -101,6 +103,51 @@ describe("task automation api", () => { cron_expression: "0 9 * * *", timezone: "Europe/London", enabled: true, + template_id: "developer-morning-brief", + }), + }), + ); + }); + + it("serializes repo-optional template creation payloads with an empty repository", async () => { + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + ...automationPayload, + id: "automation-2", + name: "PM pulse", + repository: "", + github_integration: null, + template_id: "pm-product-pulse", + }), + { status: 200 }, + ), + ); + + await createTaskAutomation({ + name: "PM pulse", + prompt: "Summarize feature usage for my product areas.", + repository: "", + github_integration: null, + cron_expression: "0 8 * * 1-5", + timezone: "America/New_York", + enabled: true, + template_id: "pm-product-pulse", + }); + + expect(mockFetch).toHaveBeenCalledWith( + "https://app.posthog.test/api/projects/42/task_automations/", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + name: "PM pulse", + prompt: "Summarize feature usage for my product areas.", + repository: "", + github_integration: null, + cron_expression: "0 8 * * 1-5", + timezone: "America/New_York", + enabled: true, + template_id: "pm-product-pulse", }), }), ); @@ -146,6 +193,37 @@ describe("task automation api", () => { }); }); + it("surfaces repo-optional template validation failures without losing backend attr info", async () => { + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + type: "validation_error", + code: "invalid_input", + detail: "Repository is still required for this template.", + attr: "repository", + }), + { status: 400, statusText: "Bad Request" }, + ), + ); + + await expect( + createTaskAutomation({ + name: "PM pulse", + prompt: "Summarize feature usage for my product areas.", + repository: "", + github_integration: null, + cron_expression: "0 8 * * 1-5", + timezone: "America/New_York", + enabled: true, + template_id: "pm-product-pulse", + }), + ).rejects.toMatchObject({ + attr: "repository", + code: "invalid_input", + message: "Repository is still required for this template.", + }); + }); + it("supports retrieve, update, delete, and run-now automation flows", async () => { mockFetch .mockResolvedValueOnce( diff --git a/apps/mobile/src/features/tasks/components/AutomationDetail.tsx b/apps/mobile/src/features/tasks/components/AutomationDetail.tsx index d37efd562..6838b4076 100644 --- a/apps/mobile/src/features/tasks/components/AutomationDetail.tsx +++ b/apps/mobile/src/features/tasks/components/AutomationDetail.tsx @@ -2,6 +2,7 @@ import { Text } from "@components/text"; import { ActivityIndicator, Pressable, View } from "react-native"; import type { TaskAutomation, TaskRun } from "../types"; import { formatAutomationScheduleSummary } from "../utils/automationSchedule"; +import { getAutomationTemplatePresentation } from "../utils/automationTemplatePresentation"; import { AutomationStatusBadge } from "./AutomationStatusBadge"; interface AutomationDetailProps { @@ -23,6 +24,8 @@ export function AutomationDetail({ onEdit, onDelete, }: AutomationDetailProps) { + const presentation = getAutomationTemplatePresentation(automation); + return ( @@ -38,12 +41,29 @@ export function AutomationDetail({ - - Repository - - {automation.repository} - - + {presentation.templateName && ( + + Template + + {presentation.templateName} + + + )} + {presentation.repositoryLabel ? ( + + Repository + + {presentation.repositoryLabel} + + + ) : presentation.contextLabel ? ( + + Context + + {presentation.contextLabel} + + + ) : null} Schedule diff --git a/apps/mobile/src/features/tasks/components/AutomationForm.test.tsx b/apps/mobile/src/features/tasks/components/AutomationForm.test.tsx new file mode 100644 index 000000000..adb601362 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/AutomationForm.test.tsx @@ -0,0 +1,211 @@ +import { createElement } from "react"; +import { act, create } from "react-test-renderer"; +import { describe, expect, it, vi } from "vitest"; + +const { mockUseIntegrations } = vi.hoisted(() => ({ + mockUseIntegrations: vi.fn(), +})); + +vi.mock("@/lib/theme", () => ({ + useThemeColors: () => ({ + gray: { + 9: "#666666", + 12: "#111111", + }, + accent: { + 9: "#ff5500", + contrast: "#ffffff", + }, + }), +})); + +vi.mock("../hooks/useIntegrations", () => ({ + useIntegrations: mockUseIntegrations, +})); + +vi.mock("./GitHubConnectionPrompt", () => ({ + GitHubConnectionPrompt: (props: Record) => + createElement("GitHubConnectionPrompt", props), +})); + +vi.mock("./GitHubLoadNotice", () => ({ + GitHubLoadNotice: (props: Record) => + createElement("GitHubLoadNotice", props, props.message as string), +})); + +vi.mock("./RepositorySelector", () => ({ + RepositorySelector: (props: Record) => + createElement("RepositorySelector", props), +})); + +vi.mock("./ScheduleEditor", () => ({ + ScheduleEditor: (props: Record) => + createElement("ScheduleEditor", props), +})); + +import { AutomationForm } from "./AutomationForm"; + +describe("AutomationForm", () => { + it("submits repo-optional templates without repository context", async () => { + mockUseIntegrations.mockReturnValue({ + error: null, + hasGithubIntegration: null, + repositoryOptions: [], + repositoryWarning: null, + isLoading: false, + refetch: vi.fn(), + }); + const onSubmit = vi.fn().mockResolvedValue(undefined); + let renderer: ReturnType | null = null; + + act(() => { + renderer = create( + createElement(AutomationForm, { + initialValues: { + name: "PM product pulse", + prompt: "Summarize my product signals", + timezone: "UTC", + enabled: true, + }, + isSubmitting: false, + submitLabel: "Create automation", + repositoryRequired: false, + onSubmit, + }), + ); + }); + + if (!renderer) { + throw new Error("Renderer not created"); + } + + expect(renderer.root.findAllByType("RepositorySelector")).toHaveLength(0); + + const submitButton = renderer.root + .findAll( + (node) => + typeof node.props.onPress === "function" && + node.props.disabled === false, + ) + .at(-1); + + await act(async () => { + await submitButton?.props.onPress(); + }); + + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + name: "PM product pulse", + prompt: "Summarize my product signals", + repository: "", + github_integration: null, + timezone: "UTC", + }), + ); + }); + + it("shows the GitHub connection prompt when repository access is required", () => { + mockUseIntegrations.mockReturnValue({ + error: null, + hasGithubIntegration: false, + repositoryOptions: [], + repositoryWarning: null, + isLoading: false, + refetch: vi.fn(), + }); + let renderer: ReturnType | null = null; + + act(() => { + renderer = create( + createElement(AutomationForm, { + initialValues: { + name: "Developer morning briefing", + prompt: "Summarize my PRs", + timezone: "UTC", + enabled: true, + }, + isSubmitting: false, + submitLabel: "Create automation", + repositoryRequired: true, + onSubmit: vi.fn(), + }), + ); + }); + + if (!renderer) { + throw new Error("Renderer not created"); + } + + expect(renderer.root.findAllByType("GitHubConnectionPrompt")).toHaveLength( + 1, + ); + }); + + it("requires repository selection for repo-backed submissions", async () => { + mockUseIntegrations.mockReturnValue({ + error: null, + hasGithubIntegration: true, + repositoryOptions: [ + { + integrationId: 7, + integrationLabel: "PostHog", + repository: "posthog/posthog", + }, + ], + repositoryWarning: null, + isLoading: false, + refetch: vi.fn(), + }); + const onSubmit = vi.fn().mockResolvedValue(undefined); + let renderer: ReturnType | null = null; + + act(() => { + renderer = create( + createElement(AutomationForm, { + initialValues: { + name: "Developer morning briefing", + prompt: "Summarize my PRs", + timezone: "UTC", + enabled: true, + }, + isSubmitting: false, + submitLabel: "Create automation", + repositoryRequired: true, + onSubmit, + }), + ); + }); + + if (!renderer) { + throw new Error("Renderer not created"); + } + + const repositorySelector = renderer.root.findByType("RepositorySelector"); + + act(() => { + repositorySelector.props.onChange({ + integrationId: 7, + repository: "posthog/posthog", + }); + }); + + const submitButton = renderer.root + .findAll( + (node) => + typeof node.props.onPress === "function" && + node.props.disabled === false, + ) + .at(-1); + + await act(async () => { + await submitButton?.props.onPress(); + }); + + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + repository: "posthog/posthog", + github_integration: 7, + }), + ); + }); +}); diff --git a/apps/mobile/src/features/tasks/components/AutomationForm.tsx b/apps/mobile/src/features/tasks/components/AutomationForm.tsx index 9026fdf2b..3477c87bb 100644 --- a/apps/mobile/src/features/tasks/components/AutomationForm.tsx +++ b/apps/mobile/src/features/tasks/components/AutomationForm.tsx @@ -44,6 +44,7 @@ interface AutomationFormProps { generalError?: string | null; onSubmit: (values: CreateTaskAutomationOptions) => Promise | void; onCancel?: () => void; + repositoryRequired?: boolean; } export function AutomationForm({ @@ -54,6 +55,7 @@ export function AutomationForm({ generalError, onSubmit, onCancel, + repositoryRequired = true, }: AutomationFormProps) { const themeColors = useThemeColors(); const { @@ -63,7 +65,7 @@ export function AutomationForm({ repositoryWarning, isLoading, refetch, - } = useIntegrations(); + } = useIntegrations({ enabled: repositoryRequired }); const [name, setName] = useState(initialValues?.name ?? ""); const [prompt, setPrompt] = useState(initialValues?.prompt ?? ""); @@ -111,7 +113,8 @@ export function AutomationForm({ repository: fieldError?.attr === "repository" ? fieldError.message - : hasAttemptedSubmit && + : repositoryRequired && + hasAttemptedSubmit && !isRepositorySelectionComplete(repositorySelection) ? "Repository selection is required." : null, @@ -130,6 +133,7 @@ export function AutomationForm({ name, prompt, repositorySelection, + repositoryRequired, timezone, ], ); @@ -138,10 +142,11 @@ export function AutomationForm({ !!name.trim() && !!prompt.trim() && !!timezone.trim() && - isRepositorySelectionComplete(repositorySelection) && + (!repositoryRequired || + isRepositorySelectionComplete(repositorySelection)) && !isSubmitting; const repositoryLoadBlocked = - !!repositoryWarning && repositoryOptions.length === 0; + repositoryRequired && !!repositoryWarning && repositoryOptions.length === 0; const handleSubmit = async () => { setHasAttemptedSubmit(true); @@ -152,15 +157,19 @@ export function AutomationForm({ await onSubmit({ name: name.trim(), prompt: prompt.trim(), - repository: repositorySelection.repository ?? "", - github_integration: repositorySelection.integrationId, + repository: repositoryRequired + ? (repositorySelection.repository ?? "") + : "", + github_integration: repositoryRequired + ? repositorySelection.integrationId + : null, cron_expression: buildCronExpression(scheduleDraft), timezone: timezone.trim(), enabled, }); }; - if (isLoading && hasGithubIntegration === null) { + if (repositoryRequired && isLoading && hasGithubIntegration === null) { return ( @@ -171,7 +180,7 @@ export function AutomationForm({ ); } - if (error || repositoryLoadBlocked) { + if (repositoryRequired && (error || repositoryLoadBlocked)) { return ( - - {repositoryWarning && ( - - )} - - Repository - - - {validationErrors.repository && ( - - {validationErrors.repository} + {repositoryRequired && ( + + {repositoryWarning && ( + + )} + + Repository - )} - + + {validationErrors.repository && ( + + {validationErrors.repository} + + )} + + )} Date.now() - 24 * 60 * 60 * 1000 @@ -50,7 +52,7 @@ function AutomationItemComponent({ - {automation.repository} + {presentation.secondaryLabel} {formatAutomationScheduleSummary(automation)} diff --git a/apps/mobile/src/features/tasks/components/AutomationList.tsx b/apps/mobile/src/features/tasks/components/AutomationList.tsx index 22fd56c26..c1ed2df82 100644 --- a/apps/mobile/src/features/tasks/components/AutomationList.tsx +++ b/apps/mobile/src/features/tasks/components/AutomationList.tsx @@ -8,11 +8,9 @@ import { } from "react-native"; import { useThemeColors } from "@/lib/theme"; import { useAutomations } from "../hooks/useAutomations"; -import { useIntegrations } from "../hooks/useIntegrations"; import { useTasks } from "../hooks/useTasks"; import type { TaskAutomation } from "../types"; import { AutomationItem } from "./AutomationItem"; -import { GitHubConnectionPrompt } from "./GitHubConnectionPrompt"; interface AutomationListProps { onAutomationPress?: (automationId: string) => void; @@ -55,15 +53,10 @@ export function AutomationList({ const { allTasks: automationTasks } = useTasks({ originProduct: "automation", }); - const { - error: integrationsError, - hasGithubIntegration, - refetch: refetchIntegrations, - } = useIntegrations(); const themeColors = useThemeColors(); const handleRefresh = async () => { - await Promise.all([refetch(), refetchIntegrations()]); + await refetch(); }; const handleAutomationPress = (automation: TaskAutomation) => { @@ -74,10 +67,6 @@ export function AutomationList({ automationTasks.map((task) => [task.id, task.latest_run?.status ?? null]), ); - const isInitialLoading = - (isLoading && automations.length === 0) || - (automations.length === 0 && hasGithubIntegration === null); - if (error) { return ( @@ -92,23 +81,7 @@ export function AutomationList({ ); } - if (integrationsError && automations.length === 0) { - return ( - - - {integrationsError} - - - Retry - - - ); - } - - if (isInitialLoading) { + if (isLoading && automations.length === 0) { return ( @@ -117,10 +90,6 @@ export function AutomationList({ ); } - if (hasGithubIntegration === false && automations.length === 0) { - return ; - } - if (automations.length === 0) { return ; } diff --git a/apps/mobile/src/features/tasks/components/AutomationTemplateCard.tsx b/apps/mobile/src/features/tasks/components/AutomationTemplateCard.tsx new file mode 100644 index 000000000..2f6cb5cac --- /dev/null +++ b/apps/mobile/src/features/tasks/components/AutomationTemplateCard.tsx @@ -0,0 +1,50 @@ +import { Pressable, View } from "react-native"; +import { Text } from "@/components/text"; +import type { AutomationTemplate } from "../types"; + +interface AutomationTemplateCardProps { + template: AutomationTemplate; + onPress: (templateId: string) => void; +} + +export function AutomationTemplateCard({ + template, + onPress, +}: AutomationTemplateCardProps) { + return ( + onPress(template.id)} + className={`rounded-xl border px-4 py-4 active:opacity-80 ${ + template.hero + ? "border-accent-6 bg-accent-2" + : "border-gray-6 bg-gray-1" + }`} + > + + + {template.audienceLabel} + + + {template.categoryLabel} + + {template.hero && ( + + Recommended + + )} + + + + {template.name} + + {template.description} + + + {template.requiresRepository + ? "Requires repository access" + : "No repository required"} + + + ); +} diff --git a/apps/mobile/src/features/tasks/components/AutomationTemplateGallery.test.tsx b/apps/mobile/src/features/tasks/components/AutomationTemplateGallery.test.tsx new file mode 100644 index 000000000..b2225d749 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/AutomationTemplateGallery.test.tsx @@ -0,0 +1,93 @@ +import { createElement } from "react"; +import { act, create } from "react-test-renderer"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("./AutomationTemplateCard", () => ({ + AutomationTemplateCard: ({ + template, + onPress, + }: { + template: { id: string; name: string }; + onPress: (templateId: string) => void; + }) => + createElement( + "AutomationTemplateCard", + { + onPress: () => onPress(template.id), + title: template.name, + }, + template.name, + ), +})); + +import { AutomationTemplateGallery } from "./AutomationTemplateGallery"; + +describe("AutomationTemplateGallery", () => { + it("renders the developer template first and includes the launch set", () => { + let renderer: ReturnType | null = null; + act(() => { + renderer = create( + createElement(AutomationTemplateGallery, { + onSelectTemplate: vi.fn(), + onCreateCustom: vi.fn(), + }), + ); + }); + + if (!renderer) { + throw new Error("Renderer not created"); + } + + const labels = renderer.root + .findAll( + (node) => + typeof node.props.children === "string" && + /brief|pulse|opener/i.test(node.props.children), + ) + .map((node) => node.props.children); + + expect(labels).toContain("Developer morning briefing"); + expect(labels).toContain("PM product pulse"); + expect(labels).toContain("Executive day opener"); + expect( + renderer.root.findAll( + (node) => node.props.children === "Start from scratch", + ).length, + ).toBeGreaterThan(0); + expect(labels.indexOf("Developer morning briefing")).toBeLessThan( + labels.indexOf("PM product pulse"), + ); + }); + + it("routes template and custom selections through the expected callbacks", () => { + const onSelectTemplate = vi.fn(); + let renderer: ReturnType | null = null; + act(() => { + renderer = create( + createElement(AutomationTemplateGallery, { + onSelectTemplate, + onCreateCustom: vi.fn(), + }), + ); + }); + + if (!renderer) { + throw new Error("Renderer not created"); + } + + const buttons = renderer.root.findAll( + (node) => + typeof node.props.onPress === "function" && + (node.type === "AutomationTemplateCard" || + node.props.children === "Start from scratch"), + ); + + const developerButton = buttons.find( + (node) => node.props.title === "Developer morning briefing", + ); + + developerButton?.props.onPress(); + + expect(onSelectTemplate).toHaveBeenCalledWith("developer-morning-brief"); + }); +}); diff --git a/apps/mobile/src/features/tasks/components/AutomationTemplateGallery.tsx b/apps/mobile/src/features/tasks/components/AutomationTemplateGallery.tsx new file mode 100644 index 000000000..ca41c2ced --- /dev/null +++ b/apps/mobile/src/features/tasks/components/AutomationTemplateGallery.tsx @@ -0,0 +1,57 @@ +import { Pressable, View } from "react-native"; +import { Text } from "@/components/text"; +import { + AUTOMATION_TEMPLATES, + getAutomationTemplates, +} from "../templates/automationTemplates"; +import type { AutomationTemplate } from "../types"; +import { AutomationTemplateCard } from "./AutomationTemplateCard"; + +interface AutomationTemplateGalleryProps { + templates?: ReadonlyArray; + onSelectTemplate: (templateId: string) => void; + onCreateCustom: () => void; +} + +export function AutomationTemplateGallery({ + templates = AUTOMATION_TEMPLATES, + onSelectTemplate, + onCreateCustom, +}: AutomationTemplateGalleryProps) { + const launchTemplates = + templates.length > 0 ? templates : getAutomationTemplates(); + + return ( + + + + Launch templates + + + Choose a starter workflow and tweak it before saving. + + + + {launchTemplates.map((template) => ( + + ))} + + + + Start from scratch + + + Create a custom automation without using one of the launch templates. + + + + ); +} diff --git a/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts b/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts index 661bc9728..1354450af 100644 --- a/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts +++ b/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts @@ -112,6 +112,7 @@ const automationPayload = { github_integration: 7, cron_expression: "0 9 * * *", timezone: "Europe/London", + template_id: "developer-morning-brief", enabled: true, last_run_at: null, last_run_status: null, @@ -199,9 +200,19 @@ describe("useAutomations", () => { github_integration: 7, cron_expression: "0 9 * * *", timezone: "Europe/London", + template_id: "developer-morning-brief", }); }); + expect(mockCreateTaskAutomation).toHaveBeenCalledWith({ + name: "Daily PRs", + prompt: "Check PRs", + repository: "posthog/posthog", + github_integration: 7, + cron_expression: "0 9 * * *", + timezone: "Europe/London", + template_id: "developer-morning-brief", + }); expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: automationKeys.lists(), }); @@ -243,7 +254,39 @@ describe("useAutomations", () => { ).toMatchObject({ enabled: false, cron_expression: "30 14 * * *", + template_id: "developer-morning-brief", }); unmount(); }); + + it("does not populate automation caches when creation fails", async () => { + const queryClient = new QueryClient(); + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); + mockCreateTaskAutomation.mockRejectedValueOnce( + new Error("Repository is still required for this template."), + ); + + const { result, unmount } = renderTestHook( + () => useCreateTaskAutomation(), + createWrapper(queryClient), + ); + + await expect( + result.current.mutateAsync({ + name: "PM pulse", + prompt: "Summarize feature usage for my product areas.", + repository: "", + github_integration: null, + cron_expression: "0 8 * * 1-5", + timezone: "America/New_York", + template_id: "pm-product-pulse", + }), + ).rejects.toThrow("Repository is still required for this template."); + + expect( + queryClient.getQueryData(automationKeys.detail("automation-1")), + ).toBe(undefined); + expect(invalidateSpy).not.toHaveBeenCalled(); + unmount(); + }); }); diff --git a/apps/mobile/src/features/tasks/hooks/useIntegrations.test.ts b/apps/mobile/src/features/tasks/hooks/useIntegrations.test.ts index caf1bcade..6a7670c4a 100644 --- a/apps/mobile/src/features/tasks/hooks/useIntegrations.test.ts +++ b/apps/mobile/src/features/tasks/hooks/useIntegrations.test.ts @@ -174,4 +174,31 @@ describe("useIntegrations", () => { expect(result.current.repositoryWarning).toBeNull(); unmount(); }); + + it("skips integration loading when repository requirements are disabled", async () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + const { result, unmount } = renderTestHook( + () => useIntegrations({ enabled: false }), + createWrapper(queryClient), + ); + + expect(result.current.hasGithubIntegration).toBeNull(); + expect(result.current.githubIntegrations).toEqual([]); + expect(result.current.repositories).toEqual([]); + expect(result.current.repositoryOptions).toEqual([]); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + + await act(async () => { + await result.current.refetch(); + }); + + expect(mockGetIntegrations).not.toHaveBeenCalled(); + expect(mockGetGithubRepositories).not.toHaveBeenCalled(); + unmount(); + }); }); diff --git a/apps/mobile/src/features/tasks/hooks/useIntegrations.ts b/apps/mobile/src/features/tasks/hooks/useIntegrations.ts index 54c2fdb03..8ed95cea7 100644 --- a/apps/mobile/src/features/tasks/hooks/useIntegrations.ts +++ b/apps/mobile/src/features/tasks/hooks/useIntegrations.ts @@ -16,7 +16,12 @@ interface RepositoryLoadResult { partialError: string | null; } -export function useIntegrations() { +interface UseIntegrationsOptions { + enabled?: boolean; +} + +export function useIntegrations(options: UseIntegrationsOptions = {}) { + const { enabled = true } = options; const { projectId, oauthAccessToken } = useAuthStore(); const integrationsQuery = useQuery({ @@ -25,10 +30,10 @@ export function useIntegrations() { const data = await getIntegrations(); return data.filter((i) => i.kind === "github"); }, - enabled: !!projectId && !!oauthAccessToken, + enabled: enabled && !!projectId && !!oauthAccessToken, }); - const githubIntegrations = integrationsQuery.data ?? []; + const githubIntegrations = enabled ? (integrationsQuery.data ?? []) : []; const repositoriesQuery = useQuery({ queryKey: [ @@ -67,7 +72,7 @@ export function useIntegrations() { : "Some GitHub repositories could not be loaded. Pull to retry.", }; }, - enabled: githubIntegrations.length > 0, + enabled: enabled && githubIntegrations.length > 0, }); const repositoriesByIntegration = @@ -80,21 +85,29 @@ export function useIntegrations() { const repositoryWarning = repositoriesQuery.data?.partialError ?? null; const refetch = async () => { + if (!enabled) { + return; + } + await integrationsQuery.refetch(); await repositoriesQuery.refetch(); }; return { - hasGithubIntegration: integrationsQuery.isFetched - ? githubIntegrations.length > 0 - : null, + hasGithubIntegration: !enabled + ? null + : integrationsQuery.isFetched + ? githubIntegrations.length > 0 + : null, githubIntegrations, repositories, repositoriesByIntegration, repositoryOptions, - isLoading: integrationsQuery.isLoading || repositoriesQuery.isLoading, - error: integrationsQuery.error?.message ?? null, - repositoryWarning, + isLoading: enabled + ? integrationsQuery.isLoading || repositoriesQuery.isLoading + : false, + error: enabled ? (integrationsQuery.error?.message ?? null) : null, + repositoryWarning: enabled ? repositoryWarning : null, refetch, }; } diff --git a/apps/mobile/src/features/tasks/templates/automationTemplates.test.ts b/apps/mobile/src/features/tasks/templates/automationTemplates.test.ts new file mode 100644 index 000000000..1d80f6975 --- /dev/null +++ b/apps/mobile/src/features/tasks/templates/automationTemplates.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { + AUTOMATION_TEMPLATES, + getAutomationTemplate, + getAutomationTemplateInitialValues, + requiresAutomationTemplateRepository, +} from "./automationTemplates"; + +describe("automationTemplates", () => { + it("returns the developer template first and includes all launch templates", () => { + expect(AUTOMATION_TEMPLATES.map((template) => template.id)).toEqual([ + "developer-morning-brief", + "pm-product-pulse", + "executive-day-opener", + ]); + expect(AUTOMATION_TEMPLATES[0]?.hero).toBe(true); + }); + + it("derives initial editor values from a selected template", () => { + expect( + getAutomationTemplateInitialValues("developer-morning-brief"), + ).toEqual({ + name: "Developer morning briefing", + prompt: expect.stringContaining("Create my developer morning briefing."), + cron_expression: "0 9 * * 1-5", + enabled: true, + template_id: "developer-morning-brief", + }); + }); + + it("handles unknown template ids without throwing", () => { + expect(getAutomationTemplate("unknown-template")).toBeNull(); + expect(getAutomationTemplateInitialValues("unknown-template")).toBeNull(); + }); + + it("exposes repository requirements per template", () => { + expect( + requiresAutomationTemplateRepository("developer-morning-brief"), + ).toBe(true); + expect(requiresAutomationTemplateRepository("pm-product-pulse")).toBe( + false, + ); + expect(requiresAutomationTemplateRepository("executive-day-opener")).toBe( + false, + ); + }); +}); diff --git a/apps/mobile/src/features/tasks/templates/automationTemplates.ts b/apps/mobile/src/features/tasks/templates/automationTemplates.ts new file mode 100644 index 000000000..d3494a4db --- /dev/null +++ b/apps/mobile/src/features/tasks/templates/automationTemplates.ts @@ -0,0 +1,105 @@ +import type { + AutomationTemplate, + AutomationTemplateInitialValues, +} from "../types"; +import { + buildCronExpression, + createDefaultScheduleDraft, +} from "../utils/automationSchedule"; + +function buildWeekdayCron(hour: string, minute: string): string { + return buildCronExpression({ + ...createDefaultScheduleDraft(), + mode: "weekdays", + hour, + minute, + }); +} + +export const AUTOMATION_TEMPLATES: ReadonlyArray = [ + { + id: "developer-morning-brief", + name: "Developer morning briefing", + description: + "Review open PRs, review requests, CI failures, and active work before you start coding.", + audience: "developer", + audienceLabel: "Developer", + categoryLabel: "Daily briefing", + prompt: + "Create my developer morning briefing. Summarize my open pull requests, outstanding review requests, CI failures, and work in progress in my selected repository. Call out blockers first, then list the most important items I should tackle this morning.", + suggestedName: "Developer morning briefing", + cron_expression: buildWeekdayCron("09", "00"), + enabled: true, + requiresRepository: true, + hero: true, + }, + { + id: "pm-product-pulse", + name: "PM product pulse", + description: + "Get a concise update on feature usage, product health, and the biggest signals worth following up on.", + audience: "pm", + audienceLabel: "PM", + categoryLabel: "Daily briefing", + prompt: + "Create my PM product pulse. Summarize the most important usage trends, product quality signals, and feature areas I should pay attention to today. Lead with notable changes, regressions, or unusual user behavior, then suggest the follow-ups that matter most.", + suggestedName: "PM product pulse", + cron_expression: buildWeekdayCron("09", "30"), + enabled: true, + requiresRepository: false, + }, + { + id: "executive-day-opener", + name: "Executive day opener", + description: + "Start the day with meetings, priorities, and the highest-level updates that need attention.", + audience: "executive", + audienceLabel: "Executive", + categoryLabel: "Daily briefing", + prompt: + "Create my executive day opener. Summarize today's meetings, the highest-priority follow-ups, and the top company or product signals that need my attention. Keep it short, scannable, and focused on decisions or risks.", + suggestedName: "Executive day opener", + cron_expression: buildWeekdayCron("07", "30"), + enabled: true, + requiresRepository: false, + }, +] as const; + +export function getAutomationTemplates(): ReadonlyArray { + return AUTOMATION_TEMPLATES; +} + +export function getAutomationTemplate( + templateId: string | null | undefined, +): AutomationTemplate | null { + if (!templateId) { + return null; + } + + return ( + AUTOMATION_TEMPLATES.find((template) => template.id === templateId) ?? null + ); +} + +export function getAutomationTemplateInitialValues( + templateId: string | null | undefined, +): AutomationTemplateInitialValues | null { + const template = getAutomationTemplate(templateId); + if (!template) { + return null; + } + + return { + name: template.suggestedName, + prompt: template.prompt, + cron_expression: template.cron_expression, + enabled: template.enabled, + template_id: template.id, + }; +} + +export function requiresAutomationTemplateRepository( + templateId: string | null | undefined, +): boolean { + return getAutomationTemplate(templateId)?.requiresRepository ?? false; +} diff --git a/apps/mobile/src/features/tasks/types.ts b/apps/mobile/src/features/tasks/types.ts index 40459b387..f2d4d2b79 100644 --- a/apps/mobile/src/features/tasks/types.ts +++ b/apps/mobile/src/features/tasks/types.ts @@ -143,6 +143,31 @@ export interface RepositorySelection { repository: string | null; } +export type AutomationTemplateAudience = "developer" | "pm" | "executive"; + +export interface AutomationTemplate { + id: string; + name: string; + description: string; + audience: AutomationTemplateAudience; + audienceLabel: string; + categoryLabel: string; + prompt: string; + suggestedName: string; + cron_expression: string; + enabled: boolean; + requiresRepository: boolean; + hero?: boolean; +} + +export interface AutomationTemplateInitialValues { + name: string; + prompt: string; + cron_expression: string; + enabled: boolean; + template_id: string; +} + export interface CreateTaskOptions { description: string; title?: string; diff --git a/apps/mobile/src/features/tasks/utils/automationTemplatePresentation.test.ts b/apps/mobile/src/features/tasks/utils/automationTemplatePresentation.test.ts new file mode 100644 index 000000000..f60d88512 --- /dev/null +++ b/apps/mobile/src/features/tasks/utils/automationTemplatePresentation.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { getAutomationTemplatePresentation } from "./automationTemplatePresentation"; + +describe("automationTemplatePresentation", () => { + it("prefers repository context when one exists", () => { + expect( + getAutomationTemplatePresentation({ + repository: "posthog/posthog", + template_id: "developer-morning-brief", + }), + ).toMatchObject({ + templateName: "Developer morning briefing", + repositoryLabel: "posthog/posthog", + contextLabel: "Developer · Daily briefing", + secondaryLabel: "posthog/posthog", + }); + }); + + it("falls back to template context for repo-optional automations", () => { + expect( + getAutomationTemplatePresentation({ + repository: "", + template_id: "pm-product-pulse", + }), + ).toMatchObject({ + templateName: "PM product pulse", + repositoryLabel: null, + contextLabel: "PM · Daily briefing", + secondaryLabel: "PM · Daily briefing", + }); + }); + + it("handles unknown template ids and blank repositories safely", () => { + expect( + getAutomationTemplatePresentation({ + repository: "", + template_id: "unknown-template", + }), + ).toMatchObject({ + templateName: "Template automation", + repositoryLabel: null, + contextLabel: null, + secondaryLabel: "No repository context", + }); + }); +}); diff --git a/apps/mobile/src/features/tasks/utils/automationTemplatePresentation.ts b/apps/mobile/src/features/tasks/utils/automationTemplatePresentation.ts new file mode 100644 index 000000000..6d7ba5889 --- /dev/null +++ b/apps/mobile/src/features/tasks/utils/automationTemplatePresentation.ts @@ -0,0 +1,27 @@ +import { getAutomationTemplate } from "../templates/automationTemplates"; +import type { TaskAutomation } from "../types"; + +export interface AutomationTemplatePresentation { + templateName: string | null; + repositoryLabel: string | null; + contextLabel: string | null; + secondaryLabel: string; +} + +export function getAutomationTemplatePresentation( + automation: Pick, +): AutomationTemplatePresentation { + const repositoryLabel = automation.repository.trim() || null; + const template = getAutomationTemplate(automation.template_id); + const contextLabel = template + ? `${template.audienceLabel} · ${template.categoryLabel}` + : null; + + return { + templateName: + template?.name ?? (automation.template_id ? "Template automation" : null), + repositoryLabel, + contextLabel, + secondaryLabel: repositoryLabel ?? contextLabel ?? "No repository context", + }; +} diff --git a/apps/mobile/src/test/setup.ts b/apps/mobile/src/test/setup.ts index 88c78122e..9411d4537 100644 --- a/apps/mobile/src/test/setup.ts +++ b/apps/mobile/src/test/setup.ts @@ -8,6 +8,38 @@ vi.mock("expo-constants", () => ({ }, })); +vi.mock("react-native", async () => { + const actual = await import("react-native-web"); + const { createElement } = await import("react"); + + return { + ...actual, + Alert: { + alert: vi.fn(), + }, + BackHandler: { + addEventListener: vi.fn(() => ({ + remove: vi.fn(), + })), + }, + InteractionManager: { + runAfterInteractions: (callback: () => void) => { + callback(); + return { + cancel: vi.fn(), + }; + }, + }, + Platform: { + OS: "ios", + select: (options: { ios?: T; android?: T; default?: T }) => + options.ios ?? options.default, + }, + TextInput: (props: Record) => + createElement("TextInput", props), + }; +}); + vi.mock("@/lib/logger", () => { const mockLogger = { info: vi.fn(), diff --git a/docs/plans/2026-05-13-001-feat-mobile-automation-templates-plan.md b/docs/plans/2026-05-13-001-feat-mobile-automation-templates-plan.md new file mode 100644 index 000000000..f74a43d05 --- /dev/null +++ b/docs/plans/2026-05-13-001-feat-mobile-automation-templates-plan.md @@ -0,0 +1,342 @@ +--- +title: feat: Add mobile automation templates +type: feat +status: active +date: 2026-05-13 +--- + +# feat: Add mobile automation templates + +## Summary + +Add a template-first creation flow to the mobile Automations tab by turning the current blank "New automation" modal into a template gallery, moving the existing editor to a dedicated create screen, and teaching the form, list, and detail surfaces how to handle both repo-backed and repo-optional templates. The first catalog ships developer, PM, and executive starters from a mobile-owned template registry while keeping a repo-backed custom escape hatch. + +--- + +## Problem Frame + +Today every new mobile automation starts as a blank prompt plus mandatory repository selection. That works for developer workflows, but it makes the launch PM and executive templates either impossible or awkward because the UI assumes every automation is GitHub-shaped before the user has even chosen what outcome they want. + +V1 needs to make "choose a template and tweak it" the default creation experience without introducing a backend-managed template system or a complex prompt-builder. It also needs to preserve the current scheduling, run-now, enable/disable, and edit flows so the feature feels like an extension of the existing automations surface rather than a parallel product. + +| Launch variant | Primary outcome | Repository required in v1 | +|---|---|---| +| Developer morning briefing | PR status, review queue, and work-in-progress summary | Yes | +| PM product pulse | Usage and product-health summary for owned areas | No | +| Executive day opener | Meetings and high-level status summary | No | + +--- + +## Requirements + +- R1. The mobile "New automation" entry point starts with a template gallery instead of dropping users directly into a blank automation form. +- R2. V1 ships curated starter templates for developer, PM, and executive workflows, with the developer template presented as the hero/default-first option. +- R3. Selecting a template pre-populates the existing automation editor with a stable `template_id`, starter prompt, suggested name, and default schedule, while still letting the user tweak the final automation before saving. +- R4. Repository selection and GitHub connection gating are required only for templates that explicitly need repository context; PM and executive templates must be creatable without forcing a GitHub-shaped setup. +- R5. Existing automation list, detail, and edit surfaces must render template-backed automations gracefully, including cases where `repository` is blank or the `template_id` is unknown to the current mobile catalog. +- R6. Client-side API and hook coverage must explicitly verify template serialization, repo-optional behavior, and failure handling so the launch catches backend contract mismatches before release. + +--- + +## Scope Boundaries + +- No backend-managed or remotely configurable template catalog in v1; launch templates live in the mobile app codebase. +- No prompt-building skill, modular briefing builder, or template authoring UI in this release. +- No template switching for an existing automation after it has been created; edit keeps the stored template association and lets users change the prompt/schedule directly. +- No new source-specific onboarding flows beyond the current GitHub connection pattern for repo-backed templates. + +### Deferred to Follow-Up Work + +- Server-driven template distribution and experimentation. +- Advanced role personalization, generated prompts, or a dedicated prompt-authoring assistant. +- Additional integrations or setup experiences for PM/executive-specific data sources beyond the initial template prompts. + +--- + +## Context & Research + +### Relevant Code and Patterns + +- `apps/mobile/src/app/(tabs)/automations.tsx` is the current tab entry point and already owns the "New automation" button navigation. +- `apps/mobile/src/app/automation/index.tsx` contains the current blank create form and is the natural route to repurpose into a template gallery. +- `apps/mobile/src/app/automation/[id].tsx` and `apps/mobile/src/features/tasks/components/AutomationDetail.tsx` define the existing edit/detail modal flow that should remain intact. +- `apps/mobile/src/features/tasks/components/AutomationForm.tsx` is the central composition point for prompt, repository, schedule, and enabled-state editing. +- `apps/mobile/src/features/tasks/hooks/useAutomations.ts` and `apps/mobile/src/features/tasks/api.ts` already encapsulate create/update/list/detail automation behavior and query invalidation. +- `apps/mobile/src/features/tasks/hooks/useIntegrations.ts`, `apps/mobile/src/features/tasks/components/RepositorySelector.tsx`, and `apps/mobile/src/features/tasks/components/GitHubConnectionPrompt.tsx` establish the current GitHub gating pattern. +- `apps/mobile/src/features/tasks/utils/automationSchedule.ts` shows the preferred style for pure, testable helper modules that derive defaults and display values. +- `apps/mobile/src/features/tasks/api.automations.test.ts`, `apps/mobile/src/features/tasks/hooks/useAutomations.test.ts`, and `apps/mobile/src/features/tasks/hooks/useIntegrations.test.ts` show the existing testing style for API and hook behavior. + +### Institutional Learnings + +- No `docs/solutions/` entries were present for the mobile automations area. + +### External References + +- None. Local patterns were sufficient for this plan. + +--- + +## Key Technical Decisions + +- Use a mobile-owned template catalog with stable launch IDs, starter copy, schedule defaults, and a `requiresRepository` flag instead of introducing a remote template dependency in v1. +- Repurpose `/automation` into a template gallery and move the current editor into a dedicated `/automation/create` route so the default path becomes template-first without breaking the existing modal stack. +- Keep the blank custom flow as a secondary CTA and preserve its current repo-backed behavior in v1; repo-less creation is introduced only for first-party templates that the catalog explicitly marks as repo-optional. +- Preserve `template_id` through create, list, detail, and edit flows, but do not support re-selecting a template after creation in v1. +- Treat unknown `template_id` values and blank repositories as compatibility cases rather than fatal errors so automations created elsewhere in the product can still render and edit safely on mobile. + +--- + +## Open Questions + +### Resolved During Planning + +- Should v1 depend on a backend-managed template library? No. The first release should use a local mobile catalog and stable IDs. +- Should "New automation" still open a blank form first? No. The primary entry should be the template gallery, with a secondary custom CTA. +- Should custom blank automations become repo-optional in the same release? No. Keep the existing repo-backed custom path and scope repo-optional behavior to first-party launch templates. +- Should users be able to switch templates while editing an existing automation? No. V1 keeps template choice fixed after creation and lets users edit prompt/schedule fields directly. + +### Deferred to Implementation + +- Whether the upstream task automations API already accepts `repository: ""` (or another repo-optional contract) for non-repo templates, or whether mobile launch must wait for a backend validation change. +- Whether the cleanest repository gating change is an `enabled` parameter on `useIntegrations` or a thinner wrapper around the existing hook can be decided once the form refactor begins. + +--- + +## Output Structure + +```text +apps/mobile/src/app/automation/ + index.tsx + create.tsx + +apps/mobile/src/features/tasks/ + components/ + AutomationTemplateGallery.tsx + AutomationTemplateCard.tsx + templates/ + automationTemplates.ts + automationTemplates.test.ts + utils/ + automationTemplatePresentation.ts + automationTemplatePresentation.test.ts +``` + +--- + +## High-Level Technical Design + +> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.* + +```mermaid +flowchart TB + A["Automations tab"] --> B["/automation template gallery"] + B -->|"Choose launch template"| C["Resolve template defaults + template_id"] + B -->|"Start from scratch"| D["Custom create mode"] + C --> E["/automation/create editor"] + D --> E + E --> F{"Requires repository?"} + F -->|Yes| G["GitHub connection + repository selector"] + F -->|No| H["Prompt + schedule only"] + G --> I["createTaskAutomation / updateTaskAutomation"] + H --> I + I --> J["List + detail render repo or template metadata"] +``` + +--- + +## Implementation Units + +### U1. Define the launch template catalog + +**Goal:** Create the local source of truth for launch templates, including audience, display copy, prefilled automation defaults, and repository requirements. + +**Requirements:** R2, R3, R5 + +**Dependencies:** None + +**Files:** +- Create: `apps/mobile/src/features/tasks/templates/automationTemplates.ts` +- Modify: `apps/mobile/src/features/tasks/types.ts` +- Test: `apps/mobile/src/features/tasks/templates/automationTemplates.test.ts` + +**Approach:** +- Define stable template records for the developer, PM, and executive launch templates, including hero ordering, human-readable metadata, starter prompt text, suggested schedule defaults, and a `requiresRepository` flag. +- Add pure helpers to look up a template by ID, derive form initial values, and expose fallback metadata for list/detail rendering when a saved automation references a known template. +- Keep the catalog declarative and self-contained so later template additions do not require editing form validation logic in multiple places. + +**Patterns to follow:** +- `apps/mobile/src/features/tasks/utils/automationSchedule.ts` for pure helper composition and display/default derivation. +- `apps/mobile/src/features/tasks/types.ts` for shared automation-specific types. + +**Test scenarios:** +- Happy path: the catalog returns the developer launch template first and includes PM and executive launch entries. +- Happy path: the initial-value helper derives a name, prompt, schedule defaults, and `template_id` for a selected template. +- Edge case: lookup by unknown `template_id` returns `null` without throwing so remote/older automations degrade gracefully. +- Edge case: both repo-backed and repo-optional templates expose the correct `requiresRepository` behavior flag. + +**Verification:** +- Template metadata can drive screen copy and form defaults without additional hardcoded switches spread across components. + +--- + +### U2. Replace the blank create entry with a template gallery + +**Goal:** Make template selection the default "New automation" flow while preserving a clear custom escape hatch and the existing modal navigation behavior. + +**Requirements:** R1, R2, R3 + +**Dependencies:** U1 + +**Files:** +- Modify: `apps/mobile/src/app/(tabs)/automations.tsx` +- Modify: `apps/mobile/src/app/_layout.tsx` +- Modify: `apps/mobile/src/app/automation/index.tsx` +- Create: `apps/mobile/src/app/automation/create.tsx` +- Create: `apps/mobile/src/features/tasks/components/AutomationTemplateGallery.tsx` +- Create: `apps/mobile/src/features/tasks/components/AutomationTemplateCard.tsx` +- Test: `apps/mobile/src/features/tasks/components/AutomationTemplateGallery.test.tsx` + +**Approach:** +- Repurpose `apps/mobile/src/app/automation/index.tsx` into a template gallery screen that shows the curated launch templates first and a secondary "start from scratch" action below them. +- Move the current editor screen behavior into `apps/mobile/src/app/automation/create.tsx`, carrying the chosen `templateId` (or custom mode) via route params so the existing modal header/back behavior remains predictable. +- Register the new create route in the root stack and keep the automations tab button/navigation wiring simple: "New automation" always opens the gallery first, then the gallery owns the transition into the editor. +- Treat invalid or missing template route params as a safe fallback to custom mode or a guarded error state rather than letting the modal crash. + +**Patterns to follow:** +- `apps/mobile/src/app/(tabs)/automations.tsx` for modal navigation from the automations tab. +- `apps/mobile/src/app/_layout.tsx` for modal route registration and header treatment. +- `apps/mobile/src/features/tasks/components/AutomationList.tsx` for empty-state CTA behavior and button styling conventions. + +**Test scenarios:** +- Happy path: the gallery renders the developer template first and includes PM, executive, and custom entry options. +- Happy path: selecting a template navigates to the create editor with the chosen `templateId`. +- Edge case: selecting the custom CTA opens the editor without template defaults. +- Edge case: missing or invalid `templateId` on the create route falls back safely instead of crashing or trapping the user in a blank screen. + +**Verification:** +- A user tapping "New automation" sees the template gallery first, can still reach a blank custom form, and can navigate back cleanly through the modal stack. + +--- + +### U3. Make the editor, list, and detail views template-aware + +**Goal:** Update the existing automation surfaces so launch templates feel native rather than GitHub-shaped, while keeping edit/run/pause behavior unchanged. + +**Requirements:** R3, R4, R5 + +**Dependencies:** U1, U2 + +**Files:** +- Modify: `apps/mobile/src/app/automation/[id].tsx` +- Modify: `apps/mobile/src/features/tasks/components/AutomationForm.tsx` +- Modify: `apps/mobile/src/features/tasks/components/AutomationList.tsx` +- Modify: `apps/mobile/src/features/tasks/components/AutomationItem.tsx` +- Modify: `apps/mobile/src/features/tasks/components/AutomationDetail.tsx` +- Modify: `apps/mobile/src/features/tasks/hooks/useIntegrations.ts` +- Create: `apps/mobile/src/features/tasks/utils/automationTemplatePresentation.ts` +- Test: `apps/mobile/src/features/tasks/components/AutomationForm.test.tsx` +- Test: `apps/mobile/src/features/tasks/utils/automationTemplatePresentation.test.ts` +- Test: `apps/mobile/src/features/tasks/hooks/useIntegrations.test.ts` + +**Approach:** +- Extend the editor props so a selected template can prefill the form and carry `template_id` through create and edit submissions, while keeping prompt, name, schedule, and enabled-state fields editable. +- Make repository selection a template-driven requirement: repo-backed templates and the custom blank path continue to use GitHub connection/repository selection, while repo-optional templates bypass that gating and do not show GitHub blocking states. +- Update list/detail presentation helpers so repo-backed automations still show repository metadata, while repo-less automations can show template-driven context instead of an empty repository row. +- Preserve existing template association during edit flows and handle unknown template IDs with safe fallback rendering rather than special-case failures. + +**Execution note:** Add or preserve coverage around the form-state branching before changing the UI logic so repo-backed and repo-optional validation paths stay explicit. + +**Patterns to follow:** +- `apps/mobile/src/features/tasks/components/AutomationForm.tsx` for grouped editor sections and field/general error handling. +- `apps/mobile/src/features/tasks/components/GitHubConnectionPrompt.tsx` and `apps/mobile/src/features/tasks/components/RepositorySelector.tsx` for repository-gating UX. +- `apps/mobile/src/features/tasks/components/AutomationItem.tsx` and `apps/mobile/src/features/tasks/components/AutomationDetail.tsx` for summary/detail metadata rendering. + +**Test scenarios:** +- Happy path: a developer template still requires GitHub connection plus repository selection before submit is allowed. +- Happy path: a PM or executive template can be created after prompt/schedule tweaks without forcing repository selection. +- Edge case: an existing automation with an unknown `template_id` or blank repository still renders safely in list, detail, and edit views. +- Edge case: editing a template-backed automation preserves `template_id` when only prompt, schedule, or enabled state changes. +- Error path: GitHub integration load failures still block repo-backed templates but do not block repo-optional templates. +- Integration: list and detail views show meaningful secondary text for both repo-backed and repo-less automations. + +**Verification:** +- Developer, PM, executive, and custom automation flows all reach create/edit/detail successfully without false GitHub blockers or broken blank-repository UI. + +--- + +### U4. Harden the API contract and query-layer coverage + +**Goal:** Make template-backed automation persistence explicit in the mobile client and catch backend contract mismatches through focused API and hook coverage. + +**Requirements:** R4, R5, R6 + +**Dependencies:** U1, U3 + +**Files:** +- Modify: `apps/mobile/src/features/tasks/api.ts` +- Modify: `apps/mobile/src/features/tasks/hooks/useAutomations.ts` +- Modify: `apps/mobile/src/features/tasks/api.automations.test.ts` +- Modify: `apps/mobile/src/features/tasks/hooks/useAutomations.test.ts` +- Modify: `apps/mobile/src/features/tasks/types.ts` + +**Approach:** +- Ensure create/update payloads serialize `template_id` consistently and centralize the decision about whether repo-optional templates send a blank repository string or another supported no-repo contract. +- Keep the React Query cache behavior unchanged for template-backed automations so create/update still refresh list/detail/task state predictably. +- Add explicit tests for launch-template payloads, detail-cache updates, and backend validation failures so this repo surfaces contract regressions even if the upstream API evolves separately. +- Treat backend support for repo-less templates as a launch dependency: if the API rejects the chosen no-repo contract, PM and executive templates should remain unavailable rather than shipping a broken submission flow. + +**Patterns to follow:** +- `apps/mobile/src/features/tasks/api.automations.test.ts` for API contract serialization and validation error assertions. +- `apps/mobile/src/features/tasks/hooks/useAutomations.test.ts` for cache invalidation and mutation-side effects. + +**Test scenarios:** +- Happy path: create requests for launch templates include the expected `template_id` and repo payload for both repo-backed and repo-optional cases. +- Happy path: editing a template-backed automation keeps the detail cache synchronized after update. +- Error path: backend validation failures for repo-less template creation surface cleanly without leaving stale automation cache state behind. +- Integration: list/detail polling and invalidation continue to behave correctly when a template-backed automation runs or updates. + +**Verification:** +- Mobile API coverage explicitly proves how template-backed automations are serialized and how failures are surfaced before rollout. + +--- + +## System-Wide Impact + +- **Interaction graph:** `apps/mobile/src/app/(tabs)/automations.tsx` opens the template gallery, which routes into the create editor, which in turn uses the existing mutation layer in `apps/mobile/src/features/tasks/api.ts` / `apps/mobile/src/features/tasks/hooks/useAutomations.ts`, after which list/detail surfaces re-render from React Query state. +- **Error propagation:** `TaskAutomationValidationError` remains the path for backend validation failures and should continue to feed field-level or general form errors after template-aware serialization is added. +- **State lifecycle risks:** route params can drift from the local template catalog, list/detail caches can lose template metadata if create/update payload handling drops `template_id`, and integration loading must not block repo-optional flows. +- **API surface parity:** mobile uses hand-authored task automation types while the wider product also exposes `template_id` in generated schemas, so repo-optional launch behavior needs explicit coordination rather than assuming all clients already agree. +- **Integration coverage:** manual and automated verification should cover create/edit/detail for developer, PM, executive, and custom automations, plus unaffected run-now/pause/delete behavior. +- **Unchanged invariants:** scheduling helpers, run-now behavior, enable/disable toggles, delete behavior, and task-run polling remain unchanged by this plan. + +--- + +## Risks & Dependencies + +| Risk | Mitigation | +|------|------------| +| The upstream task automations API may reject repo-less template creation even though mobile wants PM/executive templates to skip repository context. | Treat repo-less submission support as a launch prerequisite; if support is missing, keep PM/executive templates unavailable until the API contract lands. | +| A local mobile catalog can drift from backend-supported `template_id` values or from template copy used elsewhere in the product. | Centralize launch IDs in one catalog file and coordinate the initial IDs/copy with the broader product owners before rollout. | +| Repo-less automations can make existing list/detail UI look broken because those screens currently assume `repository` is always present. | Add presentation helpers and explicit tests for blank/unknown repository/template combinations before shipping. | +| PM/executive templates may imply data sources or tools that the automation runtime cannot actually access yet. | Validate each launch template against currently available runtime capabilities; if a template needs unavailable tools, narrow its prompt/copy or hold it back from the initial catalog. | + +--- + +## Documentation / Operational Notes + +- Confirm with the upstream task automations API owners which no-repository contract should be used for PM and executive templates before enabling those templates in production builds. +- Keep the launch template prompts/names in one catalog file so product copy tweaks do not require touching form logic. +- Sanity-check each launch template prompt against the tools the automation runtime can actually use today so PM/executive templates do not overpromise unavailable context. +- If repo-less backend support misses the mobile release window, hide or disable the affected templates instead of surfacing a submission path that can only fail at runtime. + +--- + +## Sources & References + +- Related code: `apps/mobile/src/app/(tabs)/automations.tsx` +- Related code: `apps/mobile/src/app/automation/index.tsx` +- Related code: `apps/mobile/src/app/automation/[id].tsx` +- Related code: `apps/mobile/src/features/tasks/components/AutomationForm.tsx` +- Related code: `apps/mobile/src/features/tasks/hooks/useAutomations.ts` +- Related code: `apps/mobile/src/features/tasks/hooks/useIntegrations.ts` +- Related code: `apps/mobile/src/features/tasks/api.ts` From 2a1cc207ea217d4bb2225152d793f907f18e9b44 Mon Sep 17 00:00:00 2001 From: dylan Date: Thu, 14 May 2026 00:10:32 -0400 Subject: [PATCH 53/94] i think this fixes tool calls --- .../features/tasks/stores/taskSessionStore.ts | 58 ++++++++++++++++++- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts index 1fe92c542..ffd8bb498 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts @@ -238,6 +238,11 @@ export interface TaskSession { // Timestamp of the last new event received. Used to detect stale local // sessions (desktop stopped syncing). lastEventAt?: number; + // Maps toolCallId → cloud requestId for routing permission responses. The + // cloud's permission_response command requires the requestId it generated + // when emitting the original permission_request SSE event; we capture it + // here so the response can be routed back to the awaiting tool call. + cloudPermissionRequestIds?: Record; } interface TaskSessionStore { @@ -551,8 +556,14 @@ export const useTaskSessionStore = create((set, get) => ({ }; }); + // The cloud command requires the requestId it generated when emitting + // the permission_request SSE event — toolCallId alone is not sufficient + // for routing the response back to the awaiting tool call. + const cloudRequestId = session.cloudPermissionRequestIds?.[args.toolCallId]; + try { await sendCloudCommand(taskId, session.taskRunId, "permission_response", { + ...(cloudRequestId ? { requestId: cloudRequestId } : {}), toolCallId: args.toolCallId, optionId: args.optionId, ...(args.answers ? { answers: args.answers } : {}), @@ -562,7 +573,28 @@ export const useTaskSessionStore = create((set, get) => ({ taskId, runId: session.taskRunId, toolCallId: args.toolCallId, + requestId: cloudRequestId, }); + + // One-shot: drop the mapping once we've responded so we don't reuse + // it accidentally. + if (cloudRequestId) { + set((state) => { + const current = state.sessions[session.taskRunId]; + if (!current?.cloudPermissionRequestIds) return state; + const next = { ...current.cloudPermissionRequestIds }; + delete next[args.toolCallId]; + return { + sessions: { + ...state.sessions, + [session.taskRunId]: { + ...current, + cloudPermissionRequestIds: next, + }, + }, + }; + }); + } } catch (err) { log.error("Failed to send permission_response", err); set((state) => { @@ -683,9 +715,29 @@ export const useTaskSessionStore = create((set, get) => ({ } if (update.kind === "permission_request") { - // Permission requests surface via `session/update` tool_call entries - // that already flow through the log stream; this dedicated payload is a - // desktop convenience and a no-op on mobile. + // The tool_call UI itself comes from the `session/update` log stream; + // this SSE-only payload exists so we can capture the cloud-side + // requestId required to route a permission_response back to the + // correct pending tool call. + const toolCallId = update.toolCall?.toolCallId; + if (toolCallId && update.requestId) { + set((state) => { + const current = state.sessions[taskRunId]; + if (!current) return state; + return { + sessions: { + ...state.sessions, + [taskRunId]: { + ...current, + cloudPermissionRequestIds: { + ...(current.cloudPermissionRequestIds ?? {}), + [toolCallId]: update.requestId, + }, + }, + }, + }; + }); + } return; } From 6510d928e694062886877f3f6f2b770942e2fb81 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Thu, 14 May 2026 09:50:39 -0400 Subject: [PATCH 54/94] Added mcp servers --- apps/mobile/assets/services/airops.png | Bin 0 -> 4354 bytes apps/mobile/assets/services/atlassian.svg | 5 + apps/mobile/assets/services/attio.png | Bin 0 -> 5158 bytes apps/mobile/assets/services/box.svg | 1 + apps/mobile/assets/services/browserbase.svg | 6 + apps/mobile/assets/services/canva.svg | 1 + apps/mobile/assets/services/circle.png | Bin 0 -> 35647 bytes .../assets/services/cisco_thousandeyes.png | Bin 0 -> 11326 bytes apps/mobile/assets/services/clerk.svg | 7 + apps/mobile/assets/services/clickhouse.svg | 1 + apps/mobile/assets/services/cloudflare.svg | 13 + apps/mobile/assets/services/context7.svg | 8 + apps/mobile/assets/services/datadog.svg | 14 + apps/mobile/assets/services/figma.svg | 1 + apps/mobile/assets/services/firetiger.svg | 25 ++ apps/mobile/assets/services/github.svg | 10 + apps/mobile/assets/services/gitlab.svg | 25 ++ apps/mobile/assets/services/hex.svg | 1 + apps/mobile/assets/services/hubspot.svg | 22 ++ apps/mobile/assets/services/launchdarkly.png | Bin 0 -> 5230 bytes apps/mobile/assets/services/linear.svg | 1 + apps/mobile/assets/services/monday.svg | 20 + apps/mobile/assets/services/neon.svg | 13 + apps/mobile/assets/services/notion.svg | 4 + apps/mobile/assets/services/pagerduty.svg | 17 + apps/mobile/assets/services/planetscale.svg | 11 + apps/mobile/assets/services/postman.svg | 50 +++ apps/mobile/assets/services/prisma.svg | 12 + apps/mobile/assets/services/render.svg | 5 + apps/mobile/assets/services/sanity.svg | 17 + apps/mobile/assets/services/sentry.svg | 1 + apps/mobile/assets/services/slack.png | Bin 0 -> 11043 bytes apps/mobile/assets/services/stripe.png | Bin 0 -> 1663 bytes apps/mobile/assets/services/supabase.svg | 13 + apps/mobile/assets/services/svelte.png | Bin 0 -> 13718 bytes apps/mobile/assets/services/wix.png | Bin 0 -> 17240 bytes apps/mobile/metro.config.js | 13 + apps/mobile/package.json | 3 + apps/mobile/src/app/_layout.tsx | 15 + .../mobile/src/app/mcp-servers/add-custom.tsx | 222 +++++++++++ apps/mobile/src/app/mcp-servers/index.tsx | 234 ++++++++++++ .../src/app/mcp-servers/installation/[id].tsx | 347 ++++++++++++++++++ .../src/app/mcp-servers/template/[id].tsx | 260 +++++++++++++ apps/mobile/src/app/settings/index.tsx | 11 + .../features/chat/components/ToolMessage.tsx | 20 + apps/mobile/src/features/mcp/api.ts | 194 ++++++++++ .../mcp/components/FloatingMcpHeader.tsx | 89 +++++ .../features/mcp/components/McpAppHost.tsx | 305 +++++++++++++++ .../features/mcp/components/McpServerRow.tsx | 143 ++++++++ .../features/mcp/components/ServerIcon.tsx | 43 +++ .../features/mcp/components/serverIcons.ts | 104 ++++++ apps/mobile/src/features/mcp/hooks.ts | 141 +++++++ apps/mobile/src/features/mcp/oauth.ts | 114 ++++++ .../src/features/mcp/sandbox/mcpAppTheme.ts | 113 ++++++ .../features/mcp/sandbox/sandboxProxyHtml.ts | 117 ++++++ .../features/mcp/sandbox/useMcpUiResource.ts | 80 ++++ .../mcp/sandbox/useMobileAppBridge.ts | 335 +++++++++++++++++ .../mcp/sandbox/webViewTransport.test.ts | 111 ++++++ .../features/mcp/sandbox/webViewTransport.ts | 83 +++++ apps/mobile/src/features/mcp/service.ts | 184 ++++++++++ apps/mobile/src/features/mcp/types.ts | 115 ++++++ .../features/mcp/utils/mcpToolName.test.ts | 48 +++ .../src/features/mcp/utils/mcpToolName.ts | 35 ++ .../navigation/components/NavDrawer.tsx | 27 +- apps/mobile/svg.d.ts | 8 + pnpm-lock.yaml | 332 ++++++++++++++++- 66 files changed, 4138 insertions(+), 12 deletions(-) create mode 100644 apps/mobile/assets/services/airops.png create mode 100644 apps/mobile/assets/services/atlassian.svg create mode 100644 apps/mobile/assets/services/attio.png create mode 100644 apps/mobile/assets/services/box.svg create mode 100644 apps/mobile/assets/services/browserbase.svg create mode 100644 apps/mobile/assets/services/canva.svg create mode 100644 apps/mobile/assets/services/circle.png create mode 100644 apps/mobile/assets/services/cisco_thousandeyes.png create mode 100644 apps/mobile/assets/services/clerk.svg create mode 100644 apps/mobile/assets/services/clickhouse.svg create mode 100644 apps/mobile/assets/services/cloudflare.svg create mode 100644 apps/mobile/assets/services/context7.svg create mode 100644 apps/mobile/assets/services/datadog.svg create mode 100644 apps/mobile/assets/services/figma.svg create mode 100644 apps/mobile/assets/services/firetiger.svg create mode 100755 apps/mobile/assets/services/github.svg create mode 100644 apps/mobile/assets/services/gitlab.svg create mode 100644 apps/mobile/assets/services/hex.svg create mode 100644 apps/mobile/assets/services/hubspot.svg create mode 100644 apps/mobile/assets/services/launchdarkly.png create mode 100644 apps/mobile/assets/services/linear.svg create mode 100644 apps/mobile/assets/services/monday.svg create mode 100644 apps/mobile/assets/services/neon.svg create mode 100644 apps/mobile/assets/services/notion.svg create mode 100644 apps/mobile/assets/services/pagerduty.svg create mode 100644 apps/mobile/assets/services/planetscale.svg create mode 100644 apps/mobile/assets/services/postman.svg create mode 100644 apps/mobile/assets/services/prisma.svg create mode 100644 apps/mobile/assets/services/render.svg create mode 100644 apps/mobile/assets/services/sanity.svg create mode 100644 apps/mobile/assets/services/sentry.svg create mode 100644 apps/mobile/assets/services/slack.png create mode 100644 apps/mobile/assets/services/stripe.png create mode 100644 apps/mobile/assets/services/supabase.svg create mode 100644 apps/mobile/assets/services/svelte.png create mode 100644 apps/mobile/assets/services/wix.png create mode 100644 apps/mobile/src/app/mcp-servers/add-custom.tsx create mode 100644 apps/mobile/src/app/mcp-servers/index.tsx create mode 100644 apps/mobile/src/app/mcp-servers/installation/[id].tsx create mode 100644 apps/mobile/src/app/mcp-servers/template/[id].tsx create mode 100644 apps/mobile/src/features/mcp/api.ts create mode 100644 apps/mobile/src/features/mcp/components/FloatingMcpHeader.tsx create mode 100644 apps/mobile/src/features/mcp/components/McpAppHost.tsx create mode 100644 apps/mobile/src/features/mcp/components/McpServerRow.tsx create mode 100644 apps/mobile/src/features/mcp/components/ServerIcon.tsx create mode 100644 apps/mobile/src/features/mcp/components/serverIcons.ts create mode 100644 apps/mobile/src/features/mcp/hooks.ts create mode 100644 apps/mobile/src/features/mcp/oauth.ts create mode 100644 apps/mobile/src/features/mcp/sandbox/mcpAppTheme.ts create mode 100644 apps/mobile/src/features/mcp/sandbox/sandboxProxyHtml.ts create mode 100644 apps/mobile/src/features/mcp/sandbox/useMcpUiResource.ts create mode 100644 apps/mobile/src/features/mcp/sandbox/useMobileAppBridge.ts create mode 100644 apps/mobile/src/features/mcp/sandbox/webViewTransport.test.ts create mode 100644 apps/mobile/src/features/mcp/sandbox/webViewTransport.ts create mode 100644 apps/mobile/src/features/mcp/service.ts create mode 100644 apps/mobile/src/features/mcp/types.ts create mode 100644 apps/mobile/src/features/mcp/utils/mcpToolName.test.ts create mode 100644 apps/mobile/src/features/mcp/utils/mcpToolName.ts create mode 100644 apps/mobile/svg.d.ts diff --git a/apps/mobile/assets/services/airops.png b/apps/mobile/assets/services/airops.png new file mode 100644 index 0000000000000000000000000000000000000000..a70365de09598465a48338d11b33f07e5a062b07 GIT binary patch literal 4354 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 NY^;!$ug`hh`#)7p&@uo3 literal 0 HcmV?d00001 diff --git a/apps/mobile/assets/services/atlassian.svg b/apps/mobile/assets/services/atlassian.svg new file mode 100644 index 000000000..8a9e9f402 --- /dev/null +++ b/apps/mobile/assets/services/atlassian.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/apps/mobile/assets/services/attio.png b/apps/mobile/assets/services/attio.png new file mode 100644 index 0000000000000000000000000000000000000000..6065fbe8977e185e775589bc4313a877b28e2cc4 GIT binary patch literal 5158 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 Tfh0iFc3+u%&+^{`Og8=>h#V>m literal 0 HcmV?d00001 diff --git a/apps/mobile/assets/services/box.svg b/apps/mobile/assets/services/box.svg new file mode 100644 index 000000000..5eec50e55 --- /dev/null +++ b/apps/mobile/assets/services/box.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/mobile/assets/services/browserbase.svg b/apps/mobile/assets/services/browserbase.svg new file mode 100644 index 000000000..5a168aa0e --- /dev/null +++ b/apps/mobile/assets/services/browserbase.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/mobile/assets/services/canva.svg b/apps/mobile/assets/services/canva.svg new file mode 100644 index 000000000..938fcd63e --- /dev/null +++ b/apps/mobile/assets/services/canva.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/mobile/assets/services/circle.png b/apps/mobile/assets/services/circle.png new file mode 100644 index 0000000000000000000000000000000000000000..a020b87f5ac5974cc88647b3d4a4f2a8f561f092 GIT binary patch literal 35647 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 + + + + + + diff --git a/apps/mobile/assets/services/clickhouse.svg b/apps/mobile/assets/services/clickhouse.svg new file mode 100644 index 000000000..6484dc6b0 --- /dev/null +++ b/apps/mobile/assets/services/clickhouse.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/mobile/assets/services/cloudflare.svg b/apps/mobile/assets/services/cloudflare.svg new file mode 100644 index 000000000..11ff6493a --- /dev/null +++ b/apps/mobile/assets/services/cloudflare.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/apps/mobile/assets/services/context7.svg b/apps/mobile/assets/services/context7.svg new file mode 100644 index 000000000..664bf9d94 --- /dev/null +++ b/apps/mobile/assets/services/context7.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/mobile/assets/services/datadog.svg b/apps/mobile/assets/services/datadog.svg new file mode 100644 index 000000000..bb83b6337 --- /dev/null +++ b/apps/mobile/assets/services/datadog.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/apps/mobile/assets/services/figma.svg b/apps/mobile/assets/services/figma.svg new file mode 100644 index 000000000..5c8bb0d73 --- /dev/null +++ b/apps/mobile/assets/services/figma.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/mobile/assets/services/firetiger.svg b/apps/mobile/assets/services/firetiger.svg new file mode 100644 index 000000000..d9c1a670f --- /dev/null +++ b/apps/mobile/assets/services/firetiger.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/assets/services/github.svg b/apps/mobile/assets/services/github.svg new file mode 100755 index 000000000..4f46bf357 --- /dev/null +++ b/apps/mobile/assets/services/github.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/mobile/assets/services/gitlab.svg b/apps/mobile/assets/services/gitlab.svg new file mode 100644 index 000000000..e0e22ac32 --- /dev/null +++ b/apps/mobile/assets/services/gitlab.svg @@ -0,0 +1,25 @@ + + + + Group + Created with Sketch. + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/mobile/assets/services/hex.svg b/apps/mobile/assets/services/hex.svg new file mode 100644 index 000000000..f9aeebe33 --- /dev/null +++ b/apps/mobile/assets/services/hex.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/mobile/assets/services/hubspot.svg b/apps/mobile/assets/services/hubspot.svg new file mode 100644 index 000000000..9423b8af9 --- /dev/null +++ b/apps/mobile/assets/services/hubspot.svg @@ -0,0 +1,22 @@ + + + + Group 29 + Created with Sketch. + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/mobile/assets/services/launchdarkly.png b/apps/mobile/assets/services/launchdarkly.png new file mode 100644 index 0000000000000000000000000000000000000000..75a86dda1b3abbee4a9ea31834d0ced2a7b6d6fb GIT binary patch literal 5230 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 YVR~>o)VDzo)Da=N+D2McXxs4r0qfv+7XSbN literal 0 HcmV?d00001 diff --git a/apps/mobile/assets/services/linear.svg b/apps/mobile/assets/services/linear.svg new file mode 100644 index 000000000..53d625598 --- /dev/null +++ b/apps/mobile/assets/services/linear.svg @@ -0,0 +1 @@ + diff --git a/apps/mobile/assets/services/monday.svg b/apps/mobile/assets/services/monday.svg new file mode 100644 index 000000000..c2feb2b4c --- /dev/null +++ b/apps/mobile/assets/services/monday.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/assets/services/neon.svg b/apps/mobile/assets/services/neon.svg new file mode 100644 index 000000000..eddf84052 --- /dev/null +++ b/apps/mobile/assets/services/neon.svg @@ -0,0 +1,13 @@ + + + + diff --git a/apps/mobile/assets/services/notion.svg b/apps/mobile/assets/services/notion.svg new file mode 100644 index 000000000..bf6442f76 --- /dev/null +++ b/apps/mobile/assets/services/notion.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/assets/services/pagerduty.svg b/apps/mobile/assets/services/pagerduty.svg new file mode 100644 index 000000000..477f6bb3d --- /dev/null +++ b/apps/mobile/assets/services/pagerduty.svg @@ -0,0 +1,17 @@ + + + + 216px copy + Created with Sketch. + + + + + + + + + \ No newline at end of file diff --git a/apps/mobile/assets/services/planetscale.svg b/apps/mobile/assets/services/planetscale.svg new file mode 100644 index 000000000..2f3a95c3c --- /dev/null +++ b/apps/mobile/assets/services/planetscale.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/apps/mobile/assets/services/postman.svg b/apps/mobile/assets/services/postman.svg new file mode 100644 index 000000000..87ed4ac39 --- /dev/null +++ b/apps/mobile/assets/services/postman.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/mobile/assets/services/prisma.svg b/apps/mobile/assets/services/prisma.svg new file mode 100644 index 000000000..fbf24acc7 --- /dev/null +++ b/apps/mobile/assets/services/prisma.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/mobile/assets/services/render.svg b/apps/mobile/assets/services/render.svg new file mode 100644 index 000000000..cfb5a52e5 --- /dev/null +++ b/apps/mobile/assets/services/render.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/mobile/assets/services/sanity.svg b/apps/mobile/assets/services/sanity.svg new file mode 100644 index 000000000..613194b4c --- /dev/null +++ b/apps/mobile/assets/services/sanity.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/assets/services/sentry.svg b/apps/mobile/assets/services/sentry.svg new file mode 100644 index 000000000..d81053896 --- /dev/null +++ b/apps/mobile/assets/services/sentry.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/mobile/assets/services/slack.png b/apps/mobile/assets/services/slack.png new file mode 100644 index 0000000000000000000000000000000000000000..b0342108319035b8b7799a189c9f6f1df50aad61 GIT binary patch literal 11043 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 literal 0 HcmV?d00001 diff --git a/apps/mobile/assets/services/stripe.png b/apps/mobile/assets/services/stripe.png new file mode 100644 index 0000000000000000000000000000000000000000..26850e852e7d0ca8b51078d555c76fdb7887ea17 GIT binary patch literal 1663 zcmaJ?jXx9k1NL?uN@j(zyGSpb2^}+jdZAXC?c`;*ybYssB>cu^q~(W<4AaZ8WJKPw z%*!=2o0l6!cVi_XgcR~JlQ?C3&+i|&=kvULKF=TUJU86k&S_~HYAPuyX<=N@p4&P3 zf2pf(yGEpm%XZ|tDBml{Ypt=$a4}w41L1ju@jgrzg)fluhKE7~~ zGapxd4B|vq!5ACID+`+@$}e&0plRbVKfljc1FC&`aDlp2c#=4bAldjSJHQd2{K3Fc(InHmtID3zWTnL=!Jd6I%WhiP8ruE+`DXuy3TDcb`P~Hqr{^*C zDdnZlFX6@f<8Ac?6I`F~Ecym|GuGxiJ^k%^q2K97?bBR1@v$|EK8eCW?Z;)*RX2*A z>IYjiwE6cO*RLXM4%>7-O0;8l;CSiZy}{jcQAs-Y8&Rv{rb7p~MtPfChM{JSx|_N1 zlq-)cGTuN&-KfrgF?I=dsOi8{3fYj(2=!4HZ4*2E;nwXwAk+oIExFBxqA#gm?|%uz#g34QVeJZG_^y?5mx@mo${)1T|R_|utTWu40wi|QdSXNo)%6hmLg2h*gG z7_AP=sgom;gZdUdy{u@dforyt4LKENov;FeJ#<;_ap-Qj0aCLJC4k>vZQ( zhL$)4^1)G8zuIaUf#4kBQy>@I^HDAehc!ZzqvYXJS(IIVeWe;_^z=yuUlf&*EeX`~ z(X-LZ`SnaI_c`h7lMNaB?s=_!)}nblTI&*bLw|zT6qHOe=?e+jw}cQqTLtsX zkx%6!^m2vE^T{KO^yZZUE+gwT^u_0_sK4MxlkpDC>`{^=5Cd2n&3%e5oSGRABOC6Y7`Qwufbn?2f`k@?G+Ov-eq_o88=ciee>4;s-XV-W6d zR+C^snicLbPRUYl-WuePO;BBUe(bA+jfd&;=}@7PjN^; zG=NgXwQax~lzx#vJ3eFPa-CN0R9o|VmBD^rK2g{s8Xx8*zVOtn`59h;lSI=7Ohq@x z^mt2Gk5M$9p-e{TqO7wwK5ezcuG(Fr@rTzUu`wlg3O|fE*|fn(&rRoqC~MAH5B%YN zR(ON8qVx|9T=Mh@hFxi&+>${=b6;-2qTCndOQa0M2l}^SC9i`bCMhS(Ib=*+mu}uW zDu(oDE@qI$$kOb7c!gH}?A%@)++?Rs9$5?x)LKYOwbzv7`MI{~@@6Po<`14i}C6 z51Ti^RV+8|Jd}JyT7$L{H+QclInJPI7fVK5{Z+4aq^Gqdea8H%ZztE$$mOZe47ZxK zTBh3XmGnC*-%5<+7Kd`vjoJo~*#f3TZ`!-;fej`0d)|LZd8`dFg`yDg-*@d!M&vtc uX&mttwo!lJs3t9%uKeqm8mp^(50s% + + + + + + + + + + + + diff --git a/apps/mobile/assets/services/svelte.png b/apps/mobile/assets/services/svelte.png new file mode 100644 index 0000000000000000000000000000000000000000..7008ac16e6dcb055b1fc2ea9d94ad7682a90a36f GIT binary patch literal 13718 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