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