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 && (
+
+ )}
+
+ {/* 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
+