diff --git a/eslint.config.js b/eslint.config.js index c30ff0f15..cc1167afc 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -15,6 +15,7 @@ export default tseslint.config( "packages/web/postcss.config.mjs", "test-clipboard*.mjs", "test-clipboard*.sh", + "packages/mobile/**", ], }, 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..ad1e56dab --- /dev/null +++ b/packages/mobile/app.json @@ -0,0 +1,55 @@ +{ + "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", + "usesCleartextTraffic": true + }, + "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 000000000..9e2529313 Binary files /dev/null and b/packages/mobile/assets/adaptive-icon.png differ diff --git a/packages/mobile/assets/favicon.png b/packages/mobile/assets/favicon.png new file mode 100644 index 000000000..9e2529313 Binary files /dev/null and b/packages/mobile/assets/favicon.png differ diff --git a/packages/mobile/assets/icon.png b/packages/mobile/assets/icon.png new file mode 100644 index 000000000..9e2529313 Binary files /dev/null and b/packages/mobile/assets/icon.png differ diff --git a/packages/mobile/assets/splash.png b/packages/mobile/assets/splash.png new file mode 100644 index 000000000..9e2529313 Binary files /dev/null and b/packages/mobile/assets/splash.png differ 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..c44f3c870 --- /dev/null +++ b/packages/mobile/src/App.tsx @@ -0,0 +1,51 @@ +import React, { useEffect } from "react"; +import { SafeAreaProvider } from "react-native-safe-area-context"; +import * as Notifications from "expo-notifications"; +import { BackendProvider } from "./context/BackendContext"; +import RootNavigator from "./navigation/RootNavigator"; +import { navigationRef } from "./navigation/RootNavigator"; +import { setupNotifications } from "./notifications"; +import { registerBackgroundTask } from "./notifications/backgroundTask"; + +/** Navigate to a session when a notification is tapped. */ +function handleNotificationResponse(response: Notifications.NotificationResponse) { + const data = response.notification.request.content.data as + | { sessionId?: string } + | undefined; + const sessionId = data?.sessionId; + if (!sessionId) return; + + // Wait for navigation to be ready (cold-start case) + const tryNavigate = () => { + if (navigationRef.isReady()) { + navigationRef.navigate("SessionDetail", { sessionId }); + } else { + setTimeout(tryNavigate, 100); + } + }; + tryNavigate(); +} + +export default function App() { + useEffect(() => { + void setupNotifications(); + void registerBackgroundTask(); + + // Handle notification tapped while app was killed or in background + void Notifications.getLastNotificationResponseAsync().then((response) => { + if (response) handleNotificationResponse(response); + }); + + // Handle notification tapped while app is running + const sub = Notifications.addNotificationResponseReceivedListener(handleNotificationResponse); + return () => sub.remove(); + }, []); + + 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..2ec784949 --- /dev/null +++ b/packages/mobile/src/context/BackendContext.tsx @@ -0,0 +1,153 @@ +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; + mergePR: (prNumber: number) => Promise; + spawnSession: (projectId: string, issueId?: 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]); + + const mergePR = useCallback(async (prNumber: number): Promise => { + await apiFetch(`/api/prs/${prNumber}/merge`, { method: "POST" }); + }, [apiFetch]); + + const spawnSession = useCallback(async (projectId: string, issueId?: string): Promise => { + const res = await apiFetch("/api/spawn", { + method: "POST", + body: JSON.stringify({ projectId, ...(issueId ? { issueId } : {}) }), + }); + const data = (await res.json()) as { session: DashboardSession }; + return data.session; + }, [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..7bdf901ca --- /dev/null +++ b/packages/mobile/src/hooks/useSession.ts @@ -0,0 +1,93 @@ +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 UseSessionOptions { + /** Set to false to disable polling. Defaults to true. */ + enabled?: boolean; +} + +interface UseSessionResult { + session: DashboardSession | null; + loading: boolean; + error: string | null; + refresh: () => void; +} + +export function useSession(id: string, options?: UseSessionOptions): UseSessionResult { + const enabled = options?.enabled ?? true; + const { fetchSession } = useBackend(); + const [session, setSession] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const intervalRef = useRef | null>(null); + // Generation counter — incremented on cleanup to invalidate in-flight fetches + // from a previous effect run (e.g. when backend URL changes). + const fetchGenRef = useRef(0); + + const doFetch = useCallback(async () => { + const gen = fetchGenRef.current; + try { + const data = await fetchSession(id); + if (gen !== fetchGenRef.current) return; + setSession(data); + setError(null); + } catch (err) { + if (gen !== fetchGenRef.current) return; + setError(err instanceof Error ? err.message : "Failed to load session"); + } finally { + if (gen === fetchGenRef.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(() => { + if (!enabled) { + setSession(null); + setLoading(false); + return; + } + + startPolling(); + + const handleAppState = (nextState: AppStateStatus) => { + if (nextState === "active") { + stopPolling(); + startPolling(); + } else { + stopPolling(); + } + }; + + const sub = AppState.addEventListener("change", handleAppState); + + return () => { + fetchGenRef.current++; // Invalidate in-flight fetches from this effect run + stopPolling(); + sub.remove(); + }; + }, [enabled, startPolling, stopPolling]); + + const refresh = useCallback(() => { + if (!enabled) return; + setLoading(true); + doFetch(); + }, [enabled, 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..29aea2956 --- /dev/null +++ b/packages/mobile/src/hooks/useSessionNotifications.ts @@ -0,0 +1,63 @@ +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") { + void scheduleNotification(session, "respond").catch(() => {}); + lastNotifiedAt.current[session.id] = now; + } + } else if (level === "merge" && (prev !== "merge" || cooldownExpired)) { + if (isBackground || prev !== "merge") { + void scheduleNotification(session, "merge").catch(() => {}); + lastNotifiedAt.current[session.id] = now; + } + } else if (level === "review" && (prev !== "review" || cooldownExpired)) { + if (isBackground || prev !== "review") { + void scheduleNotification(session, "review").catch(() => {}); + 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..e7891ecdc --- /dev/null +++ b/packages/mobile/src/hooks/useSessions.ts @@ -0,0 +1,88 @@ +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; + orchestratorId: string | 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 [orchestratorId, setOrchestratorId] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const intervalRef = useRef | null>(null); + // Generation counter — incremented on cleanup to invalidate in-flight fetches + // from a previous effect run (e.g. when backend URL changes). + const fetchGenRef = useRef(0); + + const doFetch = useCallback(async () => { + const gen = fetchGenRef.current; + try { + const data = await fetchSessions(); + if (gen !== fetchGenRef.current) return; + setSessions(data.sessions ?? []); + setStats(data.stats ?? null); + setOrchestratorId(data.orchestratorId ?? null); + setError(null); + } catch (err) { + if (gen !== fetchGenRef.current) return; + setError(err instanceof Error ? err.message : "Failed to load sessions"); + } finally { + if (gen === fetchGenRef.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(() => { + 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 () => { + fetchGenRef.current++; // Invalidate in-flight fetches from this effect run + stopPolling(); + sub.remove(); + }; + }, [startPolling, stopPolling]); + + const refresh = useCallback(() => { + setLoading(true); + doFetch(); + }, [doFetch]); + + return { sessions, stats, orchestratorId, 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..9939907e4 --- /dev/null +++ b/packages/mobile/src/navigation/RootNavigator.tsx @@ -0,0 +1,90 @@ +import React from "react"; +import { NavigationContainer, DarkTheme, createNavigationContainerRef } 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"; +import SpawnSessionScreen from "../screens/SpawnSessionScreen"; +import OrchestratorScreen from "../screens/OrchestratorScreen"; +import CommandsScreen from "../screens/CommandsScreen"; + +export type RootStackParamList = { + Home: undefined; + SessionDetail: { sessionId: string }; + Terminal: { sessionId: string; terminalWsUrl: string }; + Settings: undefined; + SpawnSession: undefined; + Orchestrator: undefined; + Commands: undefined; +}; + +export const navigationRef = createNavigationContainerRef(); + +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..2e2132e60 --- /dev/null +++ b/packages/mobile/src/notifications/backgroundTask.ts @@ -0,0 +1,83 @@ +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)) { + await scheduleNotification(session, "respond"); + timestamps[session.id] = now; + } else if (level === "merge" && (prev !== "merge" || cooldownExpired)) { + await scheduleNotification(session, "merge"); + timestamps[session.id] = now; + } else if (level === "review" && (prev !== "review" || cooldownExpired)) { + await scheduleNotification(session, "review"); + 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..9808399ad --- /dev/null +++ b/packages/mobile/src/notifications/index.ts @@ -0,0 +1,86 @@ +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 async function scheduleNotification( + session: DashboardSession, + level: "respond" | "merge" | "review", +): Promise { + const sessionLabel = + session.issueLabel ?? + session.id; + + const body = + session.issueTitle ?? + (session.summary && !session.summaryIsFallback ? session.summary : null) ?? + session.activity ?? + session.status; + + const titles: Record = { + respond: "Agent needs your input", + merge: "PR ready to merge", + review: "Session needs review", + }; + + const bodies: Record = { + respond: `${sessionLabel}: ${body}`, + merge: `${sessionLabel}${session.pr ? `: PR #${session.pr.number}` : ""}`, + review: `${sessionLabel}: ${session.pr?.ciStatus === "failing" ? "CI failing" : session.pr?.reviewDecision === "changes_requested" ? "Changes requested" : body}`, + }; + + const content: Notifications.NotificationContentInput = { + title: titles[level], + body: bodies[level], + data: { sessionId: session.id }, + sound: true, + ...(Platform.OS === "android" && { channelId: ANDROID_CHANNEL_ID }), + }; + + // Use timeInterval trigger instead of null — trigger: null fails silently on Android in background tasks. + // SDK 53 requires explicit `type` field on trigger objects. + await Notifications.scheduleNotificationAsync({ + content, + trigger: { + type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL, + seconds: 1, + channelId: Platform.OS === "android" ? ANDROID_CHANNEL_ID : undefined, + }, + }); +} diff --git a/packages/mobile/src/screens/CommandsScreen.tsx b/packages/mobile/src/screens/CommandsScreen.tsx new file mode 100644 index 000000000..b584c78d6 --- /dev/null +++ b/packages/mobile/src/screens/CommandsScreen.tsx @@ -0,0 +1,75 @@ +import React from "react"; +import { View, Text, StyleSheet, ScrollView } from "react-native"; + +const CLI_COMMANDS = [ + { cmd: "ao start [project]", desc: "Start orchestrator + dashboard" }, + { cmd: "ao stop [project]", desc: "Stop orchestrator + dashboard" }, + { cmd: "ao spawn [issue]", desc: "Spawn a session for an issue" }, + { cmd: "ao batch-spawn ", desc: "Spawn multiple sessions" }, + { cmd: "ao session ls [-p ]", desc: "List all active sessions" }, + { cmd: "ao session kill ", desc: "Kill a session" }, + { cmd: "ao session restore ", desc: "Restore a crashed session" }, + { cmd: "ao session cleanup [-p ]", desc: "Clean up merged/closed sessions" }, + { cmd: "ao send [message]", desc: "Send message to a session" }, + { cmd: "ao status [-p ]", desc: "Show sessions with PR/CI status" }, + { cmd: "ao review-check [project]", desc: "Check PRs and trigger agents" }, + { cmd: "ao dashboard [-p ]", desc: "Start the web dashboard" }, + { cmd: "ao open [target]", desc: "Open session(s) in terminal" }, + { cmd: "ao init [project]", desc: "Initialize config file" }, +]; + +export default function CommandsScreen() { + return ( + + + Quick reference for managing sessions from terminal. + {CLI_COMMANDS.map((c, i) => ( + + {c.cmd} + {c.desc} + + ))} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "#0d1117", + }, + content: { + padding: 14, + paddingBottom: 32, + }, + section: { + backgroundColor: "#161b22", + borderRadius: 10, + padding: 16, + }, + hint: { + color: "#8b949e", + fontSize: 13, + lineHeight: 18, + marginBottom: 12, + }, + cmdRow: { + backgroundColor: "#0d1117", + borderRadius: 6, + padding: 10, + marginBottom: 6, + borderWidth: 1, + borderColor: "#30363d", + }, + cmdText: { + color: "#58a6ff", + fontSize: 12, + fontFamily: "monospace", + marginBottom: 4, + }, + cmdDesc: { + color: "#8b949e", + fontSize: 12, + }, +}); diff --git a/packages/mobile/src/screens/HomeScreen.tsx b/packages/mobile/src/screens/HomeScreen.tsx new file mode 100644 index 000000000..d1015d14f --- /dev/null +++ b/packages/mobile/src/screens/HomeScreen.tsx @@ -0,0 +1,176 @@ +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("SpawnSession")} + style={{ flexDirection: "row", alignItems: "center", gap: 2 }} + > + + + Session + + navigation.navigate("Orchestrator")}> + {"\uD83E\uDD16"} + + navigation.navigate("Settings")} + style={{ paddingRight: 4 }} + > + {"\u2699\uFE0F"} + + + ), + }); + }, [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/OrchestratorScreen.tsx b/packages/mobile/src/screens/OrchestratorScreen.tsx new file mode 100644 index 000000000..8d386a221 --- /dev/null +++ b/packages/mobile/src/screens/OrchestratorScreen.tsx @@ -0,0 +1,370 @@ +import React, { useState, useCallback } from "react"; +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + ActivityIndicator, + TextInput, + Alert, +} from "react-native"; +import type { NativeStackScreenProps } from "@react-navigation/native-stack"; +import type { RootStackParamList } from "../navigation/RootNavigator"; +import { useSessions } from "../hooks/useSessions"; +import { useSession } from "../hooks/useSession"; +import { useBackend } from "../context/BackendContext"; +import AttentionBadge from "../components/AttentionBadge"; +import { + getAttentionLevel, + relativeTime, + ATTENTION_COLORS, + type DashboardSession, +} from "../types"; + +type Props = NativeStackScreenProps; + +function getZoneCounts(sessions: DashboardSession[]) { + const counts = { merge: 0, respond: 0, review: 0, pending: 0, working: 0, done: 0 }; + for (const s of sessions) { + const level = getAttentionLevel(s); + counts[level]++; + } + return counts; +} + +export default function OrchestratorScreen({ navigation }: Props) { + const { sessions, orchestratorId, loading, error, refresh } = useSessions(); + const { sendMessage, terminalWsUrl } = useBackend(); + const { session: orchSession } = useSession(orchestratorId ?? "", { enabled: !!orchestratorId }); + const [message, setMessage] = useState(""); + const [sending, setSending] = useState(false); + + React.useLayoutEffect(() => { + navigation.setOptions({ + headerRight: () => ( + navigation.navigate("Commands")}> + Commands + + ), + }); + }, [navigation]); + + const zones = getZoneCounts(sessions); + + const handleSend = useCallback(async () => { + if (!message.trim() || !orchestratorId) return; + setSending(true); + try { + await sendMessage(orchestratorId, message.trim()); + setMessage(""); + } catch (err) { + Alert.alert("Error", err instanceof Error ? err.message : "Failed to send message"); + } finally { + setSending(false); + } + }, [message, sendMessage, orchestratorId]); + + if (loading && sessions.length === 0) { + return ( + + + + ); + } + + if (error && sessions.length === 0) { + return ( + + {error} + + Retry + + + ); + } + + const orchLevel = orchSession ? getAttentionLevel(orchSession) : null; + const orchColor = orchLevel ? ATTENTION_COLORS[orchLevel] : "#8b949e"; + + return ( + + {/* Orchestrator Session Details */} + + Orchestrator + {orchestratorId && orchSession ? ( + + + + + Running + + + + {orchestratorId} + + {orchSession.status} + {orchSession.activity ? ` · ${orchSession.activity}` : ""} + + {orchSession.summary && !orchSession.summaryIsFallback && ( + {orchSession.summary} + )} + + Last activity: {relativeTime(orchSession.lastActivityAt)} + + + {/* Actions */} + + navigation.navigate("Terminal", { sessionId: orchestratorId, terminalWsUrl })} + > + Open Terminal + + + + {/* Send message */} + + + + {sending ? ( + + ) : ( + Send + )} + + + + ) : ( + + + + Not running + + Start with: ao start <project> + + )} + + + {/* Zone Overview */} + + Session Zones + + + + + + + + + + + + ); +} + +function ZoneBadge({ label, count, color }: { label: string; count: number; color: string }) { + return ( + + {count} + {label} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "#0d1117", + }, + content: { + padding: 14, + paddingBottom: 32, + gap: 12, + }, + center: { + flex: 1, + alignItems: "center", + justifyContent: "center", + padding: 24, + backgroundColor: "#0d1117", + }, + section: { + backgroundColor: "#161b22", + borderRadius: 10, + padding: 16, + }, + sectionTitle: { + color: "#8b949e", + fontSize: 11, + fontWeight: "700", + letterSpacing: 0.8, + textTransform: "uppercase", + marginBottom: 12, + }, + // Orchestrator detail card + orchDetailCard: { + backgroundColor: "#0d1117", + borderWidth: 1, + borderColor: "#30363d", + borderLeftWidth: 3, + borderRadius: 8, + padding: 14, + }, + orchHeaderRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 6, + }, + orchStatusRow: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + dot: { + width: 10, + height: 10, + borderRadius: 5, + }, + orchRunning: { + color: "#3fb950", + fontSize: 15, + fontWeight: "600", + }, + orchId: { + color: "#8b949e", + fontSize: 12, + fontFamily: "monospace", + marginBottom: 4, + }, + orchStatus: { + color: "#8b949e", + fontSize: 13, + marginBottom: 4, + }, + orchSummary: { + color: "#e6edf3", + fontSize: 13, + lineHeight: 18, + marginBottom: 4, + }, + orchTimingRow: { + marginBottom: 10, + }, + orchTiming: { + color: "#6e7681", + fontSize: 12, + }, + orchHint: { + color: "#6e7681", + fontSize: 12, + marginTop: 4, + }, + orchActions: { + marginBottom: 10, + }, + terminalButton: { + backgroundColor: "#21262d", + borderWidth: 1, + borderColor: "#30363d", + borderRadius: 8, + padding: 12, + alignItems: "center", + }, + terminalButtonText: { + color: "#e6edf3", + fontSize: 14, + fontWeight: "600", + }, + orchMessageRow: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + orchMessageInput: { + flex: 1, + backgroundColor: "#161b22", + borderWidth: 1, + borderColor: "#30363d", + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 8, + color: "#e6edf3", + fontSize: 14, + }, + sendButton: { + backgroundColor: "#238636", + borderRadius: 8, + paddingHorizontal: 14, + height: 38, + alignItems: "center", + justifyContent: "center", + }, + sendButtonDisabled: { + backgroundColor: "#21262d", + }, + sendButtonText: { + color: "#fff", + fontSize: 14, + fontWeight: "700", + }, + // Zones grid + zonesGrid: { + flexDirection: "row", + flexWrap: "wrap", + gap: 8, + }, + zoneBadge: { + backgroundColor: "#0d1117", + borderWidth: 1, + borderColor: "#30363d", + borderRadius: 8, + paddingVertical: 10, + paddingHorizontal: 14, + alignItems: "center", + minWidth: 90, + flex: 1, + }, + zoneCount: { + fontSize: 22, + fontWeight: "700", + }, + zoneLabel: { + color: "#8b949e", + fontSize: 11, + fontWeight: "600", + marginTop: 2, + }, + // Error + 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, + }, +}); diff --git a/packages/mobile/src/screens/SessionDetailScreen.tsx b/packages/mobile/src/screens/SessionDetailScreen.tsx new file mode 100644 index 000000000..c18e760b9 --- /dev/null +++ b/packages/mobile/src/screens/SessionDetailScreen.tsx @@ -0,0 +1,729 @@ +import React, { useState, useCallback, useRef, useEffect } from "react"; +import { + View, + Text, + StyleSheet, + ScrollView, + TextInput, + TouchableOpacity, + Alert, + ActivityIndicator, + Keyboard, + Platform, + Linking, +} 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, + isPRRateLimited, + ATTENTION_COLORS, + type DashboardCICheck, + type DashboardUnresolvedComment, + type DashboardPR, +} from "../types"; + +type Props = NativeStackScreenProps; + +type ActionButtonState = "idle" | "sending" | "sent" | "error"; + +export default function SessionDetailScreen({ route, navigation }: Props) { + const { sessionId } = route.params; + const { session, loading, error, refresh } = useSession(sessionId); + const { sendMessage, killSession, restoreSession, mergePR, terminalWsUrl } = useBackend(); + const [message, setMessage] = useState(""); + const [sending, setSending] = useState(false); + const [merging, setMerging] = useState(false); + const [ciFixState, setCiFixState] = useState("idle"); + const [commentFixStates, setCommentFixStates] = useState>({}); + const scrollRef = useRef(null); + const [keyboardHeight, setKeyboardHeight] = useState(0); + + useEffect(() => { + const showEvent = Platform.OS === "ios" ? "keyboardWillShow" : "keyboardDidShow"; + const hideEvent = Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide"; + + const showSub = Keyboard.addListener(showEvent, (e) => { + setKeyboardHeight(e.endCoordinates.height); + }); + const hideSub = Keyboard.addListener(hideEvent, () => { + setKeyboardHeight(0); + }); + + return () => { + showSub.remove(); + hideSub.remove(); + }; + }, []); + + 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]); + + const handleMergePR = useCallback(async (prNumber: number) => { + setMerging(true); + try { + await mergePR(prNumber); + Alert.alert("Merged", `PR #${prNumber} merged successfully.`); + refresh(); + } catch (err) { + Alert.alert("Merge failed", err instanceof Error ? err.message : "Failed to merge PR"); + } finally { + setMerging(false); + } + }, [mergePR, refresh]); + + const handleAskFixCI = useCallback(async (pr: DashboardPR) => { + setCiFixState("sending"); + try { + await sendMessage(sessionId, `Please fix the failing CI checks on ${pr.url}`); + setCiFixState("sent"); + setTimeout(() => setCiFixState("idle"), 3000); + } catch { + setCiFixState("error"); + setTimeout(() => setCiFixState("idle"), 3000); + } + }, [sendMessage, sessionId]); + + const handleAskFixComment = useCallback(async (comment: DashboardUnresolvedComment) => { + const key = comment.url; + setCommentFixStates((prev) => ({ ...prev, [key]: "sending" })); + try { + const msg = `Please address this review comment:\n\nFile: ${comment.path}\nComment: ${comment.body}\n\nComment URL: ${comment.url}\n\nAfter fixing, mark the comment as resolved at ${comment.url}`; + await sendMessage(sessionId, msg); + setCommentFixStates((prev) => ({ ...prev, [key]: "sent" })); + setTimeout(() => setCommentFixStates((prev) => ({ ...prev, [key]: "idle" })), 3000); + } catch { + setCommentFixStates((prev) => ({ ...prev, [key]: "error" })); + setTimeout(() => setCommentFixStates((prev) => ({ ...prev, [key]: "idle" })), 3000); + } + }, [sendMessage, sessionId]); + + 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); + const pr = session.pr; + const isReadyToMerge = pr?.mergeability.mergeable && pr.state === "open" && !isPRRateLimited(pr); + const failedChecks = pr?.ciChecks.filter((c) => c.status === "failed") ?? []; + const unresolvedComments = pr?.unresolvedComments ?? []; + + return ( + + + {/* Header row */} + + + + {session.id} + + + + + {session.status} + {session.activity ? ` · ${session.activity}` : ""} + + + + {/* Alerts */} + {pr && pr.ciStatus === "failing" && failedChecks.length > 0 && ( + + + + {failedChecks.length} CI check{failedChecks.length > 1 ? "s" : ""} failing + + handleAskFixCI(pr)} + /> + + + )} + + {pr && !pr.mergeability.noConflicts && ( + + Merge conflict + + )} + + {pr && pr.reviewDecision === "changes_requested" && ( + + Changes requested + + )} + + {/* Issue */} + {(session.issueLabel || session.issueTitle) && ( +
+ + {session.issueLabel ? `${session.issueLabel}: ` : ""} + {session.issueTitle ?? ""} + +
+ )} + + {/* Summary */} + {session.summary && !session.summaryIsFallback && ( +
+ {session.summary} +
+ )} + + {/* Branch */} + {session.branch && ( +
+ {session.branch} +
+ )} + + {/* PR */} + {pr && ( +
+ Linking.openURL(pr.url)}> + + {pr.title} + + + + + + + {pr.isDraft && } + {pr.mergeability.blockers.length > 0 && ( + + Blockers: + {pr.mergeability.blockers.map((b, i) => ( + - {b} + ))} + + )} +
+ )} + + {/* CI Checks */} + {pr && pr.ciChecks.length > 0 && ( +
+ {pr.ciChecks.map((check, i) => ( + + ))} +
+ )} + + {/* Unresolved Comments */} + {unresolvedComments.length > 0 && ( +
+ {unresolvedComments.map((comment, i) => ( + + + {comment.author} + {comment.path} + + {comment.body} + + handleAskFixComment(comment)} + /> + Linking.openURL(comment.url)}> + View + + + + ))} +
+ )} + + {/* Timestamps */} +
+ + +
+ + {/* Actions */} + + {isReadyToMerge && ( + handleMergePR(pr.number)} + disabled={merging} + > + {merging ? ( + + ) : ( + + Merge PR #{pr.number} + + )} + + )} + + + Open Terminal + + + {canRestore && ( + + Restore Session + + )} + + {!isDone && ( + + Kill Session + + )} + +
+ + {/* Message input — only show for active sessions */} + {!isDone && ( + 0 ? keyboardHeight + 48 : 10 }]}> + + + {sending ? ( + + ) : ( + Send + )} + + + )} +
+ ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( + + {title} + {children} + + ); +} + +function InfoRow({ label, value, valueColor }: { label: string; value: string; valueColor?: string }) { + return ( + + {label} + {value} + + ); +} + +const CI_STATUS_ICONS: Record = { + passed: { icon: "\u2713", color: "#3fb950" }, + failed: { icon: "\u2717", color: "#f85149" }, + running: { icon: "\u25CF", color: "#e3b341" }, + pending: { icon: "\u25CF", color: "#8b949e" }, + skipped: { icon: "\u2014", color: "#6e7681" }, +}; + +function CICheckRow({ check }: { check: DashboardCICheck }) { + const info = CI_STATUS_ICONS[check.status] ?? CI_STATUS_ICONS.pending; + return ( + Linking.openURL(check.url!) : undefined} + disabled={!check.url} + > + {info.icon} + {check.name} + {check.status} + + ); +} + +function ActionButton({ state, label, onPress }: { state: ActionButtonState; label: string; onPress: () => void }) { + const isDisabled = state === "sending" || state === "sent"; + const bgColor = state === "sent" ? "#1f3a2a" : state === "error" ? "#3d1f20" : "#21262d"; + const borderColor = state === "sent" ? "#3fb950" : state === "error" ? "#f85149" : "#30363d"; + const textColor = state === "sent" ? "#3fb950" : state === "error" ? "#f85149" : "#58a6ff"; + const text = state === "sending" ? "Sending..." : state === "sent" ? "Sent" : state === "error" ? "Failed" : label; + + return ( + + {state === "sending" ? ( + + ) : ( + {text} + )} + + ); +} + +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, + }, + // Alerts + alertCard: { + backgroundColor: "#3d1f20", + borderLeftWidth: 3, + borderLeftColor: "#f85149", + borderRadius: 8, + padding: 12, + marginBottom: 10, + }, + alertRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + }, + alertText: { + color: "#f85149", + fontSize: 13, + fontWeight: "600", + flex: 1, + }, + // Sections + 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", + }, + blockersLabel: { + color: "#f85149", + fontSize: 12, + fontWeight: "600", + marginBottom: 4, + }, + blockerText: { + color: "#f85149", + fontSize: 12, + marginLeft: 4, + marginBottom: 2, + }, + // CI Checks + ciCheckRow: { + flexDirection: "row", + alignItems: "center", + paddingVertical: 6, + gap: 8, + }, + ciCheckIcon: { + fontSize: 14, + fontWeight: "700", + width: 18, + textAlign: "center", + }, + ciCheckName: { + color: "#e6edf3", + fontSize: 13, + flex: 1, + }, + ciCheckStatus: { + fontSize: 11, + fontWeight: "600", + }, + // Unresolved Comments + commentCard: { + backgroundColor: "#0d1117", + borderRadius: 6, + padding: 10, + marginBottom: 8, + borderWidth: 1, + borderColor: "#30363d", + }, + commentHeader: { + flexDirection: "row", + justifyContent: "space-between", + marginBottom: 6, + }, + commentAuthor: { + color: "#e6edf3", + fontSize: 12, + fontWeight: "600", + }, + commentPath: { + color: "#8b949e", + fontSize: 11, + fontFamily: "monospace", + flex: 1, + textAlign: "right", + marginLeft: 8, + }, + commentBody: { + color: "#8b949e", + fontSize: 13, + lineHeight: 18, + marginBottom: 8, + }, + commentActions: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + }, + viewLink: { + color: "#58a6ff", + fontSize: 13, + fontWeight: "600", + }, + // Inline action button (for "Ask to fix", etc.) + inlineActionButton: { + borderWidth: 1, + borderRadius: 6, + paddingHorizontal: 10, + paddingVertical: 6, + minWidth: 80, + alignItems: "center", + }, + inlineActionText: { + fontSize: 12, + fontWeight: "600", + }, + // Main actions + actionsSection: { + gap: 8, + marginTop: 4, + }, + actionButton: { + borderRadius: 8, + padding: 14, + alignItems: "center", + borderWidth: 1, + }, + mergeButton: { + backgroundColor: "#238636", + borderColor: "#2ea043", + }, + 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..c3c8ade36 --- /dev/null +++ b/packages/mobile/src/screens/SettingsScreen.tsx @@ -0,0 +1,443 @@ +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; + } + try { + await 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."); + } catch (err) { + Alert.alert("Failed", err instanceof Error ? err.message : "Could not schedule notification."); + } + }; + + 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; + } + try { + await 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."); + } catch (err) { + Alert.alert("Failed", err instanceof Error ? err.message : "Could not schedule notification."); + } + }; + + const handleTestReviewNotification = 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; + } + try { + await scheduleNotification( + { + id: "ao-test-session", + projectId: "test", + status: "review_pending", + activity: "idle", + branch: "feat/test", + issueId: null, + issueUrl: null, + issueLabel: "TEST-1", + issueTitle: "Refactor database connection pool", + summary: "Changes requested on PR", + summaryIsFallback: false, + createdAt: new Date().toISOString(), + lastActivityAt: new Date().toISOString(), + pr: { + number: 99, + url: "", + title: "Refactor DB pool", + owner: "", + repo: "", + branch: "feat/test", + baseBranch: "main", + isDraft: false, + state: "open", + additions: 85, + deletions: 42, + ciStatus: "passing", + ciChecks: [], + reviewDecision: "changes_requested", + mergeability: { mergeable: true, ciPassing: true, approved: false, noConflicts: true, blockers: ["Changes requested"] }, + unresolvedThreads: 2, + }, + metadata: {}, + }, + "review", + ); + Alert.alert("Sent", "A test 'review' notification was fired. Check your notification shade."); + } catch (err) { + Alert.alert("Failed", err instanceof Error ? err.message : "Could not schedule notification."); + } + }; + + 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 + + Enter the URL where your AO dashboard is running. + + Dashboard API URL + + + Terminal WebSocket URL{" "} + (leave blank to auto-derive) + + + + {saving ? "Saving..." : "Save"} + + + + + Active URLs + + + + + {__DEV__ && ( + + Test Notifications + Fire a test notification to verify permissions are working. Tap the notification to navigate to the session. + + Test "Agent needs input" (respond) + + + Test "PR ready to merge" (merge) + + + Test "Session needs review" (review) + + + )} + + + Setup Guide — Tailscale (Recommended) + + + + + + Alternative: Local Wi-Fi + + + + Alternative: ngrok + + + + + + + ); +} + +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, + }, + sectionDivider: { + color: "#8b949e", + fontSize: 11, + fontWeight: "700", + letterSpacing: 0.8, + textTransform: "uppercase", + marginTop: 12, + marginBottom: 12, + paddingTop: 12, + borderTopWidth: 1, + borderTopColor: "#30363d", + }, + 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/SpawnSessionScreen.tsx b/packages/mobile/src/screens/SpawnSessionScreen.tsx new file mode 100644 index 000000000..0d52a696c --- /dev/null +++ b/packages/mobile/src/screens/SpawnSessionScreen.tsx @@ -0,0 +1,173 @@ +import React, { useState, useCallback } from "react"; +import { + View, + Text, + TextInput, + TouchableOpacity, + StyleSheet, + Alert, + KeyboardAvoidingView, + Platform, + ScrollView, +} from "react-native"; +import type { NativeStackScreenProps } from "@react-navigation/native-stack"; +import type { RootStackParamList } from "../navigation/RootNavigator"; +import { useBackend } from "../context/BackendContext"; + +type Props = NativeStackScreenProps; + +export default function SpawnSessionScreen({ navigation }: Props) { + const { spawnSession } = useBackend(); + const [projectId, setProjectId] = useState(""); + const [issueId, setIssueId] = useState(""); + const [spawning, setSpawning] = useState(false); + + const handleSpawn = useCallback(async () => { + const trimmedProject = projectId.trim(); + if (!trimmedProject) { + Alert.alert("Invalid", "Project ID is required."); + return; + } + if (!/^[a-zA-Z0-9_-]+$/.test(trimmedProject)) { + Alert.alert("Invalid", "Project ID must be alphanumeric (hyphens and underscores allowed)."); + return; + } + const trimmedIssue = issueId.trim(); + if (trimmedIssue && !/^[a-zA-Z0-9_-]+$/.test(trimmedIssue)) { + Alert.alert("Invalid", "Issue ID must be alphanumeric (hyphens and underscores allowed)."); + return; + } + + setSpawning(true); + try { + const session = await spawnSession(trimmedProject, trimmedIssue || undefined); + navigation.replace("SessionDetail", { sessionId: session.id }); + } catch (err) { + Alert.alert("Spawn failed", err instanceof Error ? err.message : "Failed to spawn session"); + } finally { + setSpawning(false); + } + }, [projectId, issueId, spawnSession, navigation]); + + return ( + + + + Spawn New Session + + Create a new agent session. The orchestrator will assign an agent and workspace. + + + Project ID * + + + + Issue ID{" "} + (optional) + + + + + + {spawning ? "Spawning..." : "Spawn Session"} + + + + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: "#0d1117", + }, + container: { + flex: 1, + }, + content: { + padding: 14, + }, + 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: 16, + }, + fieldLabel: { + color: "#8b949e", + fontSize: 12, + fontWeight: "600", + marginBottom: 6, + }, + fieldLabelMuted: { + color: "#6e7681", + fontWeight: "400", + }, + input: { + backgroundColor: "#0d1117", + borderWidth: 1, + borderColor: "#30363d", + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 10, + color: "#e6edf3", + fontSize: 14, + fontFamily: "monospace", + marginBottom: 14, + }, + spawnButton: { + backgroundColor: "#238636", + borderRadius: 8, + paddingVertical: 14, + alignItems: "center", + marginTop: 4, + }, + spawnButtonDisabled: { + backgroundColor: "#21262d", + }, + spawnButtonText: { + color: "#fff", + fontSize: 15, + fontWeight: "600", + }, +}); diff --git a/packages/mobile/src/screens/TerminalScreen.tsx b/packages/mobile/src/screens/TerminalScreen.tsx new file mode 100644 index 000000000..88d46e0b9 --- /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..62f162462 --- /dev/null +++ b/packages/mobile/src/terminal/terminal-html.ts @@ -0,0 +1,178 @@ +/** + * 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 +