feat(mobile): add React Native app for monitoring AO sessions

Adds `packages/mobile` — an Expo SDK 53 React Native app for monitoring
agent sessions from a phone.

Features:
- Home screen: Kanban-style session list sorted by attention level
- Session detail: metadata, PR link, CI status, branch info
- Terminal screen: full xterm.js terminal via WebView (port 14801)
- Settings screen: configurable backend URL (LAN IP or ngrok)
- Push notifications: background polling for attention-level changes

Improvements over PR #235:
- Fixed terminal onmessage handler to filter JSON control messages
  (resize echoes no longer render as garbage text)
- Removed duplicate terminal.html (single source of truth in
  terminal-html.ts)
- Fixed isDone check to include "cleanup" status using isTerminal()

Tech: Expo SDK 53, React Navigation 6, React Native WebView.
Two server connections: REST API (port 3000) + WebSocket terminal (14801).

The mobile package is excluded from the pnpm workspace to avoid
interfering with the monorepo toolchain.

Closes #265

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
suraj-markup 2026-03-03 01:34:45 +05:30
parent 4cda43795b
commit c53099923d
30 changed files with 2471 additions and 0 deletions

34
packages/mobile/.gitignore vendored Normal file
View File

@ -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

54
packages/mobile/app.json Normal file
View File

@ -0,0 +1,54 @@
{
"expo": {
"name": "Agent Orchestrator",
"slug": "ao-mobile",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "dark",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#0d1117"
},
"assetBundlePatterns": [
"**/*"
],
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.composio.ao-mobile",
"infoPlist": {
"UIBackgroundModes": [
"fetch",
"processing",
"remote-notification"
]
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#0d1117"
},
"package": "com.composio.aomobile"
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-background-task",
[
"expo-notifications",
{
"color": "#f85149",
"defaultChannel": "ao-respond"
}
]
],
"extra": {
"eas": {
"projectId": "de59ca61-8fd4-47bb-bdea-6e1a50ade3df"
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 B

View File

@ -0,0 +1,6 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ["babel-preset-expo"],
};
};

21
packages/mobile/eas.json Normal file
View File

@ -0,0 +1,21 @@
{
"cli": {
"version": ">= 12.0.0"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal",
"ios": {
"simulator": false
}
},
"production": {}
},
"submit": {
"production": {}
}
}

4
packages/mobile/index.js Normal file
View File

@ -0,0 +1,4 @@
import { registerRootComponent } from "expo";
import App from "./src/App";
registerRootComponent(App);

View File

@ -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;

View File

@ -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"
}
}

View File

@ -0,0 +1,21 @@
import React, { useEffect } from "react";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { BackendProvider } from "./context/BackendContext";
import RootNavigator from "./navigation/RootNavigator";
import { setupNotifications } from "./notifications";
import { registerBackgroundTask } from "./notifications/backgroundTask";
export default function App() {
useEffect(() => {
void setupNotifications();
void registerBackgroundTask();
}, []);
return (
<SafeAreaProvider>
<BackendProvider>
<RootNavigator />
</BackendProvider>
</SafeAreaProvider>
);
}

View File

@ -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<AttentionLevel, string> = {
merge: "MERGE",
respond: "RESPOND",
review: "REVIEW",
pending: "PENDING",
working: "WORKING",
done: "DONE",
};
export default function AttentionBadge({ level }: Props) {
const color = ATTENTION_COLORS[level];
return (
<View style={[styles.badge, { borderColor: color, backgroundColor: color + "22" }]}>
<Text style={[styles.label, { color }]}>{LABELS[level]}</Text>
</View>
);
}
const styles = StyleSheet.create({
badge: {
borderWidth: 1,
borderRadius: 4,
paddingHorizontal: 6,
paddingVertical: 2,
},
label: {
fontSize: 10,
fontWeight: "700",
letterSpacing: 0.5,
},
});

View File

@ -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 (
<TouchableOpacity style={[styles.card, { borderLeftColor: color }]} onPress={onPress} activeOpacity={0.75}>
<View style={styles.header}>
<Text style={styles.id} numberOfLines={1} ellipsizeMode="middle">
{session.id}
</Text>
<AttentionBadge level={level} />
</View>
{session.issueLabel || session.issueTitle ? (
<Text style={styles.issue} numberOfLines={1}>
{session.issueLabel ? `${session.issueLabel}: ` : ""}
{session.issueTitle ?? ""}
</Text>
) : null}
{session.summary && !session.summaryIsFallback ? (
<Text style={styles.summary} numberOfLines={2}>
{session.summary}
</Text>
) : null}
<View style={styles.footer}>
{session.branch ? (
<Text style={styles.branch} numberOfLines={1}>
{session.branch}
</Text>
) : null}
<Text style={styles.time}>{time}</Text>
</View>
{session.pr ? (
<View style={styles.prRow}>
<Text style={styles.prLabel}>PR #{session.pr.number}</Text>
{session.pr.ciStatus !== "none" && (
<Text
style={[
styles.ciStatus,
{
color:
session.pr.ciStatus === "passing"
? "#3fb950"
: session.pr.ciStatus === "failing"
? "#f85149"
: "#8b949e",
},
]}
>
{session.pr.ciStatus.toUpperCase()}
</Text>
)}
</View>
) : null}
</TouchableOpacity>
);
}
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,
},
});

View File

@ -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 (
<View style={styles.container}>
<StatItem label="Sessions" value={stats.totalSessions} color="#e6edf3" />
<StatItem label="Working" value={stats.workingSessions} color="#58a6ff" />
<StatItem label="PRs" value={stats.openPRs} color="#3fb950" />
<StatItem label="Review" value={stats.needsReview} color="#d29922" />
</View>
);
}
function StatItem({ label, value, color }: { label: string; value: number; color: string }) {
return (
<View style={styles.item}>
<Text style={[styles.value, { color }]}>{value}</Text>
<Text style={styles.label}>{label}</Text>
</View>
);
}
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,
},
});

View File

@ -0,0 +1,136 @@
import React, { createContext, useContext, useState, useCallback, useEffect } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import type { DashboardSession, SessionsResponse } from "../types";
const STORAGE_KEY = "@ao_backend_url";
const TERMINAL_WS_OVERRIDE_KEY = "@ao_terminal_ws_url";
const DEFAULT_URL = "http://192.168.1.1:3000";
interface BackendContextValue {
backendUrl: string;
setBackendUrl: (url: string) => Promise<void>;
/** 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<void>;
fetchSessions: () => Promise<SessionsResponse>;
fetchSession: (id: string) => Promise<DashboardSession>;
sendMessage: (id: string, message: string) => Promise<void>;
killSession: (id: string) => Promise<void>;
restoreSession: (id: string) => Promise<void>;
}
const BackendContext = createContext<BackendContextValue | null>(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<SessionsResponse> => {
const res = await apiFetch("/api/sessions");
return res.json() as Promise<SessionsResponse>;
}, [apiFetch]);
const fetchSession = useCallback(async (id: string): Promise<DashboardSession> => {
const res = await apiFetch(`/api/sessions/${encodeURIComponent(id)}`);
return res.json() as Promise<DashboardSession>;
}, [apiFetch]);
const sendMessage = useCallback(async (id: string, message: string): Promise<void> => {
await apiFetch(`/api/sessions/${encodeURIComponent(id)}/message`, {
method: "POST",
body: JSON.stringify({ message }),
});
}, [apiFetch]);
const killSession = useCallback(async (id: string): Promise<void> => {
await apiFetch(`/api/sessions/${encodeURIComponent(id)}/kill`, { method: "POST" });
}, [apiFetch]);
const restoreSession = useCallback(async (id: string): Promise<void> => {
await apiFetch(`/api/sessions/${encodeURIComponent(id)}/restore`, { method: "POST" });
}, [apiFetch]);
return (
<BackendContext.Provider
value={{
backendUrl,
setBackendUrl,
terminalWsUrl,
terminalWsOverride,
setTerminalWsOverride,
fetchSessions,
fetchSession,
sendMessage,
killSession,
restoreSession,
}}
>
{children}
</BackendContext.Provider>
);
}
export function useBackend(): BackendContextValue {
const ctx = useContext(BackendContext);
if (!ctx) throw new Error("useBackend must be used inside BackendProvider");
return ctx;
}

View File

@ -0,0 +1,78 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { AppState, type AppStateStatus } from "react-native";
import { useBackend } from "../context/BackendContext";
import type { DashboardSession } from "../types";
const POLL_INTERVAL = 5_000;
interface UseSessionResult {
session: DashboardSession | null;
loading: boolean;
error: string | null;
refresh: () => void;
}
export function useSession(id: string): UseSessionResult {
const { fetchSession } = useBackend();
const [session, setSession] = useState<DashboardSession | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const isMountedRef = useRef(true);
const doFetch = useCallback(async () => {
try {
const data = await fetchSession(id);
if (!isMountedRef.current) return;
setSession(data);
setError(null);
} catch (err) {
if (!isMountedRef.current) return;
setError(err instanceof Error ? err.message : "Failed to load session");
} finally {
if (isMountedRef.current) setLoading(false);
}
}, [fetchSession, id]);
const startPolling = useCallback(() => {
doFetch();
intervalRef.current = setInterval(doFetch, POLL_INTERVAL);
}, [doFetch]);
const stopPolling = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}, []);
useEffect(() => {
isMountedRef.current = true;
startPolling();
const handleAppState = (nextState: AppStateStatus) => {
if (nextState === "active") {
stopPolling();
startPolling();
} else {
stopPolling();
}
};
const sub = AppState.addEventListener("change", handleAppState);
return () => {
isMountedRef.current = false;
stopPolling();
sub.remove();
};
}, [startPolling, stopPolling]);
const refresh = useCallback(() => {
setLoading(true);
doFetch();
}, [doFetch]);
return { session, loading, error, refresh };
}

View File

@ -0,0 +1,58 @@
import { useEffect, useRef } from "react";
import { AppState } from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { getAttentionLevel, type AttentionLevel, type DashboardSession } from "../types";
import { scheduleNotification } from "../notifications";
import { NOTIFY_STATE_KEY } from "../notifications/backgroundTask";
/**
* Minimum time (ms) before re-notifying for the same session.
* Prevents spam when an agent oscillates between working <-> waiting_input.
*/
const COOLDOWN_MS = 10 * 60 * 1000; // 10 minutes
export function useSessionNotifications(sessions: DashboardSession[]): void {
const prevLevels = useRef<Record<string, AttentionLevel>>({});
// Tracks when we last sent a notification per session
const lastNotifiedAt = useRef<Record<string, number>>({});
// 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<string, AttentionLevel> = {};
const now = Date.now();
// Only notify when app is in background — no point interrupting the user
// if they're actively looking at the session list
const isBackground = AppState.currentState !== "active";
for (const session of sessions) {
const level = getAttentionLevel(session);
nextLevels[session.id] = level;
if (!isFirstRender.current) {
const prev = prevLevels.current[session.id];
const lastNotified = lastNotifiedAt.current[session.id] ?? 0;
const cooldownExpired = now - lastNotified > COOLDOWN_MS;
if (level === "respond" && (prev !== "respond" || cooldownExpired)) {
if (isBackground || prev !== "respond") {
scheduleNotification(session, "respond");
lastNotifiedAt.current[session.id] = now;
}
} else if (level === "merge" && (prev !== "merge" || cooldownExpired)) {
if (isBackground || prev !== "merge") {
scheduleNotification(session, "merge");
lastNotifiedAt.current[session.id] = now;
}
}
}
}
prevLevels.current = nextLevels;
isFirstRender.current = false;
void AsyncStorage.setItem(NOTIFY_STATE_KEY, JSON.stringify(nextLevels));
}, [sessions]);
}

View File

@ -0,0 +1,83 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { AppState, type AppStateStatus } from "react-native";
import { useBackend } from "../context/BackendContext";
import type { DashboardSession, DashboardStats } from "../types";
const POLL_INTERVAL = 5_000;
interface UseSessionsResult {
sessions: DashboardSession[];
stats: DashboardStats | null;
loading: boolean;
error: string | null;
refresh: () => void;
}
export function useSessions(): UseSessionsResult {
const { fetchSessions } = useBackend();
const [sessions, setSessions] = useState<DashboardSession[]>([]);
const [stats, setStats] = useState<DashboardStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const isMountedRef = useRef(true);
const doFetch = useCallback(async () => {
try {
const data = await fetchSessions();
if (!isMountedRef.current) return;
setSessions(data.sessions ?? []);
setStats(data.stats ?? null);
setError(null);
} catch (err) {
if (!isMountedRef.current) return;
setError(err instanceof Error ? err.message : "Failed to load sessions");
} finally {
if (isMountedRef.current) setLoading(false);
}
}, [fetchSessions]);
const startPolling = useCallback(() => {
doFetch();
intervalRef.current = setInterval(doFetch, POLL_INTERVAL);
}, [doFetch]);
const stopPolling = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}, []);
useEffect(() => {
isMountedRef.current = true;
startPolling();
const handleAppState = (nextState: AppStateStatus) => {
if (nextState === "active") {
// Resumed — refresh immediately and restart polling
stopPolling();
startPolling();
} else {
// Backgrounded — stop polling to save battery
stopPolling();
}
};
const sub = AppState.addEventListener("change", handleAppState);
return () => {
isMountedRef.current = false;
stopPolling();
sub.remove();
};
}, [startPolling, stopPolling]);
const refresh = useCallback(() => {
setLoading(true);
doFetch();
}, [doFetch]);
return { sessions, stats, loading, error, refresh };
}

View File

@ -0,0 +1,67 @@
import React from "react";
import { NavigationContainer, DarkTheme } from "@react-navigation/native";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import HomeScreen from "../screens/HomeScreen";
import SessionDetailScreen from "../screens/SessionDetailScreen";
import TerminalScreen from "../screens/TerminalScreen";
import SettingsScreen from "../screens/SettingsScreen";
export type RootStackParamList = {
Home: undefined;
SessionDetail: { sessionId: string };
Terminal: { sessionId: string; terminalWsUrl: string };
Settings: undefined;
};
const Stack = createNativeStackNavigator<RootStackParamList>();
const AoDarkTheme = {
...DarkTheme,
colors: {
...DarkTheme.colors,
background: "#0d1117",
card: "#161b22",
text: "#e6edf3",
border: "#30363d",
primary: "#58a6ff",
},
};
export default function RootNavigator() {
return (
<NavigationContainer theme={AoDarkTheme}>
<Stack.Navigator
initialRouteName="Home"
screenOptions={{
headerStyle: { backgroundColor: "#161b22" },
headerTintColor: "#e6edf3",
headerTitleStyle: { fontWeight: "600" },
}}
>
<Stack.Screen
name="Home"
component={HomeScreen}
options={{ title: "Agent Orchestrator" }}
/>
<Stack.Screen
name="SessionDetail"
component={SessionDetailScreen}
options={{ title: "Session" }}
/>
<Stack.Screen
name="Terminal"
component={TerminalScreen}
options={{
title: "Terminal",
headerStyle: { backgroundColor: "#0d1117" },
}}
/>
<Stack.Screen
name="Settings"
component={SettingsScreen}
options={{ title: "Settings" }}
/>
</Stack.Navigator>
</NavigationContainer>
);
}

View File

@ -0,0 +1,80 @@
import * as TaskManager from "expo-task-manager";
import * as BackgroundTask from "expo-background-task";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { getAttentionLevel, type AttentionLevel, type DashboardSession, type SessionsResponse } from "../types";
import { scheduleNotification } from "./index";
export const TASK_ID = "ao-session-check";
const BACKEND_URL_KEY = "@ao_backend_url";
export const NOTIFY_STATE_KEY = "@ao_notify_state";
const NOTIFY_TIMESTAMPS_KEY = "@ao_notify_timestamps";
const COOLDOWN_MS = 10 * 60 * 1000; // 10 minutes
/** Define the background task globally — must be at module top level. */
TaskManager.defineTask(TASK_ID, async () => {
try {
const backendUrl = await AsyncStorage.getItem(BACKEND_URL_KEY);
if (!backendUrl) return BackgroundTask.BackgroundTaskResult.Success;
const res = await fetch(`${backendUrl}/api/sessions`, {
headers: { "Content-Type": "application/json" },
});
if (!res.ok) return BackgroundTask.BackgroundTaskResult.Failed;
const data = (await res.json()) as SessionsResponse;
const sessions: DashboardSession[] = data.sessions ?? [];
const [prevRaw, tsRaw] = await Promise.all([
AsyncStorage.getItem(NOTIFY_STATE_KEY),
AsyncStorage.getItem(NOTIFY_TIMESTAMPS_KEY),
]);
const prevState: Record<string, AttentionLevel> = prevRaw
? (JSON.parse(prevRaw) as Record<string, AttentionLevel>)
: {};
const timestamps: Record<string, number> = tsRaw
? (JSON.parse(tsRaw) as Record<string, number>)
: {};
const nextState: Record<string, AttentionLevel> = {};
const now = Date.now();
for (const session of sessions) {
const level = getAttentionLevel(session);
nextState[session.id] = level;
const prev = prevState[session.id];
const lastNotified = timestamps[session.id] ?? 0;
const cooldownExpired = now - lastNotified > COOLDOWN_MS;
if (level === "respond" && (prev !== "respond" || cooldownExpired)) {
scheduleNotification(session, "respond");
timestamps[session.id] = now;
} else if (level === "merge" && (prev !== "merge" || cooldownExpired)) {
scheduleNotification(session, "merge");
timestamps[session.id] = now;
}
}
await Promise.all([
AsyncStorage.setItem(NOTIFY_STATE_KEY, JSON.stringify(nextState)),
AsyncStorage.setItem(NOTIFY_TIMESTAMPS_KEY, JSON.stringify(timestamps)),
]);
return BackgroundTask.BackgroundTaskResult.Success;
} catch {
return BackgroundTask.BackgroundTaskResult.Failed;
}
});
/** Register the background task. Safe to call multiple times. */
export async function registerBackgroundTask(): Promise<void> {
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
}
}

View File

@ -0,0 +1,75 @@
import * as Notifications from "expo-notifications";
import { Platform } from "react-native";
import type { DashboardSession } from "../types";
const ANDROID_CHANNEL_ID = "ao-respond";
/** Configure how notifications are presented when the app is in foreground */
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
shouldShowBanner: true,
shouldShowList: true,
}),
});
/** Create Android channel + request permission. Call once on app startup. */
export async function setupNotifications(): Promise<boolean> {
// Create Android notification channel
if (Platform.OS === "android") {
await Notifications.setNotificationChannelAsync(ANDROID_CHANNEL_ID, {
name: "Agent Input Required",
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 250, 250, 250],
lightColor: "#f85149",
});
}
const { status } = await Notifications.requestPermissionsAsync({
ios: {
allowAlert: true,
allowBadge: false,
allowSound: true,
},
});
return status === "granted";
}
/** Fire an immediate local notification for a session attention transition. */
export function scheduleNotification(
session: DashboardSession,
level: "respond" | "merge",
): void {
const sessionLabel =
session.issueLabel ??
session.id;
const body =
session.issueTitle ??
(session.summary && !session.summaryIsFallback ? session.summary : null) ??
session.activity ??
session.status;
const content: Notifications.NotificationContentInput =
level === "respond"
? {
title: "Agent needs your input",
body: `${sessionLabel}: ${body}`,
data: { sessionId: session.id },
sound: true,
...(Platform.OS === "android" && { channelId: ANDROID_CHANNEL_ID }),
}
: {
title: "PR ready to merge",
body: `${sessionLabel}${session.pr ? `: PR #${session.pr.number}` : ""}`,
data: { sessionId: session.id },
sound: true,
...(Platform.OS === "android" && { channelId: ANDROID_CHANNEL_ID }),
};
// Fire immediately (trigger: null = instant)
void Notifications.scheduleNotificationAsync({ content, trigger: null });
}

View File

@ -0,0 +1,164 @@
import React from "react";
import {
View,
Text,
FlatList,
StyleSheet,
RefreshControl,
TouchableOpacity,
ActivityIndicator,
} from "react-native";
import type { NativeStackScreenProps } from "@react-navigation/native-stack";
import type { RootStackParamList } from "../navigation/RootNavigator";
import { useSessions } from "../hooks/useSessions";
import { useSessionNotifications } from "../hooks/useSessionNotifications";
import SessionCard from "../components/SessionCard";
import StatBar from "../components/StatBar";
import { getAttentionLevel, type DashboardSession } from "../types";
type Props = NativeStackScreenProps<RootStackParamList, "Home">;
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: () => (
<TouchableOpacity
onPress={() => navigation.navigate("Settings")}
style={{ paddingRight: 4 }}
>
<Text style={{ color: "#58a6ff", fontSize: 16 }}>Settings</Text>
</TouchableOpacity>
),
});
}, [navigation]);
const sorted = sortSessions(sessions);
if (loading && sessions.length === 0) {
return (
<View style={styles.center}>
<ActivityIndicator color="#58a6ff" size="large" />
<Text style={styles.loadingText}>Connecting...</Text>
</View>
);
}
if (error && sessions.length === 0) {
return (
<View style={styles.center}>
<Text style={styles.errorText}>{error}</Text>
<TouchableOpacity style={styles.retryButton} onPress={refresh}>
<Text style={styles.retryText}>Retry</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.retryButton, { marginTop: 8 }]}
onPress={() => navigation.navigate("Settings")}
>
<Text style={styles.retryText}>Configure Backend URL</Text>
</TouchableOpacity>
</View>
);
}
return (
<View style={styles.container}>
{stats && <StatBar stats={stats} />}
<FlatList
data={sorted}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<SessionCard
session={item}
onPress={() => navigation.navigate("SessionDetail", { sessionId: item.id })}
/>
)}
refreshControl={
<RefreshControl
refreshing={loading}
onRefresh={refresh}
tintColor="#58a6ff"
colors={["#58a6ff"]}
/>
}
contentContainerStyle={
sorted.length === 0 ? styles.emptyContainer : styles.listContent
}
ListEmptyComponent={
<View style={styles.center}>
<Text style={styles.emptyText}>No sessions</Text>
<Text style={styles.emptySubtext}>Sessions will appear here when agents are running</Text>
</View>
}
/>
</View>
);
}
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",
},
});

View File

@ -0,0 +1,414 @@
import React, { useState, useCallback } from "react";
import {
View,
Text,
StyleSheet,
ScrollView,
TextInput,
TouchableOpacity,
Alert,
ActivityIndicator,
KeyboardAvoidingView,
Platform,
} from "react-native";
import type { NativeStackScreenProps } from "@react-navigation/native-stack";
import type { RootStackParamList } from "../navigation/RootNavigator";
import { useSession } from "../hooks/useSession";
import { useBackend } from "../context/BackendContext";
import AttentionBadge from "../components/AttentionBadge";
import {
getAttentionLevel,
relativeTime,
isRestorable,
isTerminal,
ATTENTION_COLORS,
} from "../types";
type Props = NativeStackScreenProps<RootStackParamList, "SessionDetail">;
export default function SessionDetailScreen({ route, navigation }: Props) {
const { sessionId } = route.params;
const { session, loading, error, refresh } = useSession(sessionId);
const { sendMessage, killSession, restoreSession, terminalWsUrl } = useBackend();
const [message, setMessage] = useState("");
const [sending, setSending] = useState(false);
const handleSend = useCallback(async () => {
if (!message.trim()) return;
setSending(true);
try {
await sendMessage(sessionId, message.trim());
setMessage("");
} catch (err) {
Alert.alert("Error", err instanceof Error ? err.message : "Failed to send message");
} finally {
setSending(false);
}
}, [message, sendMessage, sessionId]);
const handleKill = useCallback(() => {
Alert.alert(
"Kill Session",
"This will terminate the agent process. Are you sure?",
[
{ text: "Cancel", style: "cancel" },
{
text: "Kill",
style: "destructive",
onPress: async () => {
try {
await killSession(sessionId);
refresh();
} catch (err) {
Alert.alert("Error", err instanceof Error ? err.message : "Failed to kill session");
}
},
},
],
);
}, [killSession, sessionId, refresh]);
const handleRestore = useCallback(async () => {
try {
await restoreSession(sessionId);
refresh();
} catch (err) {
Alert.alert("Error", err instanceof Error ? err.message : "Failed to restore session");
}
}, [restoreSession, sessionId, refresh]);
const handleOpenTerminal = useCallback(() => {
navigation.navigate("Terminal", { sessionId, terminalWsUrl });
}, [navigation, sessionId, terminalWsUrl]);
if (loading && !session) {
return (
<View style={styles.center}>
<ActivityIndicator color="#58a6ff" size="large" />
</View>
);
}
if (error && !session) {
return (
<View style={styles.center}>
<Text style={styles.errorText}>{error}</Text>
<TouchableOpacity style={styles.button} onPress={refresh}>
<Text style={styles.buttonText}>Retry</Text>
</TouchableOpacity>
</View>
);
}
if (!session) return null;
const level = getAttentionLevel(session);
const color = ATTENTION_COLORS[level];
const canRestore = isRestorable(session);
const isDone = isTerminal(session);
return (
<KeyboardAvoidingView
style={styles.root}
behavior={Platform.OS === "ios" ? "padding" : undefined}
keyboardVerticalOffset={88}
>
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
{/* Header row */}
<View style={[styles.headerCard, { borderLeftColor: color }]}>
<View style={styles.headerRow}>
<Text style={styles.sessionId} numberOfLines={1} ellipsizeMode="middle">
{session.id}
</Text>
<AttentionBadge level={level} />
</View>
<Text style={styles.status}>
{session.status}
{session.activity ? ` · ${session.activity}` : ""}
</Text>
</View>
{/* Issue */}
{(session.issueLabel || session.issueTitle) && (
<Section title="Issue">
<Text style={styles.issueText}>
{session.issueLabel ? `${session.issueLabel}: ` : ""}
{session.issueTitle ?? ""}
</Text>
</Section>
)}
{/* Summary */}
{session.summary && !session.summaryIsFallback && (
<Section title="Summary">
<Text style={styles.bodyText}>{session.summary}</Text>
</Section>
)}
{/* Branch */}
{session.branch && (
<Section title="Branch">
<Text style={styles.monoText}>{session.branch}</Text>
</Section>
)}
{/* PR */}
{session.pr && (
<Section title={`PR #${session.pr.number}`}>
<InfoRow label="Title" value={session.pr.title} />
<InfoRow label="CI" value={session.pr.ciStatus} />
<InfoRow label="Review" value={session.pr.reviewDecision} />
<InfoRow label="Mergeable" value={session.pr.mergeability.mergeable ? "Yes" : "No"} />
{session.pr.additions > 0 && (
<InfoRow
label="Changes"
value={`+${session.pr.additions} / -${session.pr.deletions}`}
/>
)}
</Section>
)}
{/* Timestamps */}
<Section title="Timing">
<InfoRow label="Created" value={relativeTime(session.createdAt)} />
<InfoRow label="Last activity" value={relativeTime(session.lastActivityAt)} />
</Section>
{/* Actions */}
<View style={styles.actionsSection}>
<TouchableOpacity style={[styles.actionButton, styles.terminalButton]} onPress={handleOpenTerminal}>
<Text style={styles.actionButtonText}>Open Terminal</Text>
</TouchableOpacity>
{canRestore && (
<TouchableOpacity style={[styles.actionButton, styles.restoreButton]} onPress={handleRestore}>
<Text style={styles.actionButtonText}>Restore Session</Text>
</TouchableOpacity>
)}
{!isDone && (
<TouchableOpacity style={[styles.actionButton, styles.killButton]} onPress={handleKill}>
<Text style={[styles.actionButtonText, { color: "#f85149" }]}>Kill Session</Text>
</TouchableOpacity>
)}
</View>
</ScrollView>
{/* Message input — only show for active sessions */}
{!isDone && (
<View style={styles.messageBar}>
<TextInput
style={styles.messageInput}
placeholder="Send message to agent..."
placeholderTextColor="#8b949e"
value={message}
onChangeText={setMessage}
multiline
returnKeyType="send"
onSubmitEditing={handleSend}
blurOnSubmit={false}
/>
<TouchableOpacity
style={[styles.sendButton, (!message.trim() || sending) && styles.sendButtonDisabled]}
onPress={handleSend}
disabled={!message.trim() || sending}
>
{sending ? (
<ActivityIndicator color="#fff" size="small" />
) : (
<Text style={styles.sendButtonText}>Send</Text>
)}
</TouchableOpacity>
</View>
)}
</KeyboardAvoidingView>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{title}</Text>
{children}
</View>
);
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<View style={styles.infoRow}>
<Text style={styles.infoLabel}>{label}</Text>
<Text style={styles.infoValue}>{value}</Text>
</View>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: "#0d1117",
},
container: {
flex: 1,
},
content: {
padding: 12,
paddingBottom: 24,
},
center: {
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: 24,
backgroundColor: "#0d1117",
},
headerCard: {
backgroundColor: "#161b22",
borderLeftWidth: 3,
borderRadius: 8,
padding: 14,
marginBottom: 12,
},
headerRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 6,
},
sessionId: {
color: "#8b949e",
fontSize: 13,
fontFamily: "monospace",
flex: 1,
marginRight: 8,
},
status: {
color: "#8b949e",
fontSize: 13,
},
section: {
backgroundColor: "#161b22",
borderRadius: 8,
padding: 14,
marginBottom: 10,
},
sectionTitle: {
color: "#8b949e",
fontSize: 11,
fontWeight: "700",
letterSpacing: 0.8,
textTransform: "uppercase",
marginBottom: 8,
},
bodyText: {
color: "#e6edf3",
fontSize: 14,
lineHeight: 20,
},
issueText: {
color: "#e6edf3",
fontSize: 14,
fontWeight: "600",
},
monoText: {
color: "#58a6ff",
fontSize: 13,
fontFamily: "monospace",
},
infoRow: {
flexDirection: "row",
justifyContent: "space-between",
paddingVertical: 4,
},
infoLabel: {
color: "#8b949e",
fontSize: 13,
},
infoValue: {
color: "#e6edf3",
fontSize: 13,
fontWeight: "500",
},
actionsSection: {
gap: 8,
marginTop: 4,
},
actionButton: {
borderRadius: 8,
padding: 14,
alignItems: "center",
borderWidth: 1,
},
terminalButton: {
backgroundColor: "#21262d",
borderColor: "#30363d",
},
restoreButton: {
backgroundColor: "#1f3a2a",
borderColor: "#3fb950",
},
killButton: {
backgroundColor: "#21262d",
borderColor: "#30363d",
},
actionButtonText: {
color: "#e6edf3",
fontSize: 15,
fontWeight: "600",
},
messageBar: {
flexDirection: "row",
alignItems: "flex-end",
padding: 10,
backgroundColor: "#161b22",
borderTopWidth: 1,
borderTopColor: "#30363d",
gap: 8,
},
messageInput: {
flex: 1,
backgroundColor: "#0d1117",
borderWidth: 1,
borderColor: "#30363d",
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 8,
color: "#e6edf3",
fontSize: 14,
maxHeight: 100,
},
sendButton: {
backgroundColor: "#238636",
borderRadius: 8,
paddingHorizontal: 16,
height: 40,
alignItems: "center",
justifyContent: "center",
},
sendButtonDisabled: {
backgroundColor: "#21262d",
},
sendButtonText: {
color: "#fff",
fontSize: 14,
fontWeight: "700",
},
button: {
backgroundColor: "#21262d",
borderWidth: 1,
borderColor: "#30363d",
borderRadius: 6,
paddingHorizontal: 16,
paddingVertical: 8,
},
buttonText: {
color: "#e6edf3",
fontSize: 14,
},
errorText: {
color: "#f85149",
fontSize: 14,
textAlign: "center",
marginBottom: 16,
},
});

View File

@ -0,0 +1,367 @@
import React, { useState } from "react";
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
ScrollView,
Alert,
KeyboardAvoidingView,
Platform,
} from "react-native";
import type { NativeStackScreenProps } from "@react-navigation/native-stack";
import type { RootStackParamList } from "../navigation/RootNavigator";
import { useBackend } from "../context/BackendContext";
import { scheduleNotification } from "../notifications";
import * as Notifications from "expo-notifications";
type Props = NativeStackScreenProps<RootStackParamList, "Settings">;
export default function SettingsScreen({ navigation }: Props) {
const { backendUrl, setBackendUrl, terminalWsUrl, terminalWsOverride, setTerminalWsOverride } = useBackend();
const [input, setInput] = useState(backendUrl);
const [wsInput, setWsInput] = useState(terminalWsOverride);
const [saving, setSaving] = useState(false);
const handleTestRespondNotification = async () => {
const { status } = await Notifications.getPermissionsAsync();
if (status !== "granted") {
Alert.alert("Permission denied", "Notification permission is not granted. Enable it in your phone's Settings app.");
return;
}
scheduleNotification(
{
id: "ao-test-session",
projectId: "test",
status: "needs_input",
activity: "waiting_input",
branch: "feat/test",
issueId: null,
issueUrl: null,
issueLabel: "TEST-1",
issueTitle: "Fix the flaky integration test",
summary: "Waiting for your decision on the approach",
summaryIsFallback: false,
createdAt: new Date().toISOString(),
lastActivityAt: new Date().toISOString(),
pr: null,
metadata: {},
},
"respond",
);
Alert.alert("Sent", "A test 'respond' notification was fired. Check your notification shade.");
};
const handleTestMergeNotification = async () => {
const { status } = await Notifications.getPermissionsAsync();
if (status !== "granted") {
Alert.alert("Permission denied", "Notification permission is not granted. Enable it in your phone's Settings app.");
return;
}
scheduleNotification(
{
id: "ao-test-session",
projectId: "test",
status: "mergeable",
activity: "idle",
branch: "feat/test",
issueId: null,
issueUrl: null,
issueLabel: "TEST-1",
issueTitle: "Add user authentication flow",
summary: null,
summaryIsFallback: false,
createdAt: new Date().toISOString(),
lastActivityAt: new Date().toISOString(),
pr: {
number: 42,
url: "",
title: "Add auth flow",
owner: "",
repo: "",
branch: "feat/test",
baseBranch: "main",
isDraft: false,
state: "open",
additions: 120,
deletions: 8,
ciStatus: "passing",
ciChecks: [],
reviewDecision: "approved",
mergeability: { mergeable: true, ciPassing: true, approved: true, noConflicts: true, blockers: [] },
unresolvedThreads: 0,
},
metadata: {},
},
"merge",
);
Alert.alert("Sent", "A test 'merge' notification was fired. Check your notification shade.");
};
const handleSave = async () => {
const trimmed = input.trim();
if (!trimmed) {
Alert.alert("Invalid URL", "Please enter a valid backend URL.");
return;
}
if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) {
Alert.alert("Invalid URL", "URL must start with http:// or https://");
return;
}
setSaving(true);
try {
await setBackendUrl(trimmed);
await setTerminalWsOverride(wsInput.trim());
Alert.alert("Saved", "Settings updated.", [
{ text: "OK", onPress: () => navigation.goBack() },
]);
} finally {
setSaving(false);
}
};
return (
<KeyboardAvoidingView
style={styles.root}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Backend URL</Text>
<Text style={styles.hint}>
Local network: enter your Mac's LAN IP.{"\n"}
ngrok: paste the https:// tunnel URL for port 3000.
</Text>
<Text style={styles.fieldLabel}>Dashboard API URL</Text>
<TextInput
style={styles.input}
value={input}
onChangeText={setInput}
placeholder="http://192.168.x.x:3000 or https://abc.ngrok-free.app"
placeholderTextColor="#8b949e"
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
returnKeyType="next"
/>
<Text style={styles.fieldLabel}>
Terminal WebSocket URL{" "}
<Text style={styles.fieldLabelMuted}>(leave blank to auto-derive)</Text>
</Text>
<TextInput
style={styles.input}
value={wsInput}
onChangeText={setWsInput}
placeholder="wss://xyz.ngrok-free.app (only needed for ngrok)"
placeholderTextColor="#8b949e"
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
returnKeyType="done"
/>
<TouchableOpacity
style={[styles.saveButton, saving && styles.saveButtonDisabled]}
onPress={handleSave}
disabled={saving}
>
<Text style={styles.saveButtonText}>{saving ? "Saving..." : "Save"}</Text>
</TouchableOpacity>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Active URLs</Text>
<SettingsInfoRow label="Dashboard" value={backendUrl} />
<SettingsInfoRow
label="Terminal WS"
value={terminalWsUrl}
note={terminalWsOverride ? "manual" : "auto"}
/>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Test Notifications</Text>
<Text style={styles.hint}>Fire a test notification to verify permissions are working.</Text>
<TouchableOpacity style={[styles.testButton, { borderColor: "#f85149" }]} onPress={handleTestRespondNotification}>
<Text style={[styles.testButtonText, { color: "#f85149" }]}>Test "Agent needs input" (respond)</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.testButton, { borderColor: "#3fb950", marginTop: 8 }]} onPress={handleTestMergeNotification}>
<Text style={[styles.testButtonText, { color: "#3fb950" }]}>Test "PR ready to merge" (merge)</Text>
</TouchableOpacity>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Setup Guide</Text>
<Step
n="1"
text="Find your Mac's LAN IP: System Settings > Wi-Fi > Details > IP Address"
/>
<Step n="2" text="Make sure the orchestrator is running: pnpm build && pnpm dev" />
<Step
n="3"
text="Enter http://<YOUR_IP>:3000 above (the terminal server is auto-derived on port 14801)"
/>
<Step n="4" text="Tap Save and go back to see your sessions" />
<Step n="5" text="Your phone must be on the same Wi-Fi network as your Mac" />
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
function SettingsInfoRow({ label, value, note }: { label: string; value: string; note?: string }) {
return (
<View style={styles.infoRow}>
<View style={styles.infoLabelRow}>
<Text style={styles.infoLabel}>{label}</Text>
{note ? <Text style={styles.infoNote}>{note}</Text> : null}
</View>
<Text style={styles.infoValue} numberOfLines={1} ellipsizeMode="middle">
{value}
</Text>
</View>
);
}
function Step({ n, text }: { n: string; text: string }) {
return (
<View style={styles.step}>
<View style={styles.stepBadge}>
<Text style={styles.stepN}>{n}</Text>
</View>
<Text style={styles.stepText}>{text}</Text>
</View>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: "#0d1117",
},
container: {
flex: 1,
},
content: {
padding: 14,
paddingBottom: 32,
gap: 12,
},
section: {
backgroundColor: "#161b22",
borderRadius: 10,
padding: 16,
},
sectionTitle: {
color: "#8b949e",
fontSize: 11,
fontWeight: "700",
letterSpacing: 0.8,
textTransform: "uppercase",
marginBottom: 12,
},
hint: {
color: "#8b949e",
fontSize: 13,
lineHeight: 18,
marginBottom: 12,
},
input: {
backgroundColor: "#0d1117",
borderWidth: 1,
borderColor: "#30363d",
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
color: "#e6edf3",
fontSize: 14,
fontFamily: "monospace",
marginBottom: 12,
},
saveButton: {
backgroundColor: "#238636",
borderRadius: 8,
paddingVertical: 12,
alignItems: "center",
},
saveButtonDisabled: {
backgroundColor: "#21262d",
},
saveButtonText: {
color: "#fff",
fontSize: 15,
fontWeight: "600",
},
testButton: {
borderWidth: 1,
borderRadius: 8,
paddingVertical: 12,
alignItems: "center",
},
testButtonText: {
fontSize: 14,
fontWeight: "600",
},
infoRow: {
flexDirection: "row",
justifyContent: "space-between",
paddingVertical: 6,
},
infoLabel: {
color: "#8b949e",
fontSize: 13,
width: 100,
},
infoValue: {
color: "#58a6ff",
fontSize: 13,
fontFamily: "monospace",
flex: 1,
textAlign: "right",
},
infoLabelRow: {
flexDirection: "column",
width: 100,
},
infoNote: {
color: "#8b949e",
fontSize: 10,
marginTop: 1,
},
fieldLabel: {
color: "#8b949e",
fontSize: 12,
fontWeight: "600",
marginBottom: 6,
},
fieldLabelMuted: {
color: "#6e7681",
fontWeight: "400",
},
step: {
flexDirection: "row",
alignItems: "flex-start",
marginBottom: 12,
gap: 10,
},
stepBadge: {
backgroundColor: "#21262d",
borderRadius: 10,
width: 20,
height: 20,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
stepN: {
color: "#58a6ff",
fontSize: 11,
fontWeight: "700",
},
stepText: {
color: "#8b949e",
fontSize: 13,
lineHeight: 18,
flex: 1,
},
});

View File

@ -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<RootStackParamList, "Terminal">;
type ConnectionStatus = "connecting" | "connected" | "error" | "disconnected";
const STATUS_COLORS: Record<ConnectionStatus, string> = {
connecting: "#e3b341",
connected: "#3fb950",
error: "#f85149",
disconnected: "#f85149",
};
export default function TerminalScreen({ route }: Props) {
const { sessionId, terminalWsUrl } = route.params;
const webViewRef = useRef<WebView>(null);
const insets = useSafeAreaInsets();
const [status, setStatus] = useState<ConnectionStatus>("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 (
<View style={[styles.container, { paddingBottom: insets.bottom }]}>
<StatusBar barStyle="light-content" backgroundColor="#0d1117" />
{/* Status indicator in top-right */}
<View style={styles.statusDot}>
<View style={[styles.dot, { backgroundColor: dotColor }]} />
<Text style={[styles.statusText, { color: dotColor }]}>
{status}
</Text>
</View>
<WebView
ref={webViewRef}
source={{ html: TERMINAL_HTML }}
injectedJavaScriptBeforeContentLoaded={injectedJS}
onMessage={handleMessage}
onLoad={handleLoad}
style={styles.webView}
originWhitelist={["*"]}
// Required for ws:// from about:blank on Android
mixedContentMode="always"
allowFileAccess={false}
javaScriptEnabled={true}
domStorageEnabled={false}
scrollEnabled={false}
bounces={false}
overScrollMode="never"
showsVerticalScrollIndicator={false}
showsHorizontalScrollIndicator={false}
keyboardDisplayRequiresUserAction={false}
// Suppress "Can't open file" logs for blob: URLs
onError={() => {}}
/>
</View>
);
}
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",
},
});

View File

@ -0,0 +1,177 @@
/**
* Standalone xterm.js terminal page, loaded into WebView.
* WS URL and session ID are injected via injectedJavaScriptBeforeContentLoaded
* which sets window.AO_WS_URL and window.AO_SESSION_ID before the script runs.
*/
export const TERMINAL_HTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Terminal</title>
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js"><\/script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css" />
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js"><\/script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; background: #0d1117; overflow: hidden; }
#terminal { width: 100%; height: 100%; }
#status {
position: fixed;
top: 6px;
right: 8px;
width: 8px;
height: 8px;
border-radius: 50%;
background: #8b949e;
z-index: 10;
transition: background 0.3s;
}
#status.connecting { background: #e3b341; }
#status.connected { background: #3fb950; }
#status.error { background: #f85149; }
<\/style>
</head>
<body>
<div id="status" class="connecting"></div>
<div id="terminal"></div>
<script>
(function () {
var WS_BASE = window.AO_WS_URL || 'ws://localhost:3003';
var SESSION = window.AO_SESSION_ID || '';
var statusEl = document.getElementById('status');
var term = new Terminal({
cursorBlink: true,
fontSize: 13,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
theme: {
background: '#0d1117',
foreground: '#e6edf3',
cursor: '#e6edf3',
black: '#0d1117',
red: '#f85149',
green: '#3fb950',
yellow: '#e3b341',
blue: '#58a6ff',
magenta: '#bc8cff',
cyan: '#39c5cf',
white: '#b1bac4',
brightBlack: '#6e7681',
brightRed: '#ff7b72',
brightGreen: '#56d364',
brightYellow: '#e3b341',
brightBlue: '#79c0ff',
brightMagenta: '#d2a8ff',
brightCyan: '#56d4dd',
brightWhite: '#f0f6fc',
},
allowProposedApi: true,
});
var fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById('terminal'));
fitAddon.fit();
function postToRN(obj) {
try {
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(JSON.stringify(obj));
}
} catch (e) { /* ignore */ }
}
function sendResize() {
fitAddon.fit();
var cols = term.cols;
var rows = term.rows;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'resize', cols: cols, rows: rows }));
}
postToRN({ type: 'resize', cols: cols, rows: rows });
}
window.addEventListener('resize', sendResize);
var ws;
var reconnectDelay = 1000;
function connect() {
var url = WS_BASE + '/ws?session=' + encodeURIComponent(SESSION);
statusEl.className = 'connecting';
postToRN({ type: 'status', state: 'connecting' });
ws = new WebSocket(url);
ws.binaryType = 'arraybuffer';
ws.onopen = function () {
statusEl.className = 'connected';
postToRN({ type: 'status', state: 'connected' });
reconnectDelay = 1000;
sendResize();
};
ws.onmessage = function (evt) {
if (typeof evt.data === 'string') {
// Filter out JSON control messages (e.g. resize echoes)
try {
var msg = JSON.parse(evt.data);
if (msg.type === 'resize') return; // echo, ignore
} catch (e) { /* not JSON — write as terminal text */ }
term.write(evt.data);
} else {
// Binary (ArrayBuffer)
term.write(new Uint8Array(evt.data));
}
};
ws.onerror = function () {
statusEl.className = 'error';
postToRN({ type: 'status', state: 'error' });
};
ws.onclose = function () {
statusEl.className = 'error';
postToRN({ type: 'status', state: 'disconnected' });
setTimeout(function () {
reconnectDelay = Math.min(reconnectDelay * 1.5, 10000);
connect();
}, reconnectDelay);
};
}
term.onData(function (data) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
});
// XDA handler: respond to CSI > q with XTerm identity (enables tmux clipboard)
term.onData(function (data) {
if (data === '\\x1b[>q') {
term.write('\\x1bP>|XTerm(370)\\x1b\\\\');
}
});
document.addEventListener('message', function (evt) { handleRNMessage(evt.data); });
window.addEventListener('message', function (evt) { handleRNMessage(evt.data); });
function handleRNMessage(raw) {
try {
var msg = JSON.parse(raw);
if (msg.type === 'fit') sendResize();
else if (msg.type === 'focus') term.focus();
} catch (e) { /* ignore */ }
}
if (SESSION) {
connect();
} else {
term.write('\\x1b[31mError: No session ID provided.\\x1b[0m\\r\\n');
statusEl.className = 'error';
}
})();
<\/script>
</body>
</html>`;

View File

@ -0,0 +1,216 @@
/**
* Mobile-local types mirroring the web dashboard types.
* These must stay in sync with packages/web/src/lib/types.ts.
*/
export type SessionStatus =
| "spawning"
| "running"
| "working"
| "pr_open"
| "needs_input"
| "stuck"
| "errored"
| "ci_failed"
| "changes_requested"
| "review_pending"
| "mergeable"
| "approved"
| "merged"
| "killed"
| "cleanup"
| "done"
| "terminated";
export type ActivityState =
| "active"
| "ready"
| "starting"
| "thinking"
| "working"
| "waiting_input"
| "blocked"
| "idle"
| "exited";
export type CIStatus = "none" | "pending" | "passing" | "failing";
export type ReviewDecision = "none" | "pending" | "approved" | "changes_requested";
export type AttentionLevel = "merge" | "respond" | "review" | "pending" | "working" | "done";
export interface DashboardCICheck {
name: string;
status: string;
url?: string;
}
export interface DashboardMergeability {
mergeable: boolean;
ciPassing: boolean;
approved: boolean;
noConflicts: boolean;
blockers: string[];
}
export interface DashboardPR {
number: number;
url: string;
title: string;
owner: string;
repo: string;
branch: string;
baseBranch: string;
isDraft: boolean;
state: "open" | "merged" | "closed";
additions: number;
deletions: number;
ciStatus: CIStatus;
ciChecks: DashboardCICheck[];
reviewDecision: ReviewDecision;
mergeability: DashboardMergeability;
unresolvedThreads: number;
}
export interface DashboardSession {
id: string;
projectId: string;
status: SessionStatus;
activity: ActivityState | null;
branch: string | null;
issueId: string | null;
issueUrl: string | null;
issueLabel: string | null;
issueTitle: string | null;
summary: string | null;
summaryIsFallback: boolean;
createdAt: string;
lastActivityAt: string;
pr: DashboardPR | null;
metadata: Record<string, string>;
}
export interface DashboardStats {
totalSessions: number;
workingSessions: number;
openPRs: number;
needsReview: number;
}
export interface SessionsResponse {
sessions: DashboardSession[];
stats: DashboardStats;
}
/** Attention level colors matching the web dashboard */
export const ATTENTION_COLORS: Record<AttentionLevel, string> = {
merge: "#3fb950",
respond: "#f85149",
review: "#d29922",
pending: "#e3b341",
working: "#58a6ff",
done: "#8b949e",
};
/** Terminal states that cannot be restored */
const TERMINAL_STATUSES: SessionStatus[] = ["merged", "killed", "cleanup", "done", "terminated"];
const TERMINAL_ACTIVITIES: ActivityState[] = ["exited"];
const NON_RESTORABLE_STATUSES: SessionStatus[] = ["merged", "done", "terminated"];
export function isTerminal(session: DashboardSession): boolean {
return (
TERMINAL_STATUSES.includes(session.status) ||
TERMINAL_ACTIVITIES.includes(session.activity as ActivityState)
);
}
export function isRestorable(session: DashboardSession): boolean {
return (
session.activity === "exited" &&
!NON_RESTORABLE_STATUSES.includes(session.status)
);
}
function isPRRateLimited(pr: DashboardPR): boolean {
return pr.mergeability.blockers.includes("API rate limited or unavailable");
}
/** Determines which attention zone a session belongs to */
export function getAttentionLevel(session: DashboardSession): AttentionLevel {
// Done: terminal states
if (
session.status === "merged" ||
session.status === "killed" ||
session.status === "cleanup" ||
session.status === "done" ||
session.status === "terminated"
) {
return "done";
}
if (session.pr) {
if (session.pr.state === "merged" || session.pr.state === "closed") {
return "done";
}
}
// Merge: PR is ready
if (session.status === "mergeable" || session.status === "approved") {
return "merge";
}
if (session.pr?.mergeability.mergeable) {
return "merge";
}
// Respond: agent waiting for human input
if (session.activity === "waiting_input" || session.activity === "blocked") {
return "respond";
}
if (
session.status === "needs_input" ||
session.status === "stuck" ||
session.status === "errored"
) {
return "respond";
}
if (session.activity === "exited") {
return "respond";
}
// Review: problems that need investigation
if (session.status === "ci_failed" || session.status === "changes_requested") {
return "review";
}
if (session.pr && !isPRRateLimited(session.pr)) {
const pr = session.pr;
if (pr.ciStatus === "failing") return "review";
if (pr.reviewDecision === "changes_requested") return "review";
if (!pr.mergeability.noConflicts) return "review";
}
// Pending: waiting on external
if (session.status === "review_pending") {
return "pending";
}
if (session.pr && !isPRRateLimited(session.pr)) {
const pr = session.pr;
if (!pr.isDraft && pr.unresolvedThreads > 0) return "pending";
if (!pr.isDraft && (pr.reviewDecision === "pending" || pr.reviewDecision === "none")) {
return "pending";
}
}
// Working: agents doing their thing
return "working";
}
/** Human-readable relative time */
export function relativeTime(isoString: string): string {
const diff = Date.now() - new Date(isoString).getTime();
const minutes = Math.floor(diff / 60_000);
const hours = Math.floor(diff / 3_600_000);
const days = Math.floor(diff / 86_400_000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
return `${days}d ago`;
}

View File

@ -0,0 +1,17 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.d.ts"
]
}

View File

@ -1,3 +1,4 @@
packages:
- "packages/*"
- "packages/plugins/*"
- "!packages/mobile"