feat(mobile): add React Native app for monitoring AO sessions
Adds `packages/mobile` — an Expo SDK 53 React Native app for monitoring agent sessions from a phone. Features: - Home screen: Kanban-style session list sorted by attention level - Session detail: metadata, PR link, CI status, branch info - Terminal screen: full xterm.js terminal via WebView (port 14801) - Settings screen: configurable backend URL (LAN IP or ngrok) - Push notifications: background polling for attention-level changes Improvements over PR #235: - Fixed terminal onmessage handler to filter JSON control messages (resize echoes no longer render as garbage text) - Removed duplicate terminal.html (single source of truth in terminal-html.ts) - Fixed isDone check to include "cleanup" status using isTerminal() Tech: Expo SDK 53, React Navigation 6, React Native WebView. Two server connections: REST API (port 3000) + WebSocket terminal (14801). The mobile package is excluded from the pnpm workspace to avoid interfering with the monorepo toolchain. Closes #265 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4cda43795b
commit
c53099923d
|
|
@ -0,0 +1,34 @@
|
|||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
|
||||
# Native build folders (generated by `expo prebuild`)
|
||||
android/
|
||||
ios/
|
||||
|
||||
# Native
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# npm (project uses pnpm)
|
||||
package-lock.json
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
{
|
||||
"expo": {
|
||||
"name": "Agent Orchestrator",
|
||||
"slug": "ao-mobile",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "dark",
|
||||
"splash": {
|
||||
"image": "./assets/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#0d1117"
|
||||
},
|
||||
"assetBundlePatterns": [
|
||||
"**/*"
|
||||
],
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.composio.ao-mobile",
|
||||
"infoPlist": {
|
||||
"UIBackgroundModes": [
|
||||
"fetch",
|
||||
"processing",
|
||||
"remote-notification"
|
||||
]
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#0d1117"
|
||||
},
|
||||
"package": "com.composio.aomobile"
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-background-task",
|
||||
[
|
||||
"expo-notifications",
|
||||
{
|
||||
"color": "#f85149",
|
||||
"defaultChannel": "ao-respond"
|
||||
}
|
||||
]
|
||||
],
|
||||
"extra": {
|
||||
"eas": {
|
||||
"projectId": "de59ca61-8fd4-47bb-bdea-6e1a50ade3df"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 159 B |
Binary file not shown.
|
After Width: | Height: | Size: 82 B |
Binary file not shown.
|
After Width: | Height: | Size: 159 B |
Binary file not shown.
|
After Width: | Height: | Size: 231 B |
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ["babel-preset-expo"],
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"cli": {
|
||||
"version": ">= 12.0.0"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal",
|
||||
"ios": {
|
||||
"simulator": false
|
||||
}
|
||||
},
|
||||
"production": {}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
import { registerRootComponent } from "expo";
|
||||
import App from "./src/App";
|
||||
|
||||
registerRootComponent(App);
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
// Disable package exports resolution — some packages (math-intrinsics, expo-application)
|
||||
// have invalid or incomplete `exports` fields that cause Metro bundling failures.
|
||||
// Falling back to classic file-based resolution fixes these.
|
||||
config.resolver.unstable_enablePackageExports = false;
|
||||
|
||||
module.exports = config;
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"name": "@composio/ao-mobile",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.1.2",
|
||||
"@react-navigation/native": "^6.1.18",
|
||||
"@react-navigation/native-stack": "^6.11.0",
|
||||
"expo": "~53.0.0",
|
||||
"expo-application": "~6.1.5",
|
||||
"expo-background-task": "~0.2.8",
|
||||
"expo-notifications": "~0.31.5",
|
||||
"expo-status-bar": "~2.2.3",
|
||||
"expo-task-manager": "~13.1.6",
|
||||
"react": "19.0.0",
|
||||
"react-native": "0.79.6",
|
||||
"react-native-safe-area-context": "5.4.0",
|
||||
"react-native-screens": "~4.11.1",
|
||||
"react-native-webview": "13.13.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.24.0",
|
||||
"@types/react": "~19.0.10",
|
||||
"typescript": "~5.8.3"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import React, { useEffect } from "react";
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
import { BackendProvider } from "./context/BackendContext";
|
||||
import RootNavigator from "./navigation/RootNavigator";
|
||||
import { setupNotifications } from "./notifications";
|
||||
import { registerBackgroundTask } from "./notifications/backgroundTask";
|
||||
|
||||
export default function App() {
|
||||
useEffect(() => {
|
||||
void setupNotifications();
|
||||
void registerBackgroundTask();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<BackendProvider>
|
||||
<RootNavigator />
|
||||
</BackendProvider>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import React from "react";
|
||||
import { View, Text, StyleSheet } from "react-native";
|
||||
import { ATTENTION_COLORS, type AttentionLevel } from "../types";
|
||||
|
||||
interface Props {
|
||||
level: AttentionLevel;
|
||||
}
|
||||
|
||||
const LABELS: Record<AttentionLevel, string> = {
|
||||
merge: "MERGE",
|
||||
respond: "RESPOND",
|
||||
review: "REVIEW",
|
||||
pending: "PENDING",
|
||||
working: "WORKING",
|
||||
done: "DONE",
|
||||
};
|
||||
|
||||
export default function AttentionBadge({ level }: Props) {
|
||||
const color = ATTENTION_COLORS[level];
|
||||
return (
|
||||
<View style={[styles.badge, { borderColor: color, backgroundColor: color + "22" }]}>
|
||||
<Text style={[styles.label, { color }]}>{LABELS[level]}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
badge: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 4,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
label: {
|
||||
fontSize: 10,
|
||||
fontWeight: "700",
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
import React from "react";
|
||||
import { View, Text, TouchableOpacity, StyleSheet } from "react-native";
|
||||
import {
|
||||
getAttentionLevel,
|
||||
relativeTime,
|
||||
ATTENTION_COLORS,
|
||||
type DashboardSession,
|
||||
} from "../types";
|
||||
import AttentionBadge from "./AttentionBadge";
|
||||
|
||||
interface Props {
|
||||
session: DashboardSession;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
export default function SessionCard({ session, onPress }: Props) {
|
||||
const level = getAttentionLevel(session);
|
||||
const color = ATTENTION_COLORS[level];
|
||||
const time = relativeTime(session.lastActivityAt);
|
||||
|
||||
return (
|
||||
<TouchableOpacity style={[styles.card, { borderLeftColor: color }]} onPress={onPress} activeOpacity={0.75}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.id} numberOfLines={1} ellipsizeMode="middle">
|
||||
{session.id}
|
||||
</Text>
|
||||
<AttentionBadge level={level} />
|
||||
</View>
|
||||
|
||||
{session.issueLabel || session.issueTitle ? (
|
||||
<Text style={styles.issue} numberOfLines={1}>
|
||||
{session.issueLabel ? `${session.issueLabel}: ` : ""}
|
||||
{session.issueTitle ?? ""}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{session.summary && !session.summaryIsFallback ? (
|
||||
<Text style={styles.summary} numberOfLines={2}>
|
||||
{session.summary}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<View style={styles.footer}>
|
||||
{session.branch ? (
|
||||
<Text style={styles.branch} numberOfLines={1}>
|
||||
{session.branch}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text style={styles.time}>{time}</Text>
|
||||
</View>
|
||||
|
||||
{session.pr ? (
|
||||
<View style={styles.prRow}>
|
||||
<Text style={styles.prLabel}>PR #{session.pr.number}</Text>
|
||||
{session.pr.ciStatus !== "none" && (
|
||||
<Text
|
||||
style={[
|
||||
styles.ciStatus,
|
||||
{
|
||||
color:
|
||||
session.pr.ciStatus === "passing"
|
||||
? "#3fb950"
|
||||
: session.pr.ciStatus === "failing"
|
||||
? "#f85149"
|
||||
: "#8b949e",
|
||||
},
|
||||
]}
|
||||
>
|
||||
{session.pr.ciStatus.toUpperCase()}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: "#161b22",
|
||||
borderLeftWidth: 3,
|
||||
borderRadius: 8,
|
||||
padding: 14,
|
||||
marginHorizontal: 12,
|
||||
marginVertical: 5,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 6,
|
||||
},
|
||||
id: {
|
||||
color: "#8b949e",
|
||||
fontSize: 12,
|
||||
fontFamily: "monospace",
|
||||
flex: 1,
|
||||
marginRight: 8,
|
||||
},
|
||||
issue: {
|
||||
color: "#e6edf3",
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
marginBottom: 4,
|
||||
},
|
||||
summary: {
|
||||
color: "#8b949e",
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
marginBottom: 6,
|
||||
},
|
||||
footer: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
},
|
||||
branch: {
|
||||
color: "#58a6ff",
|
||||
fontSize: 12,
|
||||
fontFamily: "monospace",
|
||||
flex: 1,
|
||||
},
|
||||
time: {
|
||||
color: "#8b949e",
|
||||
fontSize: 12,
|
||||
},
|
||||
prRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginTop: 6,
|
||||
gap: 8,
|
||||
},
|
||||
prLabel: {
|
||||
color: "#3fb950",
|
||||
fontSize: 12,
|
||||
fontWeight: "600",
|
||||
},
|
||||
ciStatus: {
|
||||
fontSize: 11,
|
||||
fontWeight: "700",
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import React from "react";
|
||||
import { View, Text, StyleSheet } from "react-native";
|
||||
import type { DashboardStats } from "../types";
|
||||
|
||||
interface Props {
|
||||
stats: DashboardStats;
|
||||
}
|
||||
|
||||
export default function StatBar({ stats }: Props) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<StatItem label="Sessions" value={stats.totalSessions} color="#e6edf3" />
|
||||
<StatItem label="Working" value={stats.workingSessions} color="#58a6ff" />
|
||||
<StatItem label="PRs" value={stats.openPRs} color="#3fb950" />
|
||||
<StatItem label="Review" value={stats.needsReview} color="#d29922" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function StatItem({ label, value, color }: { label: string; value: number; color: string }) {
|
||||
return (
|
||||
<View style={styles.item}>
|
||||
<Text style={[styles.value, { color }]}>{value}</Text>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-around",
|
||||
backgroundColor: "#161b22",
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#30363d",
|
||||
},
|
||||
item: {
|
||||
alignItems: "center",
|
||||
},
|
||||
value: {
|
||||
fontSize: 20,
|
||||
fontWeight: "700",
|
||||
},
|
||||
label: {
|
||||
fontSize: 11,
|
||||
color: "#8b949e",
|
||||
marginTop: 2,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
import React, { createContext, useContext, useState, useCallback, useEffect } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import type { DashboardSession, SessionsResponse } from "../types";
|
||||
|
||||
const STORAGE_KEY = "@ao_backend_url";
|
||||
const TERMINAL_WS_OVERRIDE_KEY = "@ao_terminal_ws_url";
|
||||
const DEFAULT_URL = "http://192.168.1.1:3000";
|
||||
|
||||
interface BackendContextValue {
|
||||
backendUrl: string;
|
||||
setBackendUrl: (url: string) => Promise<void>;
|
||||
/** WebSocket URL for the terminal server. Auto-derived unless manually overridden. */
|
||||
terminalWsUrl: string;
|
||||
/** Manual override for terminal WS URL (for ngrok / different host). Empty = auto-derive. */
|
||||
terminalWsOverride: string;
|
||||
setTerminalWsOverride: (url: string) => Promise<void>;
|
||||
fetchSessions: () => Promise<SessionsResponse>;
|
||||
fetchSession: (id: string) => Promise<DashboardSession>;
|
||||
sendMessage: (id: string, message: string) => Promise<void>;
|
||||
killSession: (id: string) => Promise<void>;
|
||||
restoreSession: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const BackendContext = createContext<BackendContextValue | null>(null);
|
||||
|
||||
function deriveTerminalWsUrl(backendUrl: string): string {
|
||||
try {
|
||||
const url = new URL(backendUrl);
|
||||
return `ws://${url.hostname}:14801`;
|
||||
} catch {
|
||||
return "ws://192.168.1.1:14801";
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert an ngrok https URL to a wss URL for WebSocket connections */
|
||||
function normalizeWsUrl(url: string): string {
|
||||
return url.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://");
|
||||
}
|
||||
|
||||
export function BackendProvider({ children }: { children: React.ReactNode }) {
|
||||
const [backendUrl, setBackendUrlState] = useState(DEFAULT_URL);
|
||||
const [terminalWsOverride, setTerminalWsOverrideState] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
AsyncStorage.getItem(STORAGE_KEY),
|
||||
AsyncStorage.getItem(TERMINAL_WS_OVERRIDE_KEY),
|
||||
]).then(([storedBackend, storedWs]) => {
|
||||
if (storedBackend) setBackendUrlState(storedBackend);
|
||||
if (storedWs) setTerminalWsOverrideState(storedWs);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setBackendUrl = useCallback(async (url: string) => {
|
||||
const trimmed = url.trim().replace(/\/$/, "");
|
||||
setBackendUrlState(trimmed);
|
||||
await AsyncStorage.setItem(STORAGE_KEY, trimmed);
|
||||
}, []);
|
||||
|
||||
const setTerminalWsOverride = useCallback(async (url: string) => {
|
||||
const trimmed = url.trim().replace(/\/$/, "");
|
||||
setTerminalWsOverrideState(trimmed);
|
||||
await AsyncStorage.setItem(TERMINAL_WS_OVERRIDE_KEY, trimmed);
|
||||
}, []);
|
||||
|
||||
// Use override if set, otherwise derive from backendUrl
|
||||
const terminalWsUrl = terminalWsOverride
|
||||
? normalizeWsUrl(terminalWsOverride)
|
||||
: deriveTerminalWsUrl(backendUrl);
|
||||
|
||||
const apiFetch = useCallback(
|
||||
async (path: string, options?: RequestInit) => {
|
||||
const url = `${backendUrl}${path}`;
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers: { "Content-Type": "application/json", ...(options?.headers ?? {}) },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => res.statusText);
|
||||
throw new Error(`${res.status}: ${text}`);
|
||||
}
|
||||
return res;
|
||||
},
|
||||
[backendUrl],
|
||||
);
|
||||
|
||||
const fetchSessions = useCallback(async (): Promise<SessionsResponse> => {
|
||||
const res = await apiFetch("/api/sessions");
|
||||
return res.json() as Promise<SessionsResponse>;
|
||||
}, [apiFetch]);
|
||||
|
||||
const fetchSession = useCallback(async (id: string): Promise<DashboardSession> => {
|
||||
const res = await apiFetch(`/api/sessions/${encodeURIComponent(id)}`);
|
||||
return res.json() as Promise<DashboardSession>;
|
||||
}, [apiFetch]);
|
||||
|
||||
const sendMessage = useCallback(async (id: string, message: string): Promise<void> => {
|
||||
await apiFetch(`/api/sessions/${encodeURIComponent(id)}/message`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
}, [apiFetch]);
|
||||
|
||||
const killSession = useCallback(async (id: string): Promise<void> => {
|
||||
await apiFetch(`/api/sessions/${encodeURIComponent(id)}/kill`, { method: "POST" });
|
||||
}, [apiFetch]);
|
||||
|
||||
const restoreSession = useCallback(async (id: string): Promise<void> => {
|
||||
await apiFetch(`/api/sessions/${encodeURIComponent(id)}/restore`, { method: "POST" });
|
||||
}, [apiFetch]);
|
||||
|
||||
return (
|
||||
<BackendContext.Provider
|
||||
value={{
|
||||
backendUrl,
|
||||
setBackendUrl,
|
||||
terminalWsUrl,
|
||||
terminalWsOverride,
|
||||
setTerminalWsOverride,
|
||||
fetchSessions,
|
||||
fetchSession,
|
||||
sendMessage,
|
||||
killSession,
|
||||
restoreSession,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</BackendContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useBackend(): BackendContextValue {
|
||||
const ctx = useContext(BackendContext);
|
||||
if (!ctx) throw new Error("useBackend must be used inside BackendProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { AppState, type AppStateStatus } from "react-native";
|
||||
import { useBackend } from "../context/BackendContext";
|
||||
import type { DashboardSession } from "../types";
|
||||
|
||||
const POLL_INTERVAL = 5_000;
|
||||
|
||||
interface UseSessionResult {
|
||||
session: DashboardSession | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export function useSession(id: string): UseSessionResult {
|
||||
const { fetchSession } = useBackend();
|
||||
const [session, setSession] = useState<DashboardSession | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
const doFetch = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchSession(id);
|
||||
if (!isMountedRef.current) return;
|
||||
setSession(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (!isMountedRef.current) return;
|
||||
setError(err instanceof Error ? err.message : "Failed to load session");
|
||||
} finally {
|
||||
if (isMountedRef.current) setLoading(false);
|
||||
}
|
||||
}, [fetchSession, id]);
|
||||
|
||||
const startPolling = useCallback(() => {
|
||||
doFetch();
|
||||
intervalRef.current = setInterval(doFetch, POLL_INTERVAL);
|
||||
}, [doFetch]);
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
startPolling();
|
||||
|
||||
const handleAppState = (nextState: AppStateStatus) => {
|
||||
if (nextState === "active") {
|
||||
stopPolling();
|
||||
startPolling();
|
||||
} else {
|
||||
stopPolling();
|
||||
}
|
||||
};
|
||||
|
||||
const sub = AppState.addEventListener("change", handleAppState);
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
stopPolling();
|
||||
sub.remove();
|
||||
};
|
||||
}, [startPolling, stopPolling]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true);
|
||||
doFetch();
|
||||
}, [doFetch]);
|
||||
|
||||
return { session, loading, error, refresh };
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import { AppState } from "react-native";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { getAttentionLevel, type AttentionLevel, type DashboardSession } from "../types";
|
||||
import { scheduleNotification } from "../notifications";
|
||||
import { NOTIFY_STATE_KEY } from "../notifications/backgroundTask";
|
||||
|
||||
/**
|
||||
* Minimum time (ms) before re-notifying for the same session.
|
||||
* Prevents spam when an agent oscillates between working <-> waiting_input.
|
||||
*/
|
||||
const COOLDOWN_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
export function useSessionNotifications(sessions: DashboardSession[]): void {
|
||||
const prevLevels = useRef<Record<string, AttentionLevel>>({});
|
||||
// Tracks when we last sent a notification per session
|
||||
const lastNotifiedAt = useRef<Record<string, number>>({});
|
||||
// Skip the very first render — don't notify for state that existed before app opened
|
||||
const isFirstRender = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessions.length === 0) return;
|
||||
|
||||
const nextLevels: Record<string, AttentionLevel> = {};
|
||||
const now = Date.now();
|
||||
// Only notify when app is in background — no point interrupting the user
|
||||
// if they're actively looking at the session list
|
||||
const isBackground = AppState.currentState !== "active";
|
||||
|
||||
for (const session of sessions) {
|
||||
const level = getAttentionLevel(session);
|
||||
nextLevels[session.id] = level;
|
||||
|
||||
if (!isFirstRender.current) {
|
||||
const prev = prevLevels.current[session.id];
|
||||
const lastNotified = lastNotifiedAt.current[session.id] ?? 0;
|
||||
const cooldownExpired = now - lastNotified > COOLDOWN_MS;
|
||||
|
||||
if (level === "respond" && (prev !== "respond" || cooldownExpired)) {
|
||||
if (isBackground || prev !== "respond") {
|
||||
scheduleNotification(session, "respond");
|
||||
lastNotifiedAt.current[session.id] = now;
|
||||
}
|
||||
} else if (level === "merge" && (prev !== "merge" || cooldownExpired)) {
|
||||
if (isBackground || prev !== "merge") {
|
||||
scheduleNotification(session, "merge");
|
||||
lastNotifiedAt.current[session.id] = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
prevLevels.current = nextLevels;
|
||||
isFirstRender.current = false;
|
||||
|
||||
void AsyncStorage.setItem(NOTIFY_STATE_KEY, JSON.stringify(nextLevels));
|
||||
}, [sessions]);
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { AppState, type AppStateStatus } from "react-native";
|
||||
import { useBackend } from "../context/BackendContext";
|
||||
import type { DashboardSession, DashboardStats } from "../types";
|
||||
|
||||
const POLL_INTERVAL = 5_000;
|
||||
|
||||
interface UseSessionsResult {
|
||||
sessions: DashboardSession[];
|
||||
stats: DashboardStats | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export function useSessions(): UseSessionsResult {
|
||||
const { fetchSessions } = useBackend();
|
||||
const [sessions, setSessions] = useState<DashboardSession[]>([]);
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
const doFetch = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchSessions();
|
||||
if (!isMountedRef.current) return;
|
||||
setSessions(data.sessions ?? []);
|
||||
setStats(data.stats ?? null);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (!isMountedRef.current) return;
|
||||
setError(err instanceof Error ? err.message : "Failed to load sessions");
|
||||
} finally {
|
||||
if (isMountedRef.current) setLoading(false);
|
||||
}
|
||||
}, [fetchSessions]);
|
||||
|
||||
const startPolling = useCallback(() => {
|
||||
doFetch();
|
||||
intervalRef.current = setInterval(doFetch, POLL_INTERVAL);
|
||||
}, [doFetch]);
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
startPolling();
|
||||
|
||||
const handleAppState = (nextState: AppStateStatus) => {
|
||||
if (nextState === "active") {
|
||||
// Resumed — refresh immediately and restart polling
|
||||
stopPolling();
|
||||
startPolling();
|
||||
} else {
|
||||
// Backgrounded — stop polling to save battery
|
||||
stopPolling();
|
||||
}
|
||||
};
|
||||
|
||||
const sub = AppState.addEventListener("change", handleAppState);
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
stopPolling();
|
||||
sub.remove();
|
||||
};
|
||||
}, [startPolling, stopPolling]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true);
|
||||
doFetch();
|
||||
}, [doFetch]);
|
||||
|
||||
return { sessions, stats, loading, error, refresh };
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import React from "react";
|
||||
import { NavigationContainer, DarkTheme } from "@react-navigation/native";
|
||||
import { createNativeStackNavigator } from "@react-navigation/native-stack";
|
||||
import HomeScreen from "../screens/HomeScreen";
|
||||
import SessionDetailScreen from "../screens/SessionDetailScreen";
|
||||
import TerminalScreen from "../screens/TerminalScreen";
|
||||
import SettingsScreen from "../screens/SettingsScreen";
|
||||
|
||||
export type RootStackParamList = {
|
||||
Home: undefined;
|
||||
SessionDetail: { sessionId: string };
|
||||
Terminal: { sessionId: string; terminalWsUrl: string };
|
||||
Settings: undefined;
|
||||
};
|
||||
|
||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||
|
||||
const AoDarkTheme = {
|
||||
...DarkTheme,
|
||||
colors: {
|
||||
...DarkTheme.colors,
|
||||
background: "#0d1117",
|
||||
card: "#161b22",
|
||||
text: "#e6edf3",
|
||||
border: "#30363d",
|
||||
primary: "#58a6ff",
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootNavigator() {
|
||||
return (
|
||||
<NavigationContainer theme={AoDarkTheme}>
|
||||
<Stack.Navigator
|
||||
initialRouteName="Home"
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: "#161b22" },
|
||||
headerTintColor: "#e6edf3",
|
||||
headerTitleStyle: { fontWeight: "600" },
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="Home"
|
||||
component={HomeScreen}
|
||||
options={{ title: "Agent Orchestrator" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="SessionDetail"
|
||||
component={SessionDetailScreen}
|
||||
options={{ title: "Session" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Terminal"
|
||||
component={TerminalScreen}
|
||||
options={{
|
||||
title: "Terminal",
|
||||
headerStyle: { backgroundColor: "#0d1117" },
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Settings"
|
||||
component={SettingsScreen}
|
||||
options={{ title: "Settings" }}
|
||||
/>
|
||||
</Stack.Navigator>
|
||||
</NavigationContainer>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import * as TaskManager from "expo-task-manager";
|
||||
import * as BackgroundTask from "expo-background-task";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { getAttentionLevel, type AttentionLevel, type DashboardSession, type SessionsResponse } from "../types";
|
||||
import { scheduleNotification } from "./index";
|
||||
|
||||
export const TASK_ID = "ao-session-check";
|
||||
const BACKEND_URL_KEY = "@ao_backend_url";
|
||||
export const NOTIFY_STATE_KEY = "@ao_notify_state";
|
||||
const NOTIFY_TIMESTAMPS_KEY = "@ao_notify_timestamps";
|
||||
const COOLDOWN_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
/** Define the background task globally — must be at module top level. */
|
||||
TaskManager.defineTask(TASK_ID, async () => {
|
||||
try {
|
||||
const backendUrl = await AsyncStorage.getItem(BACKEND_URL_KEY);
|
||||
if (!backendUrl) return BackgroundTask.BackgroundTaskResult.Success;
|
||||
|
||||
const res = await fetch(`${backendUrl}/api/sessions`, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
if (!res.ok) return BackgroundTask.BackgroundTaskResult.Failed;
|
||||
|
||||
const data = (await res.json()) as SessionsResponse;
|
||||
const sessions: DashboardSession[] = data.sessions ?? [];
|
||||
|
||||
const [prevRaw, tsRaw] = await Promise.all([
|
||||
AsyncStorage.getItem(NOTIFY_STATE_KEY),
|
||||
AsyncStorage.getItem(NOTIFY_TIMESTAMPS_KEY),
|
||||
]);
|
||||
const prevState: Record<string, AttentionLevel> = prevRaw
|
||||
? (JSON.parse(prevRaw) as Record<string, AttentionLevel>)
|
||||
: {};
|
||||
const timestamps: Record<string, number> = tsRaw
|
||||
? (JSON.parse(tsRaw) as Record<string, number>)
|
||||
: {};
|
||||
|
||||
const nextState: Record<string, AttentionLevel> = {};
|
||||
const now = Date.now();
|
||||
|
||||
for (const session of sessions) {
|
||||
const level = getAttentionLevel(session);
|
||||
nextState[session.id] = level;
|
||||
|
||||
const prev = prevState[session.id];
|
||||
const lastNotified = timestamps[session.id] ?? 0;
|
||||
const cooldownExpired = now - lastNotified > COOLDOWN_MS;
|
||||
|
||||
if (level === "respond" && (prev !== "respond" || cooldownExpired)) {
|
||||
scheduleNotification(session, "respond");
|
||||
timestamps[session.id] = now;
|
||||
} else if (level === "merge" && (prev !== "merge" || cooldownExpired)) {
|
||||
scheduleNotification(session, "merge");
|
||||
timestamps[session.id] = now;
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
AsyncStorage.setItem(NOTIFY_STATE_KEY, JSON.stringify(nextState)),
|
||||
AsyncStorage.setItem(NOTIFY_TIMESTAMPS_KEY, JSON.stringify(timestamps)),
|
||||
]);
|
||||
return BackgroundTask.BackgroundTaskResult.Success;
|
||||
} catch {
|
||||
return BackgroundTask.BackgroundTaskResult.Failed;
|
||||
}
|
||||
});
|
||||
|
||||
/** Register the background task. Safe to call multiple times. */
|
||||
export async function registerBackgroundTask(): Promise<void> {
|
||||
try {
|
||||
const isRegistered = await TaskManager.isTaskRegisteredAsync(TASK_ID);
|
||||
if (isRegistered) return;
|
||||
|
||||
await BackgroundTask.registerTaskAsync(TASK_ID, {
|
||||
minimumInterval: 15 * 60, // 15 minutes (OS minimum)
|
||||
});
|
||||
} catch {
|
||||
// Background tasks are not supported in Expo Go — ignore silently
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import * as Notifications from "expo-notifications";
|
||||
import { Platform } from "react-native";
|
||||
import type { DashboardSession } from "../types";
|
||||
|
||||
const ANDROID_CHANNEL_ID = "ao-respond";
|
||||
|
||||
/** Configure how notifications are presented when the app is in foreground */
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: false,
|
||||
shouldShowBanner: true,
|
||||
shouldShowList: true,
|
||||
}),
|
||||
});
|
||||
|
||||
/** Create Android channel + request permission. Call once on app startup. */
|
||||
export async function setupNotifications(): Promise<boolean> {
|
||||
// Create Android notification channel
|
||||
if (Platform.OS === "android") {
|
||||
await Notifications.setNotificationChannelAsync(ANDROID_CHANNEL_ID, {
|
||||
name: "Agent Input Required",
|
||||
importance: Notifications.AndroidImportance.HIGH,
|
||||
vibrationPattern: [0, 250, 250, 250],
|
||||
lightColor: "#f85149",
|
||||
});
|
||||
}
|
||||
|
||||
const { status } = await Notifications.requestPermissionsAsync({
|
||||
ios: {
|
||||
allowAlert: true,
|
||||
allowBadge: false,
|
||||
allowSound: true,
|
||||
},
|
||||
});
|
||||
|
||||
return status === "granted";
|
||||
}
|
||||
|
||||
/** Fire an immediate local notification for a session attention transition. */
|
||||
export function scheduleNotification(
|
||||
session: DashboardSession,
|
||||
level: "respond" | "merge",
|
||||
): void {
|
||||
const sessionLabel =
|
||||
session.issueLabel ??
|
||||
session.id;
|
||||
|
||||
const body =
|
||||
session.issueTitle ??
|
||||
(session.summary && !session.summaryIsFallback ? session.summary : null) ??
|
||||
session.activity ??
|
||||
session.status;
|
||||
|
||||
const content: Notifications.NotificationContentInput =
|
||||
level === "respond"
|
||||
? {
|
||||
title: "Agent needs your input",
|
||||
body: `${sessionLabel}: ${body}`,
|
||||
data: { sessionId: session.id },
|
||||
sound: true,
|
||||
...(Platform.OS === "android" && { channelId: ANDROID_CHANNEL_ID }),
|
||||
}
|
||||
: {
|
||||
title: "PR ready to merge",
|
||||
body: `${sessionLabel}${session.pr ? `: PR #${session.pr.number}` : ""}`,
|
||||
data: { sessionId: session.id },
|
||||
sound: true,
|
||||
...(Platform.OS === "android" && { channelId: ANDROID_CHANNEL_ID }),
|
||||
};
|
||||
|
||||
// Fire immediately (trigger: null = instant)
|
||||
void Notifications.scheduleNotificationAsync({ content, trigger: null });
|
||||
}
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
import React from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
RefreshControl,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
} from "react-native";
|
||||
import type { NativeStackScreenProps } from "@react-navigation/native-stack";
|
||||
import type { RootStackParamList } from "../navigation/RootNavigator";
|
||||
import { useSessions } from "../hooks/useSessions";
|
||||
import { useSessionNotifications } from "../hooks/useSessionNotifications";
|
||||
import SessionCard from "../components/SessionCard";
|
||||
import StatBar from "../components/StatBar";
|
||||
import { getAttentionLevel, type DashboardSession } from "../types";
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList, "Home">;
|
||||
|
||||
const ATTENTION_ORDER = ["respond", "merge", "review", "pending", "working", "done"] as const;
|
||||
|
||||
function sortSessions(sessions: DashboardSession[]): DashboardSession[] {
|
||||
return [...sessions].sort((a, b) => {
|
||||
const la = getAttentionLevel(a);
|
||||
const lb = getAttentionLevel(b);
|
||||
const ia = ATTENTION_ORDER.indexOf(la);
|
||||
const ib = ATTENTION_ORDER.indexOf(lb);
|
||||
if (ia !== ib) return ia - ib;
|
||||
return new Date(b.lastActivityAt).getTime() - new Date(a.lastActivityAt).getTime();
|
||||
});
|
||||
}
|
||||
|
||||
export default function HomeScreen({ navigation }: Props) {
|
||||
const { sessions, stats, loading, error, refresh } = useSessions();
|
||||
useSessionNotifications(sessions);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.navigate("Settings")}
|
||||
style={{ paddingRight: 4 }}
|
||||
>
|
||||
<Text style={{ color: "#58a6ff", fontSize: 16 }}>Settings</Text>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
});
|
||||
}, [navigation]);
|
||||
|
||||
const sorted = sortSessions(sessions);
|
||||
|
||||
if (loading && sessions.length === 0) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator color="#58a6ff" size="large" />
|
||||
<Text style={styles.loadingText}>Connecting...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && sessions.length === 0) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
<TouchableOpacity style={styles.retryButton} onPress={refresh}>
|
||||
<Text style={styles.retryText}>Retry</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.retryButton, { marginTop: 8 }]}
|
||||
onPress={() => navigation.navigate("Settings")}
|
||||
>
|
||||
<Text style={styles.retryText}>Configure Backend URL</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{stats && <StatBar stats={stats} />}
|
||||
<FlatList
|
||||
data={sorted}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<SessionCard
|
||||
session={item}
|
||||
onPress={() => navigation.navigate("SessionDetail", { sessionId: item.id })}
|
||||
/>
|
||||
)}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={loading}
|
||||
onRefresh={refresh}
|
||||
tintColor="#58a6ff"
|
||||
colors={["#58a6ff"]}
|
||||
/>
|
||||
}
|
||||
contentContainerStyle={
|
||||
sorted.length === 0 ? styles.emptyContainer : styles.listContent
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.emptyText}>No sessions</Text>
|
||||
<Text style={styles.emptySubtext}>Sessions will appear here when agents are running</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#0d1117",
|
||||
},
|
||||
center: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 24,
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
listContent: {
|
||||
paddingVertical: 8,
|
||||
},
|
||||
loadingText: {
|
||||
color: "#8b949e",
|
||||
marginTop: 12,
|
||||
fontSize: 14,
|
||||
},
|
||||
errorText: {
|
||||
color: "#f85149",
|
||||
fontSize: 14,
|
||||
textAlign: "center",
|
||||
marginBottom: 16,
|
||||
},
|
||||
retryButton: {
|
||||
backgroundColor: "#21262d",
|
||||
borderWidth: 1,
|
||||
borderColor: "#30363d",
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
retryText: {
|
||||
color: "#e6edf3",
|
||||
fontSize: 14,
|
||||
},
|
||||
emptyText: {
|
||||
color: "#e6edf3",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
marginBottom: 8,
|
||||
},
|
||||
emptySubtext: {
|
||||
color: "#8b949e",
|
||||
fontSize: 13,
|
||||
textAlign: "center",
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,414 @@
|
|||
import React, { useState, useCallback } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import type { NativeStackScreenProps } from "@react-navigation/native-stack";
|
||||
import type { RootStackParamList } from "../navigation/RootNavigator";
|
||||
import { useSession } from "../hooks/useSession";
|
||||
import { useBackend } from "../context/BackendContext";
|
||||
import AttentionBadge from "../components/AttentionBadge";
|
||||
import {
|
||||
getAttentionLevel,
|
||||
relativeTime,
|
||||
isRestorable,
|
||||
isTerminal,
|
||||
ATTENTION_COLORS,
|
||||
} from "../types";
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList, "SessionDetail">;
|
||||
|
||||
export default function SessionDetailScreen({ route, navigation }: Props) {
|
||||
const { sessionId } = route.params;
|
||||
const { session, loading, error, refresh } = useSession(sessionId);
|
||||
const { sendMessage, killSession, restoreSession, terminalWsUrl } = useBackend();
|
||||
const [message, setMessage] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!message.trim()) return;
|
||||
setSending(true);
|
||||
try {
|
||||
await sendMessage(sessionId, message.trim());
|
||||
setMessage("");
|
||||
} catch (err) {
|
||||
Alert.alert("Error", err instanceof Error ? err.message : "Failed to send message");
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}, [message, sendMessage, sessionId]);
|
||||
|
||||
const handleKill = useCallback(() => {
|
||||
Alert.alert(
|
||||
"Kill Session",
|
||||
"This will terminate the agent process. Are you sure?",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Kill",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
await killSession(sessionId);
|
||||
refresh();
|
||||
} catch (err) {
|
||||
Alert.alert("Error", err instanceof Error ? err.message : "Failed to kill session");
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}, [killSession, sessionId, refresh]);
|
||||
|
||||
const handleRestore = useCallback(async () => {
|
||||
try {
|
||||
await restoreSession(sessionId);
|
||||
refresh();
|
||||
} catch (err) {
|
||||
Alert.alert("Error", err instanceof Error ? err.message : "Failed to restore session");
|
||||
}
|
||||
}, [restoreSession, sessionId, refresh]);
|
||||
|
||||
const handleOpenTerminal = useCallback(() => {
|
||||
navigation.navigate("Terminal", { sessionId, terminalWsUrl });
|
||||
}, [navigation, sessionId, terminalWsUrl]);
|
||||
|
||||
if (loading && !session) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator color="#58a6ff" size="large" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !session) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
<TouchableOpacity style={styles.button} onPress={refresh}>
|
||||
<Text style={styles.buttonText}>Retry</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
const level = getAttentionLevel(session);
|
||||
const color = ATTENTION_COLORS[level];
|
||||
const canRestore = isRestorable(session);
|
||||
const isDone = isTerminal(session);
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.root}
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
keyboardVerticalOffset={88}
|
||||
>
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
{/* Header row */}
|
||||
<View style={[styles.headerCard, { borderLeftColor: color }]}>
|
||||
<View style={styles.headerRow}>
|
||||
<Text style={styles.sessionId} numberOfLines={1} ellipsizeMode="middle">
|
||||
{session.id}
|
||||
</Text>
|
||||
<AttentionBadge level={level} />
|
||||
</View>
|
||||
<Text style={styles.status}>
|
||||
{session.status}
|
||||
{session.activity ? ` · ${session.activity}` : ""}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Issue */}
|
||||
{(session.issueLabel || session.issueTitle) && (
|
||||
<Section title="Issue">
|
||||
<Text style={styles.issueText}>
|
||||
{session.issueLabel ? `${session.issueLabel}: ` : ""}
|
||||
{session.issueTitle ?? ""}
|
||||
</Text>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
{session.summary && !session.summaryIsFallback && (
|
||||
<Section title="Summary">
|
||||
<Text style={styles.bodyText}>{session.summary}</Text>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Branch */}
|
||||
{session.branch && (
|
||||
<Section title="Branch">
|
||||
<Text style={styles.monoText}>{session.branch}</Text>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* PR */}
|
||||
{session.pr && (
|
||||
<Section title={`PR #${session.pr.number}`}>
|
||||
<InfoRow label="Title" value={session.pr.title} />
|
||||
<InfoRow label="CI" value={session.pr.ciStatus} />
|
||||
<InfoRow label="Review" value={session.pr.reviewDecision} />
|
||||
<InfoRow label="Mergeable" value={session.pr.mergeability.mergeable ? "Yes" : "No"} />
|
||||
{session.pr.additions > 0 && (
|
||||
<InfoRow
|
||||
label="Changes"
|
||||
value={`+${session.pr.additions} / -${session.pr.deletions}`}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Timestamps */}
|
||||
<Section title="Timing">
|
||||
<InfoRow label="Created" value={relativeTime(session.createdAt)} />
|
||||
<InfoRow label="Last activity" value={relativeTime(session.lastActivityAt)} />
|
||||
</Section>
|
||||
|
||||
{/* Actions */}
|
||||
<View style={styles.actionsSection}>
|
||||
<TouchableOpacity style={[styles.actionButton, styles.terminalButton]} onPress={handleOpenTerminal}>
|
||||
<Text style={styles.actionButtonText}>Open Terminal</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{canRestore && (
|
||||
<TouchableOpacity style={[styles.actionButton, styles.restoreButton]} onPress={handleRestore}>
|
||||
<Text style={styles.actionButtonText}>Restore Session</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{!isDone && (
|
||||
<TouchableOpacity style={[styles.actionButton, styles.killButton]} onPress={handleKill}>
|
||||
<Text style={[styles.actionButtonText, { color: "#f85149" }]}>Kill Session</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Message input — only show for active sessions */}
|
||||
{!isDone && (
|
||||
<View style={styles.messageBar}>
|
||||
<TextInput
|
||||
style={styles.messageInput}
|
||||
placeholder="Send message to agent..."
|
||||
placeholderTextColor="#8b949e"
|
||||
value={message}
|
||||
onChangeText={setMessage}
|
||||
multiline
|
||||
returnKeyType="send"
|
||||
onSubmitEditing={handleSend}
|
||||
blurOnSubmit={false}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[styles.sendButton, (!message.trim() || sending) && styles.sendButtonDisabled]}
|
||||
onPress={handleSend}
|
||||
disabled={!message.trim() || sending}
|
||||
>
|
||||
{sending ? (
|
||||
<ActivityIndicator color="#fff" size="small" />
|
||||
) : (
|
||||
<Text style={styles.sendButtonText}>Send</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{title}</Text>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<View style={styles.infoRow}>
|
||||
<Text style={styles.infoLabel}>{label}</Text>
|
||||
<Text style={styles.infoValue}>{value}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
backgroundColor: "#0d1117",
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
padding: 12,
|
||||
paddingBottom: 24,
|
||||
},
|
||||
center: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 24,
|
||||
backgroundColor: "#0d1117",
|
||||
},
|
||||
headerCard: {
|
||||
backgroundColor: "#161b22",
|
||||
borderLeftWidth: 3,
|
||||
borderRadius: 8,
|
||||
padding: 14,
|
||||
marginBottom: 12,
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 6,
|
||||
},
|
||||
sessionId: {
|
||||
color: "#8b949e",
|
||||
fontSize: 13,
|
||||
fontFamily: "monospace",
|
||||
flex: 1,
|
||||
marginRight: 8,
|
||||
},
|
||||
status: {
|
||||
color: "#8b949e",
|
||||
fontSize: 13,
|
||||
},
|
||||
section: {
|
||||
backgroundColor: "#161b22",
|
||||
borderRadius: 8,
|
||||
padding: 14,
|
||||
marginBottom: 10,
|
||||
},
|
||||
sectionTitle: {
|
||||
color: "#8b949e",
|
||||
fontSize: 11,
|
||||
fontWeight: "700",
|
||||
letterSpacing: 0.8,
|
||||
textTransform: "uppercase",
|
||||
marginBottom: 8,
|
||||
},
|
||||
bodyText: {
|
||||
color: "#e6edf3",
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
issueText: {
|
||||
color: "#e6edf3",
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
},
|
||||
monoText: {
|
||||
color: "#58a6ff",
|
||||
fontSize: 13,
|
||||
fontFamily: "monospace",
|
||||
},
|
||||
infoRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: 4,
|
||||
},
|
||||
infoLabel: {
|
||||
color: "#8b949e",
|
||||
fontSize: 13,
|
||||
},
|
||||
infoValue: {
|
||||
color: "#e6edf3",
|
||||
fontSize: 13,
|
||||
fontWeight: "500",
|
||||
},
|
||||
actionsSection: {
|
||||
gap: 8,
|
||||
marginTop: 4,
|
||||
},
|
||||
actionButton: {
|
||||
borderRadius: 8,
|
||||
padding: 14,
|
||||
alignItems: "center",
|
||||
borderWidth: 1,
|
||||
},
|
||||
terminalButton: {
|
||||
backgroundColor: "#21262d",
|
||||
borderColor: "#30363d",
|
||||
},
|
||||
restoreButton: {
|
||||
backgroundColor: "#1f3a2a",
|
||||
borderColor: "#3fb950",
|
||||
},
|
||||
killButton: {
|
||||
backgroundColor: "#21262d",
|
||||
borderColor: "#30363d",
|
||||
},
|
||||
actionButtonText: {
|
||||
color: "#e6edf3",
|
||||
fontSize: 15,
|
||||
fontWeight: "600",
|
||||
},
|
||||
messageBar: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-end",
|
||||
padding: 10,
|
||||
backgroundColor: "#161b22",
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: "#30363d",
|
||||
gap: 8,
|
||||
},
|
||||
messageInput: {
|
||||
flex: 1,
|
||||
backgroundColor: "#0d1117",
|
||||
borderWidth: 1,
|
||||
borderColor: "#30363d",
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
color: "#e6edf3",
|
||||
fontSize: 14,
|
||||
maxHeight: 100,
|
||||
},
|
||||
sendButton: {
|
||||
backgroundColor: "#238636",
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 16,
|
||||
height: 40,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
sendButtonDisabled: {
|
||||
backgroundColor: "#21262d",
|
||||
},
|
||||
sendButtonText: {
|
||||
color: "#fff",
|
||||
fontSize: 14,
|
||||
fontWeight: "700",
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#21262d",
|
||||
borderWidth: 1,
|
||||
borderColor: "#30363d",
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
buttonText: {
|
||||
color: "#e6edf3",
|
||||
fontSize: 14,
|
||||
},
|
||||
errorText: {
|
||||
color: "#f85149",
|
||||
fontSize: 14,
|
||||
textAlign: "center",
|
||||
marginBottom: 16,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,367 @@
|
|||
import React, { useState } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import type { NativeStackScreenProps } from "@react-navigation/native-stack";
|
||||
import type { RootStackParamList } from "../navigation/RootNavigator";
|
||||
import { useBackend } from "../context/BackendContext";
|
||||
import { scheduleNotification } from "../notifications";
|
||||
import * as Notifications from "expo-notifications";
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList, "Settings">;
|
||||
|
||||
export default function SettingsScreen({ navigation }: Props) {
|
||||
const { backendUrl, setBackendUrl, terminalWsUrl, terminalWsOverride, setTerminalWsOverride } = useBackend();
|
||||
const [input, setInput] = useState(backendUrl);
|
||||
const [wsInput, setWsInput] = useState(terminalWsOverride);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleTestRespondNotification = async () => {
|
||||
const { status } = await Notifications.getPermissionsAsync();
|
||||
if (status !== "granted") {
|
||||
Alert.alert("Permission denied", "Notification permission is not granted. Enable it in your phone's Settings app.");
|
||||
return;
|
||||
}
|
||||
scheduleNotification(
|
||||
{
|
||||
id: "ao-test-session",
|
||||
projectId: "test",
|
||||
status: "needs_input",
|
||||
activity: "waiting_input",
|
||||
branch: "feat/test",
|
||||
issueId: null,
|
||||
issueUrl: null,
|
||||
issueLabel: "TEST-1",
|
||||
issueTitle: "Fix the flaky integration test",
|
||||
summary: "Waiting for your decision on the approach",
|
||||
summaryIsFallback: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastActivityAt: new Date().toISOString(),
|
||||
pr: null,
|
||||
metadata: {},
|
||||
},
|
||||
"respond",
|
||||
);
|
||||
Alert.alert("Sent", "A test 'respond' notification was fired. Check your notification shade.");
|
||||
};
|
||||
|
||||
const handleTestMergeNotification = async () => {
|
||||
const { status } = await Notifications.getPermissionsAsync();
|
||||
if (status !== "granted") {
|
||||
Alert.alert("Permission denied", "Notification permission is not granted. Enable it in your phone's Settings app.");
|
||||
return;
|
||||
}
|
||||
scheduleNotification(
|
||||
{
|
||||
id: "ao-test-session",
|
||||
projectId: "test",
|
||||
status: "mergeable",
|
||||
activity: "idle",
|
||||
branch: "feat/test",
|
||||
issueId: null,
|
||||
issueUrl: null,
|
||||
issueLabel: "TEST-1",
|
||||
issueTitle: "Add user authentication flow",
|
||||
summary: null,
|
||||
summaryIsFallback: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastActivityAt: new Date().toISOString(),
|
||||
pr: {
|
||||
number: 42,
|
||||
url: "",
|
||||
title: "Add auth flow",
|
||||
owner: "",
|
||||
repo: "",
|
||||
branch: "feat/test",
|
||||
baseBranch: "main",
|
||||
isDraft: false,
|
||||
state: "open",
|
||||
additions: 120,
|
||||
deletions: 8,
|
||||
ciStatus: "passing",
|
||||
ciChecks: [],
|
||||
reviewDecision: "approved",
|
||||
mergeability: { mergeable: true, ciPassing: true, approved: true, noConflicts: true, blockers: [] },
|
||||
unresolvedThreads: 0,
|
||||
},
|
||||
metadata: {},
|
||||
},
|
||||
"merge",
|
||||
);
|
||||
Alert.alert("Sent", "A test 'merge' notification was fired. Check your notification shade.");
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
Alert.alert("Invalid URL", "Please enter a valid backend URL.");
|
||||
return;
|
||||
}
|
||||
if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) {
|
||||
Alert.alert("Invalid URL", "URL must start with http:// or https://");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await setBackendUrl(trimmed);
|
||||
await setTerminalWsOverride(wsInput.trim());
|
||||
Alert.alert("Saved", "Settings updated.", [
|
||||
{ text: "OK", onPress: () => navigation.goBack() },
|
||||
]);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.root}
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
>
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Backend URL</Text>
|
||||
<Text style={styles.hint}>
|
||||
Local network: enter your Mac's LAN IP.{"\n"}
|
||||
ngrok: paste the https:// tunnel URL for port 3000.
|
||||
</Text>
|
||||
<Text style={styles.fieldLabel}>Dashboard API URL</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={input}
|
||||
onChangeText={setInput}
|
||||
placeholder="http://192.168.x.x:3000 or https://abc.ngrok-free.app"
|
||||
placeholderTextColor="#8b949e"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
returnKeyType="next"
|
||||
/>
|
||||
<Text style={styles.fieldLabel}>
|
||||
Terminal WebSocket URL{" "}
|
||||
<Text style={styles.fieldLabelMuted}>(leave blank to auto-derive)</Text>
|
||||
</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={wsInput}
|
||||
onChangeText={setWsInput}
|
||||
placeholder="wss://xyz.ngrok-free.app (only needed for ngrok)"
|
||||
placeholderTextColor="#8b949e"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
returnKeyType="done"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[styles.saveButton, saving && styles.saveButtonDisabled]}
|
||||
onPress={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
<Text style={styles.saveButtonText}>{saving ? "Saving..." : "Save"}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Active URLs</Text>
|
||||
<SettingsInfoRow label="Dashboard" value={backendUrl} />
|
||||
<SettingsInfoRow
|
||||
label="Terminal WS"
|
||||
value={terminalWsUrl}
|
||||
note={terminalWsOverride ? "manual" : "auto"}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Test Notifications</Text>
|
||||
<Text style={styles.hint}>Fire a test notification to verify permissions are working.</Text>
|
||||
<TouchableOpacity style={[styles.testButton, { borderColor: "#f85149" }]} onPress={handleTestRespondNotification}>
|
||||
<Text style={[styles.testButtonText, { color: "#f85149" }]}>Test "Agent needs input" (respond)</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[styles.testButton, { borderColor: "#3fb950", marginTop: 8 }]} onPress={handleTestMergeNotification}>
|
||||
<Text style={[styles.testButtonText, { color: "#3fb950" }]}>Test "PR ready to merge" (merge)</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Setup Guide</Text>
|
||||
<Step
|
||||
n="1"
|
||||
text="Find your Mac's LAN IP: System Settings > Wi-Fi > Details > IP Address"
|
||||
/>
|
||||
<Step n="2" text="Make sure the orchestrator is running: pnpm build && pnpm dev" />
|
||||
<Step
|
||||
n="3"
|
||||
text="Enter http://<YOUR_IP>:3000 above (the terminal server is auto-derived on port 14801)"
|
||||
/>
|
||||
<Step n="4" text="Tap Save and go back to see your sessions" />
|
||||
<Step n="5" text="Your phone must be on the same Wi-Fi network as your Mac" />
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsInfoRow({ label, value, note }: { label: string; value: string; note?: string }) {
|
||||
return (
|
||||
<View style={styles.infoRow}>
|
||||
<View style={styles.infoLabelRow}>
|
||||
<Text style={styles.infoLabel}>{label}</Text>
|
||||
{note ? <Text style={styles.infoNote}>{note}</Text> : null}
|
||||
</View>
|
||||
<Text style={styles.infoValue} numberOfLines={1} ellipsizeMode="middle">
|
||||
{value}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Step({ n, text }: { n: string; text: string }) {
|
||||
return (
|
||||
<View style={styles.step}>
|
||||
<View style={styles.stepBadge}>
|
||||
<Text style={styles.stepN}>{n}</Text>
|
||||
</View>
|
||||
<Text style={styles.stepText}>{text}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
backgroundColor: "#0d1117",
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
padding: 14,
|
||||
paddingBottom: 32,
|
||||
gap: 12,
|
||||
},
|
||||
section: {
|
||||
backgroundColor: "#161b22",
|
||||
borderRadius: 10,
|
||||
padding: 16,
|
||||
},
|
||||
sectionTitle: {
|
||||
color: "#8b949e",
|
||||
fontSize: 11,
|
||||
fontWeight: "700",
|
||||
letterSpacing: 0.8,
|
||||
textTransform: "uppercase",
|
||||
marginBottom: 12,
|
||||
},
|
||||
hint: {
|
||||
color: "#8b949e",
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
marginBottom: 12,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: "#0d1117",
|
||||
borderWidth: 1,
|
||||
borderColor: "#30363d",
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
color: "#e6edf3",
|
||||
fontSize: 14,
|
||||
fontFamily: "monospace",
|
||||
marginBottom: 12,
|
||||
},
|
||||
saveButton: {
|
||||
backgroundColor: "#238636",
|
||||
borderRadius: 8,
|
||||
paddingVertical: 12,
|
||||
alignItems: "center",
|
||||
},
|
||||
saveButtonDisabled: {
|
||||
backgroundColor: "#21262d",
|
||||
},
|
||||
saveButtonText: {
|
||||
color: "#fff",
|
||||
fontSize: 15,
|
||||
fontWeight: "600",
|
||||
},
|
||||
testButton: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
paddingVertical: 12,
|
||||
alignItems: "center",
|
||||
},
|
||||
testButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
},
|
||||
infoRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: 6,
|
||||
},
|
||||
infoLabel: {
|
||||
color: "#8b949e",
|
||||
fontSize: 13,
|
||||
width: 100,
|
||||
},
|
||||
infoValue: {
|
||||
color: "#58a6ff",
|
||||
fontSize: 13,
|
||||
fontFamily: "monospace",
|
||||
flex: 1,
|
||||
textAlign: "right",
|
||||
},
|
||||
infoLabelRow: {
|
||||
flexDirection: "column",
|
||||
width: 100,
|
||||
},
|
||||
infoNote: {
|
||||
color: "#8b949e",
|
||||
fontSize: 10,
|
||||
marginTop: 1,
|
||||
},
|
||||
fieldLabel: {
|
||||
color: "#8b949e",
|
||||
fontSize: 12,
|
||||
fontWeight: "600",
|
||||
marginBottom: 6,
|
||||
},
|
||||
fieldLabelMuted: {
|
||||
color: "#6e7681",
|
||||
fontWeight: "400",
|
||||
},
|
||||
step: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
marginBottom: 12,
|
||||
gap: 10,
|
||||
},
|
||||
stepBadge: {
|
||||
backgroundColor: "#21262d",
|
||||
borderRadius: 10,
|
||||
width: 20,
|
||||
height: 20,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
stepN: {
|
||||
color: "#58a6ff",
|
||||
fontSize: 11,
|
||||
fontWeight: "700",
|
||||
},
|
||||
stepText: {
|
||||
color: "#8b949e",
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
import React, { useRef, useCallback, useState } from "react";
|
||||
import { View, StyleSheet, StatusBar, Text } from "react-native";
|
||||
import { WebView, type WebViewMessageEvent } from "react-native-webview";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { NativeStackScreenProps } from "@react-navigation/native-stack";
|
||||
import type { RootStackParamList } from "../navigation/RootNavigator";
|
||||
|
||||
import { TERMINAL_HTML } from "../terminal/terminal-html";
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList, "Terminal">;
|
||||
|
||||
type ConnectionStatus = "connecting" | "connected" | "error" | "disconnected";
|
||||
|
||||
const STATUS_COLORS: Record<ConnectionStatus, string> = {
|
||||
connecting: "#e3b341",
|
||||
connected: "#3fb950",
|
||||
error: "#f85149",
|
||||
disconnected: "#f85149",
|
||||
};
|
||||
|
||||
export default function TerminalScreen({ route }: Props) {
|
||||
const { sessionId, terminalWsUrl } = route.params;
|
||||
const webViewRef = useRef<WebView>(null);
|
||||
const insets = useSafeAreaInsets();
|
||||
const [status, setStatus] = useState<ConnectionStatus>("connecting");
|
||||
|
||||
// Inject WS URL and session ID BEFORE xterm.js init runs
|
||||
const injectedJS = `
|
||||
window.AO_WS_URL = ${JSON.stringify(terminalWsUrl)};
|
||||
window.AO_SESSION_ID = ${JSON.stringify(sessionId)};
|
||||
true; // required return value
|
||||
`;
|
||||
|
||||
const handleMessage = useCallback((event: WebViewMessageEvent) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.nativeEvent.data) as {
|
||||
type: string;
|
||||
state?: ConnectionStatus;
|
||||
};
|
||||
if (msg.type === "status" && msg.state) {
|
||||
setStatus(msg.state);
|
||||
}
|
||||
} catch {
|
||||
// ignore unparseable messages
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLoad = useCallback(() => {
|
||||
// Ask xterm to fit to current viewport
|
||||
webViewRef.current?.injectJavaScript(
|
||||
'window.dispatchEvent(new MessageEvent("message", { data: JSON.stringify({ type: "fit" }) })); true;',
|
||||
);
|
||||
}, []);
|
||||
|
||||
const dotColor = STATUS_COLORS[status];
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingBottom: insets.bottom }]}>
|
||||
<StatusBar barStyle="light-content" backgroundColor="#0d1117" />
|
||||
|
||||
{/* Status indicator in top-right */}
|
||||
<View style={styles.statusDot}>
|
||||
<View style={[styles.dot, { backgroundColor: dotColor }]} />
|
||||
<Text style={[styles.statusText, { color: dotColor }]}>
|
||||
{status}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
source={{ html: TERMINAL_HTML }}
|
||||
injectedJavaScriptBeforeContentLoaded={injectedJS}
|
||||
onMessage={handleMessage}
|
||||
onLoad={handleLoad}
|
||||
style={styles.webView}
|
||||
originWhitelist={["*"]}
|
||||
// Required for ws:// from about:blank on Android
|
||||
mixedContentMode="always"
|
||||
allowFileAccess={false}
|
||||
javaScriptEnabled={true}
|
||||
domStorageEnabled={false}
|
||||
scrollEnabled={false}
|
||||
bounces={false}
|
||||
overScrollMode="never"
|
||||
showsVerticalScrollIndicator={false}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
keyboardDisplayRequiresUserAction={false}
|
||||
// Suppress "Can't open file" logs for blob: URLs
|
||||
onError={() => {}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#0d1117",
|
||||
},
|
||||
webView: {
|
||||
flex: 1,
|
||||
backgroundColor: "#0d1117",
|
||||
},
|
||||
statusDot: {
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 12,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
zIndex: 10,
|
||||
gap: 4,
|
||||
},
|
||||
dot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 11,
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
/**
|
||||
* Standalone xterm.js terminal page, loaded into WebView.
|
||||
* WS URL and session ID are injected via injectedJavaScriptBeforeContentLoaded
|
||||
* which sets window.AO_WS_URL and window.AO_SESSION_ID before the script runs.
|
||||
*/
|
||||
export const TERMINAL_HTML = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>Terminal</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js"><\/script>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js"><\/script>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { width: 100%; height: 100%; background: #0d1117; overflow: hidden; }
|
||||
#terminal { width: 100%; height: 100%; }
|
||||
#status {
|
||||
position: fixed;
|
||||
top: 6px;
|
||||
right: 8px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #8b949e;
|
||||
z-index: 10;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
#status.connecting { background: #e3b341; }
|
||||
#status.connected { background: #3fb950; }
|
||||
#status.error { background: #f85149; }
|
||||
<\/style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="status" class="connecting"></div>
|
||||
<div id="terminal"></div>
|
||||
<script>
|
||||
(function () {
|
||||
var WS_BASE = window.AO_WS_URL || 'ws://localhost:3003';
|
||||
var SESSION = window.AO_SESSION_ID || '';
|
||||
|
||||
var statusEl = document.getElementById('status');
|
||||
var term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 13,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
theme: {
|
||||
background: '#0d1117',
|
||||
foreground: '#e6edf3',
|
||||
cursor: '#e6edf3',
|
||||
black: '#0d1117',
|
||||
red: '#f85149',
|
||||
green: '#3fb950',
|
||||
yellow: '#e3b341',
|
||||
blue: '#58a6ff',
|
||||
magenta: '#bc8cff',
|
||||
cyan: '#39c5cf',
|
||||
white: '#b1bac4',
|
||||
brightBlack: '#6e7681',
|
||||
brightRed: '#ff7b72',
|
||||
brightGreen: '#56d364',
|
||||
brightYellow: '#e3b341',
|
||||
brightBlue: '#79c0ff',
|
||||
brightMagenta: '#d2a8ff',
|
||||
brightCyan: '#56d4dd',
|
||||
brightWhite: '#f0f6fc',
|
||||
},
|
||||
allowProposedApi: true,
|
||||
});
|
||||
|
||||
var fitAddon = new FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.open(document.getElementById('terminal'));
|
||||
fitAddon.fit();
|
||||
|
||||
function postToRN(obj) {
|
||||
try {
|
||||
if (window.ReactNativeWebView) {
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify(obj));
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function sendResize() {
|
||||
fitAddon.fit();
|
||||
var cols = term.cols;
|
||||
var rows = term.rows;
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'resize', cols: cols, rows: rows }));
|
||||
}
|
||||
postToRN({ type: 'resize', cols: cols, rows: rows });
|
||||
}
|
||||
|
||||
window.addEventListener('resize', sendResize);
|
||||
|
||||
var ws;
|
||||
var reconnectDelay = 1000;
|
||||
|
||||
function connect() {
|
||||
var url = WS_BASE + '/ws?session=' + encodeURIComponent(SESSION);
|
||||
statusEl.className = 'connecting';
|
||||
postToRN({ type: 'status', state: 'connecting' });
|
||||
|
||||
ws = new WebSocket(url);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onopen = function () {
|
||||
statusEl.className = 'connected';
|
||||
postToRN({ type: 'status', state: 'connected' });
|
||||
reconnectDelay = 1000;
|
||||
sendResize();
|
||||
};
|
||||
|
||||
ws.onmessage = function (evt) {
|
||||
if (typeof evt.data === 'string') {
|
||||
// Filter out JSON control messages (e.g. resize echoes)
|
||||
try {
|
||||
var msg = JSON.parse(evt.data);
|
||||
if (msg.type === 'resize') return; // echo, ignore
|
||||
} catch (e) { /* not JSON — write as terminal text */ }
|
||||
term.write(evt.data);
|
||||
} else {
|
||||
// Binary (ArrayBuffer)
|
||||
term.write(new Uint8Array(evt.data));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = function () {
|
||||
statusEl.className = 'error';
|
||||
postToRN({ type: 'status', state: 'error' });
|
||||
};
|
||||
|
||||
ws.onclose = function () {
|
||||
statusEl.className = 'error';
|
||||
postToRN({ type: 'status', state: 'disconnected' });
|
||||
setTimeout(function () {
|
||||
reconnectDelay = Math.min(reconnectDelay * 1.5, 10000);
|
||||
connect();
|
||||
}, reconnectDelay);
|
||||
};
|
||||
}
|
||||
|
||||
term.onData(function (data) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(data);
|
||||
}
|
||||
});
|
||||
|
||||
// XDA handler: respond to CSI > q with XTerm identity (enables tmux clipboard)
|
||||
term.onData(function (data) {
|
||||
if (data === '\\x1b[>q') {
|
||||
term.write('\\x1bP>|XTerm(370)\\x1b\\\\');
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('message', function (evt) { handleRNMessage(evt.data); });
|
||||
window.addEventListener('message', function (evt) { handleRNMessage(evt.data); });
|
||||
|
||||
function handleRNMessage(raw) {
|
||||
try {
|
||||
var msg = JSON.parse(raw);
|
||||
if (msg.type === 'fit') sendResize();
|
||||
else if (msg.type === 'focus') term.focus();
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
if (SESSION) {
|
||||
connect();
|
||||
} else {
|
||||
term.write('\\x1b[31mError: No session ID provided.\\x1b[0m\\r\\n');
|
||||
statusEl.className = 'error';
|
||||
}
|
||||
})();
|
||||
<\/script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
/**
|
||||
* Mobile-local types mirroring the web dashboard types.
|
||||
* These must stay in sync with packages/web/src/lib/types.ts.
|
||||
*/
|
||||
|
||||
export type SessionStatus =
|
||||
| "spawning"
|
||||
| "running"
|
||||
| "working"
|
||||
| "pr_open"
|
||||
| "needs_input"
|
||||
| "stuck"
|
||||
| "errored"
|
||||
| "ci_failed"
|
||||
| "changes_requested"
|
||||
| "review_pending"
|
||||
| "mergeable"
|
||||
| "approved"
|
||||
| "merged"
|
||||
| "killed"
|
||||
| "cleanup"
|
||||
| "done"
|
||||
| "terminated";
|
||||
|
||||
export type ActivityState =
|
||||
| "active"
|
||||
| "ready"
|
||||
| "starting"
|
||||
| "thinking"
|
||||
| "working"
|
||||
| "waiting_input"
|
||||
| "blocked"
|
||||
| "idle"
|
||||
| "exited";
|
||||
|
||||
export type CIStatus = "none" | "pending" | "passing" | "failing";
|
||||
export type ReviewDecision = "none" | "pending" | "approved" | "changes_requested";
|
||||
|
||||
export type AttentionLevel = "merge" | "respond" | "review" | "pending" | "working" | "done";
|
||||
|
||||
export interface DashboardCICheck {
|
||||
name: string;
|
||||
status: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface DashboardMergeability {
|
||||
mergeable: boolean;
|
||||
ciPassing: boolean;
|
||||
approved: boolean;
|
||||
noConflicts: boolean;
|
||||
blockers: string[];
|
||||
}
|
||||
|
||||
export interface DashboardPR {
|
||||
number: number;
|
||||
url: string;
|
||||
title: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
branch: string;
|
||||
baseBranch: string;
|
||||
isDraft: boolean;
|
||||
state: "open" | "merged" | "closed";
|
||||
additions: number;
|
||||
deletions: number;
|
||||
ciStatus: CIStatus;
|
||||
ciChecks: DashboardCICheck[];
|
||||
reviewDecision: ReviewDecision;
|
||||
mergeability: DashboardMergeability;
|
||||
unresolvedThreads: number;
|
||||
}
|
||||
|
||||
export interface DashboardSession {
|
||||
id: string;
|
||||
projectId: string;
|
||||
status: SessionStatus;
|
||||
activity: ActivityState | null;
|
||||
branch: string | null;
|
||||
issueId: string | null;
|
||||
issueUrl: string | null;
|
||||
issueLabel: string | null;
|
||||
issueTitle: string | null;
|
||||
summary: string | null;
|
||||
summaryIsFallback: boolean;
|
||||
createdAt: string;
|
||||
lastActivityAt: string;
|
||||
pr: DashboardPR | null;
|
||||
metadata: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
totalSessions: number;
|
||||
workingSessions: number;
|
||||
openPRs: number;
|
||||
needsReview: number;
|
||||
}
|
||||
|
||||
export interface SessionsResponse {
|
||||
sessions: DashboardSession[];
|
||||
stats: DashboardStats;
|
||||
}
|
||||
|
||||
/** Attention level colors matching the web dashboard */
|
||||
export const ATTENTION_COLORS: Record<AttentionLevel, string> = {
|
||||
merge: "#3fb950",
|
||||
respond: "#f85149",
|
||||
review: "#d29922",
|
||||
pending: "#e3b341",
|
||||
working: "#58a6ff",
|
||||
done: "#8b949e",
|
||||
};
|
||||
|
||||
/** Terminal states that cannot be restored */
|
||||
const TERMINAL_STATUSES: SessionStatus[] = ["merged", "killed", "cleanup", "done", "terminated"];
|
||||
const TERMINAL_ACTIVITIES: ActivityState[] = ["exited"];
|
||||
const NON_RESTORABLE_STATUSES: SessionStatus[] = ["merged", "done", "terminated"];
|
||||
|
||||
export function isTerminal(session: DashboardSession): boolean {
|
||||
return (
|
||||
TERMINAL_STATUSES.includes(session.status) ||
|
||||
TERMINAL_ACTIVITIES.includes(session.activity as ActivityState)
|
||||
);
|
||||
}
|
||||
|
||||
export function isRestorable(session: DashboardSession): boolean {
|
||||
return (
|
||||
session.activity === "exited" &&
|
||||
!NON_RESTORABLE_STATUSES.includes(session.status)
|
||||
);
|
||||
}
|
||||
|
||||
function isPRRateLimited(pr: DashboardPR): boolean {
|
||||
return pr.mergeability.blockers.includes("API rate limited or unavailable");
|
||||
}
|
||||
|
||||
/** Determines which attention zone a session belongs to */
|
||||
export function getAttentionLevel(session: DashboardSession): AttentionLevel {
|
||||
// Done: terminal states
|
||||
if (
|
||||
session.status === "merged" ||
|
||||
session.status === "killed" ||
|
||||
session.status === "cleanup" ||
|
||||
session.status === "done" ||
|
||||
session.status === "terminated"
|
||||
) {
|
||||
return "done";
|
||||
}
|
||||
if (session.pr) {
|
||||
if (session.pr.state === "merged" || session.pr.state === "closed") {
|
||||
return "done";
|
||||
}
|
||||
}
|
||||
|
||||
// Merge: PR is ready
|
||||
if (session.status === "mergeable" || session.status === "approved") {
|
||||
return "merge";
|
||||
}
|
||||
if (session.pr?.mergeability.mergeable) {
|
||||
return "merge";
|
||||
}
|
||||
|
||||
// Respond: agent waiting for human input
|
||||
if (session.activity === "waiting_input" || session.activity === "blocked") {
|
||||
return "respond";
|
||||
}
|
||||
if (
|
||||
session.status === "needs_input" ||
|
||||
session.status === "stuck" ||
|
||||
session.status === "errored"
|
||||
) {
|
||||
return "respond";
|
||||
}
|
||||
if (session.activity === "exited") {
|
||||
return "respond";
|
||||
}
|
||||
|
||||
// Review: problems that need investigation
|
||||
if (session.status === "ci_failed" || session.status === "changes_requested") {
|
||||
return "review";
|
||||
}
|
||||
if (session.pr && !isPRRateLimited(session.pr)) {
|
||||
const pr = session.pr;
|
||||
if (pr.ciStatus === "failing") return "review";
|
||||
if (pr.reviewDecision === "changes_requested") return "review";
|
||||
if (!pr.mergeability.noConflicts) return "review";
|
||||
}
|
||||
|
||||
// Pending: waiting on external
|
||||
if (session.status === "review_pending") {
|
||||
return "pending";
|
||||
}
|
||||
if (session.pr && !isPRRateLimited(session.pr)) {
|
||||
const pr = session.pr;
|
||||
if (!pr.isDraft && pr.unresolvedThreads > 0) return "pending";
|
||||
if (!pr.isDraft && (pr.reviewDecision === "pending" || pr.reviewDecision === "none")) {
|
||||
return "pending";
|
||||
}
|
||||
}
|
||||
|
||||
// Working: agents doing their thing
|
||||
return "working";
|
||||
}
|
||||
|
||||
/** Human-readable relative time */
|
||||
export function relativeTime(isoString: string): string {
|
||||
const diff = Date.now() - new Date(isoString).getTime();
|
||||
const minutes = Math.floor(diff / 60_000);
|
||||
const hours = Math.floor(diff / 3_600_000);
|
||||
const days = Math.floor(diff / 86_400_000);
|
||||
|
||||
if (minutes < 1) return "just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".expo/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
packages:
|
||||
- "packages/*"
|
||||
- "packages/plugins/*"
|
||||
- "!packages/mobile"
|
||||
|
|
|
|||
Loading…
Reference in New Issue