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:
parent
4741ba2461
commit
4c4fada522
8
SETUP.md
8
SETUP.md
|
|
@ -618,10 +618,10 @@ projects:
|
|||
path: ~/backend
|
||||
sessionPrefix: api
|
||||
|
||||
mobile:
|
||||
repo: org/mobile
|
||||
path: ~/mobile
|
||||
sessionPrefix: mob
|
||||
docs:
|
||||
repo: org/docs
|
||||
path: ~/docs
|
||||
sessionPrefix: doc
|
||||
```
|
||||
|
||||
See [examples/multi-project.yaml](./examples/multi-project.yaml) for full example.
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ export default tseslint.config(
|
|||
"packages/web/postcss.config.mjs",
|
||||
"test-clipboard*.mjs",
|
||||
"test-clipboard*.sh",
|
||||
"packages/mobile/**",
|
||||
],
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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 |
|
|
@ -1,6 +0,0 @@
|
|||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ["babel-preset-expo"],
|
||||
};
|
||||
};
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
{
|
||||
"cli": {
|
||||
"version": ">= 12.0.0"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal",
|
||||
"ios": {
|
||||
"simulator": false
|
||||
}
|
||||
},
|
||||
"production": {}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
import { registerRootComponent } from "expo";
|
||||
import App from "./src/App";
|
||||
|
||||
registerRootComponent(App);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
|
|
@ -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 <project></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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
|
|
@ -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>`;
|
||||
|
|
@ -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`;
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".expo/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
packages:
|
||||
- "packages/*"
|
||||
- "packages/plugins/*"
|
||||
- "!packages/mobile"
|
||||
|
|
|
|||
Loading…
Reference in New Issue