diff --git a/.github/workflows/mobile-build.yml b/.github/workflows/mobile-build.yml new file mode 100644 index 000000000..1600e9e93 --- /dev/null +++ b/.github/workflows/mobile-build.yml @@ -0,0 +1,91 @@ +name: Mobile Build + +# Builds the Expo mobile app (apps/mobile) for iOS and Android on EAS. +# The two platforms run as parallel matrix jobs, so both builds are queued +# simultaneously. +# +# Required repository secret: +# EXPO_TOKEN - an Expo access token with build permissions for the +# "posthog" account (https://expo.dev/settings/access-tokens). + +on: + workflow_dispatch: + inputs: + profile: + description: "EAS build profile" + type: choice + default: production + options: + - development + - preview + - production + wait: + description: "Wait for the EAS build to finish (uncheck to just queue and exit)" + type: boolean + default: true + +concurrency: + group: mobile-build-${{ github.ref }}-${{ inputs.profile }} + cancel-in-progress: false + +jobs: + build: + name: Build (${{ matrix.platform }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + platform: [ios, android] + permissions: + contents: read + defaults: + run: + working-directory: apps/mobile + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + cache: "pnpm" + + - name: Setup EAS + uses: expo/expo-github-action@4479f9c12e08b76bb8a6ae00a31544a13d3b3d68 # v8 + with: + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + + - name: Install dependencies + run: pnpm install --frozen-lockfile + working-directory: . + + - name: EAS build + env: + PLATFORM: ${{ matrix.platform }} + PROFILE: ${{ inputs.profile }} + WAIT_FLAG: ${{ inputs.wait && '--wait' || '--no-wait' }} + run: | + eas build \ + --non-interactive \ + --platform "$PLATFORM" \ + --profile "$PROFILE" \ + "$WAIT_FLAG" + + # Auto-promote the freshly built production binaries. Only runs when the build + # job waited for completion (otherwise there is no finished build to submit). + promote: + permissions: {} + needs: build + if: ${{ inputs.profile == 'production' && inputs.wait }} + uses: ./.github/workflows/mobile-promote.yml + with: + platform: all + profile: production + secrets: inherit diff --git a/.github/workflows/mobile-promote.yml b/.github/workflows/mobile-promote.yml new file mode 100644 index 000000000..c55d44943 --- /dev/null +++ b/.github/workflows/mobile-promote.yml @@ -0,0 +1,98 @@ +name: Mobile Promote + +# Promotes the most recent finished EAS build to the stores: +# iOS -> App Store Connect (build becomes available in TestFlight) +# Android -> Google Play "internal" testing track +# +# Uses `eas submit --latest`, which submits the latest successful build for the +# selected platform/profile. Run "Mobile Build" with the matching profile first. +# +# Required repository secret: +# EXPO_TOKEN - Expo access token (submit permissions). +# +# Store credentials live on EAS, not in this repo: the iOS App Store Connect API +# key and the Android Google Play service account key are configured once via +# `eas credentials` so that `--non-interactive` submits can authenticate. + +on: + workflow_dispatch: + inputs: + platform: + description: "Platform to promote" + type: choice + default: all + options: + - all + - ios + - android + profile: + description: "EAS submit profile" + type: choice + default: production + options: + - production + # Allows "Mobile Build" to chain into this workflow after a successful build. + workflow_call: + inputs: + platform: + type: string + default: all + profile: + type: string + default: production + secrets: + EXPO_TOKEN: + required: true + +concurrency: + group: mobile-promote-${{ github.ref }} + cancel-in-progress: false + +jobs: + submit: + name: Submit (${{ matrix.platform }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + platform: ${{ inputs.platform == 'all' && fromJSON('["ios","android"]') || fromJSON(format('["{0}"]', inputs.platform)) }} + permissions: + contents: read + defaults: + run: + working-directory: apps/mobile + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + cache: "pnpm" + + - name: Setup EAS + uses: expo/expo-github-action@4479f9c12e08b76bb8a6ae00a31544a13d3b3d68 # v8 + with: + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + + - name: Install dependencies + run: pnpm install --frozen-lockfile + working-directory: . + + - name: EAS submit + env: + PLATFORM: ${{ matrix.platform }} + PROFILE: ${{ inputs.profile }} + run: | + eas submit \ + --non-interactive \ + --platform "$PLATFORM" \ + --profile "$PROFILE" \ + --latest diff --git a/.gitignore b/.gitignore index 98e90d7f6..45c7cef68 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,7 @@ plugins/posthog/local-skills/ # Symlinked copies of posthog, to make developing against those APIs easier posthog-sym + +.claude/settings.local.json + +apps/mobile/ROADMAP.md \ No newline at end of file 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 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..1336a8726 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 + +`; 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/MenuButton.tsx b/apps/mobile/src/features/navigation/components/MenuButton.tsx new file mode 100644 index 000000000..b123197d7 --- /dev/null +++ b/apps/mobile/src/features/navigation/components/MenuButton.tsx @@ -0,0 +1,27 @@ +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..a60541053 --- /dev/null +++ b/apps/mobile/src/features/navigation/components/NavDrawer.tsx @@ -0,0 +1,375 @@ +import { Text } from "@components/text"; +import { usePathname, useRouter } from "expo-router"; +import { + CaretRight, + Clock, + GearSix, + ListBullets, + PuzzlePiece, + Tray, +} from "phosphor-react-native"; +import { memo, type ReactNode, useEffect, useMemo, useState } from "react"; +import { + Dimensions, + 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"; +import { useTasks } from "@/features/tasks/hooks/useTasks"; +import { useArchivedTasksStore } from "@/features/tasks/stores/archivedTasksStore"; +import { useNetworkStatus } from "@/hooks/useNetworkStatus"; +import { useThemeColors } from "@/lib/theme"; +import { useNavDrawerStore } from "../stores/navDrawerStore"; +import { SwipeableArchivedDrawerRow } from "./SwipeableArchivedDrawerRow"; + +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; + label: string; + active?: boolean; + onPress: () => void; +} + +function DrawerItem({ icon, label, active, onPress }: DrawerItemProps) { + return ( + + + {icon} + + + {label} + + + ); +} + +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 { tasks } = useTasks({ originProduct: "user_created" }); + const { archivedTasks, unarchive } = useArchivedTasksStore(); + const [archivedExpanded, setArchivedExpanded] = useState(false); + + const { activeTasks, archivedTaskList } = useMemo(() => { + const active: typeof tasks = []; + const archived: typeof tasks = []; + for (const task of tasks) { + if (task.id in archivedTasks) { + archived.push(task); + } else { + active.push(task); + } + } + archived.sort( + (a, b) => (archivedTasks[b.id] ?? 0) - (archivedTasks[a.id] ?? 0), + ); + return { activeTasks: active, archivedTaskList: archived }; + }, [tasks, archivedTasks]); + + const navigateTo = (target: string) => { + close(); + if (pathname === target) return; + router.replace(target); + }; + + const handleTasks = () => navigateTo("/tasks"); + const handleInbox = () => navigateTo("/inbox"); + const handleAutomations = () => navigateTo("/automations"); + // 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 handleMcpServers = () => { + close(); + if (pathname === "/mcp-servers") return; + router.push("/mcp-servers"); + }; + const handleHome = () => navigateTo("/tasks"); + + const handleTaskPress = (taskId: string) => { + close(); + router.push(`/task/${taskId}`); + }; + + 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"; + const isOnMcpServers = pathname === "/mcp-servers"; + + return ( + + + PostHog Code + + + + + } + label="Tasks" + active={isOnTasks} + onPress={handleTasks} + /> + + } + label="Inbox" + active={isOnInbox} + onPress={handleInbox} + /> + + } + label="Automations" + active={isOnAutomations} + onPress={handleAutomations} + /> + + } + label="MCP servers" + active={isOnMcpServers} + onPress={handleMcpServers} + /> + + + + + + + Tasks + + + + + {activeTasks.length === 0 && archivedTaskList.length === 0 ? ( + + No tasks yet + + ) : ( + <> + {activeTasks.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} + + + ); + })} + + {archivedTaskList.length > 0 && ( + + setArchivedExpanded((prev) => !prev)} + className="flex-row items-center gap-2 rounded-md px-3 py-2 active:bg-gray-2" + > + + + Archived + + + {archivedTaskList.length} + + + + {archivedExpanded && + archivedTaskList.map((task) => { + const taskHref = `/task/${task.id}`; + return ( + + ); + })} + + )} + + )} + + + + + + + } + 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. */} + + + + + + + + ); +} diff --git a/apps/mobile/src/features/navigation/components/SwipeableArchivedDrawerRow.tsx b/apps/mobile/src/features/navigation/components/SwipeableArchivedDrawerRow.tsx new file mode 100644 index 000000000..4bb258387 --- /dev/null +++ b/apps/mobile/src/features/navigation/components/SwipeableArchivedDrawerRow.tsx @@ -0,0 +1,132 @@ +import { Text } from "@components/text"; +import * as Haptics from "expo-haptics"; +import { ArrowCounterClockwise } from "phosphor-react-native"; +import { useEffect, useRef } from "react"; +import { + Animated, + Easing, + LayoutAnimation, + PanResponder, + Pressable, + View, +} from "react-native"; +import { TaskStatusIcon } from "@/features/tasks/components/TaskStatusIcon"; +import type { Task } from "@/features/tasks/types"; +import { useThemeColors } from "@/lib/theme"; + +const SWIPE_THRESHOLD = 60; + +interface SwipeableArchivedDrawerRowProps { + task: Task; + active: boolean; + onPress: (taskId: string) => void; + onUnarchive: (taskId: string) => void; +} + +export function SwipeableArchivedDrawerRow({ + task, + active, + onPress, + onUnarchive, +}: SwipeableArchivedDrawerRowProps) { + const themeColors = useThemeColors(); + const translateX = useRef(new Animated.Value(0)).current; + const actionTriggeredRef = useRef(false); + + const propsRef = useRef({ task, onUnarchive }); + propsRef.current = { task, onUnarchive }; + + useEffect(() => { + translateX.setValue(0); + actionTriggeredRef.current = false; + }, [translateX]); + + const panResponder = useRef( + PanResponder.create({ + onStartShouldSetPanResponder: () => false, + onMoveShouldSetPanResponder: (_, gesture) => + Math.abs(gesture.dx) > 5 && + Math.abs(gesture.dx) > Math.abs(gesture.dy) && + gesture.dx < 0, + onMoveShouldSetPanResponderCapture: (_, gesture) => + Math.abs(gesture.dx) > 8 && + Math.abs(gesture.dx) > Math.abs(gesture.dy * 1.2) && + gesture.dx < 0, + onPanResponderTerminationRequest: () => false, + onShouldBlockNativeResponder: () => true, + onPanResponderGrant: () => { + actionTriggeredRef.current = false; + }, + onPanResponderMove: (_, gesture) => { + translateX.setValue(gesture.dx > 0 ? 0 : gesture.dx); + }, + onPanResponderRelease: (_, gesture) => { + const p = propsRef.current; + if (gesture.dx < -SWIPE_THRESHOLD && !actionTriggeredRef.current) { + actionTriggeredRef.current = true; + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + Animated.timing(translateX, { + toValue: -400, + duration: 150, + easing: Easing.in(Easing.ease), + useNativeDriver: true, + }).start(() => { + LayoutAnimation.configureNext( + LayoutAnimation.Presets.easeInEaseOut, + ); + p.onUnarchive(p.task.id); + }); + } else { + Animated.spring(translateX, { + toValue: 0, + useNativeDriver: true, + tension: 40, + friction: 8, + }).start(); + } + }, + onPanResponderTerminate: () => { + Animated.spring(translateX, { + toValue: 0, + useNativeDriver: true, + }).start(); + }, + }), + ).current; + + return ( + + + + Unarchive + + + + onPress(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} + + + + + ); +} 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/apps/mobile/src/features/notifications/lib/notifications.ts b/apps/mobile/src/features/notifications/lib/notifications.ts new file mode 100644 index 000000000..780dc5196 --- /dev/null +++ b/apps/mobile/src/features/notifications/lib/notifications.ts @@ -0,0 +1,175 @@ +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 interface NotificationTapPayload { + /** App-relative path to navigate to (e.g. "/task/abc"). */ + path: string; +} + +export type NotificationResponseHandler = ( + payload: NotificationTapPayload, +) => 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", + }); + } + + 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; + } + + 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"); + 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 extractTapPayload( + response: Notifications.NotificationResponse, +): NotificationTapPayload | null { + const data = response.notification.request.content.data as + | { url?: unknown; taskId?: unknown; taskRunId?: unknown } + | undefined; + 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; + } + + if (typeof data.taskId === "string") { + return { path: paths.task(data.taskId) }; + } + + return null; +} + +/** + * 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 payload = extractTapPayload(response); + if (payload) onTap(payload); + }) + .catch((err) => { + log.warn("Failed to read last notification response", { error: err }); + }); + + const subscription = Notifications.addNotificationResponseReceivedListener( + (response) => { + const payload = extractTapPayload(response); + if (payload) onTap(payload); + }, + ); + + 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..4f334f258 --- /dev/null +++ b/apps/mobile/src/features/notifications/stores/pushTokenStore.ts @@ -0,0 +1,100 @@ +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) return; + + try { + await registerPushToken({ token, platform: Platform.OS }); + await writeSecure(LAST_UPLOADED_KEY, token); + set({ lastUploadedToken: token }); + } catch (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), + }); + } + }, + + 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..7be276e1a 100644 --- a/apps/mobile/src/features/preferences/stores/preferencesStore.ts +++ b/apps/mobile/src/features/preferences/stores/preferencesStore.ts @@ -2,27 +2,77 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; +export type ThemePreference = "light" | "dark" | "system"; + +export type CompletionSound = + | "meep" + | "knock" + | "ring" + | "shoot" + | "slide" + | "drop"; + +export type InitialTaskMode = "plan" | "last_used"; + interface PreferencesState { - aiChatEnabled: boolean; - setAiChatEnabled: (enabled: boolean) => void; pingsEnabled: boolean; 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; } export const usePreferencesStore = create()( persist( (set) => ({ - aiChatEnabled: false, - setAiChatEnabled: (enabled) => set({ aiChatEnabled: enabled }), pingsEnabled: true, setPingsEnabled: (enabled) => set({ pingsEnabled: enabled }), + 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 }), }), { name: "posthog-preferences", storage: createJSONStorage(() => AsyncStorage), partialize: (state) => ({ - aiChatEnabled: state.aiChatEnabled, pingsEnabled: state.pingsEnabled, + pushNotificationsEnabled: state.pushNotificationsEnabled, + theme: state.theme, + completionSound: state.completionSound, + completionVolume: state.completionVolume, + defaultInitialTaskMode: state.defaultInitialTaskMode, + lastNewTaskMode: state.lastNewTaskMode, }), }, ), diff --git a/apps/mobile/src/features/settings/components/DebugInfoSection.tsx b/apps/mobile/src/features/settings/components/DebugInfoSection.tsx new file mode 100644 index 000000000..8f1dda91e --- /dev/null +++ b/apps/mobile/src/features/settings/components/DebugInfoSection.tsx @@ -0,0 +1,117 @@ +import { Text } from "@components/text"; +import * as Application from "expo-application"; +import * as Clipboard from "expo-clipboard"; +import Constants from "expo-constants"; +import * as Haptics from "expo-haptics"; +import { Copy } from "phosphor-react-native"; +import { useState } from "react"; +import { Platform, Pressable, View } from "react-native"; +import { useThemeColors } from "@/lib/theme"; +import { SettingsRow } from "./SettingsRow"; +import { SettingsSection } from "./SettingsSection"; + +interface DebugInfoSectionProps { + cloudRegion: string | null; + projectId: number | null; + userId?: number; + userUuid?: string; +} + +/** + * Staff-only diagnostics shown at the bottom of Settings. Surfaces the build / + * version identifiers we need to tell exactly which binary a user is running + * (native app version + build number, runtime version, EAS project, execution + * environment) plus the active region/project/user. Gate rendering on + * `is_staff` at the call site — this component does not check itself. + */ +export function DebugInfoSection({ + cloudRegion, + projectId, + userId, + userUuid, +}: DebugInfoSectionProps) { + const themeColors = useThemeColors(); + const [copied, setCopied] = useState(false); + + const appVersion = + Application.nativeApplicationVersion ?? + Constants.expoConfig?.version ?? + "—"; + const buildVersion = Application.nativeBuildVersion ?? "—"; + const bundleId = Application.applicationId ?? "—"; + const runtimeVersion = + typeof Constants.expoConfig?.runtimeVersion === "string" + ? Constants.expoConfig.runtimeVersion + : "—"; + const easProjectId = + (Constants.expoConfig?.extra?.eas?.projectId as string | undefined) ?? "—"; + const platform = `${Platform.OS} ${String(Platform.Version)}`; + const executionEnv = Constants.executionEnvironment ?? "—"; + const buildType = __DEV__ ? "development" : "production"; + + // Build-number label differs by platform (iOS buildNumber vs Android + // versionCode) — name it so staff aren't guessing which they're reading. + const buildLabel = + Platform.OS === "android" ? "Version code" : "Build number"; + + const rows: Array<{ label: string; value: string }> = [ + { label: "App version", value: appVersion }, + { label: buildLabel, value: buildVersion }, + { label: "Runtime version", value: runtimeVersion }, + { label: "Build type", value: buildType }, + { label: "Execution env", value: executionEnv }, + { label: "Platform", value: platform }, + { label: "Bundle ID", value: bundleId }, + { label: "EAS project", value: easProjectId }, + { label: "Region", value: cloudRegion?.toUpperCase() ?? "—" }, + { label: "Project ID", value: projectId != null ? String(projectId) : "—" }, + { label: "User ID", value: userId != null ? String(userId) : "—" }, + { label: "User UUID", value: userUuid ?? "—" }, + ]; + + const handleCopy = async () => { + const payload = rows.map((r) => `${r.label}: ${r.value}`).join("\n"); + await Clipboard.setStringAsync(payload); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch( + () => {}, + ); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + + return ( + + {rows.map((row, index) => ( + + {row.value} + + } + /> + ))} + + + + + {copied ? "Copied!" : "Copy debug info"} + + + + + ); +} 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/api.automations.test.ts b/apps/mobile/src/features/tasks/api.automations.test.ts new file mode 100644 index 000000000..ea9a06834 --- /dev/null +++ b/apps/mobile/src/features/tasks/api.automations.test.ts @@ -0,0 +1,275 @@ +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", + template_id: "llm-skill:shared-daily-brief", + 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, + template_id: "llm-skill:shared-daily-brief", + }); + + 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, + template_id: "llm-skill:shared-daily-brief", + }), + }), + ); + }); + + it("serializes skill-backed automation payloads with a prefixed template id", async () => { + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + ...automationPayload, + id: "automation-2", + name: "Shared daily brief", + template_id: "llm-skill:shared-daily-brief", + }), + { status: 200 }, + ), + ); + + await createTaskAutomation({ + name: "Shared daily brief", + prompt: "Summarize feature usage for my product areas.", + repository: "posthog/posthog", + github_integration: 7, + cron_expression: "0 8 * * 1-5", + timezone: "America/New_York", + enabled: true, + template_id: "llm-skill:shared-daily-brief", + }); + + expect(mockFetch).toHaveBeenCalledWith( + "https://app.posthog.test/api/projects/42/task_automations/", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + name: "Shared daily brief", + prompt: "Summarize feature usage for my product areas.", + repository: "posthog/posthog", + github_integration: 7, + cron_expression: "0 8 * * 1-5", + timezone: "America/New_York", + enabled: true, + template_id: "llm-skill:shared-daily-brief", + }), + }), + ); + }); + + 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("surfaces skill-backed 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: "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: "llm-skill:shared-daily-brief", + }), + ).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( + 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..d09d2afc5 100644 --- a/apps/mobile/src/features/tasks/api.ts +++ b/apps/mobile/src/features/tasks/api.ts @@ -1,12 +1,22 @@ import { fetch } from "expo/fetch"; -import { getBaseUrl, getHeaders, getProjectId } from "@/lib/api"; +import { + createTimeoutSignal, + getAccessToken, + getBaseUrl, + getHeaders, + getProjectId, +} from "@/lib/api"; import { logger } from "@/lib/logger"; import type { + CreateTaskAutomationOptions, CreateTaskOptions, Integration, StoredLogEntry, Task, + TaskAutomation, TaskRun, + UpdateTaskAutomationOptions, + UserGithubIntegration, } from "./types"; const log = logger.scope("tasks-api"); @@ -21,6 +31,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 +123,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 +136,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 +153,7 @@ export async function getTasks(filters?: { ); } - const data = await response.json(); + const data = await parseJsonResponse<{ results?: Task[] }>(response); return data.results ?? []; } @@ -117,7 +175,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 +346,7 @@ export async function createTask(options: CreateTaskOptions): Promise { ); } - return await response.json(); + return await parseJsonResponse(response); } export async function updateTask( @@ -172,7 +374,7 @@ export async function updateTask( ); } - return await response.json(); + return await parseJsonResponse(response); } export async function deleteTask(taskId: string): Promise { @@ -202,6 +404,18 @@ 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" | "auto". */ + initialPermissionMode?: string; + /** Source that triggered this run. */ + runSource?: "manual" | "signal_report"; + /** Signal report ID when run_source is "signal_report". */ + signalReportId?: string; } export async function runTaskInCloud( @@ -220,7 +434,13 @@ 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 || + options.runSource !== undefined || + options.signalReportId !== undefined); let body: string | undefined; if (hasOptions) { @@ -234,6 +454,19 @@ 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; + } + if (options?.runSource) payload.run_source = options.runSource; + if (options?.signalReportId) + payload.signal_report_id = options.signalReportId; body = JSON.stringify(payload); } @@ -414,30 +647,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: createTimeoutSignal(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(); @@ -456,8 +746,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; @@ -510,3 +805,119 @@ export async function getGithubRepositories( offset += repos.length; } } + +export interface GithubUserConnectResult { + install_url: string; + connect_flow?: "oauth_authorize" | "oauth_discover" | "app_install"; +} + +/** + * Starts the user-scoped GitHub connection flow (mirrors desktop). The backend + * picks the lightweight OAuth flow when the team already has the GitHub App + * installed, otherwise a discover/install flow, and returns the URL to open. + * + * `connect_from: "posthog_mobile"` tells the backend to redirect the OAuth + * callback to `posthog://github/callback` so the in-app browser auto-closes. + */ +export async function startGithubUserIntegrationConnect(): Promise { + const baseUrl = getBaseUrl(); + const projectId = getProjectId(); + const headers = getHeaders(); + + const response = await fetch( + `${baseUrl}/api/users/@me/integrations/github/start/`, + { + method: "POST", + headers, + body: JSON.stringify({ + team_id: projectId, + connect_from: "posthog_mobile", + }), + }, + ); + + if (!response.ok) { + const payload = (await response.json().catch(() => ({}))) as { + detail?: unknown; + }; + const detail = + typeof payload.detail === "string" + ? payload.detail + : "Failed to start GitHub connection"; + throw new HttpError(response.status, response.statusText, detail); + } + + return parseJsonResponse(response); +} + +export async function getUserGithubIntegrations(): Promise< + UserGithubIntegration[] +> { + const baseUrl = getBaseUrl(); + const headers = getHeaders(); + + const response = await fetch(`${baseUrl}/api/users/@me/integrations/`, { + headers, + }); + + if (!response.ok) { + throw new HttpError( + response.status, + response.statusText, + "Failed to fetch personal GitHub integrations", + ); + } + + const data = await parseJsonResponse<{ results?: UserGithubIntegration[] }>( + response, + ); + return (data.results ?? []).filter((i) => i.kind === "github"); +} + +export async function getUserGithubRepositories( + installationId: string, +): Promise { + const baseUrl = getBaseUrl(); + const headers = getHeaders(); + + const allRepos: string[] = []; + let offset = 0; + + while (true) { + const params = new URLSearchParams({ + limit: String(GITHUB_REPOS_PAGE_SIZE), + offset: String(offset), + }); + const response = await fetch( + `${baseUrl}/api/users/@me/integrations/github/${installationId}/repos/?${params}`, + { headers }, + ); + + if (!response.ok) { + throw new HttpError( + response.status, + response.statusText, + "Failed to fetch repositories", + ); + } + + const data = await response.json(); + const repos: Array = + data.repositories ?? data.results ?? data ?? []; + + const normalized = repos + .map((repo) => { + if (typeof repo === "string") return repo.toLowerCase(); + return (repo.full_name ?? repo.name ?? "").toLowerCase(); + }) + .filter((name) => name.length > 0); + + allRepos.push(...normalized); + + if (!data.has_more || repos.length === 0) { + return allRepos; + } + + offset += repos.length; + } +} 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..6838b4076 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/AutomationDetail.tsx @@ -0,0 +1,140 @@ +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 { + automation: TaskAutomation; + lastTaskRunStatus?: TaskRun["status"] | null; + isWorking?: boolean; + onRunNow: () => void; + onToggleEnabled: () => void; + onEdit: () => void; + onDelete: () => void; +} + +export function AutomationDetail({ + automation, + lastTaskRunStatus, + isWorking = false, + onRunNow, + onToggleEnabled, + onEdit, + onDelete, +}: AutomationDetailProps) { + const presentation = getAutomationTemplatePresentation(automation); + + return ( + + + + {automation.name} + + + + + + + {presentation.templateName && ( + + Template + + {presentation.templateName} + + + )} + {presentation.repositoryLabel ? ( + + Repository + + {presentation.repositoryLabel} + + + ) : presentation.contextLabel ? ( + + Context + + {presentation.contextLabel} + + + ) : null} + + 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.test.tsx b/apps/mobile/src/features/tasks/components/AutomationForm.test.tsx new file mode 100644 index 000000000..b53f959db --- /dev/null +++ b/apps/mobile/src/features/tasks/components/AutomationForm.test.tsx @@ -0,0 +1,263 @@ +import { createElement } from "react"; +import { TextInput } from "react-native"; +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("../composer/RepositoryPickerInline", () => ({ + RepositoryPickerInline: (props: Record) => + createElement("RepositoryPickerInline", props), +})); + +vi.mock("./ScheduleEditor", () => ({ + ScheduleEditor: (props: Record) => + createElement("ScheduleEditor", props), +})); + +vi.mock("@/features/chat/components/MarkdownText", () => ({ + MarkdownText: (props: Record) => + createElement("MarkdownText", props), +})); + +import { AutomationForm } from "./AutomationForm"; + +describe("AutomationForm", () => { + it("submits successfully when repository selection is optional", 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"); + } + + // Repository picker is only mounted when `repositoryRequired` is true. + expect(renderer.root.findAllByType("RepositoryPickerInline")).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("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, + 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 repositoryPicker = renderer.root.findByType("RepositoryPickerInline"); + + // The new picker emits `RepositoryOption` objects (with the integration + // label too) rather than the raw `RepositorySelection` shape used by + // the old inline list. + act(() => { + repositoryPicker.props.onChange({ + integrationId: 7, + integrationLabel: "PostHog", + 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 new file mode 100644 index 000000000..d6f076ef8 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/AutomationForm.tsx @@ -0,0 +1,519 @@ +import { Text } from "@components/text"; +import { CaretDown, GithubLogo } from "phosphor-react-native"; +import { type MutableRefObject, useEffect, useMemo, useState } from "react"; +import { + ActivityIndicator, + Pressable, + Switch, + TextInput, + View, +} from "react-native"; +import { MarkdownText } from "@/features/chat/components/MarkdownText"; +import { useThemeColors } from "@/lib/theme"; +import { RepositoryPickerInline } from "../composer/RepositoryPickerInline"; +import { useIntegrations } from "../hooks/useIntegrations"; +import type { + CreateTaskAutomationOptions, + RepositorySelection, +} from "../types"; +import { + type AutomationScheduleDraft, + buildCronExpression, + createDefaultScheduleDraft, + deriveAutomationName, + parseCronExpression, +} from "../utils/automationSchedule"; +import { + findRepositoryOption, + isRepositorySelectionComplete, + toRepositorySelection, +} from "../utils/repositorySelection"; +import { GitHubConnectionPrompt } from "./GitHubConnectionPrompt"; +import { GitHubLoadNotice } from "./GitHubLoadNotice"; +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; + repositoryRequired?: boolean; + initialPromptMode?: "edit" | "preview"; + /** When true, suppress the built-in Cancel + Submit row. The parent + * screen is then responsible for rendering its own footer and + * triggering submission via `submitRef`. Used by screens that want a + * floating action button anchored to the screen instead of an inline + * footer that scrolls with the form. */ + hideFooter?: boolean; + /** Mutable ref that the form populates with its internal submit handler. + * Lets a parent screen trigger validation+submission from a button + * rendered outside the form's tree (e.g. a screen-anchored FAB). + * Only meaningful alongside `hideFooter`. */ + submitRef?: MutableRefObject<(() => void) | null>; + /** Fires whenever the form's derived `canSubmit` flag changes, so the + * parent can mirror the disabled/enabled state on an external button. */ + onCanSubmitChange?: (canSubmit: boolean) => void; +} + +export function AutomationForm({ + initialValues, + isSubmitting, + submitLabel, + fieldError, + generalError, + onSubmit, + onCancel, + repositoryRequired = true, + initialPromptMode = "edit", + hideFooter = false, + submitRef, + onCanSubmitChange, +}: AutomationFormProps) { + const themeColors = useThemeColors(); + const { + error, + hasGithubIntegration, + repositoryOptions, + repositoryWarning, + isLoading, + isRefreshingInBackground, + refetch, + } = useIntegrations({ enabled: repositoryRequired }); + + 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); + const [promptMode, setPromptMode] = useState<"edit" | "preview">( + initialPromptMode, + ); + const [repoPickerOpen, setRepoPickerOpen] = 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 + : repositoryRequired && + 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, + repositoryRequired, + timezone, + ], + ); + + const canSubmit = + !!name.trim() && + !!prompt.trim() && + !!timezone.trim() && + (!repositoryRequired || + isRepositorySelectionComplete(repositorySelection)) && + !isSubmitting; + const repositoryLoadBlocked = + repositoryRequired && !!repositoryWarning && repositoryOptions.length === 0; + + const selectedRepositoryOption = useMemo( + () => findRepositoryOption(repositoryOptions, repositorySelection), + [repositoryOptions, repositorySelection], + ); + + // Disambiguate repos that exist across multiple integrations by appending + // the integration label — matches the new-task screen's pill behaviour. + const repositoryPillLabel = useMemo(() => { + if (!selectedRepositoryOption) return "Select repository…"; + const sameRepoCount = repositoryOptions.filter( + (option) => option.repository === selectedRepositoryOption.repository, + ).length; + return sameRepoCount > 1 + ? `${selectedRepositoryOption.repository} · ${selectedRepositoryOption.integrationLabel}` + : selectedRepositoryOption.repository; + }, [repositoryOptions, selectedRepositoryOption]); + + const handleSubmit = async () => { + setHasAttemptedSubmit(true); + if (!canSubmit) { + return; + } + + await onSubmit({ + name: name.trim(), + prompt: prompt.trim(), + repository: repositoryRequired + ? (repositorySelection.repository ?? "") + : "", + github_integration: repositoryRequired + ? repositorySelection.integrationId + : null, + cron_expression: buildCronExpression(scheduleDraft), + timezone: timezone.trim(), + enabled, + }); + }; + + // Expose the submit handler to a parent screen that wants to render its + // own footer (e.g. a floating action button). We re-assign on every + // render so the ref always points at the latest closure — the handler + // captures current form state via the surrounding `useState` values. + useEffect(() => { + if (!submitRef) return; + submitRef.current = handleSubmit; + return () => { + if (submitRef.current === handleSubmit) submitRef.current = null; + }; + }); + + // Mirror `canSubmit` to the parent so an external button can disable + // itself the moment the form becomes invalid. + useEffect(() => { + onCanSubmitChange?.(canSubmit); + }, [onCanSubmitChange, canSubmit]); + + if (repositoryRequired && isLoading && hasGithubIntegration === null) { + return ( + + + + Loading repositories... + + + ); + } + + if (repositoryRequired && (error || repositoryLoadBlocked)) { + return ( + + ); + } + + if (repositoryRequired && hasGithubIntegration === false) { + return ( + + ); + } + + return ( + + {/* 1. Name — first config field. Auto-populates from the prompt until + the user edits it manually. */} + + + Name + + { + setHasEditedName(true); + setName(nextName); + }} + /> + {validationErrors.name && ( + + {validationErrors.name} + + )} + + + {/* 2. Repository — pill trigger + inline dropdown. Mirrors the + new-task screen so the picker UX is consistent across the app. */} + {repositoryRequired && ( + + {repositoryWarning && ( + + + + )} + + Repository + + setRepoPickerOpen((prev) => !prev)} + accessibilityRole="button" + accessibilityLabel="Select repository" + className={`flex-row items-center gap-2 rounded-xl border px-3.5 py-3 active:bg-gray-3 ${ + repoPickerOpen + ? "border-accent-7 bg-accent-3" + : "border-gray-5 bg-background" + }`} + > + + + {repositoryPillLabel} + + + + + {/* Inline dropdown — same component used by the new-task screen. + Renders directly below the pill, so the form pushes content + down rather than popping a modal. The picker self-unmounts + once its exit animation finishes, so we only need the + wrapper margin while it's open. */} + + + setRepositorySelection(toRepositorySelection(option)) + } + onClose={() => setRepoPickerOpen(false)} + /> + + + {validationErrors.repository && ( + + {validationErrors.repository} + + )} + + )} + + {/* 3. Schedule */} + + + {(validationErrors.cronExpression || validationErrors.timezone) && ( + + {validationErrors.cronExpression || validationErrors.timezone} + + )} + + + {/* 4. Enabled */} + + + + Enabled + + + Turn this off to pause scheduled runs without deleting it. + + + + + + {/* 5. Prompt — the "skill" content. Placed last so the configuration + (name, repo, schedule, enabled) is visible above the fold and the + potentially-long prompt sits at the bottom for editing/preview. */} + + + 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} + + )} + + + {generalError && ( + + {generalError} + + )} + + {!hideFooter && ( + + {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..5ce8a7fe8 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/AutomationItem.tsx @@ -0,0 +1,70 @@ +import { Text } from "@components/text"; +import { format, formatDistanceToNow } from "date-fns"; +import { memo } from "react"; +import { 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 AutomationItemProps { + automation: TaskAutomation; + onPress: (automation: TaskAutomation) => void; + lastTaskRunStatus?: TaskRun["status"] | null; +} + +function AutomationItemComponent({ + automation, + onPress, + lastTaskRunStatus, +}: AutomationItemProps) { + const presentation = getAutomationTemplatePresentation(automation); + 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} + + + + + + + + {presentation.secondaryLabel} + + + {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..31a582196 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/AutomationList.tsx @@ -0,0 +1,130 @@ +import { Text } from "@components/text"; +import { Plus } from "phosphor-react-native"; +import { + ActivityIndicator, + FlatList, + Pressable, + RefreshControl, + View, +} from "react-native"; +import { useThemeColors } from "@/lib/theme"; +import { useAutomations } from "../hooks/useAutomations"; +import { useTasks } from "../hooks/useTasks"; +import type { TaskAutomation } from "../types"; +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({ + onCreateAutomation, +}: Pick) { + const themeColors = useThemeColors(); + + return ( + + + No automations yet + + + Schedule recurring tasks + + {onCreateAutomation && ( + + + + New automation + + + )} + + ); +} + +export function AutomationList({ + onAutomationPress, + onCreateAutomation, + contentInsetTop = 0, +}: AutomationListProps) { + const { automations, isLoading, error, refetch } = useAutomations(); + const { allTasks: automationTasks } = useTasks({ + originProduct: "automation", + }); + const themeColors = useThemeColors(); + + const handleRefresh = async () => { + await refetch(); + }; + + const handleAutomationPress = (automation: TaskAutomation) => { + onAutomationPress?.(automation.id); + }; + + const taskStatusById = new Map( + automationTasks.map((task) => [task.id, task.latest_run?.status ?? null]), + ); + + if (error) { + return ( + + {error} + + Retry + + + ); + } + + if (isLoading && automations.length === 0) { + return ( + + + Loading automations... + + ); + } + + if (automations.length === 0) { + return ; + } + + return ( + item.id} + renderItem={({ item }) => ( + + )} + refreshControl={ + + } + contentContainerStyle={{ + paddingTop: contentInsetTop, + paddingBottom: 100, + }} + /> + ); +} 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/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 new file mode 100644 index 000000000..970de00d6 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/AutomationStatusBadge.tsx @@ -0,0 +1,44 @@ +import { Text } from "@components/text"; +import { View } from "react-native"; +import type { TaskRun } from "../types"; +import { getAutomationStatusPresentation } from "../utils/automationStatus"; + +interface AutomationStatusBadgeProps { + enabled: boolean; + lastRunStatus: string | null; + lastTaskRunStatus?: TaskRun["status"] | null; +} + +export function AutomationStatusBadge({ + enabled, + lastRunStatus, + lastTaskRunStatus, +}: AutomationStatusBadgeProps) { + const runStatus = getAutomationStatusPresentation({ + lastRunStatus, + lastTaskRunStatus, + }); + + return ( + + + + {enabled ? "Enabled" : "Paused"} + + + {runStatus ? ( + + + {runStatus.label} + + + ) : null} + + ); +} 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/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/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 + + + ); +} 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..a4b40ac97 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/FloatingNewTaskButton.tsx @@ -0,0 +1,41 @@ +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/FloatingTaskHeader.tsx b/apps/mobile/src/features/tasks/components/FloatingTaskHeader.tsx new file mode 100644 index 000000000..c12189fc1 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/FloatingTaskHeader.tsx @@ -0,0 +1,100 @@ +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 { Platform, 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(); + }; + + // 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 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 ( + + + + + + + + + + + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} + + + + {rightSlot} + + + + ); +} 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..e3e0297c6 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/FloatingTasksHeader.tsx @@ -0,0 +1,69 @@ +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; + showFilter?: boolean; +} + +/** + * 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, + showFilter = true, +}: FloatingTasksHeaderProps) { + const insets = useSafeAreaInsets(); + const themeColors = useThemeColors(); + + const fadeHeight = insets.top + 88; + + return ( + + + + + + + + + PostHog Code + + + + {showFilter ? ( + + ) : ( + + )} + + + ); +} 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..296c8ead4 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/GitHubConnectionPrompt.tsx @@ -0,0 +1,117 @@ +import { Text } from "@components/text"; +import * as WebBrowser from "expo-web-browser"; +import { Pressable, View } from "react-native"; +import { useAuthStore } from "@/features/auth"; +import { logger } from "@/lib/logger"; +import { useThemeColors } from "@/lib/theme"; +import { startGithubUserIntegrationConnect } from "../api"; + +const log = logger.scope("github-connection-prompt"); + +interface GitHubConnectionPromptProps { + onConnected?: () => void; + mode?: "card" | "empty"; + title?: string; + description?: string; + /** + * Which GitHub integration to create: + * - `"user"` (default): the per-user flow (matches desktop) for interactive + * task creation — detected via `/api/users/@me/integrations/`. + * - `"team"`: the environment-level flow for automations, which run + * server-side and need a team integration. + */ + scope?: "user" | "team"; +} + +export function GitHubConnectionPrompt({ + onConnected, + mode = "card", + title = "Connect GitHub to continue", + description = "You need to connect your GitHub account before using this workflow.", + scope = "user", +}: GitHubConnectionPromptProps) { + const { cloudRegion, projectId, getCloudUrlFromRegion } = useAuthStore(); + const themeColors = useThemeColors(); + + const handleConnectGitHub = async () => { + if (!cloudRegion || !projectId) { + return; + } + + let authorizeUrl: string; + if (scope === "user") { + // Per-user flow (like desktop): the backend picks the right GitHub flow + // and, because we pass `connect_from: "posthog_mobile"`, redirects the + // callback to `posthog://github/callback` so this in-app browser closes. + try { + const { install_url } = await startGithubUserIntegrationConnect(); + authorizeUrl = install_url; + } catch (error) { + log.error("Failed to start GitHub connection", { error }); + return; + } + } else { + // Team/environment flow for automations: creates an environment-scoped + // integration that `useIntegrations` detects. + const baseUrl = getCloudUrlFromRegion(cloudRegion); + 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/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/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 new file mode 100644 index 000000000..ead34a002 --- /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/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/SwipeableTaskItem.tsx b/apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx index 244c2dfb1..2cb20a1ec 100644 --- a/apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx +++ b/apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx @@ -21,6 +21,9 @@ interface SwipeableTaskItemProps { onPress: (task: Task) => void; onArchive: (taskId: string) => void; onUnarchive: (taskId: string) => void; + onLongPress?: (task: Task) => void; + selectionMode?: boolean; + selected?: boolean; onSwipeStart?: () => void; onSwipeEnd?: () => void; } @@ -31,6 +34,9 @@ export function SwipeableTaskItem({ onPress, onArchive, onUnarchive, + onLongPress, + selectionMode = false, + selected = false, onSwipeStart, onSwipeEnd, }: SwipeableTaskItemProps) { @@ -46,6 +52,7 @@ export function SwipeableTaskItem({ isArchived, onArchive, onUnarchive, + selectionMode, onSwipeStart, onSwipeEnd, }); @@ -54,6 +61,7 @@ export function SwipeableTaskItem({ isArchived, onArchive, onUnarchive, + selectionMode, onSwipeStart, onSwipeEnd, }; @@ -69,11 +77,13 @@ export function SwipeableTaskItem({ // Start tracking immediately on horizontal movement onStartShouldSetPanResponder: () => false, onMoveShouldSetPanResponder: (_, gesture) => + !propsRef.current.selectionMode && Math.abs(gesture.dx) > 5 && Math.abs(gesture.dx) > Math.abs(gesture.dy) && gesture.dx < 0, // Capture before children so FlatList doesn't steal onMoveShouldSetPanResponderCapture: (_, gesture) => + !propsRef.current.selectionMode && Math.abs(gesture.dx) > 8 && Math.abs(gesture.dx) > Math.abs(gesture.dy * 1.2) && gesture.dx < 0, @@ -153,7 +163,13 @@ export function SwipeableTaskItem({ }} {...panResponder.panHandlers} > - + ); 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..81c0d8bde --- /dev/null +++ b/apps/mobile/src/features/tasks/components/TaskFilterMenu.tsx @@ -0,0 +1,174 @@ +import { Text } from "@components/text"; +import { Check, FunnelSimple } from "phosphor-react-native"; +import { useState } from "react"; +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"; +import { + type OrganizeMode, + type SortMode, + useTaskStore, +} from "../stores/taskStore"; + +interface TaskFilterMenuProps { + open: boolean; + onClose: () => void; +} + +function SectionHeader({ title }: { title: string }) { + return ( + + {title} + + ); +} + +interface OptionRowProps { + label: string; + selected: boolean; + onPress: () => void; +} + +function OptionRow({ label, selected, onPress }: OptionRowProps) { + const themeColors = useThemeColors(); + return ( + + {label} + {selected && } + + ); +} + +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); + 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 pickOrganize = (mode: OrganizeMode) => { + setOrganizeMode(mode); + }; + const pickSort = (mode: SortMode) => { + setSortMode(mode); + }; + + return ( + + + {/* Header */} + + + Filter & Sort + + + + Done + + + + + + {/* Organize */} + + + pickOrganize("by-project")} + /> + pickOrganize("chronological")} + /> + + + {/* Sort by */} + + + pickSort("created")} + /> + pickSort("updated")} + /> + + + {/* Task visibility (staff only) */} + {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 e067abda7..715c70ebb 100644 --- a/apps/mobile/src/features/tasks/components/TaskItem.tsx +++ b/apps/mobile/src/features/tasks/components/TaskItem.tsx @@ -1,31 +1,28 @@ import { Text } from "@components/text"; import { differenceInHours, format, formatDistanceToNow } from "date-fns"; +import { Check } from "phosphor-react-native"; import { memo } from "react"; import { Pressable, View } from "react-native"; +import { useThemeColors } from "@/lib/theme"; import type { Task } from "../types"; +import { TaskStatusIcon } from "./TaskStatusIcon"; interface TaskItemProps { task: Task; onPress: (task: Task) => void; + onLongPress?: (task: Task) => void; + selectionMode?: boolean; + selected?: boolean; } -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) { +function TaskItemComponent({ + task, + onPress, + onLongPress, + selectionMode = false, + selected = false, +}: TaskItemProps) { + const themeColors = useThemeColors(); const createdAt = new Date(task.created_at); const hoursSinceCreated = differenceInHours(new Date(), createdAt); const timeDisplay = @@ -33,68 +30,51 @@ 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" + onLongPress={onLongPress ? () => onLongPress(task) : undefined} + delayLongPress={300} + className={`flex-row items-start gap-3 border-gray-6 border-b px-3 py-3 ${selected ? "bg-accent-3" : "active:bg-gray-3"}`} > - - {/* Slug */} - {task.slug} - - {/* Status Badge */} - - - {statusDisplayMap[status] || status} - - - - {/* Environment badge */} - {environment === "cloud" && ( - - Cloud - - )} - {environment === "local" && ( - - Local + {/* Status icon column (or selection checkbox in selection mode) */} + + {selectionMode ? ( + + {selected ? : null} + ) : ( + )} - {/* Title */} - - {task.title} - - - {/* Description preview */} - {task.description && ( - - {task.description} - - )} + {/* Content column */} + + + + {task.title} + + + {timeDisplay} + + - {/* 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..403d9c683 100644 --- a/apps/mobile/src/features/tasks/components/TaskList.tsx +++ b/apps/mobile/src/features/tasks/components/TaskList.tsx @@ -1,7 +1,7 @@ import { Text } from "@components/text"; -import * as WebBrowser from "expo-web-browser"; -import { CaretRight } from "phosphor-react-native"; -import { useMemo, useState } from "react"; +import * as Haptics from "expo-haptics"; +import { Archive, GitBranch, Plus, Sparkle, X } from "phosphor-react-native"; +import { useCallback, useMemo, useState } from "react"; import { ActivityIndicator, FlatList, @@ -9,73 +9,21 @@ 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 { useUserIntegrations } from "../hooks/useUserIntegrations"; 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 { onTaskPress?: (taskId: string) => void; 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 - - - - ); + /** Top inset so the list can scroll behind a floating header. */ + contentInsetTop?: number; } interface CreateTaskEmptyStateProps { @@ -86,24 +34,28 @@ function CreateTaskEmptyState({ onCreateTask }: CreateTaskEmptyStateProps) { const themeColors = useThemeColors(); return ( - - - + + + - - No tasks yet + + Start your first task - - Create your first task to get PostHog working. + + Describe what you want built, fixed, or investigated. {onCreateTask && ( - - Create task + + + New task )} @@ -112,65 +64,176 @@ function CreateTaskEmptyState({ onCreateTask }: CreateTaskEmptyStateProps) { } type ListItem = - | { type: "task"; task: Task; isArchived: boolean } - | { type: "archived-header"; count: number; expanded: boolean }; + | { type: "task"; task: Task } + | { type: "repo-header"; repoLabel: string; count: number } + | { type: "date-header"; label: string; count: number }; + +const NO_REPO_LABEL = "No repository"; + +function relativeDateGroup(ms: number): string { + const startOfToday = new Date(); + startOfToday.setHours(0, 0, 0, 0); + const startOfDate = new Date(ms); + startOfDate.setHours(0, 0, 0, 0); + const days = Math.round( + (startOfToday.getTime() - startOfDate.getTime()) / 86_400_000, + ); + if (days <= 0) return "Today"; + if (days === 1) return "Yesterday"; + if (days < 7) return "This week"; + if (days < 30) return "This month"; + return "Earlier"; +} + +const DATE_GROUP_ORDER = [ + "Today", + "Yesterday", + "This week", + "This month", + "Earlier", +]; -export function TaskList({ onTaskPress, onCreateTask }: TaskListProps) { - const { tasks, isLoading, error, refetch } = useTasks(); - const { hasGithubIntegration, refetch: refetchIntegrations } = - useIntegrations(); +export function TaskList({ + onTaskPress, + onCreateTask, + contentInsetTop = 0, +}: TaskListProps) { + const { tasks, isLoading, error, refetch } = useTasks({ + originProduct: "user_created", + }); + const { + error: integrationsError, + hasGithubIntegration, + refetch: refetchIntegrations, + } = useUserIntegrations(); const themeColors = useThemeColors(); - const { archivedTasks, archive, unarchive } = useArchivedTasksStore(); - const [archivedExpanded, setArchivedExpanded] = useState(false); + const { archivedTasks, archive, archiveMany, unarchive } = + useArchivedTasksStore(); + const organizeMode = useTaskStore((s) => s.organizeMode); + const sortMode = useTaskStore((s) => s.sortMode); const [scrollEnabled, setScrollEnabled] = useState(true); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const selectionMode = selectedIds.size > 0; + + const exitSelection = useCallback(() => { + setSelectedIds(new Set()); + }, []); + + const toggleSelected = useCallback((taskId: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(taskId)) { + next.delete(taskId); + } else { + next.add(taskId); + } + return next; + }); + }, []); + const handleTaskPress = (task: Task) => { + if (selectionMode) { + toggleSelected(task.id); + return; + } onTaskPress?.(task.id); }; + const handleTaskLongPress = useCallback((task: Task) => { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + setSelectedIds((prev) => { + if (prev.has(task.id)) return prev; + const next = new Set(prev); + next.add(task.id); + return next; + }); + }, []); + + const handleBulkArchive = useCallback(() => { + if (selectedIds.size === 0) return; + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + archiveMany(Array.from(selectedIds)); + exitSelection(); + }, [selectedIds, archiveMany, exitSelection]); + const handleRefresh = async () => { await Promise.all([refetch(), refetchIntegrations()]); }; const listItems = useMemo((): ListItem[] => { - const active: Task[] = []; - const archived: Task[] = []; + const active = tasks.filter((task) => !(task.id in archivedTasks)); + const items: ListItem[] = []; - for (const task of tasks) { - if (task.id in archivedTasks) { - archived.push(task); - } else { - active.push(task); + if (organizeMode === "by-project") { + 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 archived by FIFO (earliest archived first) - archived.sort( - (a, b) => (archivedTasks[a.id] ?? 0) - (archivedTasks[b.id] ?? 0), - ); + for (const tasksInRepo of groups.values()) { + tasksInRepo.sort( + (a, b) => + taskActivityTimestamp(b, sortMode) - + taskActivityTimestamp(a, sortMode), + ); + } - const items: ListItem[] = active.map((task) => ({ - type: "task", - task, - isArchived: false, - })); - - if (archived.length > 0) { - items.push({ - type: "archived-header", - count: archived.length, - expanded: archivedExpanded, + 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) + ); }); - if (archivedExpanded) { - for (const task of archived) { - items.push({ type: "task", task, isArchived: true }); + 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 }); + } + } + } else { + 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 }); } } } return items; - }, [tasks, archivedTasks, archivedExpanded]); + }, [tasks, archivedTasks, organizeMode, sortMode]); if (error) { return ( @@ -186,7 +249,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); @@ -200,67 +278,137 @@ export function TaskList({ onTaskPress, onCreateTask }: TaskListProps) { ); } - // No GitHub connection and no tasks - prompt to connect GitHub - if (hasGithubIntegration === false && tasks.length === 0) { - return ; + const activeTaskCount = tasks.reduce( + (count, task) => count + (task.id in archivedTasks ? 0 : 1), + 0, + ); + + if (hasGithubIntegration === false && activeTaskCount === 0) { + return ; } - // Has GitHub connection but no tasks - prompt to create first task - if (tasks.length === 0) { + if (activeTaskCount === 0) { return ; } return ( - - item.type === "archived-header" - ? "__archived_header__" - : `${item.task.id}-${item.isArchived ? "a" : "v"}` - } - renderItem={({ item }) => { - if (item.type === "archived-header") { + + { + switch (item.type) { + case "repo-header": + return `__repo__${item.repoLabel}`; + case "date-header": + return `__date__${item.label}`; + case "task": + return item.task.id; + } + }} + ListHeaderComponent={ + integrationsError ? ( + + ) : null + } + renderItem={({ item }) => { + if (item.type === "repo-header") { + return ( + + + + {item.repoLabel} + + {item.count} + + ); + } + + if (item.type === "date-header") { + return ( + + + {item.label} + + {item.count} + + ); + } + return ( - setArchivedExpanded(!item.expanded)} - className="flex-row items-center gap-2 border-gray-6 border-t bg-gray-2 px-3 py-2.5" - > - - - Archived - - {item.count} - + setScrollEnabled(false)} + onSwipeEnd={() => setScrollEnabled(true)} + /> ); + }} + refreshControl={ + } + contentContainerStyle={{ + paddingTop: contentInsetTop, + paddingBottom: 100, + }} + /> - return ( - setScrollEnabled(false)} - onSwipeEnd={() => setScrollEnabled(true)} - /> - ); - }} - refreshControl={ - - } - contentContainerStyle={{ paddingBottom: 100 }} - /> + {selectionMode ? ( + + + + + + {selectedIds.size} selected + + + + + Archive + + + + ) : null} + ); } 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 4c5a75fd3..1b0050333 100644 --- a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx +++ b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx @@ -20,8 +20,16 @@ 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 type { + CloudPendingPermissionRequest, + PlanEntry, + SessionEvent, + SessionNotification, + SessionNotificationAttachment, +} from "../types"; +import { PlanApprovalCard } from "./PlanApprovalCard"; import { PlanStatusBar } from "./PlanStatusBar"; import { QuestionCard } from "./QuestionCard"; @@ -35,6 +43,7 @@ interface PermissionResponseArgs { interface TaskSessionViewProps { events: SessionEvent[]; + pendingPermissions?: Record; isConnecting?: boolean; isThinking?: boolean; terminalStatus?: "failed" | "completed"; @@ -47,6 +56,7 @@ interface TaskSessionViewProps { interface ToolData { toolName: string; + rawToolName?: string; toolCallId: string; status: ToolStatus; args?: Record; @@ -62,6 +72,7 @@ interface ParsedMessage { ts?: number; toolData?: ToolData; children?: ParsedMessage[]; + attachments?: SessionNotificationAttachment[]; } function mapToolStatus( @@ -82,7 +93,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 +113,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; @@ -131,6 +160,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 +175,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 +207,36 @@ 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; +} + +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 { @@ -235,14 +296,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; }; @@ -283,6 +356,7 @@ function processNewEvents( type: "user", content: parsed.content ?? "", ts: event.ts, + attachments: parsed.attachments, }); state.lastAgentMsgIdx = null; break; @@ -293,7 +367,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" @@ -302,6 +376,7 @@ function processNewEvents( if (!state.messages[state.lastAgentMsgIdx].ts) { state.messages[state.lastAgentMsgIdx].ts = event.ts; } + hasItemMutation = true; state.pendingAgentText = ""; state.pendingAgentTs = undefined; } else { @@ -385,22 +460,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 + + + )} + )} - + ); } @@ -568,7 +679,10 @@ function AgentToolCard({ { const interval = setInterval(() => { @@ -612,13 +727,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 +753,9 @@ function ThinkingIndicator() { } function ConnectingIndicator() { - const themeColors = useThemeColors(); const [dots, setDots] = useState(1); const elapsed = useElapsedTimer(); + const themeColors = useThemeColors(); useEffect(() => { const interval = setInterval(() => { @@ -660,6 +783,7 @@ function ConnectingIndicator() { export function TaskSessionView({ events, + pendingPermissions, isConnecting, isThinking, terminalStatus, @@ -694,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; @@ -714,25 +843,48 @@ export function TaskSessionView({ const reversedMessages = useMemo(() => [...messages].reverse(), [messages]); const themeColors = useThemeColors(); const flatListRef = useRef(null); - const buttonRef = useRef(null); - const isScrolledRef = useRef(false); + 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. + // 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 + // 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); }, [], ); @@ -741,7 +893,13 @@ export function TaskSessionView({ ({ item }: { item: ParsedMessage }) => { switch (item.type) { case "user": - return ; + return ( + + ); case "agent": return ( ; case "tool": if (!item.toolData) return null; + if ( + isPlanApprovalTool( + item.toolData, + pendingPermissions?.[item.toolData.toolCallId], + ) + ) { + return ( + + ); + } if (isQuestionTool(item.toolData)) { return ( item.id} inverted - contentContainerStyle={contentContainerStyle} + contentContainerStyle={effectiveContentContainerStyle} keyboardDismissMode="interactive" keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator @@ -845,10 +1020,13 @@ export function TaskSessionView({ ) : null } /> - {/* Thinking/connecting indicators absolutely positioned above the Composer area. - Rendered outside FlatList to avoid inverted-list double-mount bugs. */} - {(isConnecting || isThinking) && ( - + {/* 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 ? ( @@ -856,15 +1034,10 @@ export function TaskSessionView({ ) : null} )} - + {scrolledFromBottom && ( - + )} ); } 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 new file mode 100644 index 000000000..06c992f04 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/TaskStatusIcon.tsx @@ -0,0 +1,80 @@ +import { + ChatCircle, + CheckCircle, + CircleIcon, + CircleNotch, + GitPullRequest, + XCircle, +} from "phosphor-react-native"; +import { memo, useEffect, useRef } from "react"; +import { Animated, Easing } from "react-native"; +import { useThemeColors } from "@/lib/theme"; +import type { Task } from "../types"; +import { getTaskStatusIconKind } from "./taskStatusIconKind"; + +interface TaskStatusIconProps { + task: Task; + size?: number; +} + +function TaskStatusIconComponent({ task, size = 16 }: TaskStatusIconProps) { + const colors = useThemeColors(); + const iconKind = getTaskStatusIconKind(task); + + const rotation = useRef(new Animated.Value(0)).current; + const isRunning = iconKind === "running"; + + useEffect(() => { + 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]); + + if (iconKind === "pr") { + return ( + + ); + } + + if (iconKind === "completed") { + return ( + + ); + } + + if (iconKind === "failed") { + return ; + } + + if (iconKind === "running") { + const spin = rotation.interpolate({ + inputRange: [0, 1], + outputRange: ["0deg", "360deg"], + }); + return ( + + + + ); + } + + if (iconKind === "started") { + 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"; +} 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..84643ec3e --- /dev/null +++ b/apps/mobile/src/features/tasks/composer/DotBackground.tsx @@ -0,0 +1,23 @@ +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/RepositoryPickerInline.tsx b/apps/mobile/src/features/tasks/composer/RepositoryPickerInline.tsx new file mode 100644 index 000000000..e953df43b --- /dev/null +++ b/apps/mobile/src/features/tasks/composer/RepositoryPickerInline.tsx @@ -0,0 +1,287 @@ +import { Text } from "@components/text"; +import { ArrowsClockwise, Check, MagnifyingGlass } from "phosphor-react-native"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { + ActivityIndicator, + FlatList, + Pressable, + ScrollView, + TextInput, + View, +} from "react-native"; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; +import type { RepositoryOption } from "@/features/tasks/types"; +import { useThemeColors } from "@/lib/theme"; + +// Tuning for the nested (ScrollView) path's progressive mount. The first +// chunk needs to cover the rows the user can actually see (~5 with the +// 240px height cap) plus a small buffer so a quick flick scroll doesn't +// hit empty space. Subsequent chunks fill in over the next few frames. +const NESTED_INITIAL_RENDER = 30; +const NESTED_RENDER_CHUNK = 100; +const NESTED_RENDER_INTERVAL_MS = 16; + +interface RepositoryPickerInlineProps { + open: boolean; + repositoryOptions: RepositoryOption[]; + selected: RepositoryOption | null; + /** True when we have no data to show yet (no cache, fresh fetch in flight). + * Mutually exclusive with `isRefreshing` from the caller's perspective. */ + loading?: boolean; + /** True when we're rendering cached data while a background refetch runs. + * Surfaces as a small spinner badge instead of a blocking state. */ + isRefreshing?: boolean; + /** Set when this picker lives inside a parent `ScrollView`/`FlatList` of + * the same orientation (e.g. the automation form). Disables the + * internal `FlatList` and uses a plain `ScrollView` instead, since + * React Native warns about nested VirtualizedLists. Default `false` + * keeps `FlatList`'s windowing so opening with many repos is instant. */ + nested?: boolean; + onChange: (option: RepositoryOption) => void; + onClose: () => void; +} + +/** + * Inline repository picker that pops up above the composer with a quick + * fade + lift animation. Lives in the screen tree (no Modal) so it stays + * with the layout when the keyboard moves, and feels more like a dropdown + * than a bottom sheet. Tap a row to select; tap outside the card (handled + * by the caller's backdrop) or onClose to dismiss. + */ +export function RepositoryPickerInline({ + open, + repositoryOptions, + selected, + loading, + isRefreshing, + nested = false, + onChange, + onClose, +}: RepositoryPickerInlineProps) { + const themeColors = useThemeColors(); + const [search, setSearch] = useState(""); + const searchInputRef = useRef(null); + + // Reset the search on each open so the user starts with the full list. + useEffect(() => { + if (open) { + setSearch(""); + // Focusing the search lets the user type immediately. Slight delay + // gives the popover its animation frame so the keyboard rise feels + // synced with the popover entrance. + const t = setTimeout(() => searchInputRef.current?.focus(), 80); + return () => clearTimeout(t); + } + }, [open]); + + // Entrance/exit animation — fade + small upward translate so it reads as + // a dropdown popping out of the pill rather than a slide-in sheet. + const progress = useSharedValue(0); + useEffect(() => { + progress.value = withTiming(open ? 1 : 0, { + duration: open ? 160 : 120, + easing: Easing.out(Easing.cubic), + }); + }, [open, progress]); + + const cardStyle = useAnimatedStyle(() => ({ + opacity: progress.value, + transform: [{ translateY: (1 - progress.value) * 8 }], + })); + + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return repositoryOptions; + return repositoryOptions.filter( + (option) => + option.repository.toLowerCase().includes(q) || + option.integrationLabel.toLowerCase().includes(q), + ); + }, [repositoryOptions, search]); + + // Keep the popover mounted briefly after `open` flips to false so the + // exit animation can play. Unmount after the animation duration. + const [mounted, setMounted] = useState(open); + useEffect(() => { + if (open) { + setMounted(true); + return; + } + const t = setTimeout(() => setMounted(false), 140); + return () => clearTimeout(t); + }, [open]); + + // Progressive row mount for the nested (ScrollView) path. Without this, + // opening the picker on a screen with hundreds of repos would block the + // entrance animation while every row materializes synchronously. We + // mount a small first batch (enough to fill the visible area), then + // grow the slice every frame until the list is complete. The FlatList + // path doesn't need this because `initialNumToRender` already windows + // its initial mount. + const [nestedRenderedCount, setNestedRenderedCount] = useState( + NESTED_INITIAL_RENDER, + ); + // Reset whenever the picker reopens or the filtered set changes (e.g. + // the user typed a query, or new data arrived from a background + // refetch). Reusing a stale `renderedCount` across filter changes would + // either show too few rows (after broadening the filter) or be wasted. + useEffect(() => { + if (!nested) return; + if (!open) return; + setNestedRenderedCount(NESTED_INITIAL_RENDER); + }, [nested, open]); + useEffect(() => { + if (!nested) return; + if (!open) return; + if (nestedRenderedCount >= filtered.length) return; + const t = setTimeout(() => { + setNestedRenderedCount((current) => + Math.min(current + NESTED_RENDER_CHUNK, filtered.length), + ); + }, NESTED_RENDER_INTERVAL_MS); + return () => clearTimeout(t); + }, [nested, open, nestedRenderedCount, filtered.length]); + + // Hoisted row renderer so both the ScrollView and FlatList paths share + // identical row markup without duplicating the closure. + const renderRow = (item: RepositoryOption) => { + const isSelected = + item.integrationId === selected?.integrationId && + item.repository === selected.repository; + return ( + { + onChange(item); + onClose(); + }} + className={`flex-row items-center gap-2 px-3 py-2.5 active:bg-gray-2 ${ + isSelected ? "bg-accent-3" : "" + }`} + > + + + {item.repository} + + + {item.integrationLabel} + + + {isSelected ? ( + + ) : null} + + ); + }; + + if (!mounted) return null; + + return ( + + {/* Header: title + search + background-refresh indicator */} + + + + Repository + + + {isRefreshing ? ( + + + Refreshing… + + ) : null} + + Done + + + + + + + + + + {loading ? ( + + + + Loading repositories… + + + ) : filtered.length === 0 ? ( + + + {search + ? `No repositories match “${search}”` + : "No repositories available"} + + + ) : nested ? ( + // ScrollView path — used when the picker lives inside a parent + // ScrollView (e.g. the automation form). Plain `.map()` because + // RN warns about nested VirtualizedLists. Rows are mounted + // progressively via `nestedRenderedCount` so opening with many + // repos doesn't block the entrance animation. + + {filtered + .slice(0, nestedRenderedCount) + .map((item) => renderRow(item))} + + ) : ( + // FlatList path — default. Windowing keeps the open animation + // snappy even when the user has hundreds of repos, by only + // mounting the visible rows up front. + `${item.integrationId}:${item.repository}`} + keyboardShouldPersistTaps="handled" + style={{ maxHeight: 240 }} + contentContainerStyle={{ paddingVertical: 4 }} + // Tuned so the first mount only pays for the rows the user + // actually sees, then fills in lazily as they scroll. + initialNumToRender={12} + maxToRenderPerBatch={20} + windowSize={5} + removeClippedSubviews + renderItem={({ item }) => renderRow(item)} + /> + )} + + ); +} 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/TaskChatComposer.tsx b/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx new file mode 100644 index 000000000..40ba430e7 --- /dev/null +++ b/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx @@ -0,0 +1,422 @@ +import * as Haptics from "expo-haptics"; +import { + ArrowUp, + BrainIcon, + Microphone, + PaperclipIcon, + PauseIcon, + PencilIcon, + Robot, + ShieldCheck, + Sparkle, + Stop, +} from "phosphor-react-native"; +import { + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import { + ActivityIndicator, + Animated, + Easing, + Keyboard, + Pressable, + ScrollView, + TextInput, + 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, + 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"; + +const log = logger.scope("task-chat-composer"); + +interface TaskChatComposerProps { + onSend: (message: string, attachments: PendingAttachment[]) => void; + onStop?: () => void; + disabled?: boolean; + placeholder?: string; + initialMessage?: 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 ; + case "auto": + 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", + initialMessage, + isUserTurn = false, + mode, + model, + reasoning, + onModeChange, + onModelChange, + onReasoningChange, +}: TaskChatComposerProps) { + const themeColors = useThemeColors(); + 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)); + }, []); + + const { status, startRecording, stopRecording, cancelRecording } = + useVoiceRecording({ onTranscript: appendTranscript }); + + 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 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 (!hasContent || disabled) return; + setMessage(""); + setAttachments([]); + Keyboard.dismiss(); + 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 () => { + if (isRecording) { + await stopRecording(); + } else if (!isTranscribing) { + await startRecording(); + } + }; + + const handleMicLongPress = async () => { + if (isRecording) { + await cancelRecording(); + } + }; + + const handleStop = () => { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + onStop?.(); + }; + + return ( + <> + + + + + + + + + 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"} + /> + + + + 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: , + }))} + /> + + setAttachmentSheetOpen(false)} + onPickPhoto={() => addAttachment(pickPhotoFromLibrary)} + onPickCamera={() => addAttachment(captureFromCamera)} + onPickDocument={() => addAttachment(pickDocument)} + /> + + ); +} + +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/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..b3e220f16 --- /dev/null +++ b/apps/mobile/src/features/tasks/composer/attachments/pickers.ts @@ -0,0 +1,159 @@ +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; +} + +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( + "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 ImagePicker = await loadImagePicker(); + if (!ImagePicker) return null; + + 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 DocumentPicker = await loadDocumentPicker(); + if (!DocumentPicker) return null; + + 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/composer/options.ts b/apps/mobile/src/features/tasks/composer/options.ts new file mode 100644 index 000000000..885554cf3 --- /dev/null +++ b/apps/mobile/src/features/tasks/composer/options.ts @@ -0,0 +1,88 @@ +export type ExecutionMode = "default" | "acceptEdits" | "plan" | "auto"; +export type ReasoningEffort = "low" | "medium" | "high" | "xhigh" | "max"; + +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", + }, + { + value: "auto", + label: "Auto", + description: "Model decides which prompts to approve or deny", + }, +]; + +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" }, + { value: "xhigh", label: "Extra High" }, + { value: "max", label: "Max" }, +]; + +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/features/tasks/hooks/useAutomations.test.ts b/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts new file mode 100644 index 000000000..93c4cf1dc --- /dev/null +++ b/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts @@ -0,0 +1,292 @@ +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, + getAutomationPollingInterval, + 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", + template_id: "llm-skill:shared-daily-brief", + 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("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"); + 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", + template_id: "llm-skill:shared-daily-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: "llm-skill:shared-daily-brief", + }); + 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 * * *", + template_id: "llm-skill:shared-daily-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: "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: "llm-skill:shared-daily-brief", + }), + ).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/useAutomations.ts b/apps/mobile/src/features/tasks/hooks/useAutomations.ts new file mode 100644 index 000000000..e22d3e6d1 --- /dev/null +++ b/apps/mobile/src/features/tasks/hooks/useAutomations.ts @@ -0,0 +1,168 @@ +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"); +const ACTIVE_AUTOMATION_POLLING_INTERVAL_MS = 5_000; + +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, +}; + +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, +) { + 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, + refetchInterval: (query) => + getAutomationPollingInterval( + query.state.data as TaskAutomation[] | undefined, + ), + }); + + 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, + refetchInterval: (query) => + getAutomationPollingInterval( + query.state.data as TaskAutomation | undefined, + ), + }); +} + +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..070fb37c4 --- /dev/null +++ b/apps/mobile/src/features/tasks/hooks/useIntegrations.test.ts @@ -0,0 +1,206 @@ +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 { useRepositoryCacheStore } from "../stores/repositoryCacheStore"; +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(); + useRepositoryCacheStore.setState({ options: [], updatedAt: null }); + }); + + 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(); + }); + + 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 50e988299..49c163878 100644 --- a/apps/mobile/src/features/tasks/hooks/useIntegrations.ts +++ b/apps/mobile/src/features/tasks/hooks/useIntegrations.ts @@ -1,6 +1,34 @@ import { useQuery } from "@tanstack/react-query"; +import { useEffect, useMemo } from "react"; import { useAuthStore } from "@/features/auth"; import { getGithubRepositories, getIntegrations } from "../api"; +import { useRepositoryCacheStore } from "../stores/repositoryCacheStore"; +import type { RepositoryOption } from "../types"; +import { buildRepositoryOptions } from "../utils/repositorySelection"; + +/** Cheap content-equality check for repository option lists. Lets the cache + * write effect skip no-op updates, which is what kept retriggering renders + * before — `buildRepositoryOptions` always returns a fresh array, so the + * effect's dep array churned every render. */ +function repositoryOptionsEqual( + a: RepositoryOption[], + b: RepositoryOption[], +): boolean { + if (a === b) return true; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + const left = a[i]; + const right = b[i]; + if ( + left.integrationId !== right.integrationId || + left.repository !== right.repository || + left.integrationLabel !== right.integrationLabel + ) { + return false; + } + } + return true; +} export const integrationKeys = { all: ["integrations"] as const, @@ -10,19 +38,34 @@ export const integrationKeys = { [...integrationKeys.all, "repos", integrationId] as const, }; -export function useIntegrations() { +interface RepositoryLoadResult { + repositoriesByIntegration: Record; + partialError: string | null; +} + +interface UseIntegrationsOptions { + enabled?: boolean; +} + +export function useIntegrations(options: UseIntegrationsOptions = {}) { + const { enabled = true } = options; const { projectId, oauthAccessToken } = useAuthStore(); + // Persisted snapshot from the last successful fetch. Survives app launches + // so the picker can render instantly while we refetch in the background. + const cachedOptions = useRepositoryCacheStore((s) => s.options); + const setCachedOptions = useRepositoryCacheStore((s) => s.setOptions); + const integrationsQuery = useQuery({ queryKey: integrationKeys.github(), queryFn: async () => { 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: [ @@ -30,33 +73,135 @@ 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, + enabled: enabled && githubIntegrations.length > 0, }); + const repositoriesByIntegration = + repositoriesQuery.data?.repositoriesByIntegration ?? {}; + const repositories = Object.values(repositoriesByIntegration).flat().sort(); + + // Memoize the derived options list keyed on the underlying query data so + // its reference is stable across renders when the data hasn't actually + // changed. Without this, every render produces a fresh array — which both + // churns the cache-write effect below AND defeats downstream React.memo / + // useMemo callers that depend on `repositoryOptions`. + const liveRepositoryOptions = useMemo( + () => buildRepositoryOptions(githubIntegrations, repositoriesByIntegration), + [githubIntegrations, repositoriesByIntegration], + ); + + // Mirror the latest successful fetch into the persisted cache so the next + // cold start can render the picker instantly. We sync whatever the live + // result is — including an empty array — but only once both queries have + // succeeded, so a transient fetch failure or in-flight refresh can't wipe + // out a working snapshot. Compare contents before writing so we don't + // bump `updatedAt` (and re-render every cache subscriber) on no-op syncs. + const integrationsSettled = + integrationsQuery.isFetched && !integrationsQuery.isError; + // biome-ignore lint/correctness/useExhaustiveDependencies: setCachedOptions is a stable Zustand action + useEffect(() => { + if (!enabled) return; + if (!integrationsSettled) return; + if (githubIntegrations.length === 0) { + // No integrations — clear the cache so the picker doesn't surface + // stale repos for a connection the user has since removed. + if (cachedOptions.length > 0) setCachedOptions([]); + return; + } + if (!repositoriesQuery.isSuccess) return; + // Skip the write when the cache already matches — otherwise every + // render that produces a structurally-equal options list (e.g. after + // an unrelated re-render of the consumer) would push a new `updatedAt`, + // re-trigger every cache subscriber, and the consumer's `useEffect` + // would fire again on the new reference. Infinite loop. + if (repositoryOptionsEqual(liveRepositoryOptions, cachedOptions)) return; + setCachedOptions(liveRepositoryOptions); + }, [ + enabled, + integrationsSettled, + githubIntegrations.length, + repositoriesQuery.isSuccess, + liveRepositoryOptions, + cachedOptions, + ]); + + // Prefer live data once it's in; fall back to the persisted snapshot so + // consumers always have *something* to render on cold start. + const repositoryOptions = + liveRepositoryOptions.length > 0 ? liveRepositoryOptions : cachedOptions; + const repositoryWarning = repositoriesQuery.data?.partialError ?? null; + const hasCachedRepositories = cachedOptions.length > 0; + 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 + : // If we have cached repos we know there's at least one integration, + // so don't gate the screen on the integrations query. + hasCachedRepositories + ? true + : null, githubIntegrations, - repositories: repositoriesQuery.data ?? [], - isLoading: integrationsQuery.isLoading || repositoriesQuery.isLoading, - error: - integrationsQuery.error?.message ?? - repositoriesQuery.error?.message ?? - null, + repositories, + repositoriesByIntegration, + repositoryOptions, + /** True iff we have cached options but the live fetch is still running. + * Lets the UI render the cached list while showing a subtle background + * refresh indicator instead of a blocking spinner. */ + isRefreshingInBackground: + enabled && + hasCachedRepositories && + (integrationsQuery.isLoading || repositoriesQuery.isLoading), + /** Only true when we have nothing to show yet — no cache, no live data. + * Consumers should treat this as "block the screen on a spinner"; + * background refreshes don't count. */ + isLoading: enabled + ? !hasCachedRepositories && + (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/hooks/usePrChangedFiles.ts b/apps/mobile/src/features/tasks/hooks/usePrChangedFiles.ts new file mode 100644 index 000000000..ff757b07d --- /dev/null +++ b/apps/mobile/src/features/tasks/hooks/usePrChangedFiles.ts @@ -0,0 +1,66 @@ +import { useQuery } from "@tanstack/react-query"; +import { logger } from "@/lib/logger"; + +const log = logger.scope("usePrChangedFiles"); + +export type ChangedFileStatus = + | "added" + | "removed" + | "modified" + | "renamed" + | "copied" + | "changed" + | "unchanged"; + +export interface ChangedFile { + filename: string; + status: ChangedFileStatus; + additions: number; + deletions: number; + previous_filename?: string; + patch?: string; +} + +function parsePrUrl(prUrl: string) { + 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 prChangedFilesKeys = { + byUrl: (prUrl: string) => ["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 new file mode 100644 index 000000000..0c4ed080e --- /dev/null +++ b/apps/mobile/src/features/tasks/hooks/usePrStatus.ts @@ -0,0 +1,71 @@ +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; + additions: number; + deletions: number; +} + +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; + 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); + return null; + } + }, + }); +} 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 340d83ef7..1af7d5aa8 100644 --- a/apps/mobile/src/features/tasks/hooks/useTasks.ts +++ b/apps/mobile/src/features/tasks/hooks/useTasks.ts @@ -13,20 +13,54 @@ 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, 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 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; +}) { const { projectId, oauthAccessToken } = useAuthStore(); const { data: currentUser } = useUserQuery(); - const { orderBy, orderDirection, filter } = useTaskStore(); + const { sortMode, showInternal, filter } = useTaskStore(); const queryFilters = { ...filters, @@ -37,18 +71,26 @@ export function useTasks(filters?: { repository?: string }) { queryKey: taskKeys.list(queryFilters), queryFn: () => getTasks(queryFilters), enabled: !!projectId && !!oauthAccessToken && !!currentUser?.id, + refetchInterval: (query) => + getTaskPollingInterval(query.state.data as Task[] | undefined), }); + // Mobile never runs tasks locally — hide desktop-only local runs so the + // mobile list mirrors what's actually shareable across devices. + const cloudTasks = (query.data ?? []).filter( + (task) => task.latest_run?.environment !== "local", + ); + const filteredTasks = filterAndSortTasks( - query.data ?? [], - orderBy, - orderDirection, + cloudTasks, + sortMode, + showInternal, filter, ); return { tasks: filteredTasks, - allTasks: query.data ?? [], + allTasks: cloudTasks, isLoading: query.isLoading, error: query.error?.message ?? null, refetch: query.refetch, @@ -62,6 +104,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/hooks/useUserIntegrations.ts b/apps/mobile/src/features/tasks/hooks/useUserIntegrations.ts new file mode 100644 index 000000000..68ae4f980 --- /dev/null +++ b/apps/mobile/src/features/tasks/hooks/useUserIntegrations.ts @@ -0,0 +1,140 @@ +import { useQuery } from "@tanstack/react-query"; +import { useCallback, useMemo } from "react"; +import { useAuthStore } from "@/features/auth"; +import { getUserGithubIntegrations, getUserGithubRepositories } from "../api"; +import type { RepositoryOption, UserGithubIntegration } from "../types"; + +/** + * User-scoped sibling of {@link useIntegrations}. Reads the authenticated + * user's personal GitHub integrations (`/api/users/@me/integrations/`) rather + * than the team-level ones, matching how the desktop app links GitHub per user. + * + * Used by the interactive task-creation flow (new task screen, task list empty + * state, connect prompt). Automations stay on {@link useIntegrations} because + * they run server-side without a user and need the team integration. + * + * Repos are keyed by the numeric GitHub `installation_id` so the existing + * number-based picker/`RepositoryOption` keep working; `getUserIntegrationId` + * maps that back to the `UserIntegration` UUID for task creation. No persisted + * cache here (unlike the team hook) so it can't clobber the automations cache. + */ +export const userIntegrationKeys = { + all: ["user-integrations"] as const, + github: () => [...userIntegrationKeys.all, "github"] as const, + repos: (installationIds: string[]) => + [...userIntegrationKeys.all, "repos", installationIds] as const, +}; + +interface UseUserIntegrationsOptions { + enabled?: boolean; +} + +function integrationLabel(integration: UserGithubIntegration): string { + return integration.account?.name ?? `GitHub ${integration.installation_id}`; +} + +export function useUserIntegrations(options: UseUserIntegrationsOptions = {}) { + const { enabled = true } = options; + const { oauthAccessToken } = useAuthStore(); + + const integrationsQuery = useQuery({ + queryKey: userIntegrationKeys.github(), + queryFn: getUserGithubIntegrations, + enabled: enabled && !!oauthAccessToken, + }); + + const integrations = enabled ? (integrationsQuery.data ?? []) : []; + + const repositoriesQuery = useQuery({ + queryKey: userIntegrationKeys.repos( + integrations.map((i) => i.installation_id), + ), + queryFn: async () => { + const byInstallation: Record = {}; + const results = await Promise.allSettled( + integrations.map(async (integration) => ({ + installationId: integration.installation_id, + repositories: await getUserGithubRepositories( + integration.installation_id, + ), + })), + ); + + let failedCount = 0; + for (const result of results) { + if (result.status === "fulfilled") { + byInstallation[result.value.installationId] = + result.value.repositories; + } else { + failedCount += 1; + } + } + + return { + byInstallation, + partialError: + failedCount === 0 + ? null + : failedCount === integrations.length + ? "Could not load GitHub repositories. Pull to retry." + : "Some GitHub repositories could not be loaded. Pull to retry.", + }; + }, + enabled: enabled && integrations.length > 0, + }); + + const repositoryOptions = useMemo(() => { + const byInstallation = repositoriesQuery.data?.byInstallation ?? {}; + return integrations + .flatMap((integration) => { + const repositories = byInstallation[integration.installation_id] ?? []; + return repositories.map((repository) => ({ + // GitHub installation ids fit in a JS number; use it as the numeric + // key the picker/RepositoryOption already expect. + integrationId: Number(integration.installation_id), + integrationLabel: integrationLabel(integration), + repository, + })); + }) + .sort((left, right) => left.repository.localeCompare(right.repository)); + }, [integrations, repositoriesQuery.data]); + + /** Resolve the `UserIntegration` UUID for a selected installation id, to send + * as `github_user_integration` on task creation. */ + const getUserIntegrationId = useCallback( + (installationId: number | null): string | undefined => { + if (installationId == null) return undefined; + return integrations.find( + (i) => Number(i.installation_id) === installationId, + )?.id; + }, + [integrations], + ); + + const refetch = useCallback(async () => { + if (!enabled) return; + await integrationsQuery.refetch(); + await repositoriesQuery.refetch(); + }, [enabled, integrationsQuery, repositoriesQuery]); + + return { + hasGithubIntegration: !enabled + ? null + : integrationsQuery.isFetched + ? integrations.length > 0 + : null, + integrations, + repositoryOptions, + getUserIntegrationId, + // No persisted cache, so there is no "cached list while refreshing" state. + isRefreshingInBackground: false, + isLoading: enabled + ? integrationsQuery.isLoading || repositoriesQuery.isLoading + : false, + error: enabled ? (integrationsQuery.error?.message ?? null) : null, + repositoryWarning: enabled + ? (repositoriesQuery.data?.partialError ?? null) + : null, + refetch, + }; +} 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..2541eed7e --- /dev/null +++ b/apps/mobile/src/features/tasks/lib/cloudTaskStream.ts @@ -0,0 +1,912 @@ +import { fetch } from "expo/fetch"; +import { createTimeoutSignal } from "@/lib/api"; +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 { parseSessionLogs } from "../utils/parseSessionLogs"; +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 fetchHistoricalEntries(watcher, run); + 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 fetchHistoricalEntries(watcher, run); + 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; + } +} + +/** + * Loads the historical log entries for the run, mirroring the desktop's + * dual-source strategy: + * 1. Try the paginated `session_logs/` API — the live source while a run + * is active. For older / archived runs this can come back empty even + * though the canonical log exists on S3. + * 2. Fall back to the run's presigned `log_url` (S3 NDJSON), which is the + * canonical archive for completed runs. + * + * Returns `null` only when both sources fail outright (so the bootstrap can + * surface a retryable error). An empty paginated result is treated as "no + * data yet" and falls through to S3 — if S3 also has nothing we return the + * empty array so the snapshot can still flip the session to `"connected"`. + */ +async function fetchHistoricalEntries( + watcher: WatcherState, + run: TaskRun, +): Promise { + const paginated = await fetchAllSessionLogs(watcher); + if (watcher.stopped || watcher.failed) return null; + if (paginated && paginated.length > 0) return paginated; + + if (run.log_url) { + const s3Entries = await fetchS3LogEntries(watcher, run.log_url); + if (watcher.stopped || watcher.failed) return null; + if (s3Entries && s3Entries.length > 0) return s3Entries; + } + + // Both sources returned no rows. Prefer the paginated result (which is + // `[]` rather than `null`) so the caller can still emit an empty snapshot + // and the session flips to `"connected"` instead of hanging on loading. + return paginated ?? null; +} + +async function fetchS3LogEntries( + watcher: WatcherState, + logUrl: string, +): Promise { + try { + const response = await fetch(logUrl, { + signal: createTimeoutSignal(15_000), + }); + if (response.status === 404) { + // No archived log yet for this run — not an error, just no data. + return []; + } + if (!response.ok) { + log.warn("S3 session log fetch returned non-OK", { + runId: watcher.runId, + status: response.status, + }); + return null; + } + const content = await response.text(); + if (!content.trim()) return []; + return parseSessionLogs(content).rawEntries; + } catch (error) { + log.warn("S3 session log fetch failed", { + 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/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/stores/archivedTasksStore.ts b/apps/mobile/src/features/tasks/stores/archivedTasksStore.ts index 9a87f0072..72f880a73 100644 --- a/apps/mobile/src/features/tasks/stores/archivedTasksStore.ts +++ b/apps/mobile/src/features/tasks/stores/archivedTasksStore.ts @@ -10,6 +10,7 @@ interface ArchivedTasksState { // taskId → timestamp (ms) for eviction ordering archivedTasks: Record; archive: (taskId: string) => void; + archiveMany: (taskIds: string[]) => void; unarchive: (taskId: string) => void; isArchived: (taskId: string) => boolean; } @@ -38,6 +39,15 @@ export const useArchivedTasksStore = create()( }), })), + archiveMany: (taskIds: string[]) => + set((state) => { + if (taskIds.length === 0) return state; + const now = Date.now(); + const next = { ...state.archivedTasks }; + for (const id of taskIds) next[id] = now; + return { archivedTasks: withCap(next) }; + }), + unarchive: (taskId: string) => set((state) => { const { [taskId]: _, ...rest } = state.archivedTasks; 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/repositoryCacheStore.ts b/apps/mobile/src/features/tasks/stores/repositoryCacheStore.ts new file mode 100644 index 000000000..fee1d54c7 --- /dev/null +++ b/apps/mobile/src/features/tasks/stores/repositoryCacheStore.ts @@ -0,0 +1,37 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; +import type { RepositoryOption } from "../types"; + +interface RepositoryCacheState { + /** Last successfully fetched, sorted list of repository options across all + * GitHub integrations. Persisted to AsyncStorage so the picker can render + * instantly on cold start while a background refetch confirms/updates the + * list. Empty array means "no cache yet". */ + options: RepositoryOption[]; + /** Epoch ms of the last successful write. `null` before any cache hit — + * useful if we want to surface a "last updated" hint or invalidate stale + * caches in the future. */ + updatedAt: number | null; + setOptions: (options: RepositoryOption[]) => void; + clear: () => void; +} + +export const useRepositoryCacheStore = create()( + persist( + (set) => ({ + options: [], + updatedAt: null, + setOptions: (options) => set({ options, updatedAt: Date.now() }), + clear: () => set({ options: [], updatedAt: null }), + }), + { + name: "posthog-repository-cache", + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => ({ + options: state.options, + updatedAt: state.updatedAt, + }), + }, + ), +); diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts index 2d2be0f5b..fa3c84a29 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts @@ -1,83 +1,252 @@ 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 { CloudCommandError, - fetchS3Logs, getTask, - getTaskRun, runTaskInCloud, sendCloudCommand, } from "../api"; -import type { - SessionEvent, - SessionNotification, - StoredLogEntry, - Task, -} from "../types"; +import { buildCloudPromptBlocks } from "../composer/attachments/buildCloudPrompt"; +import { serializeCloudPrompt } from "../composer/attachments/cloudPrompt"; +import type { PendingAttachment } from "../composer/attachments/types"; +import { + type WatchCloudTaskHandle, + watchCloudTask, +} from "../lib/cloudTaskStream"; import { - convertRawEntriesToEvents, - parseSessionLogs, -} from "../utils/parseSessionLogs"; + type CloudPendingPermissionRequest, + 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 +// 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; + } + } +} + +type LocalNotificationKind = + | "turn_complete" + | "awaiting_user_input" + | "task_failed"; + +// Per-task cooldown so a noisy stream of terminal/awaiting events doesn't +// fire a burst of identical banners. Keyed by taskId, value is the epoch ms +// of the most recent notification for that task. +const NOTIFICATION_DEDUP_WINDOW_MS = 30_000; +const lastNotificationAt = new Map(); + +// TODO: server-side device presence. Today we can only suppress notifications +// when *this* device is foregrounded on the task (`focusedTaskId` check +// below). That leaves the cross-device case uncovered — e.g. desktop is open +// on the same task, server fans the push to every registered token, mobile +// still rings. Once the `/api/projects/{team_id}/tasks/{task_id}/presence/` +// beacon endpoint lands in posthog/posthog, add: +// 1. A stable per-install device_id (probably derive from pushTokenStore). +// 2. POST presence every ~30s while a task screen is mounted AND AppState +// is "active". +// 3. DELETE presence on screen blur or AppState → "background"/"inactive". +// The server will then drop pushes to devices with non-expired presence for +// the target task, so this client-side maybePresentLocalNotification stays +// as-is (it's only the OS fanout path we're improving). + +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; + + // Dedup: skip if we just notified about this task. + const now = Date.now(); + const previous = lastNotificationAt.get(session.taskId); + if (previous && now - previous < NOTIFICATION_DEDUP_WINDOW_MS) return; + lastNotificationAt.set(session.taskId, now); + + 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(() => {}); +} + +// 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; + hasVisibleAgentOutput: boolean; + externalUserMessageCount: number; + agentMessageFinalized: boolean; +} + +function analyzeEntries( + entries: StoredLogEntry[], + localUserEchoes: Set, +): BatchAnalysis { + let hasTurnEnd = false; + let hasAwaitingUserInput = 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; + } + } -// 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" + entry.type === "notification" && + method === "session/update" && + entry.notification?.params ) { - return true; + 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; + } + } } - // 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; - } + return { + hasTurnEnd, + hasAwaitingUserInput, + 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 ( - su === "user_message_chunk" || - su === "tool_call" || - su === "tool_call_update" || - su === "agent_thought_chunk" + entry.type === "notification" && + entry.notification?.method === "session/update" ) { - return false; + 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 false; + return result; } -const CLOUD_POLLING_INTERVAL_MS = 500; - export interface TaskSession { taskRunId: string; taskId: string; + taskTitle?: string; 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 @@ -85,19 +254,32 @@ 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; + // 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; + pendingPermissions?: Record; } interface TaskSessionStore { sessions: Record; + focusedTaskId: string | null; + + setFocusedTaskId: (taskId: string | null) => void; 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: { @@ -109,10 +291,19 @@ 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; - _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, @@ -120,154 +311,77 @@ 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: {}, + focusedTaskId: null, + + setFocusedTaskId: (taskId) => set({ focusedTaskId: taskId }), 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, - 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, - ); - - // 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, - 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" - ); - }), + taskTitle: task.title, + 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); } @@ -277,29 +391,46 @@ 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 (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"); } - // 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 + // and resource blocks alongside the text. + const wirePayload = + attachments.length > 0 + ? serializeCloudPrompt( + await buildCloudPromptBlocks(prompt, attachments), + ) + : prompt; - // Local echo for immediate UX feedback — polling will re-surface the - // canonical copy once the agent writes it to the log; any duplicate is - // removed by content-based dedup in the polling loop below. const ts = Date.now(); + const 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, @@ -307,9 +438,15 @@ export const useTaskSessionStore = create((set, get) => ({ update: { sessionUpdate: "user_message_chunk", content: { type: "text", text: prompt }, + 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]; @@ -332,21 +469,18 @@ 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", { + 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, }); @@ -370,24 +504,21 @@ 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, }); 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); + 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; @@ -409,11 +540,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) { @@ -452,26 +578,83 @@ 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] ?? + 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", { + ...(cloudRequestId ? { requestId: cloudRequestId } : {}), toolCallId: args.toolCallId, optionId: args.optionId, ...(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, + 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) { - 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; const nextLocalEchoes = new Set(current.localUserEchoes ?? []); nextLocalEchoes.delete(args.displayText); + const currentPermission = current.pendingPermissions?.[args.toolCallId]; return { sessions: { ...state.sessions, @@ -479,6 +662,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, }, }, @@ -488,13 +680,38 @@ export const useTaskSessionStore = create((set, get) => ({ } }, + 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, + }); + log.debug("Sent set_config_option", { + taskId, + runId: session.taskRunId, + configId, + value, + }); + } catch (err) { + log.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; try { await sendCloudCommand(taskId, session.taskRunId, "cancel"); - logger.debug("Sent cancel command", { + log.debug("Sent cancel command", { taskId, runId: session.taskRunId, }); @@ -505,12 +722,14 @@ export const useTaskSessionStore = create((set, get) => ({ [session.taskRunId]: { ...state.sessions[session.taskRunId], isPromptPending: false, + awaitingPing: false, + awaitingAgentOutput: false, }, }, })); return true; } catch (error) { - logger.error("Failed to send cancel request", error); + log.error("Failed to send cancel request", error); return false; } }, @@ -519,292 +738,215 @@ 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") { + // 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, + }, + pendingPermissions: { + ...(current.pendingPermissions ?? {}), + [toolCallId]: { + requestId: update.requestId, + toolCall: update.toolCall, + options: update.options, + }, + }, + }, + }, + }; + }); + } + 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, - ); - } - } - } 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. + // + // awaitingPing is only ever set by this-device actions (sendPrompt, + // sendPermissionResponse, fresh runs, resumes). External user + // messages — i.e. another device chatting in the same task — must + // NOT arm it; otherwise mobile would fire notifications for desktop + // activity. Clearing on turn-end / finalized agent message stays. + let nextAwaitingPing = current.awaitingPing; + if ( + !isSnapshot && + (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; - // 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.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 - } - } - - // 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, - }, + 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, + }, + }, + }; + }); + + // Live `logs` deltas only fire pings for the "agent is blocked on the + // user" case. Terminal completion / failure is handled by the status + // block below, so we don't double-fire on every intermediate turn. + // Snapshots are historical replay — never ping for those. + const shouldPingNow = + !isSnapshot && wasAwaitingPing && analysis.hasAwaitingUserInput; + if (shouldPingNow && usePreferencesStore.getState().pingsEnabled) { + playMeepSound().catch(() => {}); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + } + if (shouldPingNow) { + maybePresentLocalNotification({ + taskRunId, + kind: "awaiting_user_input", + }); + } + } + + 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); - } } - } 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 }); } }, @@ -813,9 +955,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; @@ -826,15 +965,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]; @@ -846,11 +981,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, }, @@ -858,8 +990,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, @@ -867,25 +999,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/stores/taskStore.ts b/apps/mobile/src/features/tasks/stores/taskStore.ts index 92d20a79d..ff31d9b5c 100644 --- a/apps/mobile/src/features/tasks/stores/taskStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskStore.ts @@ -1,44 +1,140 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; -import type { Task } from "../types"; +import { createJSONStorage, persist } from "zustand/middleware"; +import type { ExecutionMode, ReasoningEffort } from "../composer/options"; +import type { RepositorySelection, Task } from "../types"; -export type OrderByField = "created_at" | "status" | "title"; -export type OrderDirection = "asc" | "desc"; +export type OrganizeMode = "by-project" | "chronological"; +export type SortMode = "created" | "updated"; + +const EMPTY_REPOSITORY_SELECTION: RepositorySelection = { + integrationId: null, + 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; - orderBy: OrderByField; - orderDirection: OrderDirection; + 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; + composerConfigByTaskId: Record; + pendingPromptByTaskId: Record; selectTask: (taskId: string | null) => 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; + setLastRepository: (selection: RepositorySelection) => void; + setComposerConfig: ( + taskId: string, + config: Partial, + ) => void; + setPendingPrompt: (taskId: string, prompt: string) => void; + consumePendingPrompt: (taskId: string) => string | undefined; } -export const useTaskStore = create((set) => ({ - selectedTaskId: null, - orderBy: "created_at", - orderDirection: "desc", - filter: "", +export const useTaskStore = create()( + persist( + (set, get) => ({ + selectedTaskId: null, + organizeMode: "by-project", + sortMode: "updated", + showInternal: false, + filter: "", + lastRepository: EMPTY_REPOSITORY_SELECTION, + composerConfigByTaskId: {}, + pendingPromptByTaskId: {}, - 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 }), + setLastRepository: (lastRepository) => set({ lastRepository }), + setComposerConfig: (taskId, config) => + set((state) => ({ + composerConfigByTaskId: { + ...state.composerConfigByTaskId, + [taskId]: { + ...state.composerConfigByTaskId[taskId], + ...config, + }, + }, + })), + 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", + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => ({ + organizeMode: state.organizeMode, + sortMode: state.sortMode, + showInternal: state.showInternal, + lastRepository: state.lastRepository, + composerConfigByTaskId: state.composerConfigByTaskId, + }), + }, + ), +); + +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 +142,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..9e7ec5a54 100644 --- a/apps/mobile/src/features/tasks/types.ts +++ b/apps/mobile/src/features/tasks/types.ts @@ -9,9 +9,50 @@ export interface Task { origin_product: string; repository?: string | null; github_integration?: number | null; + internal?: boolean; 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 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; @@ -19,7 +60,7 @@ export interface TaskRun { branch: string | null; stage?: string | null; environment?: "local" | "cloud"; - status: "started" | "in_progress" | "completed" | "failed"; + status: TaskRunStatus; log_url: string; error_message: string | null; output: Record | null; @@ -42,10 +83,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; @@ -63,7 +116,7 @@ export interface SessionNotification { export interface PlanEntry { content: string; - status: "pending" | "in_progress" | "completed"; + status: "pending" | "in_progress" | "completed" | "failed"; priority: string; } @@ -82,6 +135,146 @@ 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; +} + +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; +} + +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; @@ -93,9 +286,61 @@ export interface Integration { }; } +/** + * A user-scoped GitHub integration from `/api/users/@me/integrations/`. + * `id` is the PostHog `UserIntegration` UUID (used as `github_user_integration` + * on task creation); `installation_id` is the numeric GitHub App installation id + * (used to fetch repos and as the numeric key in `RepositoryOption`). + */ +export interface UserGithubIntegration { + id: string; + kind: string; + installation_id: string; + account?: { + name?: string; + type?: string; + }; +} + +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; + /** User-scoped GitHub integration UUID (UserIntegration pk) for user-authored + * cloud runs. Preferred over `github_integration` for interactive tasks. */ + github_user_integration?: string; +} + +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/automationStatus.test.ts b/apps/mobile/src/features/tasks/utils/automationStatus.test.ts new file mode 100644 index 000000000..3a3305451 --- /dev/null +++ b/apps/mobile/src/features/tasks/utils/automationStatus.test.ts @@ -0,0 +1,42 @@ +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("hides the running badge while the linked task run is actively in progress", () => { + expect( + getAutomationStatusPresentation({ + lastRunStatus: "running", + lastTaskRunStatus: "in_progress", + }), + ).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", () => { + 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..e5dd7c8fe --- /dev/null +++ b/apps/mobile/src/features/tasks/utils/automationStatus.ts @@ -0,0 +1,61 @@ +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 | null { + switch (lastTaskRunStatus) { + case "not_started": + case "queued": + return { + label: "Queued", + className: "bg-status-warning/20 text-status-warning", + }; + case "started": + case "in_progress": + return null; + 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 null; + 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", + }; + } +} 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..f3e44f2f4 --- /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 for skill-backed automations", () => { + expect( + getAutomationTemplatePresentation({ + repository: "posthog/posthog", + template_id: "llm-skill:shared-daily-brief", + }), + ).toMatchObject({ + templateName: "shared-daily-brief", + repositoryLabel: "posthog/posthog", + contextLabel: "Skill store", + secondaryLabel: "posthog/posthog", + }); + }); + + it("falls back to skill-store context when no repository is present", () => { + expect( + getAutomationTemplatePresentation({ + repository: "", + template_id: "llm-skill:shared-daily-brief", + }), + ).toMatchObject({ + templateName: "shared-daily-brief", + repositoryLabel: null, + contextLabel: "Skill store", + secondaryLabel: "Skill store", + }); + }); + + 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..d899a29b9 --- /dev/null +++ b/apps/mobile/src/features/tasks/utils/automationTemplatePresentation.ts @@ -0,0 +1,25 @@ +import { parseSkillTemplateId } from "../skills/skillTemplateIds"; +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 skillName = parseSkillTemplateId(automation.template_id); + const contextLabel = skillName ? "Skill store" : null; + + return { + templateName: + skillName ?? (automation.template_id ? "Template automation" : null), + repositoryLabel, + contextLabel, + secondaryLabel: repositoryLabel ?? contextLabel ?? "No repository context", + }; +} 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; +} 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/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/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"; +} 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(); +} 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/lib/api.ts b/apps/mobile/src/lib/api.ts index 0a5ac354e..0aaa6d5dc 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"}`; @@ -15,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) { @@ -30,3 +42,69 @@ export function getProjectId(): number { } return projectId; } + +/** + * Returns an `AbortSignal` that aborts after `ms` milliseconds. + * + * Replaces `AbortSignal.timeout(ms)`, which is unimplemented in the Hermes + * runtime that React Native uses — calling it throws + * `TypeError: AbortSignal.timeout is not a function`. Use this helper for any + * fetch that needs a request timeout on mobile. + */ +export function createTimeoutSignal(ms: number): AbortSignal { + const controller = new AbortController(); + setTimeout(() => controller.abort(), ms); + return controller.signal; +} + +export async function registerPushToken(args: { + token: string; + platform: string; +}): Promise { + const baseUrl = getBaseUrl(); + const headers = getHeaders(); + + // Push tokens are per-user, not per-project — endpoint lives under + // /api/users/@me/ alongside the other user-scoped APIs. + const url = `${baseUrl}/api/users/@me/push_tokens/`; + const response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(args), + }); + + if (!response.ok) { + const body = await response.text().catch(() => ""); + log.warn("registerPushToken failed", { + url, + status: response.status, + statusText: response.statusText, + body: body.slice(0, 500), + }); + throw new Error( + `registerPushToken failed: ${response.status} ${response.statusText} — ${body.slice(0, 200)}`, + ); + } +} + +export async function deletePushToken(args: { token: string }): Promise { + const baseUrl = getBaseUrl(); + 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/users/@me/push_tokens/unregister/`, + { + method: "POST", + headers, + body: JSON.stringify(args), + }, + ); + + if (!response.ok) { + log.debug("deletePushToken non-OK response", { + status: response.status, + }); + } +} 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; + } +} 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}`, + }; +} 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; +} 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..0fa4d0e81 100644 --- a/apps/mobile/src/lib/theme.ts +++ b/apps/mobile/src/lib/theme.ts @@ -4,74 +4,80 @@ 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", + // "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: { - 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 +85,8 @@ const colors = { warning: "#fbbf24", info: "#60a5fa", }, - background: "#151515", + background: "#131316", + card: "#1e1e28", }, } as const; @@ -133,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/src/test/setup.ts b/apps/mobile/src/test/setup.ts new file mode 100644 index 000000000..1cfe79aec --- /dev/null +++ b/apps/mobile/src/test/setup.ts @@ -0,0 +1,258 @@ +import { afterEach, vi } from "vitest"; + +vi.mock("expo-constants", () => ({ + default: { + expoConfig: { + version: "0.0.0-test", + }, + }, +})); + +vi.mock("@react-native-async-storage/async-storage", () => { + const store = new Map(); + return { + default: { + getItem: vi.fn(async (key: string) => store.get(key) ?? null), + setItem: vi.fn(async (key: string, value: string) => { + store.set(key, value); + }), + removeItem: vi.fn(async (key: string) => { + store.delete(key); + }), + clear: vi.fn(async () => { + store.clear(); + }), + getAllKeys: vi.fn(async () => Array.from(store.keys())), + multiGet: vi.fn(async (keys: string[]) => + keys.map((key) => [key, store.get(key) ?? null]), + ), + multiSet: vi.fn(async (pairs: [string, string][]) => { + for (const [key, value] of pairs) { + store.set(key, value); + } + }), + multiRemove: vi.fn(async (keys: string[]) => { + for (const key of keys) { + store.delete(key); + } + }), + }, + }; +}); + +vi.mock("phosphor-react-native", async () => { + const { createElement } = await import("react"); + const icon = (name: string) => (props: Record) => + createElement(name, props); + return { + __esModule: true, + Archive: icon("Archive"), + ArrowCounterClockwise: icon("ArrowCounterClockwise"), + ArrowDown: icon("ArrowDown"), + ArrowSquareOut: icon("ArrowSquareOut"), + ArrowUp: icon("ArrowUp"), + ArrowsClockwise: icon("ArrowsClockwise"), + ArrowsIn: icon("ArrowsIn"), + ArrowsOut: icon("ArrowsOut"), + Brain: icon("Brain"), + BrainIcon: icon("BrainIcon"), + Bug: icon("Bug"), + Camera: icon("Camera"), + Cards: icon("Cards"), + CaretDown: icon("CaretDown"), + CaretLeft: icon("CaretLeft"), + CaretRight: icon("CaretRight"), + CaretUp: icon("CaretUp"), + ChatCircle: icon("ChatCircle"), + Check: icon("Check"), + CheckCircle: icon("CheckCircle"), + CircleDashed: icon("CircleDashed"), + CircleIcon: icon("CircleIcon"), + CircleNotch: icon("CircleNotch"), + Clock: icon("Clock"), + CloudArrowDown: icon("CloudArrowDown"), + Code: icon("Code"), + Copy: icon("Copy"), + Eye: icon("Eye"), + File: icon("File"), + FileText: icon("FileText"), + FunnelSimple: icon("FunnelSimple"), + GearSix: icon("GearSix"), + GitBranch: icon("GitBranch"), + GitMerge: icon("GitMerge"), + GitPullRequest: icon("GitPullRequest"), + GithubLogo: icon("GithubLogo"), + Globe: icon("Globe"), + Image: icon("Image"), + ImageBroken: icon("ImageBroken"), + Lightning: icon("Lightning"), + LinkSimple: icon("LinkSimple"), + List: icon("List"), + ListBullets: icon("ListBullets"), + ListChecks: icon("ListChecks"), + Lock: icon("Lock"), + MagnifyingGlass: icon("MagnifyingGlass"), + Microphone: icon("Microphone"), + MicrophoneIcon: icon("MicrophoneIcon"), + PaperclipIcon: icon("PaperclipIcon"), + PauseIcon: icon("PauseIcon"), + PencilIcon: icon("PencilIcon"), + PencilSimple: icon("PencilSimple"), + Play: icon("Play"), + Plus: icon("Plus"), + PuzzlePiece: icon("PuzzlePiece"), + Question: icon("Question"), + RadioButton: icon("RadioButton"), + Robot: icon("Robot"), + ShieldCheck: icon("ShieldCheck"), + Sparkle: icon("Sparkle"), + SpeakerHigh: icon("SpeakerHigh"), + Stop: icon("Stop"), + StopIcon: icon("StopIcon"), + Terminal: icon("Terminal"), + ThumbsDown: icon("ThumbsDown"), + Trash: icon("Trash"), + Tray: icon("Tray"), + UsersThree: icon("UsersThree"), + Warning: icon("Warning"), + WarningCircle: icon("WarningCircle"), + WifiSlash: icon("WifiSlash"), + Wrench: icon("Wrench"), + X: icon("X"), + XCircle: icon("XCircle"), + }; +}); + +// nativewind cannot be evaluated under vitest's node environment — it pulls in +// react-native internals shipped as Flow source, which fail to parse ("Unexpected +// token 'typeof'") and wedge the module loader. Stub the two APIs the app uses so +// any module that imports the theme (directly or transitively) loads cleanly. +vi.mock("nativewind", () => ({ + useColorScheme: () => ({ + colorScheme: "light" as const, + setColorScheme: vi.fn(), + toggleColorScheme: vi.fn(), + }), + vars: (value: Record) => value, +})); + +// react-native-reanimated pulls in native worklet/runtime setup that never +// resolves under vitest's node environment, hanging the worker indefinitely. +// Replace it with a lightweight, side-effect-free stand-in so component trees +// that import it (directly or transitively) can render in tests. +vi.mock("react-native-reanimated", async () => { + const { createElement } = await import("react"); + const animatedComponent = + (name: string) => (props: Record) => + createElement(name, props, (props?.children as never) ?? null); + const passthroughEasing = () => 0; + const easingFactory = () => passthroughEasing; + + return { + default: { + View: animatedComponent("Animated.View"), + ScrollView: animatedComponent("Animated.ScrollView"), + Text: animatedComponent("Animated.Text"), + Image: animatedComponent("Animated.Image"), + createAnimatedComponent: (component: unknown) => component, + }, + Easing: { + linear: passthroughEasing, + ease: passthroughEasing, + quad: passthroughEasing, + cubic: passthroughEasing, + in: easingFactory, + out: easingFactory, + inOut: easingFactory, + bezier: easingFactory, + }, + useSharedValue: (initial: T) => ({ value: initial }), + useAnimatedStyle: (factory: () => unknown) => { + try { + return factory(); + } catch { + return {}; + } + }, + useDerivedValue: (factory: () => unknown) => ({ value: factory() }), + useAnimatedRef: () => ({ current: null }), + withTiming: (value: T) => value, + withSpring: (value: T) => value, + withDelay: (_delay: number, value: T) => value, + withRepeat: (value: T) => value, + withSequence: (...values: T[]) => values[values.length - 1], + cancelAnimation: vi.fn(), + runOnJS: + (fn: (...args: Args) => unknown) => + (...args: Args) => + fn(...args), + runOnUI: + (fn: (...args: Args) => unknown) => + (...args: Args) => + fn(...args), + interpolate: () => 0, + Extrapolation: { CLAMP: "clamp", EXTEND: "extend", IDENTITY: "identity" }, + }; +}); + +vi.mock("react-native-safe-area-context", async () => { + const { createElement } = await import("react"); + return { + SafeAreaProvider: (props: { children?: unknown }) => + createElement("SafeAreaProvider", null, props.children as never), + SafeAreaView: (props: { children?: unknown }) => + createElement("SafeAreaView", null, props.children as never), + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), + useSafeAreaFrame: () => ({ x: 0, y: 0, width: 375, height: 812 }), + }; +}); + +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(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + scope: () => mockLogger, + }; + + return { + logger: mockLogger, + }; +}); + +afterEach(() => { + vi.clearAllMocks(); +}); 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/apps/mobile/tailwind.config.js b/apps/mobile/tailwind.config.js index d09928ff2..0bed59fdb 100644 --- a/apps/mobile/tailwind.config.js +++ b/apps/mobile/tailwind.config.js @@ -41,9 +41,11 @@ module.exports = { info: "rgb(var(--status-info) / )", }, background: "rgb(var(--background) / )", + card: "rgb(var(--card) / )", }, fontFamily: { - mono: ["JetBrains Mono"], + sans: ["Open Runde"], + mono: ["Open Runde"], }, }, }, 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 164f5ef0a..d41dfe7bc 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)) @@ -547,10 +553,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) @@ -566,6 +572,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 +587,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) @@ -587,9 +599,12 @@ 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@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) @@ -604,7 +619,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 +656,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) @@ -648,15 +666,36 @@ 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-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) 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: @@ -729,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 @@ -2562,6 +2601,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} @@ -2806,6 +2848,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} @@ -2814,10 +2860,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} @@ -4491,6 +4545,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==} @@ -4738,6 +4795,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'} @@ -4824,6 +4884,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'} @@ -4938,6 +5072,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'} @@ -5191,8 +5337,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==} @@ -5299,6 +5445,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==} @@ -5379,6 +5528,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: @@ -5412,6 +5564,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==} @@ -5421,18 +5584,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==} @@ -5442,6 +5614,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: @@ -5456,6 +5631,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==} @@ -5706,6 +5884,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'} @@ -5734,6 +5915,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'} @@ -5812,6 +5997,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==} @@ -5931,8 +6119,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==} @@ -5972,6 +6160,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'} @@ -6297,6 +6489,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'} @@ -6312,6 +6513,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'} @@ -6328,6 +6532,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==} @@ -6335,6 +6542,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'} @@ -6347,6 +6562,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'} @@ -6535,6 +6754,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'} @@ -7008,6 +7230,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: @@ -7033,6 +7260,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==} @@ -7076,6 +7313,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: @@ -7223,6 +7467,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==} @@ -7327,6 +7577,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'} @@ -7449,6 +7703,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'} @@ -7756,6 +8014,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'} @@ -7831,6 +8092,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'} @@ -7855,6 +8119,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==} @@ -7865,6 +8133,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'} @@ -7898,6 +8170,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'} @@ -7931,6 +8207,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==} @@ -7959,6 +8239,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'} @@ -7982,6 +8266,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'} @@ -8047,6 +8335,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} @@ -8059,6 +8351,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} @@ -8554,6 +8850,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'} @@ -8687,6 +8986,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==} @@ -8706,6 +9011,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'} @@ -9097,6 +9405,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'} @@ -9220,6 +9531,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'} @@ -9228,6 +9543,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==} @@ -9451,6 +9770,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'} @@ -9500,6 +9822,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==} @@ -9596,6 +9922,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'} @@ -9742,6 +10072,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'} @@ -9778,6 +10112,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==} @@ -9975,8 +10312,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==} @@ -10033,12 +10370,24 @@ 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: 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: @@ -10120,6 +10469,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'} @@ -10365,6 +10719,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==} @@ -10372,6 +10730,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'} @@ -10439,6 +10801,13 @@ 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==} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -10552,6 +10921,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'} @@ -10647,6 +11019,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'} @@ -10774,6 +11149,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'} @@ -10807,6 +11185,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==} @@ -10950,6 +11336,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'} @@ -11203,6 +11593,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==} @@ -11353,6 +11747,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'} @@ -11567,6 +11964,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==} @@ -11664,6 +12102,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 @@ -13734,7 +14176,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(97f7c790f8736a7689a3c00f6dec9af6) react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0) transitivePeerDependencies: - bufferutil @@ -14015,6 +14457,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 @@ -14290,6 +14734,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 @@ -14306,10 +14752,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 @@ -14815,6 +15267,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) @@ -16241,6 +16701,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)': @@ -16273,7 +16735,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) @@ -16452,6 +16914,8 @@ snapshots: '@sinclair/typebox@0.33.22': {} + '@sinclair/typebox@0.34.49': {} + '@sindresorhus/is@4.6.0': {} '@sindresorhus/merge-streams@4.0.0': {} @@ -16561,30 +17025,109 @@ snapshots: transitivePeerDependencies: - supports-color - '@szmarczak/http-timer@4.0.6': + '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.29.0)': dependencies: - defer-to-connect: 2.0.1 + '@babel/core': 7.29.0 - '@tailwindcss/node@4.2.2': + '@svgr/babel-plugin-remove-jsx-attribute@8.0.0(@babel/core@7.29.0)': dependencies: - '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.19.0 - jiti: 2.6.1 - lightningcss: 1.32.0 - magic-string: 0.30.21 - source-map-js: 1.2.1 - tailwindcss: 4.2.2 + '@babel/core': 7.29.0 - '@tailwindcss/oxide-android-arm64@4.2.2': - optional: true + '@svgr/babel-plugin-remove-jsx-empty-expression@8.0.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 - '@tailwindcss/oxide-darwin-arm64@4.2.2': - optional: true + '@svgr/babel-plugin-replace-jsx-attribute-value@8.0.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 - '@tailwindcss/oxide-darwin-x64@4.2.2': - optional: true + '@svgr/babel-plugin-svg-dynamic-title@8.0.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 - '@tailwindcss/oxide-freebsd-x64@4.2.2': + '@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 + + '@tailwindcss/node@4.2.2': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.19.0 + jiti: 2.6.1 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.2 + + '@tailwindcss/oxide-android-arm64@4.2.2': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.2.2': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.2.2': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.2.2': optional: true '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': @@ -16660,6 +17203,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 @@ -16939,9 +17492,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: @@ -17059,6 +17612,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 @@ -17130,6 +17687,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 @@ -17171,6 +17740,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 @@ -17207,6 +17785,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 @@ -17219,6 +17806,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 @@ -17229,6 +17820,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 @@ -17241,6 +17837,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 @@ -17251,6 +17854,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 @@ -17279,6 +17884,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': @@ -17529,6 +18140,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: @@ -17551,6 +18170,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: {} @@ -17694,6 +18317,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: {} @@ -17827,7 +18452,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 @@ -17899,6 +18524,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 @@ -18208,6 +18840,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 @@ -18221,6 +18862,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 @@ -18239,6 +18886,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 @@ -18252,12 +18903,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 @@ -18328,7 +18993,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: {} @@ -18339,7 +19003,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: {} @@ -18405,6 +19068,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 @@ -18426,12 +19094,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: @@ -18833,18 +19501,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' @@ -18902,6 +19574,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) @@ -18924,6 +19600,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): @@ -18975,7 +19660,22 @@ 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-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(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 @@ -19008,8 +19708,10 @@ 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) transitivePeerDependencies: - '@react-native-masked-view/masked-view' - '@types/react' @@ -19041,12 +19743,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 @@ -19177,6 +19881,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 @@ -19291,6 +20009,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 @@ -19413,6 +20135,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: {} @@ -19611,7 +20335,6 @@ snapshots: has-property-descriptors@1.0.2: dependencies: es-define-property: 1.0.1 - optional: true has-symbols@1.1.0: {} @@ -19810,6 +20533,8 @@ snapshots: hyperdyperid@1.2.0: {} + hyphenate-style-name@1.1.0: {} + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -19868,6 +20593,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: @@ -19893,6 +20622,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: {} @@ -19901,6 +20635,8 @@ snapshots: dependencies: binary-extensions: 2.3.0 + is-callable@1.2.7: {} + is-core-module@2.16.1: dependencies: hasown: 2.0.2 @@ -19921,6 +20657,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 @@ -19951,6 +20695,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: {} @@ -19968,6 +20717,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: @@ -19982,6 +20738,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: {} @@ -20047,6 +20807,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 @@ -20074,6 +20841,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 @@ -20576,6 +21350,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: {} @@ -20841,6 +21619,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: {} @@ -20870,6 +21652,8 @@ snapshots: memoize-one@5.2.1: {} + memoize-one@6.0.0: {} + merge-descriptors@2.0.0: {} merge-options@3.0.4: @@ -21490,6 +22274,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 @@ -21608,11 +22397,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: {} @@ -21874,6 +22676,8 @@ snapshots: path-browserify@1.0.1: {} + path-dirname@1.0.2: {} + path-exists@3.0.0: {} path-exists@4.0.0: {} @@ -21911,6 +22715,8 @@ snapshots: dependencies: pify: 2.3.0 + path-type@4.0.0: {} + pathe@1.1.2: {} pathe@2.0.3: {} @@ -21988,6 +22794,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 @@ -22123,6 +22931,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 @@ -22144,6 +22959,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 @@ -22460,7 +23279,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: @@ -22532,6 +23351,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 @@ -22540,6 +23371,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 @@ -22661,6 +23507,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: @@ -22969,10 +23821,18 @@ 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: {} + sax@1.6.0: {} + saxes@6.0.0: dependencies: xmlchars: 2.2.0 @@ -23064,6 +23924,17 @@ 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: {} sf-symbols-typescript@2.2.0: {} @@ -23226,6 +24097,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 @@ -23314,6 +24190,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): @@ -23441,6 +24319,8 @@ snapshots: dependencies: inline-style-parser: 0.2.7 + styleq@0.1.3: {} + sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -23480,6 +24360,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: {} @@ -23634,6 +24526,8 @@ snapshots: tinyrainbow@3.0.3: {} + tinyrainbow@3.1.0: {} + tinyspy@3.0.2: {} tinyspy@4.0.4: {} @@ -23858,6 +24752,8 @@ snapshots: ua-parser-js@0.7.41: {} + ua-parser-js@1.0.41: {} + uc.micro@2.1.0: {} ufo@1.6.3: {} @@ -23996,6 +24892,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: {} @@ -24136,6 +25040,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 @@ -24248,6 +25169,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: @@ -24353,6 +25303,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