From c53099923d840b5fbb624f8908e054ca9e53bf52 Mon Sep 17 00:00:00 2001 From: suraj-markup Date: Tue, 3 Mar 2026 01:34:45 +0530 Subject: [PATCH 01/25] feat(mobile): add React Native app for monitoring AO sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `packages/mobile` — an Expo SDK 53 React Native app for monitoring agent sessions from a phone. Features: - Home screen: Kanban-style session list sorted by attention level - Session detail: metadata, PR link, CI status, branch info - Terminal screen: full xterm.js terminal via WebView (port 14801) - Settings screen: configurable backend URL (LAN IP or ngrok) - Push notifications: background polling for attention-level changes Improvements over PR #235: - Fixed terminal onmessage handler to filter JSON control messages (resize echoes no longer render as garbage text) - Removed duplicate terminal.html (single source of truth in terminal-html.ts) - Fixed isDone check to include "cleanup" status using isTerminal() Tech: Expo SDK 53, React Navigation 6, React Native WebView. Two server connections: REST API (port 3000) + WebSocket terminal (14801). The mobile package is excluded from the pnpm workspace to avoid interfering with the monorepo toolchain. Closes #265 Co-Authored-By: Claude Opus 4.6 --- packages/mobile/.gitignore | 34 ++ packages/mobile/app.json | 54 +++ packages/mobile/assets/adaptive-icon.png | Bin 0 -> 159 bytes packages/mobile/assets/favicon.png | Bin 0 -> 82 bytes packages/mobile/assets/icon.png | Bin 0 -> 159 bytes packages/mobile/assets/splash.png | Bin 0 -> 231 bytes packages/mobile/babel.config.js | 6 + packages/mobile/eas.json | 21 + packages/mobile/index.js | 4 + packages/mobile/metro.config.js | 10 + packages/mobile/package.json | 33 ++ packages/mobile/src/App.tsx | 21 + .../mobile/src/components/AttentionBadge.tsx | 39 ++ .../mobile/src/components/SessionCard.tsx | 143 ++++++ packages/mobile/src/components/StatBar.tsx | 51 +++ .../mobile/src/context/BackendContext.tsx | 136 ++++++ packages/mobile/src/hooks/useSession.ts | 78 ++++ .../src/hooks/useSessionNotifications.ts | 58 +++ packages/mobile/src/hooks/useSessions.ts | 83 ++++ .../mobile/src/navigation/RootNavigator.tsx | 67 +++ .../src/notifications/backgroundTask.ts | 80 ++++ packages/mobile/src/notifications/index.ts | 75 ++++ packages/mobile/src/screens/HomeScreen.tsx | 164 +++++++ .../src/screens/SessionDetailScreen.tsx | 414 ++++++++++++++++++ .../mobile/src/screens/SettingsScreen.tsx | 367 ++++++++++++++++ .../mobile/src/screens/TerminalScreen.tsx | 122 ++++++ packages/mobile/src/terminal/terminal-html.ts | 177 ++++++++ packages/mobile/src/types/index.ts | 216 +++++++++ packages/mobile/tsconfig.json | 17 + pnpm-workspace.yaml | 1 + 30 files changed, 2471 insertions(+) create mode 100644 packages/mobile/.gitignore create mode 100644 packages/mobile/app.json create mode 100644 packages/mobile/assets/adaptive-icon.png create mode 100644 packages/mobile/assets/favicon.png create mode 100644 packages/mobile/assets/icon.png create mode 100644 packages/mobile/assets/splash.png create mode 100644 packages/mobile/babel.config.js create mode 100644 packages/mobile/eas.json create mode 100644 packages/mobile/index.js create mode 100644 packages/mobile/metro.config.js create mode 100644 packages/mobile/package.json create mode 100644 packages/mobile/src/App.tsx create mode 100644 packages/mobile/src/components/AttentionBadge.tsx create mode 100644 packages/mobile/src/components/SessionCard.tsx create mode 100644 packages/mobile/src/components/StatBar.tsx create mode 100644 packages/mobile/src/context/BackendContext.tsx create mode 100644 packages/mobile/src/hooks/useSession.ts create mode 100644 packages/mobile/src/hooks/useSessionNotifications.ts create mode 100644 packages/mobile/src/hooks/useSessions.ts create mode 100644 packages/mobile/src/navigation/RootNavigator.tsx create mode 100644 packages/mobile/src/notifications/backgroundTask.ts create mode 100644 packages/mobile/src/notifications/index.ts create mode 100644 packages/mobile/src/screens/HomeScreen.tsx create mode 100644 packages/mobile/src/screens/SessionDetailScreen.tsx create mode 100644 packages/mobile/src/screens/SettingsScreen.tsx create mode 100644 packages/mobile/src/screens/TerminalScreen.tsx create mode 100644 packages/mobile/src/terminal/terminal-html.ts create mode 100644 packages/mobile/src/types/index.ts create mode 100644 packages/mobile/tsconfig.json diff --git a/packages/mobile/.gitignore b/packages/mobile/.gitignore new file mode 100644 index 000000000..bf107f1b0 --- /dev/null +++ b/packages/mobile/.gitignore @@ -0,0 +1,34 @@ +# Expo +.expo/ +dist/ +web-build/ + +# Native build folders (generated by `expo prebuild`) +android/ +ios/ + +# Native +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env*.local + +# npm (project uses pnpm) +package-lock.json diff --git a/packages/mobile/app.json b/packages/mobile/app.json new file mode 100644 index 000000000..04e78a318 --- /dev/null +++ b/packages/mobile/app.json @@ -0,0 +1,54 @@ +{ + "expo": { + "name": "Agent Orchestrator", + "slug": "ao-mobile", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "userInterfaceStyle": "dark", + "splash": { + "image": "./assets/splash.png", + "resizeMode": "contain", + "backgroundColor": "#0d1117" + }, + "assetBundlePatterns": [ + "**/*" + ], + "ios": { + "supportsTablet": true, + "bundleIdentifier": "com.composio.ao-mobile", + "infoPlist": { + "UIBackgroundModes": [ + "fetch", + "processing", + "remote-notification" + ] + } + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#0d1117" + }, + "package": "com.composio.aomobile" + }, + "web": { + "favicon": "./assets/favicon.png" + }, + "plugins": [ + "expo-background-task", + [ + "expo-notifications", + { + "color": "#f85149", + "defaultChannel": "ao-respond" + } + ] + ], + "extra": { + "eas": { + "projectId": "de59ca61-8fd4-47bb-bdea-6e1a50ade3df" + } + } + } +} diff --git a/packages/mobile/assets/adaptive-icon.png b/packages/mobile/assets/adaptive-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..a87f229d3b53d9988bccec377571afda7c8e9ba0 GIT binary patch literal 159 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=X`U{QAr-gYUN977U|?ZzVC7N! zJ9*OKpAWffQoh!RF|lw6C^$4QFjB%eP|H16+EH%J+L`l!HZpj+`njxgN@xNAeHbCD literal 0 HcmV?d00001 diff --git a/packages/mobile/assets/favicon.png b/packages/mobile/assets/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..0574b9256b13abc2d675e555e9bc35a9aba487cc GIT binary patch literal 82 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`lAbP(Ar-fhABc+l_vcKLFip79 e5Xjut&Bze7z(hbfm*p=|5re0zpUXO@geCycaTHtt literal 0 HcmV?d00001 diff --git a/packages/mobile/assets/icon.png b/packages/mobile/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..a87f229d3b53d9988bccec377571afda7c8e9ba0 GIT binary patch literal 159 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=X`U{QAr-gYUN977U|?ZzVC7N! zJ9*OKpAWffQoh!RF|lw6C^$4QFjB%eP|H16+EH%J+L`l!HZpj+`njxgN@xNAeHbCD literal 0 HcmV?d00001 diff --git a/packages/mobile/assets/splash.png b/packages/mobile/assets/splash.png new file mode 100644 index 0000000000000000000000000000000000000000..7f0556c0142ee690149751a3c6fc9b161ca01a09 GIT binary patch literal 231 zcmeAS@N?(olHy`uVBq!ia0vp^4nW+%!3HFEH|A#nsdb(%jv*Dd-d-?dWME)nFkqF@ z`#V|aaN=<;olRAC+>A^t90Cds4Ge>U@!%fQwzW;x)0$FpfG%h7boFyt=akR{0Cg-V AcK`qY literal 0 HcmV?d00001 diff --git a/packages/mobile/babel.config.js b/packages/mobile/babel.config.js new file mode 100644 index 000000000..73ebf58e3 --- /dev/null +++ b/packages/mobile/babel.config.js @@ -0,0 +1,6 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: ["babel-preset-expo"], + }; +}; diff --git a/packages/mobile/eas.json b/packages/mobile/eas.json new file mode 100644 index 000000000..59b609d6b --- /dev/null +++ b/packages/mobile/eas.json @@ -0,0 +1,21 @@ +{ + "cli": { + "version": ">= 12.0.0" + }, + "build": { + "development": { + "developmentClient": true, + "distribution": "internal" + }, + "preview": { + "distribution": "internal", + "ios": { + "simulator": false + } + }, + "production": {} + }, + "submit": { + "production": {} + } +} diff --git a/packages/mobile/index.js b/packages/mobile/index.js new file mode 100644 index 000000000..000be560b --- /dev/null +++ b/packages/mobile/index.js @@ -0,0 +1,4 @@ +import { registerRootComponent } from "expo"; +import App from "./src/App"; + +registerRootComponent(App); diff --git a/packages/mobile/metro.config.js b/packages/mobile/metro.config.js new file mode 100644 index 000000000..c75ab9503 --- /dev/null +++ b/packages/mobile/metro.config.js @@ -0,0 +1,10 @@ +const { getDefaultConfig } = require("expo/metro-config"); + +const config = getDefaultConfig(__dirname); + +// Disable package exports resolution — some packages (math-intrinsics, expo-application) +// have invalid or incomplete `exports` fields that cause Metro bundling failures. +// Falling back to classic file-based resolution fixes these. +config.resolver.unstable_enablePackageExports = false; + +module.exports = config; diff --git a/packages/mobile/package.json b/packages/mobile/package.json new file mode 100644 index 000000000..37911a4d6 --- /dev/null +++ b/packages/mobile/package.json @@ -0,0 +1,33 @@ +{ + "name": "@composio/ao-mobile", + "version": "0.1.0", + "private": true, + "main": "index.js", + "scripts": { + "start": "expo start", + "android": "expo run:android", + "ios": "expo run:ios", + "web": "expo start --web" + }, + "dependencies": { + "@react-native-async-storage/async-storage": "2.1.2", + "@react-navigation/native": "^6.1.18", + "@react-navigation/native-stack": "^6.11.0", + "expo": "~53.0.0", + "expo-application": "~6.1.5", + "expo-background-task": "~0.2.8", + "expo-notifications": "~0.31.5", + "expo-status-bar": "~2.2.3", + "expo-task-manager": "~13.1.6", + "react": "19.0.0", + "react-native": "0.79.6", + "react-native-safe-area-context": "5.4.0", + "react-native-screens": "~4.11.1", + "react-native-webview": "13.13.5" + }, + "devDependencies": { + "@babel/core": "^7.24.0", + "@types/react": "~19.0.10", + "typescript": "~5.8.3" + } +} diff --git a/packages/mobile/src/App.tsx b/packages/mobile/src/App.tsx new file mode 100644 index 000000000..b287ed735 --- /dev/null +++ b/packages/mobile/src/App.tsx @@ -0,0 +1,21 @@ +import React, { useEffect } from "react"; +import { SafeAreaProvider } from "react-native-safe-area-context"; +import { BackendProvider } from "./context/BackendContext"; +import RootNavigator from "./navigation/RootNavigator"; +import { setupNotifications } from "./notifications"; +import { registerBackgroundTask } from "./notifications/backgroundTask"; + +export default function App() { + useEffect(() => { + void setupNotifications(); + void registerBackgroundTask(); + }, []); + + return ( + + + + + + ); +} diff --git a/packages/mobile/src/components/AttentionBadge.tsx b/packages/mobile/src/components/AttentionBadge.tsx new file mode 100644 index 000000000..34e9b3e26 --- /dev/null +++ b/packages/mobile/src/components/AttentionBadge.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { View, Text, StyleSheet } from "react-native"; +import { ATTENTION_COLORS, type AttentionLevel } from "../types"; + +interface Props { + level: AttentionLevel; +} + +const LABELS: Record = { + merge: "MERGE", + respond: "RESPOND", + review: "REVIEW", + pending: "PENDING", + working: "WORKING", + done: "DONE", +}; + +export default function AttentionBadge({ level }: Props) { + const color = ATTENTION_COLORS[level]; + return ( + + {LABELS[level]} + + ); +} + +const styles = StyleSheet.create({ + badge: { + borderWidth: 1, + borderRadius: 4, + paddingHorizontal: 6, + paddingVertical: 2, + }, + label: { + fontSize: 10, + fontWeight: "700", + letterSpacing: 0.5, + }, +}); diff --git a/packages/mobile/src/components/SessionCard.tsx b/packages/mobile/src/components/SessionCard.tsx new file mode 100644 index 000000000..dc1d84338 --- /dev/null +++ b/packages/mobile/src/components/SessionCard.tsx @@ -0,0 +1,143 @@ +import React from "react"; +import { View, Text, TouchableOpacity, StyleSheet } from "react-native"; +import { + getAttentionLevel, + relativeTime, + ATTENTION_COLORS, + type DashboardSession, +} from "../types"; +import AttentionBadge from "./AttentionBadge"; + +interface Props { + session: DashboardSession; + onPress: () => void; +} + +export default function SessionCard({ session, onPress }: Props) { + const level = getAttentionLevel(session); + const color = ATTENTION_COLORS[level]; + const time = relativeTime(session.lastActivityAt); + + return ( + + + + {session.id} + + + + + {session.issueLabel || session.issueTitle ? ( + + {session.issueLabel ? `${session.issueLabel}: ` : ""} + {session.issueTitle ?? ""} + + ) : null} + + {session.summary && !session.summaryIsFallback ? ( + + {session.summary} + + ) : null} + + + {session.branch ? ( + + {session.branch} + + ) : null} + {time} + + + {session.pr ? ( + + PR #{session.pr.number} + {session.pr.ciStatus !== "none" && ( + + {session.pr.ciStatus.toUpperCase()} + + )} + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: "#161b22", + borderLeftWidth: 3, + borderRadius: 8, + padding: 14, + marginHorizontal: 12, + marginVertical: 5, + }, + header: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 6, + }, + id: { + color: "#8b949e", + fontSize: 12, + fontFamily: "monospace", + flex: 1, + marginRight: 8, + }, + issue: { + color: "#e6edf3", + fontSize: 14, + fontWeight: "600", + marginBottom: 4, + }, + summary: { + color: "#8b949e", + fontSize: 13, + lineHeight: 18, + marginBottom: 6, + }, + footer: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + }, + branch: { + color: "#58a6ff", + fontSize: 12, + fontFamily: "monospace", + flex: 1, + }, + time: { + color: "#8b949e", + fontSize: 12, + }, + prRow: { + flexDirection: "row", + alignItems: "center", + marginTop: 6, + gap: 8, + }, + prLabel: { + color: "#3fb950", + fontSize: 12, + fontWeight: "600", + }, + ciStatus: { + fontSize: 11, + fontWeight: "700", + letterSpacing: 0.5, + }, +}); diff --git a/packages/mobile/src/components/StatBar.tsx b/packages/mobile/src/components/StatBar.tsx new file mode 100644 index 000000000..d237598fb --- /dev/null +++ b/packages/mobile/src/components/StatBar.tsx @@ -0,0 +1,51 @@ +import React from "react"; +import { View, Text, StyleSheet } from "react-native"; +import type { DashboardStats } from "../types"; + +interface Props { + stats: DashboardStats; +} + +export default function StatBar({ stats }: Props) { + return ( + + + + + + + ); +} + +function StatItem({ label, value, color }: { label: string; value: number; color: string }) { + return ( + + {value} + {label} + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: "row", + justifyContent: "space-around", + backgroundColor: "#161b22", + paddingVertical: 12, + paddingHorizontal: 16, + borderBottomWidth: 1, + borderBottomColor: "#30363d", + }, + item: { + alignItems: "center", + }, + value: { + fontSize: 20, + fontWeight: "700", + }, + label: { + fontSize: 11, + color: "#8b949e", + marginTop: 2, + }, +}); diff --git a/packages/mobile/src/context/BackendContext.tsx b/packages/mobile/src/context/BackendContext.tsx new file mode 100644 index 000000000..542c2074c --- /dev/null +++ b/packages/mobile/src/context/BackendContext.tsx @@ -0,0 +1,136 @@ +import React, { createContext, useContext, useState, useCallback, useEffect } from "react"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import type { DashboardSession, SessionsResponse } from "../types"; + +const STORAGE_KEY = "@ao_backend_url"; +const TERMINAL_WS_OVERRIDE_KEY = "@ao_terminal_ws_url"; +const DEFAULT_URL = "http://192.168.1.1:3000"; + +interface BackendContextValue { + backendUrl: string; + setBackendUrl: (url: string) => Promise; + /** WebSocket URL for the terminal server. Auto-derived unless manually overridden. */ + terminalWsUrl: string; + /** Manual override for terminal WS URL (for ngrok / different host). Empty = auto-derive. */ + terminalWsOverride: string; + setTerminalWsOverride: (url: string) => Promise; + fetchSessions: () => Promise; + fetchSession: (id: string) => Promise; + sendMessage: (id: string, message: string) => Promise; + killSession: (id: string) => Promise; + restoreSession: (id: string) => Promise; +} + +const BackendContext = createContext(null); + +function deriveTerminalWsUrl(backendUrl: string): string { + try { + const url = new URL(backendUrl); + return `ws://${url.hostname}:14801`; + } catch { + return "ws://192.168.1.1:14801"; + } +} + +/** Convert an ngrok https URL to a wss URL for WebSocket connections */ +function normalizeWsUrl(url: string): string { + return url.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://"); +} + +export function BackendProvider({ children }: { children: React.ReactNode }) { + const [backendUrl, setBackendUrlState] = useState(DEFAULT_URL); + const [terminalWsOverride, setTerminalWsOverrideState] = useState(""); + + useEffect(() => { + Promise.all([ + AsyncStorage.getItem(STORAGE_KEY), + AsyncStorage.getItem(TERMINAL_WS_OVERRIDE_KEY), + ]).then(([storedBackend, storedWs]) => { + if (storedBackend) setBackendUrlState(storedBackend); + if (storedWs) setTerminalWsOverrideState(storedWs); + }); + }, []); + + const setBackendUrl = useCallback(async (url: string) => { + const trimmed = url.trim().replace(/\/$/, ""); + setBackendUrlState(trimmed); + await AsyncStorage.setItem(STORAGE_KEY, trimmed); + }, []); + + const setTerminalWsOverride = useCallback(async (url: string) => { + const trimmed = url.trim().replace(/\/$/, ""); + setTerminalWsOverrideState(trimmed); + await AsyncStorage.setItem(TERMINAL_WS_OVERRIDE_KEY, trimmed); + }, []); + + // Use override if set, otherwise derive from backendUrl + const terminalWsUrl = terminalWsOverride + ? normalizeWsUrl(terminalWsOverride) + : deriveTerminalWsUrl(backendUrl); + + const apiFetch = useCallback( + async (path: string, options?: RequestInit) => { + const url = `${backendUrl}${path}`; + const res = await fetch(url, { + ...options, + headers: { "Content-Type": "application/json", ...(options?.headers ?? {}) }, + }); + if (!res.ok) { + const text = await res.text().catch(() => res.statusText); + throw new Error(`${res.status}: ${text}`); + } + return res; + }, + [backendUrl], + ); + + const fetchSessions = useCallback(async (): Promise => { + const res = await apiFetch("/api/sessions"); + return res.json() as Promise; + }, [apiFetch]); + + const fetchSession = useCallback(async (id: string): Promise => { + const res = await apiFetch(`/api/sessions/${encodeURIComponent(id)}`); + return res.json() as Promise; + }, [apiFetch]); + + const sendMessage = useCallback(async (id: string, message: string): Promise => { + await apiFetch(`/api/sessions/${encodeURIComponent(id)}/message`, { + method: "POST", + body: JSON.stringify({ message }), + }); + }, [apiFetch]); + + const killSession = useCallback(async (id: string): Promise => { + await apiFetch(`/api/sessions/${encodeURIComponent(id)}/kill`, { method: "POST" }); + }, [apiFetch]); + + const restoreSession = useCallback(async (id: string): Promise => { + await apiFetch(`/api/sessions/${encodeURIComponent(id)}/restore`, { method: "POST" }); + }, [apiFetch]); + + return ( + + {children} + + ); +} + +export function useBackend(): BackendContextValue { + const ctx = useContext(BackendContext); + if (!ctx) throw new Error("useBackend must be used inside BackendProvider"); + return ctx; +} diff --git a/packages/mobile/src/hooks/useSession.ts b/packages/mobile/src/hooks/useSession.ts new file mode 100644 index 000000000..9b48ef29e --- /dev/null +++ b/packages/mobile/src/hooks/useSession.ts @@ -0,0 +1,78 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { AppState, type AppStateStatus } from "react-native"; +import { useBackend } from "../context/BackendContext"; +import type { DashboardSession } from "../types"; + +const POLL_INTERVAL = 5_000; + +interface UseSessionResult { + session: DashboardSession | null; + loading: boolean; + error: string | null; + refresh: () => void; +} + +export function useSession(id: string): UseSessionResult { + const { fetchSession } = useBackend(); + const [session, setSession] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const intervalRef = useRef | null>(null); + const isMountedRef = useRef(true); + + const doFetch = useCallback(async () => { + try { + const data = await fetchSession(id); + if (!isMountedRef.current) return; + setSession(data); + setError(null); + } catch (err) { + if (!isMountedRef.current) return; + setError(err instanceof Error ? err.message : "Failed to load session"); + } finally { + if (isMountedRef.current) setLoading(false); + } + }, [fetchSession, id]); + + const startPolling = useCallback(() => { + doFetch(); + intervalRef.current = setInterval(doFetch, POLL_INTERVAL); + }, [doFetch]); + + const stopPolling = useCallback(() => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }, []); + + useEffect(() => { + isMountedRef.current = true; + startPolling(); + + const handleAppState = (nextState: AppStateStatus) => { + if (nextState === "active") { + stopPolling(); + startPolling(); + } else { + stopPolling(); + } + }; + + const sub = AppState.addEventListener("change", handleAppState); + + return () => { + isMountedRef.current = false; + stopPolling(); + sub.remove(); + }; + }, [startPolling, stopPolling]); + + const refresh = useCallback(() => { + setLoading(true); + doFetch(); + }, [doFetch]); + + return { session, loading, error, refresh }; +} diff --git a/packages/mobile/src/hooks/useSessionNotifications.ts b/packages/mobile/src/hooks/useSessionNotifications.ts new file mode 100644 index 000000000..fe27b837f --- /dev/null +++ b/packages/mobile/src/hooks/useSessionNotifications.ts @@ -0,0 +1,58 @@ +import { useEffect, useRef } from "react"; +import { AppState } from "react-native"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { getAttentionLevel, type AttentionLevel, type DashboardSession } from "../types"; +import { scheduleNotification } from "../notifications"; +import { NOTIFY_STATE_KEY } from "../notifications/backgroundTask"; + +/** + * Minimum time (ms) before re-notifying for the same session. + * Prevents spam when an agent oscillates between working <-> waiting_input. + */ +const COOLDOWN_MS = 10 * 60 * 1000; // 10 minutes + +export function useSessionNotifications(sessions: DashboardSession[]): void { + const prevLevels = useRef>({}); + // Tracks when we last sent a notification per session + const lastNotifiedAt = useRef>({}); + // Skip the very first render — don't notify for state that existed before app opened + const isFirstRender = useRef(true); + + useEffect(() => { + if (sessions.length === 0) return; + + const nextLevels: Record = {}; + const now = Date.now(); + // Only notify when app is in background — no point interrupting the user + // if they're actively looking at the session list + const isBackground = AppState.currentState !== "active"; + + for (const session of sessions) { + const level = getAttentionLevel(session); + nextLevels[session.id] = level; + + if (!isFirstRender.current) { + const prev = prevLevels.current[session.id]; + const lastNotified = lastNotifiedAt.current[session.id] ?? 0; + const cooldownExpired = now - lastNotified > COOLDOWN_MS; + + if (level === "respond" && (prev !== "respond" || cooldownExpired)) { + if (isBackground || prev !== "respond") { + scheduleNotification(session, "respond"); + lastNotifiedAt.current[session.id] = now; + } + } else if (level === "merge" && (prev !== "merge" || cooldownExpired)) { + if (isBackground || prev !== "merge") { + scheduleNotification(session, "merge"); + lastNotifiedAt.current[session.id] = now; + } + } + } + } + + prevLevels.current = nextLevels; + isFirstRender.current = false; + + void AsyncStorage.setItem(NOTIFY_STATE_KEY, JSON.stringify(nextLevels)); + }, [sessions]); +} diff --git a/packages/mobile/src/hooks/useSessions.ts b/packages/mobile/src/hooks/useSessions.ts new file mode 100644 index 000000000..d640e695a --- /dev/null +++ b/packages/mobile/src/hooks/useSessions.ts @@ -0,0 +1,83 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { AppState, type AppStateStatus } from "react-native"; +import { useBackend } from "../context/BackendContext"; +import type { DashboardSession, DashboardStats } from "../types"; + +const POLL_INTERVAL = 5_000; + +interface UseSessionsResult { + sessions: DashboardSession[]; + stats: DashboardStats | null; + loading: boolean; + error: string | null; + refresh: () => void; +} + +export function useSessions(): UseSessionsResult { + const { fetchSessions } = useBackend(); + const [sessions, setSessions] = useState([]); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const intervalRef = useRef | null>(null); + const isMountedRef = useRef(true); + + const doFetch = useCallback(async () => { + try { + const data = await fetchSessions(); + if (!isMountedRef.current) return; + setSessions(data.sessions ?? []); + setStats(data.stats ?? null); + setError(null); + } catch (err) { + if (!isMountedRef.current) return; + setError(err instanceof Error ? err.message : "Failed to load sessions"); + } finally { + if (isMountedRef.current) setLoading(false); + } + }, [fetchSessions]); + + const startPolling = useCallback(() => { + doFetch(); + intervalRef.current = setInterval(doFetch, POLL_INTERVAL); + }, [doFetch]); + + const stopPolling = useCallback(() => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }, []); + + useEffect(() => { + isMountedRef.current = true; + startPolling(); + + const handleAppState = (nextState: AppStateStatus) => { + if (nextState === "active") { + // Resumed — refresh immediately and restart polling + stopPolling(); + startPolling(); + } else { + // Backgrounded — stop polling to save battery + stopPolling(); + } + }; + + const sub = AppState.addEventListener("change", handleAppState); + + return () => { + isMountedRef.current = false; + stopPolling(); + sub.remove(); + }; + }, [startPolling, stopPolling]); + + const refresh = useCallback(() => { + setLoading(true); + doFetch(); + }, [doFetch]); + + return { sessions, stats, loading, error, refresh }; +} diff --git a/packages/mobile/src/navigation/RootNavigator.tsx b/packages/mobile/src/navigation/RootNavigator.tsx new file mode 100644 index 000000000..fd26b9907 --- /dev/null +++ b/packages/mobile/src/navigation/RootNavigator.tsx @@ -0,0 +1,67 @@ +import React from "react"; +import { NavigationContainer, DarkTheme } from "@react-navigation/native"; +import { createNativeStackNavigator } from "@react-navigation/native-stack"; +import HomeScreen from "../screens/HomeScreen"; +import SessionDetailScreen from "../screens/SessionDetailScreen"; +import TerminalScreen from "../screens/TerminalScreen"; +import SettingsScreen from "../screens/SettingsScreen"; + +export type RootStackParamList = { + Home: undefined; + SessionDetail: { sessionId: string }; + Terminal: { sessionId: string; terminalWsUrl: string }; + Settings: undefined; +}; + +const Stack = createNativeStackNavigator(); + +const AoDarkTheme = { + ...DarkTheme, + colors: { + ...DarkTheme.colors, + background: "#0d1117", + card: "#161b22", + text: "#e6edf3", + border: "#30363d", + primary: "#58a6ff", + }, +}; + +export default function RootNavigator() { + return ( + + + + + + + + + ); +} diff --git a/packages/mobile/src/notifications/backgroundTask.ts b/packages/mobile/src/notifications/backgroundTask.ts new file mode 100644 index 000000000..f2ff96312 --- /dev/null +++ b/packages/mobile/src/notifications/backgroundTask.ts @@ -0,0 +1,80 @@ +import * as TaskManager from "expo-task-manager"; +import * as BackgroundTask from "expo-background-task"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { getAttentionLevel, type AttentionLevel, type DashboardSession, type SessionsResponse } from "../types"; +import { scheduleNotification } from "./index"; + +export const TASK_ID = "ao-session-check"; +const BACKEND_URL_KEY = "@ao_backend_url"; +export const NOTIFY_STATE_KEY = "@ao_notify_state"; +const NOTIFY_TIMESTAMPS_KEY = "@ao_notify_timestamps"; +const COOLDOWN_MS = 10 * 60 * 1000; // 10 minutes + +/** Define the background task globally — must be at module top level. */ +TaskManager.defineTask(TASK_ID, async () => { + try { + const backendUrl = await AsyncStorage.getItem(BACKEND_URL_KEY); + if (!backendUrl) return BackgroundTask.BackgroundTaskResult.Success; + + const res = await fetch(`${backendUrl}/api/sessions`, { + headers: { "Content-Type": "application/json" }, + }); + if (!res.ok) return BackgroundTask.BackgroundTaskResult.Failed; + + const data = (await res.json()) as SessionsResponse; + const sessions: DashboardSession[] = data.sessions ?? []; + + const [prevRaw, tsRaw] = await Promise.all([ + AsyncStorage.getItem(NOTIFY_STATE_KEY), + AsyncStorage.getItem(NOTIFY_TIMESTAMPS_KEY), + ]); + const prevState: Record = prevRaw + ? (JSON.parse(prevRaw) as Record) + : {}; + const timestamps: Record = tsRaw + ? (JSON.parse(tsRaw) as Record) + : {}; + + const nextState: Record = {}; + const now = Date.now(); + + for (const session of sessions) { + const level = getAttentionLevel(session); + nextState[session.id] = level; + + const prev = prevState[session.id]; + const lastNotified = timestamps[session.id] ?? 0; + const cooldownExpired = now - lastNotified > COOLDOWN_MS; + + if (level === "respond" && (prev !== "respond" || cooldownExpired)) { + scheduleNotification(session, "respond"); + timestamps[session.id] = now; + } else if (level === "merge" && (prev !== "merge" || cooldownExpired)) { + scheduleNotification(session, "merge"); + timestamps[session.id] = now; + } + } + + await Promise.all([ + AsyncStorage.setItem(NOTIFY_STATE_KEY, JSON.stringify(nextState)), + AsyncStorage.setItem(NOTIFY_TIMESTAMPS_KEY, JSON.stringify(timestamps)), + ]); + return BackgroundTask.BackgroundTaskResult.Success; + } catch { + return BackgroundTask.BackgroundTaskResult.Failed; + } +}); + +/** Register the background task. Safe to call multiple times. */ +export async function registerBackgroundTask(): Promise { + try { + const isRegistered = await TaskManager.isTaskRegisteredAsync(TASK_ID); + if (isRegistered) return; + + await BackgroundTask.registerTaskAsync(TASK_ID, { + minimumInterval: 15 * 60, // 15 minutes (OS minimum) + }); + } catch { + // Background tasks are not supported in Expo Go — ignore silently + } +} diff --git a/packages/mobile/src/notifications/index.ts b/packages/mobile/src/notifications/index.ts new file mode 100644 index 000000000..85feee713 --- /dev/null +++ b/packages/mobile/src/notifications/index.ts @@ -0,0 +1,75 @@ +import * as Notifications from "expo-notifications"; +import { Platform } from "react-native"; +import type { DashboardSession } from "../types"; + +const ANDROID_CHANNEL_ID = "ao-respond"; + +/** Configure how notifications are presented when the app is in foreground */ +Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowAlert: true, + shouldPlaySound: true, + shouldSetBadge: false, + shouldShowBanner: true, + shouldShowList: true, + }), +}); + +/** Create Android channel + request permission. Call once on app startup. */ +export async function setupNotifications(): Promise { + // Create Android notification channel + if (Platform.OS === "android") { + await Notifications.setNotificationChannelAsync(ANDROID_CHANNEL_ID, { + name: "Agent Input Required", + importance: Notifications.AndroidImportance.HIGH, + vibrationPattern: [0, 250, 250, 250], + lightColor: "#f85149", + }); + } + + const { status } = await Notifications.requestPermissionsAsync({ + ios: { + allowAlert: true, + allowBadge: false, + allowSound: true, + }, + }); + + return status === "granted"; +} + +/** Fire an immediate local notification for a session attention transition. */ +export function scheduleNotification( + session: DashboardSession, + level: "respond" | "merge", +): void { + const sessionLabel = + session.issueLabel ?? + session.id; + + const body = + session.issueTitle ?? + (session.summary && !session.summaryIsFallback ? session.summary : null) ?? + session.activity ?? + session.status; + + const content: Notifications.NotificationContentInput = + level === "respond" + ? { + title: "Agent needs your input", + body: `${sessionLabel}: ${body}`, + data: { sessionId: session.id }, + sound: true, + ...(Platform.OS === "android" && { channelId: ANDROID_CHANNEL_ID }), + } + : { + title: "PR ready to merge", + body: `${sessionLabel}${session.pr ? `: PR #${session.pr.number}` : ""}`, + data: { sessionId: session.id }, + sound: true, + ...(Platform.OS === "android" && { channelId: ANDROID_CHANNEL_ID }), + }; + + // Fire immediately (trigger: null = instant) + void Notifications.scheduleNotificationAsync({ content, trigger: null }); +} diff --git a/packages/mobile/src/screens/HomeScreen.tsx b/packages/mobile/src/screens/HomeScreen.tsx new file mode 100644 index 000000000..da3a02454 --- /dev/null +++ b/packages/mobile/src/screens/HomeScreen.tsx @@ -0,0 +1,164 @@ +import React from "react"; +import { + View, + Text, + FlatList, + StyleSheet, + RefreshControl, + TouchableOpacity, + ActivityIndicator, +} from "react-native"; +import type { NativeStackScreenProps } from "@react-navigation/native-stack"; +import type { RootStackParamList } from "../navigation/RootNavigator"; +import { useSessions } from "../hooks/useSessions"; +import { useSessionNotifications } from "../hooks/useSessionNotifications"; +import SessionCard from "../components/SessionCard"; +import StatBar from "../components/StatBar"; +import { getAttentionLevel, type DashboardSession } from "../types"; + +type Props = NativeStackScreenProps; + +const ATTENTION_ORDER = ["respond", "merge", "review", "pending", "working", "done"] as const; + +function sortSessions(sessions: DashboardSession[]): DashboardSession[] { + return [...sessions].sort((a, b) => { + const la = getAttentionLevel(a); + const lb = getAttentionLevel(b); + const ia = ATTENTION_ORDER.indexOf(la); + const ib = ATTENTION_ORDER.indexOf(lb); + if (ia !== ib) return ia - ib; + return new Date(b.lastActivityAt).getTime() - new Date(a.lastActivityAt).getTime(); + }); +} + +export default function HomeScreen({ navigation }: Props) { + const { sessions, stats, loading, error, refresh } = useSessions(); + useSessionNotifications(sessions); + + React.useLayoutEffect(() => { + navigation.setOptions({ + headerRight: () => ( + navigation.navigate("Settings")} + style={{ paddingRight: 4 }} + > + Settings + + ), + }); + }, [navigation]); + + const sorted = sortSessions(sessions); + + if (loading && sessions.length === 0) { + return ( + + + Connecting... + + ); + } + + if (error && sessions.length === 0) { + return ( + + {error} + + Retry + + navigation.navigate("Settings")} + > + Configure Backend URL + + + ); + } + + return ( + + {stats && } + item.id} + renderItem={({ item }) => ( + navigation.navigate("SessionDetail", { sessionId: item.id })} + /> + )} + refreshControl={ + + } + contentContainerStyle={ + sorted.length === 0 ? styles.emptyContainer : styles.listContent + } + ListEmptyComponent={ + + No sessions + Sessions will appear here when agents are running + + } + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "#0d1117", + }, + center: { + flex: 1, + alignItems: "center", + justifyContent: "center", + padding: 24, + }, + emptyContainer: { + flex: 1, + }, + listContent: { + paddingVertical: 8, + }, + loadingText: { + color: "#8b949e", + marginTop: 12, + fontSize: 14, + }, + errorText: { + color: "#f85149", + fontSize: 14, + textAlign: "center", + marginBottom: 16, + }, + retryButton: { + backgroundColor: "#21262d", + borderWidth: 1, + borderColor: "#30363d", + borderRadius: 6, + paddingHorizontal: 16, + paddingVertical: 8, + }, + retryText: { + color: "#e6edf3", + fontSize: 14, + }, + emptyText: { + color: "#e6edf3", + fontSize: 16, + fontWeight: "600", + marginBottom: 8, + }, + emptySubtext: { + color: "#8b949e", + fontSize: 13, + textAlign: "center", + }, +}); diff --git a/packages/mobile/src/screens/SessionDetailScreen.tsx b/packages/mobile/src/screens/SessionDetailScreen.tsx new file mode 100644 index 000000000..a808bf84f --- /dev/null +++ b/packages/mobile/src/screens/SessionDetailScreen.tsx @@ -0,0 +1,414 @@ +import React, { useState, useCallback } from "react"; +import { + View, + Text, + StyleSheet, + ScrollView, + TextInput, + TouchableOpacity, + Alert, + ActivityIndicator, + KeyboardAvoidingView, + Platform, +} from "react-native"; +import type { NativeStackScreenProps } from "@react-navigation/native-stack"; +import type { RootStackParamList } from "../navigation/RootNavigator"; +import { useSession } from "../hooks/useSession"; +import { useBackend } from "../context/BackendContext"; +import AttentionBadge from "../components/AttentionBadge"; +import { + getAttentionLevel, + relativeTime, + isRestorable, + isTerminal, + ATTENTION_COLORS, +} from "../types"; + +type Props = NativeStackScreenProps; + +export default function SessionDetailScreen({ route, navigation }: Props) { + const { sessionId } = route.params; + const { session, loading, error, refresh } = useSession(sessionId); + const { sendMessage, killSession, restoreSession, terminalWsUrl } = useBackend(); + const [message, setMessage] = useState(""); + const [sending, setSending] = useState(false); + + const handleSend = useCallback(async () => { + if (!message.trim()) return; + setSending(true); + try { + await sendMessage(sessionId, message.trim()); + setMessage(""); + } catch (err) { + Alert.alert("Error", err instanceof Error ? err.message : "Failed to send message"); + } finally { + setSending(false); + } + }, [message, sendMessage, sessionId]); + + const handleKill = useCallback(() => { + Alert.alert( + "Kill Session", + "This will terminate the agent process. Are you sure?", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Kill", + style: "destructive", + onPress: async () => { + try { + await killSession(sessionId); + refresh(); + } catch (err) { + Alert.alert("Error", err instanceof Error ? err.message : "Failed to kill session"); + } + }, + }, + ], + ); + }, [killSession, sessionId, refresh]); + + const handleRestore = useCallback(async () => { + try { + await restoreSession(sessionId); + refresh(); + } catch (err) { + Alert.alert("Error", err instanceof Error ? err.message : "Failed to restore session"); + } + }, [restoreSession, sessionId, refresh]); + + const handleOpenTerminal = useCallback(() => { + navigation.navigate("Terminal", { sessionId, terminalWsUrl }); + }, [navigation, sessionId, terminalWsUrl]); + + if (loading && !session) { + return ( + + + + ); + } + + if (error && !session) { + return ( + + {error} + + Retry + + + ); + } + + if (!session) return null; + + const level = getAttentionLevel(session); + const color = ATTENTION_COLORS[level]; + const canRestore = isRestorable(session); + const isDone = isTerminal(session); + + return ( + + + {/* Header row */} + + + + {session.id} + + + + + {session.status} + {session.activity ? ` · ${session.activity}` : ""} + + + + {/* Issue */} + {(session.issueLabel || session.issueTitle) && ( +
+ + {session.issueLabel ? `${session.issueLabel}: ` : ""} + {session.issueTitle ?? ""} + +
+ )} + + {/* Summary */} + {session.summary && !session.summaryIsFallback && ( +
+ {session.summary} +
+ )} + + {/* Branch */} + {session.branch && ( +
+ {session.branch} +
+ )} + + {/* PR */} + {session.pr && ( +
+ + + + + {session.pr.additions > 0 && ( + + )} +
+ )} + + {/* Timestamps */} +
+ + +
+ + {/* Actions */} + + + Open Terminal + + + {canRestore && ( + + Restore Session + + )} + + {!isDone && ( + + Kill Session + + )} + +
+ + {/* Message input — only show for active sessions */} + {!isDone && ( + + + + {sending ? ( + + ) : ( + Send + )} + + + )} +
+ ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( + + {title} + {children} + + ); +} + +function InfoRow({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: "#0d1117", + }, + container: { + flex: 1, + }, + content: { + padding: 12, + paddingBottom: 24, + }, + center: { + flex: 1, + alignItems: "center", + justifyContent: "center", + padding: 24, + backgroundColor: "#0d1117", + }, + headerCard: { + backgroundColor: "#161b22", + borderLeftWidth: 3, + borderRadius: 8, + padding: 14, + marginBottom: 12, + }, + headerRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 6, + }, + sessionId: { + color: "#8b949e", + fontSize: 13, + fontFamily: "monospace", + flex: 1, + marginRight: 8, + }, + status: { + color: "#8b949e", + fontSize: 13, + }, + section: { + backgroundColor: "#161b22", + borderRadius: 8, + padding: 14, + marginBottom: 10, + }, + sectionTitle: { + color: "#8b949e", + fontSize: 11, + fontWeight: "700", + letterSpacing: 0.8, + textTransform: "uppercase", + marginBottom: 8, + }, + bodyText: { + color: "#e6edf3", + fontSize: 14, + lineHeight: 20, + }, + issueText: { + color: "#e6edf3", + fontSize: 14, + fontWeight: "600", + }, + monoText: { + color: "#58a6ff", + fontSize: 13, + fontFamily: "monospace", + }, + infoRow: { + flexDirection: "row", + justifyContent: "space-between", + paddingVertical: 4, + }, + infoLabel: { + color: "#8b949e", + fontSize: 13, + }, + infoValue: { + color: "#e6edf3", + fontSize: 13, + fontWeight: "500", + }, + actionsSection: { + gap: 8, + marginTop: 4, + }, + actionButton: { + borderRadius: 8, + padding: 14, + alignItems: "center", + borderWidth: 1, + }, + terminalButton: { + backgroundColor: "#21262d", + borderColor: "#30363d", + }, + restoreButton: { + backgroundColor: "#1f3a2a", + borderColor: "#3fb950", + }, + killButton: { + backgroundColor: "#21262d", + borderColor: "#30363d", + }, + actionButtonText: { + color: "#e6edf3", + fontSize: 15, + fontWeight: "600", + }, + messageBar: { + flexDirection: "row", + alignItems: "flex-end", + padding: 10, + backgroundColor: "#161b22", + borderTopWidth: 1, + borderTopColor: "#30363d", + gap: 8, + }, + messageInput: { + flex: 1, + backgroundColor: "#0d1117", + borderWidth: 1, + borderColor: "#30363d", + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 8, + color: "#e6edf3", + fontSize: 14, + maxHeight: 100, + }, + sendButton: { + backgroundColor: "#238636", + borderRadius: 8, + paddingHorizontal: 16, + height: 40, + alignItems: "center", + justifyContent: "center", + }, + sendButtonDisabled: { + backgroundColor: "#21262d", + }, + sendButtonText: { + color: "#fff", + fontSize: 14, + fontWeight: "700", + }, + button: { + backgroundColor: "#21262d", + borderWidth: 1, + borderColor: "#30363d", + borderRadius: 6, + paddingHorizontal: 16, + paddingVertical: 8, + }, + buttonText: { + color: "#e6edf3", + fontSize: 14, + }, + errorText: { + color: "#f85149", + fontSize: 14, + textAlign: "center", + marginBottom: 16, + }, +}); diff --git a/packages/mobile/src/screens/SettingsScreen.tsx b/packages/mobile/src/screens/SettingsScreen.tsx new file mode 100644 index 000000000..d971df3b5 --- /dev/null +++ b/packages/mobile/src/screens/SettingsScreen.tsx @@ -0,0 +1,367 @@ +import React, { useState } from "react"; +import { + View, + Text, + TextInput, + TouchableOpacity, + StyleSheet, + ScrollView, + Alert, + KeyboardAvoidingView, + Platform, +} from "react-native"; +import type { NativeStackScreenProps } from "@react-navigation/native-stack"; +import type { RootStackParamList } from "../navigation/RootNavigator"; +import { useBackend } from "../context/BackendContext"; +import { scheduleNotification } from "../notifications"; +import * as Notifications from "expo-notifications"; + +type Props = NativeStackScreenProps; + +export default function SettingsScreen({ navigation }: Props) { + const { backendUrl, setBackendUrl, terminalWsUrl, terminalWsOverride, setTerminalWsOverride } = useBackend(); + const [input, setInput] = useState(backendUrl); + const [wsInput, setWsInput] = useState(terminalWsOverride); + const [saving, setSaving] = useState(false); + + const handleTestRespondNotification = async () => { + const { status } = await Notifications.getPermissionsAsync(); + if (status !== "granted") { + Alert.alert("Permission denied", "Notification permission is not granted. Enable it in your phone's Settings app."); + return; + } + scheduleNotification( + { + id: "ao-test-session", + projectId: "test", + status: "needs_input", + activity: "waiting_input", + branch: "feat/test", + issueId: null, + issueUrl: null, + issueLabel: "TEST-1", + issueTitle: "Fix the flaky integration test", + summary: "Waiting for your decision on the approach", + summaryIsFallback: false, + createdAt: new Date().toISOString(), + lastActivityAt: new Date().toISOString(), + pr: null, + metadata: {}, + }, + "respond", + ); + Alert.alert("Sent", "A test 'respond' notification was fired. Check your notification shade."); + }; + + const handleTestMergeNotification = async () => { + const { status } = await Notifications.getPermissionsAsync(); + if (status !== "granted") { + Alert.alert("Permission denied", "Notification permission is not granted. Enable it in your phone's Settings app."); + return; + } + scheduleNotification( + { + id: "ao-test-session", + projectId: "test", + status: "mergeable", + activity: "idle", + branch: "feat/test", + issueId: null, + issueUrl: null, + issueLabel: "TEST-1", + issueTitle: "Add user authentication flow", + summary: null, + summaryIsFallback: false, + createdAt: new Date().toISOString(), + lastActivityAt: new Date().toISOString(), + pr: { + number: 42, + url: "", + title: "Add auth flow", + owner: "", + repo: "", + branch: "feat/test", + baseBranch: "main", + isDraft: false, + state: "open", + additions: 120, + deletions: 8, + ciStatus: "passing", + ciChecks: [], + reviewDecision: "approved", + mergeability: { mergeable: true, ciPassing: true, approved: true, noConflicts: true, blockers: [] }, + unresolvedThreads: 0, + }, + metadata: {}, + }, + "merge", + ); + Alert.alert("Sent", "A test 'merge' notification was fired. Check your notification shade."); + }; + + const handleSave = async () => { + const trimmed = input.trim(); + if (!trimmed) { + Alert.alert("Invalid URL", "Please enter a valid backend URL."); + return; + } + if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) { + Alert.alert("Invalid URL", "URL must start with http:// or https://"); + return; + } + setSaving(true); + try { + await setBackendUrl(trimmed); + await setTerminalWsOverride(wsInput.trim()); + Alert.alert("Saved", "Settings updated.", [ + { text: "OK", onPress: () => navigation.goBack() }, + ]); + } finally { + setSaving(false); + } + }; + + return ( + + + + Backend URL + + Local network: enter your Mac's LAN IP.{"\n"} + ngrok: paste the https:// tunnel URL for port 3000. + + Dashboard API URL + + + Terminal WebSocket URL{" "} + (leave blank to auto-derive) + + + + {saving ? "Saving..." : "Save"} + + + + + Active URLs + + + + + + Test Notifications + Fire a test notification to verify permissions are working. + + Test "Agent needs input" (respond) + + + Test "PR ready to merge" (merge) + + + + + Setup Guide + + + + + + + + + ); +} + +function SettingsInfoRow({ label, value, note }: { label: string; value: string; note?: string }) { + return ( + + + {label} + {note ? {note} : null} + + + {value} + + + ); +} + +function Step({ n, text }: { n: string; text: string }) { + return ( + + + {n} + + {text} + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: "#0d1117", + }, + container: { + flex: 1, + }, + content: { + padding: 14, + paddingBottom: 32, + gap: 12, + }, + section: { + backgroundColor: "#161b22", + borderRadius: 10, + padding: 16, + }, + sectionTitle: { + color: "#8b949e", + fontSize: 11, + fontWeight: "700", + letterSpacing: 0.8, + textTransform: "uppercase", + marginBottom: 12, + }, + hint: { + color: "#8b949e", + fontSize: 13, + lineHeight: 18, + marginBottom: 12, + }, + input: { + backgroundColor: "#0d1117", + borderWidth: 1, + borderColor: "#30363d", + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 10, + color: "#e6edf3", + fontSize: 14, + fontFamily: "monospace", + marginBottom: 12, + }, + saveButton: { + backgroundColor: "#238636", + borderRadius: 8, + paddingVertical: 12, + alignItems: "center", + }, + saveButtonDisabled: { + backgroundColor: "#21262d", + }, + saveButtonText: { + color: "#fff", + fontSize: 15, + fontWeight: "600", + }, + testButton: { + borderWidth: 1, + borderRadius: 8, + paddingVertical: 12, + alignItems: "center", + }, + testButtonText: { + fontSize: 14, + fontWeight: "600", + }, + infoRow: { + flexDirection: "row", + justifyContent: "space-between", + paddingVertical: 6, + }, + infoLabel: { + color: "#8b949e", + fontSize: 13, + width: 100, + }, + infoValue: { + color: "#58a6ff", + fontSize: 13, + fontFamily: "monospace", + flex: 1, + textAlign: "right", + }, + infoLabelRow: { + flexDirection: "column", + width: 100, + }, + infoNote: { + color: "#8b949e", + fontSize: 10, + marginTop: 1, + }, + fieldLabel: { + color: "#8b949e", + fontSize: 12, + fontWeight: "600", + marginBottom: 6, + }, + fieldLabelMuted: { + color: "#6e7681", + fontWeight: "400", + }, + step: { + flexDirection: "row", + alignItems: "flex-start", + marginBottom: 12, + gap: 10, + }, + stepBadge: { + backgroundColor: "#21262d", + borderRadius: 10, + width: 20, + height: 20, + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + }, + stepN: { + color: "#58a6ff", + fontSize: 11, + fontWeight: "700", + }, + stepText: { + color: "#8b949e", + fontSize: 13, + lineHeight: 18, + flex: 1, + }, +}); diff --git a/packages/mobile/src/screens/TerminalScreen.tsx b/packages/mobile/src/screens/TerminalScreen.tsx new file mode 100644 index 000000000..6abde65e8 --- /dev/null +++ b/packages/mobile/src/screens/TerminalScreen.tsx @@ -0,0 +1,122 @@ +import React, { useRef, useCallback, useState } from "react"; +import { View, StyleSheet, StatusBar, Text } from "react-native"; +import { WebView, type WebViewMessageEvent } from "react-native-webview"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import type { NativeStackScreenProps } from "@react-navigation/native-stack"; +import type { RootStackParamList } from "../navigation/RootNavigator"; + +import { TERMINAL_HTML } from "../terminal/terminal-html"; + +type Props = NativeStackScreenProps; + +type ConnectionStatus = "connecting" | "connected" | "error" | "disconnected"; + +const STATUS_COLORS: Record = { + connecting: "#e3b341", + connected: "#3fb950", + error: "#f85149", + disconnected: "#f85149", +}; + +export default function TerminalScreen({ route }: Props) { + const { sessionId, terminalWsUrl } = route.params; + const webViewRef = useRef(null); + const insets = useSafeAreaInsets(); + const [status, setStatus] = useState("connecting"); + + // Inject WS URL and session ID BEFORE xterm.js init runs + const injectedJS = ` + window.AO_WS_URL = ${JSON.stringify(terminalWsUrl)}; + window.AO_SESSION_ID = ${JSON.stringify(sessionId)}; + true; // required return value + `; + + const handleMessage = useCallback((event: WebViewMessageEvent) => { + try { + const msg = JSON.parse(event.nativeEvent.data) as { + type: string; + state?: ConnectionStatus; + }; + if (msg.type === "status" && msg.state) { + setStatus(msg.state); + } + } catch { + // ignore unparseable messages + } + }, []); + + const handleLoad = useCallback(() => { + // Ask xterm to fit to current viewport + webViewRef.current?.injectJavaScript( + 'window.dispatchEvent(new MessageEvent("message", { data: JSON.stringify({ type: "fit" }) })); true;', + ); + }, []); + + const dotColor = STATUS_COLORS[status]; + + return ( + + + + {/* Status indicator in top-right */} + + + + {status} + + + + {}} + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "#0d1117", + }, + webView: { + flex: 1, + backgroundColor: "#0d1117", + }, + statusDot: { + position: "absolute", + top: 8, + right: 12, + flexDirection: "row", + alignItems: "center", + zIndex: 10, + gap: 4, + }, + dot: { + width: 8, + height: 8, + borderRadius: 4, + }, + statusText: { + fontSize: 11, + fontWeight: "600", + }, +}); diff --git a/packages/mobile/src/terminal/terminal-html.ts b/packages/mobile/src/terminal/terminal-html.ts new file mode 100644 index 000000000..00b3283b3 --- /dev/null +++ b/packages/mobile/src/terminal/terminal-html.ts @@ -0,0 +1,177 @@ +/** + * Standalone xterm.js terminal page, loaded into WebView. + * WS URL and session ID are injected via injectedJavaScriptBeforeContentLoaded + * which sets window.AO_WS_URL and window.AO_SESSION_ID before the script runs. + */ +export const TERMINAL_HTML = ` + + + + +Terminal +