chore: remove React Native/Expo mobile app

The web dashboard now has full PWA and mobile-responsive support,
making the separate native mobile app redundant.

- Delete packages/mobile/ directory
- Remove mobile exclusion from pnpm-workspace.yaml
- Remove mobile ignore from eslint.config.js
- Update SETUP.md example project
This commit is contained in:
Ashish Huddar 2026-03-25 12:00:59 +05:30
parent 4741ba2461
commit 4c4fada522
35 changed files with 4 additions and 3614 deletions

View File

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

View File

@ -15,7 +15,6 @@ export default tseslint.config(
"packages/web/postcss.config.mjs",
"test-clipboard*.mjs",
"test-clipboard*.sh",
"packages/mobile/**",
],
},

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 (
<SafeAreaProvider>
<BackendProvider>
<RootNavigator />
</BackendProvider>
</SafeAreaProvider>
);
}

View File

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

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

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

@ -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<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>;
mergePR: (prNumber: number) => Promise<void>;
spawnSession: (projectId: string, issueId?: string) => Promise<DashboardSession>;
}
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]);
const mergePR = useCallback(async (prNumber: number): Promise<void> => {
await apiFetch(`/api/prs/${prNumber}/merge`, { method: "POST" });
}, [apiFetch]);
const spawnSession = useCallback(async (projectId: string, issueId?: string): Promise<DashboardSession> => {
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 (
<BackendContext.Provider
value={{
backendUrl,
setBackendUrl,
terminalWsUrl,
terminalWsOverride,
setTerminalWsOverride,
fetchSessions,
fetchSession,
sendMessage,
killSession,
restoreSession,
mergePR,
spawnSession,
}}
>
{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

@ -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<DashboardSession | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | 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 };
}

View File

@ -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<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") {
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]);
}

View File

@ -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<DashboardSession[]>([]);
const [stats, setStats] = useState<DashboardStats | null>(null);
const [orchestratorId, setOrchestratorId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | 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 };
}

View File

@ -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<RootStackParamList>();
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 ref={navigationRef} 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.Screen
name="SpawnSession"
component={SpawnSessionScreen}
options={{ title: "New Session" }}
/>
<Stack.Screen
name="Orchestrator"
component={OrchestratorScreen}
options={{ title: "Orchestrator" }}
/>
<Stack.Screen
name="Commands"
component={CommandsScreen}
options={{ title: "CLI Commands" }}
/>
</Stack.Navigator>
</NavigationContainer>
);
}

View File

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

@ -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<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 async function scheduleNotification(
session: DashboardSession,
level: "respond" | "merge" | "review",
): Promise<void> {
const sessionLabel =
session.issueLabel ??
session.id;
const body =
session.issueTitle ??
(session.summary && !session.summaryIsFallback ? session.summary : null) ??
session.activity ??
session.status;
const titles: Record<typeof level, string> = {
respond: "Agent needs your input",
merge: "PR ready to merge",
review: "Session needs review",
};
const bodies: Record<typeof level, string> = {
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,
},
});
}

View File

@ -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 <project> [issue]", desc: "Spawn a session for an issue" },
{ cmd: "ao batch-spawn <project> <issues...>", desc: "Spawn multiple sessions" },
{ cmd: "ao session ls [-p <project>]", desc: "List all active sessions" },
{ cmd: "ao session kill <session>", desc: "Kill a session" },
{ cmd: "ao session restore <session>", desc: "Restore a crashed session" },
{ cmd: "ao session cleanup [-p <project>]", desc: "Clean up merged/closed sessions" },
{ cmd: "ao send <session> [message]", desc: "Send message to a session" },
{ cmd: "ao status [-p <project>]", desc: "Show sessions with PR/CI status" },
{ cmd: "ao review-check [project]", desc: "Check PRs and trigger agents" },
{ cmd: "ao dashboard [-p <port>]", 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 (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<View style={styles.section}>
<Text style={styles.hint}>Quick reference for managing sessions from terminal.</Text>
{CLI_COMMANDS.map((c, i) => (
<View key={i} style={styles.cmdRow}>
<Text style={styles.cmdText}>{c.cmd}</Text>
<Text style={styles.cmdDesc}>{c.desc}</Text>
</View>
))}
</View>
</ScrollView>
);
}
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,
},
});

View File

@ -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<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: () => (
<View style={{ flexDirection: "row", alignItems: "center", gap: 14 }}>
<TouchableOpacity
onPress={() => navigation.navigate("SpawnSession")}
style={{ flexDirection: "row", alignItems: "center", gap: 2 }}
>
<Text style={{ color: "#3fb950", fontSize: 18, fontWeight: "700" }}>+</Text>
<Text style={{ color: "#3fb950", fontSize: 13, fontWeight: "600" }}>Session</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => navigation.navigate("Orchestrator")}>
<Text style={{ fontSize: 20 }}>{"\uD83E\uDD16"}</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => navigation.navigate("Settings")}
style={{ paddingRight: 4 }}
>
<Text style={{ fontSize: 20 }}>{"\u2699\uFE0F"}</Text>
</TouchableOpacity>
</View>
),
});
}, [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

@ -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<RootStackParamList, "Orchestrator">;
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: () => (
<TouchableOpacity onPress={() => navigation.navigate("Commands")}>
<Text style={{ color: "#58a6ff", fontSize: 14, fontWeight: "600" }}>Commands</Text>
</TouchableOpacity>
),
});
}, [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 (
<View style={styles.center}>
<ActivityIndicator color="#58a6ff" size="large" />
</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>
</View>
);
}
const orchLevel = orchSession ? getAttentionLevel(orchSession) : null;
const orchColor = orchLevel ? ATTENTION_COLORS[orchLevel] : "#8b949e";
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
{/* Orchestrator Session Details */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Orchestrator</Text>
{orchestratorId && orchSession ? (
<View style={[styles.orchDetailCard, { borderLeftColor: orchColor }]}>
<View style={styles.orchHeaderRow}>
<View style={styles.orchStatusRow}>
<View style={[styles.dot, { backgroundColor: "#3fb950" }]} />
<Text style={styles.orchRunning}>Running</Text>
</View>
<AttentionBadge level={orchLevel!} />
</View>
<Text style={styles.orchId}>{orchestratorId}</Text>
<Text style={styles.orchStatus}>
{orchSession.status}
{orchSession.activity ? ` · ${orchSession.activity}` : ""}
</Text>
{orchSession.summary && !orchSession.summaryIsFallback && (
<Text style={styles.orchSummary} numberOfLines={3}>{orchSession.summary}</Text>
)}
<View style={styles.orchTimingRow}>
<Text style={styles.orchTiming}>Last activity: {relativeTime(orchSession.lastActivityAt)}</Text>
</View>
{/* Actions */}
<View style={styles.orchActions}>
<TouchableOpacity
style={styles.terminalButton}
onPress={() => navigation.navigate("Terminal", { sessionId: orchestratorId, terminalWsUrl })}
>
<Text style={styles.terminalButtonText}>Open Terminal</Text>
</TouchableOpacity>
</View>
{/* Send message */}
<View style={styles.orchMessageRow}>
<TextInput
style={styles.orchMessageInput}
placeholder="Send message to orchestrator..."
placeholderTextColor="#8b949e"
value={message}
onChangeText={setMessage}
returnKeyType="send"
onSubmitEditing={handleSend}
/>
<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>
</View>
) : (
<View style={styles.orchDetailCard}>
<View style={styles.orchStatusRow}>
<View style={[styles.dot, { backgroundColor: "#8b949e" }]} />
<Text style={[styles.orchRunning, { color: "#8b949e" }]}>Not running</Text>
</View>
<Text style={styles.orchHint}>Start with: ao start &lt;project&gt;</Text>
</View>
)}
</View>
{/* Zone Overview */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Session Zones</Text>
<View style={styles.zonesGrid}>
<ZoneBadge label="Merge" count={zones.merge} color="#3fb950" />
<ZoneBadge label="Respond" count={zones.respond} color="#f85149" />
<ZoneBadge label="Review" count={zones.review} color="#d29922" />
<ZoneBadge label="Pending" count={zones.pending} color="#e3b341" />
<ZoneBadge label="Working" count={zones.working} color="#58a6ff" />
<ZoneBadge label="Done" count={zones.done} color="#8b949e" />
</View>
</View>
</ScrollView>
);
}
function ZoneBadge({ label, count, color }: { label: string; count: number; color: string }) {
return (
<View style={styles.zoneBadge}>
<Text style={[styles.zoneCount, { color }]}>{count}</Text>
<Text style={styles.zoneLabel}>{label}</Text>
</View>
);
}
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,
},
});

View File

@ -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<RootStackParamList, "SessionDetail">;
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<ActionButtonState>("idle");
const [commentFixStates, setCommentFixStates] = useState<Record<string, ActionButtonState>>({});
const scrollRef = useRef<ScrollView>(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 (
<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);
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 (
<View style={styles.root}>
<ScrollView ref={scrollRef} style={styles.container} contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
{/* 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>
{/* Alerts */}
{pr && pr.ciStatus === "failing" && failedChecks.length > 0 && (
<View style={styles.alertCard}>
<View style={styles.alertRow}>
<Text style={styles.alertText}>
{failedChecks.length} CI check{failedChecks.length > 1 ? "s" : ""} failing
</Text>
<ActionButton
state={ciFixState}
label="Ask to fix"
onPress={() => handleAskFixCI(pr)}
/>
</View>
</View>
)}
{pr && !pr.mergeability.noConflicts && (
<View style={[styles.alertCard, { borderLeftColor: "#d29922" }]}>
<Text style={[styles.alertText, { color: "#d29922" }]}>Merge conflict</Text>
</View>
)}
{pr && pr.reviewDecision === "changes_requested" && (
<View style={[styles.alertCard, { borderLeftColor: "#d29922" }]}>
<Text style={[styles.alertText, { color: "#d29922" }]}>Changes requested</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 */}
{pr && (
<Section title={`PR #${pr.number}`}>
<TouchableOpacity onPress={() => Linking.openURL(pr.url)}>
<Text style={[styles.issueText, { color: "#58a6ff", marginBottom: 8 }]} numberOfLines={2}>
{pr.title}
</Text>
</TouchableOpacity>
<InfoRow label="CI" value={pr.ciStatus} valueColor={pr.ciStatus === "passing" ? "#3fb950" : pr.ciStatus === "failing" ? "#f85149" : undefined} />
<InfoRow label="Review" value={pr.reviewDecision} valueColor={pr.reviewDecision === "approved" ? "#3fb950" : pr.reviewDecision === "changes_requested" ? "#f85149" : undefined} />
<InfoRow label="Mergeable" value={pr.mergeability.mergeable ? "Yes" : "No"} valueColor={pr.mergeability.mergeable ? "#3fb950" : "#f85149"} />
<InfoRow label="Changes" value={`+${pr.additions} / -${pr.deletions}`} />
{pr.isDraft && <InfoRow label="Draft" value="Yes" />}
{pr.mergeability.blockers.length > 0 && (
<View style={{ marginTop: 6 }}>
<Text style={styles.blockersLabel}>Blockers:</Text>
{pr.mergeability.blockers.map((b, i) => (
<Text key={i} style={styles.blockerText}>- {b}</Text>
))}
</View>
)}
</Section>
)}
{/* CI Checks */}
{pr && pr.ciChecks.length > 0 && (
<Section title="CI Checks">
{pr.ciChecks.map((check, i) => (
<CICheckRow key={i} check={check} />
))}
</Section>
)}
{/* Unresolved Comments */}
{unresolvedComments.length > 0 && (
<Section title={`Unresolved Comments (${unresolvedComments.length})`}>
{unresolvedComments.map((comment, i) => (
<View key={i} style={styles.commentCard}>
<View style={styles.commentHeader}>
<Text style={styles.commentAuthor}>{comment.author}</Text>
<Text style={styles.commentPath} numberOfLines={1}>{comment.path}</Text>
</View>
<Text style={styles.commentBody} numberOfLines={4}>{comment.body}</Text>
<View style={styles.commentActions}>
<ActionButton
state={commentFixStates[comment.url] ?? "idle"}
label="Ask Agent to Fix"
onPress={() => handleAskFixComment(comment)}
/>
<TouchableOpacity onPress={() => Linking.openURL(comment.url)}>
<Text style={styles.viewLink}>View</Text>
</TouchableOpacity>
</View>
</View>
))}
</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}>
{isReadyToMerge && (
<TouchableOpacity
style={[styles.actionButton, styles.mergeButton]}
onPress={() => handleMergePR(pr.number)}
disabled={merging}
>
{merging ? (
<ActivityIndicator color="#fff" size="small" />
) : (
<Text style={[styles.actionButtonText, { color: "#fff" }]}>
Merge PR #{pr.number}
</Text>
)}
</TouchableOpacity>
)}
<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, { paddingBottom: keyboardHeight > 0 ? keyboardHeight + 48 : 10 }]}>
<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>
)}
</View>
);
}
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, valueColor }: { label: string; value: string; valueColor?: string }) {
return (
<View style={styles.infoRow}>
<Text style={styles.infoLabel}>{label}</Text>
<Text style={[styles.infoValue, valueColor ? { color: valueColor } : undefined]}>{value}</Text>
</View>
);
}
const CI_STATUS_ICONS: Record<string, { icon: string; color: string }> = {
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 (
<TouchableOpacity
style={styles.ciCheckRow}
onPress={check.url ? () => Linking.openURL(check.url!) : undefined}
disabled={!check.url}
>
<Text style={[styles.ciCheckIcon, { color: info.color }]}>{info.icon}</Text>
<Text style={styles.ciCheckName} numberOfLines={1}>{check.name}</Text>
<Text style={[styles.ciCheckStatus, { color: info.color }]}>{check.status}</Text>
</TouchableOpacity>
);
}
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 (
<TouchableOpacity
style={[styles.inlineActionButton, { backgroundColor: bgColor, borderColor }]}
onPress={onPress}
disabled={isDisabled}
>
{state === "sending" ? (
<ActivityIndicator color="#58a6ff" size="small" />
) : (
<Text style={[styles.inlineActionText, { color: textColor }]}>{text}</Text>
)}
</TouchableOpacity>
);
}
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,
},
});

View File

@ -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<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;
}
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 (
<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}>
Enter the URL where your AO dashboard is running.
</Text>
<Text style={styles.fieldLabel}>Dashboard API URL</Text>
<TextInput
style={styles.input}
value={input}
onChangeText={setInput}
placeholder="http://100.x.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>
{__DEV__ && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Test Notifications</Text>
<Text style={styles.hint}>Fire a test notification to verify permissions are working. Tap the notification to navigate to the session.</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>
<TouchableOpacity style={[styles.testButton, { borderColor: "#d29922", marginTop: 8 }]} onPress={handleTestReviewNotification}>
<Text style={[styles.testButtonText, { color: "#d29922" }]}>Test "Session needs review" (review)</Text>
</TouchableOpacity>
</View>
)}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Setup Guide Tailscale (Recommended)</Text>
<Step n="1" text="Install Tailscale on your Mac and phone (tailscale.com)" />
<Step n="2" text="Sign in to the same Tailscale account on both devices" />
<Step n="3" text="Find your Mac's Tailscale IP: run 'tailscale ip -4' in terminal (starts with 100.x)" />
<Step n="4" text="Make sure the orchestrator is running: pnpm build && pnpm dev" />
<Step n="5" text="Enter http://<TAILSCALE_IP>:3000 above and tap Save" />
<Text style={styles.sectionDivider}>Alternative: Local Wi-Fi</Text>
<Step n="1" text="Your phone must be on the same Wi-Fi as your Mac" />
<Step n="2" text="Find your Mac's LAN IP: System Settings > Wi-Fi > Details > IP Address" />
<Step n="3" text="Enter http://<LAN_IP>:3000 above and tap Save" />
<Text style={styles.sectionDivider}>Alternative: ngrok</Text>
<Step n="1" text="Run: ngrok http 3000" />
<Step n="2" text="Paste the https:// URL above as Dashboard API URL" />
<Step n="3" text="For terminal, run: ngrok http 14801 and paste the wss:// URL in Terminal WebSocket URL" />
</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,
},
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,
},
});

View File

@ -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<RootStackParamList, "SpawnSession">;
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 (
<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}>Spawn New Session</Text>
<Text style={styles.hint}>
Create a new agent session. The orchestrator will assign an agent and workspace.
</Text>
<Text style={styles.fieldLabel}>Project ID *</Text>
<TextInput
style={styles.input}
value={projectId}
onChangeText={setProjectId}
placeholder="e.g. my-project"
placeholderTextColor="#8b949e"
autoCapitalize="none"
autoCorrect={false}
returnKeyType="next"
/>
<Text style={styles.fieldLabel}>
Issue ID{" "}
<Text style={styles.fieldLabelMuted}>(optional)</Text>
</Text>
<TextInput
style={styles.input}
value={issueId}
onChangeText={setIssueId}
placeholder="e.g. 42 or PROJ-123"
placeholderTextColor="#8b949e"
autoCapitalize="none"
autoCorrect={false}
returnKeyType="done"
/>
<TouchableOpacity
style={[styles.spawnButton, spawning && styles.spawnButtonDisabled]}
onPress={handleSpawn}
disabled={spawning}
>
<Text style={styles.spawnButtonText}>
{spawning ? "Spawning..." : "Spawn Session"}
</Text>
</TouchableOpacity>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
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",
},
});

View File

@ -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<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={true}
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

@ -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 = `<!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)
// Must use parser.registerCsiHandler — onData only captures outgoing user input,
// not incoming data from the WebSocket. The XDA query arrives via ws → term.write().
term.parser.registerCsiHandler({ prefix: '>', final: 'q' }, function () {
term.write('\\x1bP>|XTerm(370)\\x1b\\\\');
return true;
});
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

@ -1,222 +0,0 @@
/**
* Mobile-local types mirroring the web dashboard types.
* These must stay in sync with packages/web/src/lib/types.ts.
*/
export type SessionStatus =
| "spawning"
| "working"
| "pr_open"
| "ci_failed"
| "review_pending"
| "changes_requested"
| "approved"
| "mergeable"
| "merged"
| "cleanup"
| "needs_input"
| "stuck"
| "errored"
| "killed"
| "done"
| "terminated";
export type ActivityState =
| "active"
| "ready"
| "idle"
| "waiting_input"
| "blocked"
| "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 DashboardUnresolvedComment {
url: string;
path: string;
author: string;
body: 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;
unresolvedComments?: DashboardUnresolvedComment[];
}
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;
orchestratorId: string | null;
}
/** 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",
};
/** Statuses that indicate the session is in a terminal (dead) state.
* Must stay in sync with packages/core/src/types.ts TERMINAL_STATUSES. */
const TERMINAL_STATUSES: SessionStatus[] = ["killed", "terminated", "done", "cleanup", "errored", "merged"];
const TERMINAL_ACTIVITIES: ActivityState[] = ["exited"];
/** Statuses that must never be restored (e.g. already merged).
* Must stay in sync with packages/core/src/types.ts NON_RESTORABLE_STATUSES. */
const NON_RESTORABLE_STATUSES: SessionStatus[] = ["merged"];
export function isTerminal(session: DashboardSession): boolean {
return (
TERMINAL_STATUSES.includes(session.status) ||
(session.activity !== null && TERMINAL_ACTIVITIES.includes(session.activity))
);
}
export function isRestorable(session: DashboardSession): boolean {
return isTerminal(session) && !NON_RESTORABLE_STATUSES.includes(session.status);
}
export 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

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

View File

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