diff --git a/SETUP.md b/SETUP.md index e745b5daa..a34dc9bfb 100644 --- a/SETUP.md +++ b/SETUP.md @@ -618,10 +618,10 @@ projects: path: ~/backend sessionPrefix: api - mobile: - repo: org/mobile - path: ~/mobile - sessionPrefix: mob + docs: + repo: org/docs + path: ~/docs + sessionPrefix: doc ``` See [examples/multi-project.yaml](./examples/multi-project.yaml) for full example. diff --git a/eslint.config.js b/eslint.config.js index cc1167afc..c30ff0f15 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -15,7 +15,6 @@ 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 deleted file mode 100644 index bf107f1b0..000000000 --- a/packages/mobile/.gitignore +++ /dev/null @@ -1,34 +0,0 @@ -# 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 deleted file mode 100644 index ad1e56dab..000000000 --- a/packages/mobile/app.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "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 deleted file mode 100644 index 9e2529313..000000000 Binary files a/packages/mobile/assets/adaptive-icon.png and /dev/null differ diff --git a/packages/mobile/assets/favicon.png b/packages/mobile/assets/favicon.png deleted file mode 100644 index 9e2529313..000000000 Binary files a/packages/mobile/assets/favicon.png and /dev/null differ diff --git a/packages/mobile/assets/icon.png b/packages/mobile/assets/icon.png deleted file mode 100644 index 9e2529313..000000000 Binary files a/packages/mobile/assets/icon.png and /dev/null differ diff --git a/packages/mobile/assets/splash.png b/packages/mobile/assets/splash.png deleted file mode 100644 index 9e2529313..000000000 Binary files a/packages/mobile/assets/splash.png and /dev/null differ diff --git a/packages/mobile/babel.config.js b/packages/mobile/babel.config.js deleted file mode 100644 index 73ebf58e3..000000000 --- a/packages/mobile/babel.config.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = function (api) { - api.cache(true); - return { - presets: ["babel-preset-expo"], - }; -}; diff --git a/packages/mobile/eas.json b/packages/mobile/eas.json deleted file mode 100644 index 59b609d6b..000000000 --- a/packages/mobile/eas.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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 deleted file mode 100644 index 000be560b..000000000 --- a/packages/mobile/index.js +++ /dev/null @@ -1,4 +0,0 @@ -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 deleted file mode 100644 index c75ab9503..000000000 --- a/packages/mobile/metro.config.js +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index 37911a4d6..000000000 --- a/packages/mobile/package.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "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 deleted file mode 100644 index c44f3c870..000000000 --- a/packages/mobile/src/App.tsx +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index 34e9b3e26..000000000 --- a/packages/mobile/src/components/AttentionBadge.tsx +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index dc1d84338..000000000 --- a/packages/mobile/src/components/SessionCard.tsx +++ /dev/null @@ -1,143 +0,0 @@ -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 deleted file mode 100644 index d237598fb..000000000 --- a/packages/mobile/src/components/StatBar.tsx +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index 2ec784949..000000000 --- a/packages/mobile/src/context/BackendContext.tsx +++ /dev/null @@ -1,153 +0,0 @@ -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 deleted file mode 100644 index 7bdf901ca..000000000 --- a/packages/mobile/src/hooks/useSession.ts +++ /dev/null @@ -1,93 +0,0 @@ -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 deleted file mode 100644 index 29aea2956..000000000 --- a/packages/mobile/src/hooks/useSessionNotifications.ts +++ /dev/null @@ -1,63 +0,0 @@ -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 deleted file mode 100644 index e7891ecdc..000000000 --- a/packages/mobile/src/hooks/useSessions.ts +++ /dev/null @@ -1,88 +0,0 @@ -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 deleted file mode 100644 index 9939907e4..000000000 --- a/packages/mobile/src/navigation/RootNavigator.tsx +++ /dev/null @@ -1,90 +0,0 @@ -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 deleted file mode 100644 index 2e2132e60..000000000 --- a/packages/mobile/src/notifications/backgroundTask.ts +++ /dev/null @@ -1,83 +0,0 @@ -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 deleted file mode 100644 index 9808399ad..000000000 --- a/packages/mobile/src/notifications/index.ts +++ /dev/null @@ -1,86 +0,0 @@ -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 deleted file mode 100644 index b584c78d6..000000000 --- a/packages/mobile/src/screens/CommandsScreen.tsx +++ /dev/null @@ -1,75 +0,0 @@ -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 deleted file mode 100644 index d1015d14f..000000000 --- a/packages/mobile/src/screens/HomeScreen.tsx +++ /dev/null @@ -1,176 +0,0 @@ -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 deleted file mode 100644 index 8d386a221..000000000 --- a/packages/mobile/src/screens/OrchestratorScreen.tsx +++ /dev/null @@ -1,370 +0,0 @@ -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 deleted file mode 100644 index c18e760b9..000000000 --- a/packages/mobile/src/screens/SessionDetailScreen.tsx +++ /dev/null @@ -1,729 +0,0 @@ -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 deleted file mode 100644 index c3c8ade36..000000000 --- a/packages/mobile/src/screens/SettingsScreen.tsx +++ /dev/null @@ -1,443 +0,0 @@ -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 deleted file mode 100644 index 0d52a696c..000000000 --- a/packages/mobile/src/screens/SpawnSessionScreen.tsx +++ /dev/null @@ -1,173 +0,0 @@ -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 deleted file mode 100644 index 88d46e0b9..000000000 --- a/packages/mobile/src/screens/TerminalScreen.tsx +++ /dev/null @@ -1,122 +0,0 @@ -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 deleted file mode 100644 index 62f162462..000000000 --- a/packages/mobile/src/terminal/terminal-html.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * 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 -