chore: format with prettier [skip ci]
This commit is contained in:
parent
9091ad57df
commit
99cb0d4357
|
|
@ -20,8 +20,8 @@ orchestrator launch/open — over LAN or Tailscale.
|
|||
## Expo SDK is pinned to 54 — do not bump
|
||||
|
||||
Expo Go supports a **single** SDK at a time. This app is pinned to **SDK 54** to
|
||||
match the test phone's Expo Go. Symptoms of a mismatch: *"incompatible with this
|
||||
version of Expo Go."* Don't change the SDK unless the user's Expo Go updated.
|
||||
match the test phone's Expo Go. Symptoms of a mismatch: _"incompatible with this
|
||||
version of Expo Go."_ Don't change the SDK unless the user's Expo Go updated.
|
||||
Read **v54** docs (<https://docs.expo.dev/versions/v54.0.0/>) — APIs differ by SDK.
|
||||
Pinned: `expo 54`, `react 19.1.0`, `react-native 0.81.5`, `expo-router 6`,
|
||||
`react-native-webview 13.15.0` (react + webview match `@fressh` peers exactly).
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ session and drive it, review and merge PRs, and launch/open orchestrators — fr
|
|||
your phone, over your LAN or Tailscale.
|
||||
|
||||
This is an [Expo](https://expo.dev) (React Native) app. It lives in the AO monorepo
|
||||
at `mobile/` but is a **standalone npm project** — it is *not* part of AO's pnpm
|
||||
at `mobile/` but is a **standalone npm project** — it is _not_ part of AO's pnpm
|
||||
workspace. Run all commands below from inside `mobile/`.
|
||||
|
||||
## Requirements — Expo Go is pinned to SDK 54
|
||||
|
|
@ -15,7 +15,7 @@ workspace. Run all commands below from inside `mobile/`.
|
|||
> **The Expo Go app supports only ONE Expo SDK at a time** (whatever the latest
|
||||
> store build targets). This project is **pinned to Expo SDK 54** to match the
|
||||
> Expo Go currently installed on the test phone. If your Expo Go shows
|
||||
> *"Project is incompatible with this version of Expo Go"*, your Expo Go and this
|
||||
> _"Project is incompatible with this version of Expo Go"_, your Expo Go and this
|
||||
> project's SDK don't match.
|
||||
>
|
||||
> - **Don't bump the Expo SDK** unless your Expo Go has updated to that SDK. When
|
||||
|
|
|
|||
104
mobile/app.json
104
mobile/app.json
|
|
@ -1,54 +1,54 @@
|
|||
{
|
||||
"expo": {
|
||||
"name": "AO",
|
||||
"slug": "ao-mobile",
|
||||
"owner": "priyanchew",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "dark",
|
||||
"backgroundColor": "#0a0b0d",
|
||||
"scheme": "aomobile",
|
||||
"splash": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#0a0b0d"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "aoagents.ao",
|
||||
"config": {
|
||||
"usesNonExemptEncryption": false
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"package": "aoagents.ao",
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#0a0b0d",
|
||||
"foregroundImage": "./assets/android-icon-foreground.png"
|
||||
},
|
||||
"predictiveBackGestureEnabled": false
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png",
|
||||
"bundler": "metro"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
"android": {
|
||||
"usesCleartextTraffic": true
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"extra": {
|
||||
"router": {},
|
||||
"eas": {
|
||||
"projectId": "5bd2863a-4238-4f2e-8017-f5df7e6899c3"
|
||||
}
|
||||
}
|
||||
}
|
||||
"expo": {
|
||||
"name": "AO",
|
||||
"slug": "ao-mobile",
|
||||
"owner": "priyanchew",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "dark",
|
||||
"backgroundColor": "#0a0b0d",
|
||||
"scheme": "aomobile",
|
||||
"splash": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#0a0b0d"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "aoagents.ao",
|
||||
"config": {
|
||||
"usesNonExemptEncryption": false
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"package": "aoagents.ao",
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#0a0b0d",
|
||||
"foregroundImage": "./assets/android-icon-foreground.png"
|
||||
},
|
||||
"predictiveBackGestureEnabled": false
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png",
|
||||
"bundler": "metro"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
"android": {
|
||||
"usesCleartextTraffic": true
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"extra": {
|
||||
"router": {},
|
||||
"eas": {
|
||||
"projectId": "5bd2863a-4238-4f2e-8017-f5df7e6899c3"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,65 +1,59 @@
|
|||
import { Feather } from '@expo/vector-icons';
|
||||
import { Tabs } from 'expo-router';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { theme } from '../../lib/theme';
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { Tabs } from "expo-router";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { theme } from "../../lib/theme";
|
||||
|
||||
export default function TabsLayout() {
|
||||
const insets = useSafeAreaInsets();
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarActiveTintColor: theme.blue,
|
||||
tabBarInactiveTintColor: theme.textTertiary,
|
||||
tabBarStyle: {
|
||||
backgroundColor: theme.bgSurface,
|
||||
borderTopColor: theme.borderSubtle,
|
||||
borderTopWidth: 1,
|
||||
// Drive height/padding from the real safe-area inset so the bar clears
|
||||
// the Android gesture-nav bar (edge-to-edge is on by default in SDK 54)
|
||||
// and the iOS home indicator — instead of guessing fixed per-platform
|
||||
// numbers that leave the bar under the system nav on Android.
|
||||
height: 56 + insets.bottom,
|
||||
paddingTop: 6,
|
||||
paddingBottom: insets.bottom + 6,
|
||||
},
|
||||
tabBarLabelStyle: { fontSize: 11, fontWeight: '600' },
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Kanban',
|
||||
tabBarIcon: ({ color, size }) => <Feather name="grid" size={size - 2} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="prs"
|
||||
options={{
|
||||
title: 'PRs',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Feather name="git-pull-request" size={size - 2} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="orchestrator"
|
||||
options={{
|
||||
title: 'Orchestrator',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Feather name="share-2" size={size - 2} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
title: 'Settings',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Feather name="settings" size={size - 2} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
const insets = useSafeAreaInsets();
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarActiveTintColor: theme.blue,
|
||||
tabBarInactiveTintColor: theme.textTertiary,
|
||||
tabBarStyle: {
|
||||
backgroundColor: theme.bgSurface,
|
||||
borderTopColor: theme.borderSubtle,
|
||||
borderTopWidth: 1,
|
||||
// Drive height/padding from the real safe-area inset so the bar clears
|
||||
// the Android gesture-nav bar (edge-to-edge is on by default in SDK 54)
|
||||
// and the iOS home indicator — instead of guessing fixed per-platform
|
||||
// numbers that leave the bar under the system nav on Android.
|
||||
height: 56 + insets.bottom,
|
||||
paddingTop: 6,
|
||||
paddingBottom: insets.bottom + 6,
|
||||
},
|
||||
tabBarLabelStyle: { fontSize: 11, fontWeight: "600" },
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: "Kanban",
|
||||
tabBarIcon: ({ color, size }) => <Feather name="grid" size={size - 2} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="prs"
|
||||
options={{
|
||||
title: "PRs",
|
||||
tabBarIcon: ({ color, size }) => <Feather name="git-pull-request" size={size - 2} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="orchestrator"
|
||||
options={{
|
||||
title: "Orchestrator",
|
||||
tabBarIcon: ({ color, size }) => <Feather name="share-2" size={size - 2} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
title: "Settings",
|
||||
tabBarIcon: ({ color, size }) => <Feather name="settings" size={size - 2} color={color} />,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,201 +1,185 @@
|
|||
import { Feather } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
SectionList,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { attentionOf, type DashboardSession } from '../../lib/api';
|
||||
import { ProjectSwitcher } from '../../lib/ProjectSwitcher';
|
||||
import { SessionCard } from '../../lib/SessionCard';
|
||||
import { useApp, useVisibleSessions } from '../../lib/store';
|
||||
import { attentionMeta, theme } from '../../lib/theme';
|
||||
import { Button, ConnectionPill, EmptyState, ScreenHeader, SectionHeader } from '../../lib/ui';
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { ActivityIndicator, Pressable, RefreshControl, SectionList, StyleSheet, Text, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { attentionOf, type DashboardSession } from "../../lib/api";
|
||||
import { ProjectSwitcher } from "../../lib/ProjectSwitcher";
|
||||
import { SessionCard } from "../../lib/SessionCard";
|
||||
import { useApp, useVisibleSessions } from "../../lib/store";
|
||||
import { attentionMeta, theme } from "../../lib/theme";
|
||||
import { Button, ConnectionPill, EmptyState, ScreenHeader, SectionHeader } from "../../lib/ui";
|
||||
|
||||
type Section = { key: string; label: string; color: string; order: number; data: DashboardSession[] };
|
||||
|
||||
function groupByAttention(sessions: DashboardSession[]): Section[] {
|
||||
const buckets = new Map<string, DashboardSession[]>();
|
||||
for (const s of sessions) {
|
||||
const key = attentionOf(s);
|
||||
if (!buckets.has(key)) buckets.set(key, []);
|
||||
buckets.get(key)!.push(s);
|
||||
}
|
||||
return [...buckets.entries()]
|
||||
.map(([key, data]) => {
|
||||
const meta = attentionMeta[key] ?? {
|
||||
label: key,
|
||||
color: theme.textTertiary,
|
||||
order: 99,
|
||||
};
|
||||
return { key, label: meta.label, color: meta.color, order: meta.order, data };
|
||||
})
|
||||
.sort((a, b) => a.order - b.order);
|
||||
const buckets = new Map<string, DashboardSession[]>();
|
||||
for (const s of sessions) {
|
||||
const key = attentionOf(s);
|
||||
if (!buckets.has(key)) buckets.set(key, []);
|
||||
buckets.get(key)!.push(s);
|
||||
}
|
||||
return [...buckets.entries()]
|
||||
.map(([key, data]) => {
|
||||
const meta = attentionMeta[key] ?? {
|
||||
label: key,
|
||||
color: theme.textTertiary,
|
||||
order: 99,
|
||||
};
|
||||
return { key, label: meta.label, color: meta.color, order: meta.order, data };
|
||||
})
|
||||
.sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
export default function FleetScreen() {
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { configured, loading, error, connection, config, refresh } = useApp();
|
||||
const sessions = useVisibleSessions();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { configured, loading, error, connection, config, refresh } = useApp();
|
||||
const sessions = useVisibleSessions();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const sections = useMemo(() => groupByAttention(sessions), [sessions]);
|
||||
const sections = useMemo(() => groupByAttention(sessions), [sessions]);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
let working = 0,
|
||||
needsYou = 0,
|
||||
mergeable = 0;
|
||||
for (const s of sessions) {
|
||||
const a = attentionOf(s);
|
||||
if (a === 'working') working++;
|
||||
else if (a === 'respond' || a === 'action') needsYou++;
|
||||
else if (a === 'merge') mergeable++;
|
||||
}
|
||||
return { working, needsYou, mergeable };
|
||||
}, [sessions]);
|
||||
const counts = useMemo(() => {
|
||||
let working = 0,
|
||||
needsYou = 0,
|
||||
mergeable = 0;
|
||||
for (const s of sessions) {
|
||||
const a = attentionOf(s);
|
||||
if (a === "working") working++;
|
||||
else if (a === "respond" || a === "action") needsYou++;
|
||||
else if (a === "merge") mergeable++;
|
||||
}
|
||||
return { working, needsYou, mergeable };
|
||||
}, [sessions]);
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
await refresh();
|
||||
setRefreshing(false);
|
||||
}, [refresh]);
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
await refresh();
|
||||
setRefreshing(false);
|
||||
}, [refresh]);
|
||||
|
||||
if (!configured) {
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<View style={{ height: insets.top }} />
|
||||
<EmptyState
|
||||
icon="server"
|
||||
title="Connect to AO"
|
||||
message="Point the app at your Agent Orchestrator server to start controlling your fleet."
|
||||
action={
|
||||
<Button title="Configure server" icon="settings" onPress={() => router.push('/settings')} />
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (!configured) {
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<View style={{ height: insets.top }} />
|
||||
<EmptyState
|
||||
icon="server"
|
||||
title="Connect to AO"
|
||||
message="Point the app at your Agent Orchestrator server to start controlling your fleet."
|
||||
action={<Button title="Configure server" icon="settings" onPress={() => router.push("/settings")} />}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<View style={{ height: insets.top }} />
|
||||
<ScreenHeader
|
||||
title="Kanban"
|
||||
subtitle={config?.host}
|
||||
right={<ConnectionPill status={connection} />}
|
||||
/>
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<View style={{ height: insets.top }} />
|
||||
<ScreenHeader title="Kanban" subtitle={config?.host} right={<ConnectionPill status={connection} />} />
|
||||
|
||||
<View style={styles.stats}>
|
||||
<Stat n={counts.working} label="working" color={theme.orange} />
|
||||
<Stat n={counts.needsYou} label="need you" color={theme.amber} />
|
||||
<Stat n={counts.mergeable} label="mergeable" color={theme.green} />
|
||||
</View>
|
||||
<View style={styles.stats}>
|
||||
<Stat n={counts.working} label="working" color={theme.orange} />
|
||||
<Stat n={counts.needsYou} label="need you" color={theme.amber} />
|
||||
<Stat n={counts.mergeable} label="mergeable" color={theme.green} />
|
||||
</View>
|
||||
|
||||
<ProjectSwitcher />
|
||||
<ProjectSwitcher />
|
||||
|
||||
{loading && sessions.length === 0 ? (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator color={theme.blue} />
|
||||
</View>
|
||||
) : (
|
||||
<SectionList
|
||||
sections={sections}
|
||||
keyExtractor={(item) => `${item.projectId}:${item.id}`}
|
||||
contentContainerStyle={{ paddingBottom: 120 }}
|
||||
stickySectionHeadersEnabled={false}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.blue} />
|
||||
}
|
||||
renderSectionHeader={({ section }) => (
|
||||
<SectionHeader
|
||||
label={(section as Section).label}
|
||||
color={(section as Section).color}
|
||||
count={(section as Section).data.length}
|
||||
/>
|
||||
)}
|
||||
renderItem={({ item }) => <SessionCard session={item} showProject />}
|
||||
ListEmptyComponent={
|
||||
error ? (
|
||||
<EmptyState
|
||||
icon="wifi-off"
|
||||
title="Couldn't reach server"
|
||||
message={error}
|
||||
action={<Button title="Retry" icon="refresh-cw" variant="ghost" onPress={onRefresh} />}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon="moon"
|
||||
title="No active agents"
|
||||
message="Spawn a worker to put your fleet to work."
|
||||
action={<Button title="New agent" icon="plus" onPress={() => router.push('/spawn')} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{loading && sessions.length === 0 ? (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator color={theme.blue} />
|
||||
</View>
|
||||
) : (
|
||||
<SectionList
|
||||
sections={sections}
|
||||
keyExtractor={(item) => `${item.projectId}:${item.id}`}
|
||||
contentContainerStyle={{ paddingBottom: 120 }}
|
||||
stickySectionHeadersEnabled={false}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.blue} />}
|
||||
renderSectionHeader={({ section }) => (
|
||||
<SectionHeader
|
||||
label={(section as Section).label}
|
||||
color={(section as Section).color}
|
||||
count={(section as Section).data.length}
|
||||
/>
|
||||
)}
|
||||
renderItem={({ item }) => <SessionCard session={item} showProject />}
|
||||
ListEmptyComponent={
|
||||
error ? (
|
||||
<EmptyState
|
||||
icon="wifi-off"
|
||||
title="Couldn't reach server"
|
||||
message={error}
|
||||
action={<Button title="Retry" icon="refresh-cw" variant="ghost" onPress={onRefresh} />}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon="moon"
|
||||
title="No active agents"
|
||||
message="Spawn a worker to put your fleet to work."
|
||||
action={<Button title="New agent" icon="plus" onPress={() => router.push("/spawn")} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Spawn FAB */}
|
||||
<Pressable
|
||||
onPress={() => router.push('/spawn')}
|
||||
style={({ pressed }) => [styles.fab, pressed && { opacity: 0.85 }]}
|
||||
>
|
||||
<Feather name="plus" size={24} color="#06101f" />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
{/* Spawn FAB */}
|
||||
<Pressable
|
||||
onPress={() => router.push("/spawn")}
|
||||
style={({ pressed }) => [styles.fab, pressed && { opacity: 0.85 }]}
|
||||
>
|
||||
<Feather name="plus" size={24} color="#06101f" />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ n, label, color }: { n: number; label: string; color: string }) {
|
||||
return (
|
||||
<View style={styles.stat}>
|
||||
<Text style={[styles.statN, { color: n > 0 ? color : theme.textFaint }]}>{n}</Text>
|
||||
<Text style={styles.statLabel}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
return (
|
||||
<View style={styles.stat}>
|
||||
<Text style={[styles.statN, { color: n > 0 ? color : theme.textFaint }]}>{n}</Text>
|
||||
<Text style={styles.statLabel}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: { flex: 1, backgroundColor: theme.bgBase },
|
||||
center: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingVertical: 60 },
|
||||
stats: {
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 4,
|
||||
paddingBottom: 14,
|
||||
},
|
||||
stat: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.bgElevated,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.borderSubtle,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 14,
|
||||
},
|
||||
statN: { fontSize: 24, fontWeight: '800', fontFamily: theme.fontMono },
|
||||
statLabel: { color: theme.textTertiary, fontSize: 11, fontWeight: '600', marginTop: 2 },
|
||||
fab: {
|
||||
position: 'absolute',
|
||||
right: 18,
|
||||
bottom: 24,
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: theme.blue,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 12,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
elevation: 8,
|
||||
},
|
||||
screen: { flex: 1, backgroundColor: theme.bgBase },
|
||||
center: { flex: 1, alignItems: "center", justifyContent: "center", paddingVertical: 60 },
|
||||
stats: {
|
||||
flexDirection: "row",
|
||||
gap: 10,
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 4,
|
||||
paddingBottom: 14,
|
||||
},
|
||||
stat: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.bgElevated,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.borderSubtle,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 14,
|
||||
},
|
||||
statN: { fontSize: 24, fontWeight: "800", fontFamily: theme.fontMono },
|
||||
statLabel: { color: theme.textTertiary, fontSize: 11, fontWeight: "600", marginTop: 2 },
|
||||
fab: {
|
||||
position: "absolute",
|
||||
right: 18,
|
||||
bottom: 24,
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: theme.blue,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 12,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
elevation: 8,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,234 +1,222 @@
|
|||
import { Feather } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { Alert, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { attentionOf, type DashboardSession, type OrchestratorLink } from '../../lib/api';
|
||||
import { useApp } from '../../lib/store';
|
||||
import { attentionMeta, statusVisual, theme, type AttentionLevel, type StatusVisual } from '../../lib/theme';
|
||||
import { Button, ConnectionPill, Dot, EmptyState, ScreenHeader } from '../../lib/ui';
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import { Alert, RefreshControl, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { attentionOf, type DashboardSession, type OrchestratorLink } from "../../lib/api";
|
||||
import { useApp } from "../../lib/store";
|
||||
import { attentionMeta, statusVisual, theme, type AttentionLevel, type StatusVisual } from "../../lib/theme";
|
||||
import { Button, ConnectionPill, Dot, EmptyState, ScreenHeader } from "../../lib/ui";
|
||||
|
||||
const ZONE_ORDER: AttentionLevel[] = ['merge', 'respond', 'review', 'pending', 'working', 'done'];
|
||||
const ZONE_ORDER: AttentionLevel[] = ["merge", "respond", "review", "pending", "working", "done"];
|
||||
|
||||
export default function OrchestratorScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { configured, connection, projects, sessions, orchestrators, refresh } = useApp();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const insets = useSafeAreaInsets();
|
||||
const { configured, connection, projects, sessions, orchestrators, refresh } = useApp();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
// Always show every project's orchestrator here — no per-project filtering.
|
||||
const visibleProjects = projects;
|
||||
// Always show every project's orchestrator here — no per-project filtering.
|
||||
const visibleProjects = projects;
|
||||
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await refresh();
|
||||
setRefreshing(false);
|
||||
};
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await refresh();
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
if (!configured) {
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<View style={{ height: insets.top }} />
|
||||
<EmptyState icon="share-2" title="No server" message="Connect to AO in Settings." />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (!configured) {
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<View style={{ height: insets.top }} />
|
||||
<EmptyState icon="share-2" title="No server" message="Connect to AO in Settings." />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<View style={{ height: insets.top }} />
|
||||
<ScreenHeader
|
||||
title="Orchestrator"
|
||||
subtitle="Orchestrators direct your worker agents"
|
||||
right={<ConnectionPill status={connection} />}
|
||||
/>
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<View style={{ height: insets.top }} />
|
||||
<ScreenHeader
|
||||
title="Orchestrator"
|
||||
subtitle="Orchestrators direct your worker agents"
|
||||
right={<ConnectionPill status={connection} />}
|
||||
/>
|
||||
|
||||
<ScrollView
|
||||
contentContainerStyle={{ paddingBottom: 110, paddingTop: 4 }}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.blue} />
|
||||
}
|
||||
>
|
||||
{visibleProjects.length === 0 ? (
|
||||
<EmptyState icon="folder" title="No projects" message="Add a project in AO to get started." />
|
||||
) : (
|
||||
visibleProjects.map((p) => {
|
||||
const link = orchestrators.find((o) => o.projectId === p.id) ?? null;
|
||||
const workers = sessions.filter((s) => s.projectId === p.id && s.id !== link?.id);
|
||||
return (
|
||||
<OrchestratorCard
|
||||
key={p.id}
|
||||
projectId={p.id}
|
||||
projectName={p.name}
|
||||
link={link}
|
||||
workerCount={workers.length}
|
||||
zones={zoneCounts(workers)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
<ScrollView
|
||||
contentContainerStyle={{ paddingBottom: 110, paddingTop: 4 }}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.blue} />}
|
||||
>
|
||||
{visibleProjects.length === 0 ? (
|
||||
<EmptyState icon="folder" title="No projects" message="Add a project in AO to get started." />
|
||||
) : (
|
||||
visibleProjects.map((p) => {
|
||||
const link = orchestrators.find((o) => o.projectId === p.id) ?? null;
|
||||
const workers = sessions.filter((s) => s.projectId === p.id && s.id !== link?.id);
|
||||
return (
|
||||
<OrchestratorCard
|
||||
key={p.id}
|
||||
projectId={p.id}
|
||||
projectName={p.name}
|
||||
link={link}
|
||||
workerCount={workers.length}
|
||||
zones={zoneCounts(workers)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function zoneCounts(sessions: DashboardSession[]): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
for (const s of sessions) {
|
||||
const a = attentionOf(s);
|
||||
out[a] = (out[a] ?? 0) + 1;
|
||||
}
|
||||
return out;
|
||||
const out: Record<string, number> = {};
|
||||
for (const s of sessions) {
|
||||
const a = attentionOf(s);
|
||||
out[a] = (out[a] ?? 0) + 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function OrchestratorCard({
|
||||
projectId,
|
||||
projectName,
|
||||
link,
|
||||
workerCount,
|
||||
zones,
|
||||
projectId,
|
||||
projectName,
|
||||
link,
|
||||
workerCount,
|
||||
zones,
|
||||
}: {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
link: OrchestratorLink | null;
|
||||
workerCount: number;
|
||||
zones: Record<string, number>;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
link: OrchestratorLink | null;
|
||||
workerCount: number;
|
||||
zones: Record<string, number>;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { launchConductor } = useApp();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const router = useRouter();
|
||||
const { launchConductor } = useApp();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// The link only appears when an orchestrator exists — so its presence means
|
||||
// it's openable. Some AO builds add hasRuntime/isTerminal; treat those as
|
||||
// "stopped" only when explicitly flagged, never on a missing field.
|
||||
const present = !!link?.id;
|
||||
const stopped = present && (link.hasRuntime === false || link.isTerminal === true);
|
||||
const open = present && !stopped;
|
||||
const v: StatusVisual = link?.status
|
||||
? statusVisual(link.status)
|
||||
: { color: theme.blue, label: 'Online' };
|
||||
// The link only appears when an orchestrator exists — so its presence means
|
||||
// it's openable. Some AO builds add hasRuntime/isTerminal; treat those as
|
||||
// "stopped" only when explicitly flagged, never on a missing field.
|
||||
const present = !!link?.id;
|
||||
const stopped = present && (link.hasRuntime === false || link.isTerminal === true);
|
||||
const open = present && !stopped;
|
||||
const v: StatusVisual = link?.status ? statusVisual(link.status) : { color: theme.blue, label: "Online" };
|
||||
|
||||
const openTerminal = (id: string) =>
|
||||
router.push({ pathname: '/session/[id]', params: { id, projectId } });
|
||||
const openTerminal = (id: string) => router.push({ pathname: "/session/[id]", params: { id, projectId } });
|
||||
|
||||
const onLaunch = async (clean: boolean) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const l = await launchConductor(projectId, clean);
|
||||
if (l?.id) openTerminal(l.id);
|
||||
} catch (e) {
|
||||
Alert.alert('Could not launch', e instanceof Error ? e.message : 'Unknown error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const onLaunch = async (clean: boolean) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const l = await launchConductor(projectId, clean);
|
||||
if (l?.id) openTerminal(l.id);
|
||||
} catch (e) {
|
||||
Alert.alert("Could not launch", e instanceof Error ? e.message : "Unknown error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<View style={styles.head}>
|
||||
<View style={styles.orgIcon}>
|
||||
<Feather name="share-2" size={18} color={theme.blue} />
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.projName}>{projectName}</Text>
|
||||
<View style={styles.statusRow}>
|
||||
<Dot
|
||||
color={open ? v.color : stopped ? theme.textTertiary : theme.textFaint}
|
||||
size={7}
|
||||
breathing={!!(open && v.breathing)}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.statusText,
|
||||
{ color: open ? v.color : stopped ? theme.textTertiary : theme.textFaint },
|
||||
]}
|
||||
>
|
||||
{open ? v.label : stopped ? 'Stopped' : 'Not started'}
|
||||
</Text>
|
||||
<Text style={styles.workers}>· {workerCount} worker{workerCount === 1 ? '' : 's'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<View style={styles.head}>
|
||||
<View style={styles.orgIcon}>
|
||||
<Feather name="share-2" size={18} color={theme.blue} />
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.projName}>{projectName}</Text>
|
||||
<View style={styles.statusRow}>
|
||||
<Dot
|
||||
color={open ? v.color : stopped ? theme.textTertiary : theme.textFaint}
|
||||
size={7}
|
||||
breathing={!!(open && v.breathing)}
|
||||
/>
|
||||
<Text
|
||||
style={[styles.statusText, { color: open ? v.color : stopped ? theme.textTertiary : theme.textFaint }]}
|
||||
>
|
||||
{open ? v.label : stopped ? "Stopped" : "Not started"}
|
||||
</Text>
|
||||
<Text style={styles.workers}>
|
||||
· {workerCount} worker{workerCount === 1 ? "" : "s"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{workerCount > 0 ? (
|
||||
<View style={styles.zones}>
|
||||
{ZONE_ORDER.filter((z) => zones[z]).map((z) => {
|
||||
const m = attentionMeta[z];
|
||||
return (
|
||||
<View key={z} style={[styles.zonePill, { backgroundColor: m.tint }]}>
|
||||
<Dot color={m.color} size={6} />
|
||||
<Text style={[styles.zoneN, { color: m.color }]}>{zones[z]}</Text>
|
||||
<Text style={styles.zoneLabel}>{m.label}</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : null}
|
||||
{workerCount > 0 ? (
|
||||
<View style={styles.zones}>
|
||||
{ZONE_ORDER.filter((z) => zones[z]).map((z) => {
|
||||
const m = attentionMeta[z];
|
||||
return (
|
||||
<View key={z} style={[styles.zonePill, { backgroundColor: m.tint }]}>
|
||||
<Dot color={m.color} size={6} />
|
||||
<Text style={[styles.zoneN, { color: m.color }]}>{zones[z]}</Text>
|
||||
<Text style={styles.zoneLabel}>{m.label}</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.actions}>
|
||||
{open ? (
|
||||
<>
|
||||
<Button
|
||||
title="Open orchestrator"
|
||||
icon="terminal"
|
||||
onPress={() => openTerminal(link.id)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
title="Restart"
|
||||
variant="ghost"
|
||||
icon="rotate-ccw"
|
||||
loading={busy}
|
||||
onPress={() => onLaunch(false)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
title={present ? 'Restart orchestrator' : 'Spawn orchestrator'}
|
||||
icon="play"
|
||||
loading={busy}
|
||||
onPress={() => onLaunch(present)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
<View style={styles.actions}>
|
||||
{open ? (
|
||||
<>
|
||||
<Button
|
||||
title="Open orchestrator"
|
||||
icon="terminal"
|
||||
onPress={() => openTerminal(link.id)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button title="Restart" variant="ghost" icon="rotate-ccw" loading={busy} onPress={() => onLaunch(false)} />
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
title={present ? "Restart orchestrator" : "Spawn orchestrator"}
|
||||
icon="play"
|
||||
loading={busy}
|
||||
onPress={() => onLaunch(present)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: { flex: 1, backgroundColor: theme.bgBase },
|
||||
card: {
|
||||
backgroundColor: theme.bgElevated,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.borderSubtle,
|
||||
padding: 16,
|
||||
marginHorizontal: 12,
|
||||
marginVertical: 6,
|
||||
},
|
||||
head: { flexDirection: 'row', alignItems: 'center', gap: 12 },
|
||||
orgIcon: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 11,
|
||||
backgroundColor: theme.tintBlue,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
projName: { color: theme.textPrimary, fontSize: 17, fontWeight: '700' },
|
||||
statusRow: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 3 },
|
||||
statusText: { fontSize: 12, fontWeight: '600' },
|
||||
workers: { color: theme.textTertiary, fontSize: 12 },
|
||||
zones: { flexDirection: 'row', flexWrap: 'wrap', gap: 7, marginTop: 14 },
|
||||
zonePill: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
paddingHorizontal: 9,
|
||||
paddingVertical: 5,
|
||||
borderRadius: 8,
|
||||
},
|
||||
zoneN: { fontSize: 12, fontWeight: '800', fontFamily: theme.fontMono },
|
||||
zoneLabel: { color: theme.textSecondary, fontSize: 11, fontWeight: '600' },
|
||||
actions: { flexDirection: 'row', gap: 8, marginTop: 16 },
|
||||
screen: { flex: 1, backgroundColor: theme.bgBase },
|
||||
card: {
|
||||
backgroundColor: theme.bgElevated,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.borderSubtle,
|
||||
padding: 16,
|
||||
marginHorizontal: 12,
|
||||
marginVertical: 6,
|
||||
},
|
||||
head: { flexDirection: "row", alignItems: "center", gap: 12 },
|
||||
orgIcon: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 11,
|
||||
backgroundColor: theme.tintBlue,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
projName: { color: theme.textPrimary, fontSize: 17, fontWeight: "700" },
|
||||
statusRow: { flexDirection: "row", alignItems: "center", gap: 6, marginTop: 3 },
|
||||
statusText: { fontSize: 12, fontWeight: "600" },
|
||||
workers: { color: theme.textTertiary, fontSize: 12 },
|
||||
zones: { flexDirection: "row", flexWrap: "wrap", gap: 7, marginTop: 14 },
|
||||
zonePill: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
paddingHorizontal: 9,
|
||||
paddingVertical: 5,
|
||||
borderRadius: 8,
|
||||
},
|
||||
zoneN: { fontSize: 12, fontWeight: "800", fontFamily: theme.fontMono },
|
||||
zoneLabel: { color: theme.textSecondary, fontSize: 11, fontWeight: "600" },
|
||||
actions: { flexDirection: "row", gap: 8, marginTop: 16 },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,231 +1,233 @@
|
|||
import { useRouter } from 'expo-router';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Alert, Linking, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import type { DashboardPR, DashboardSession } from '../../lib/api';
|
||||
import { ProjectSwitcher } from '../../lib/ProjectSwitcher';
|
||||
import { useApp, usePRs } from '../../lib/store';
|
||||
import { ciVisual, theme } from '../../lib/theme';
|
||||
import { Button, Chip, ConnectionPill, EmptyState, Pill, ScreenHeader } from '../../lib/ui';
|
||||
import { useRouter } from "expo-router";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Alert, Linking, RefreshControl, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { DashboardPR, DashboardSession } from "../../lib/api";
|
||||
import { ProjectSwitcher } from "../../lib/ProjectSwitcher";
|
||||
import { useApp, usePRs } from "../../lib/store";
|
||||
import { ciVisual, theme } from "../../lib/theme";
|
||||
import { Button, Chip, ConnectionPill, EmptyState, Pill, ScreenHeader } from "../../lib/ui";
|
||||
|
||||
type Filter = 'open' | 'merged' | 'all';
|
||||
type Filter = "open" | "merged" | "all";
|
||||
|
||||
const prKey = (pr: DashboardPR) => `${pr.owner ?? ''}/${pr.repo ?? ''}#${pr.number}`;
|
||||
const prKey = (pr: DashboardPR) => `${pr.owner ?? ""}/${pr.repo ?? ""}#${pr.number}`;
|
||||
|
||||
export default function PRsScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const { configured, connection, merge, refresh } = useApp();
|
||||
const prs = usePRs();
|
||||
const [filter, setFilter] = useState<Filter>('open');
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [merging, setMerging] = useState<string | null>(null);
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const { configured, connection, merge, refresh } = useApp();
|
||||
const prs = usePRs();
|
||||
const [filter, setFilter] = useState<Filter>("open");
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [merging, setMerging] = useState<string | null>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return prs.filter(({ pr }) => {
|
||||
const st = pr.state ?? 'open';
|
||||
if (filter === 'all') return true;
|
||||
if (filter === 'open') return st === 'open';
|
||||
return st === 'merged';
|
||||
});
|
||||
}, [prs, filter]);
|
||||
const filtered = useMemo(() => {
|
||||
return prs.filter(({ pr }) => {
|
||||
const st = pr.state ?? "open";
|
||||
if (filter === "all") return true;
|
||||
if (filter === "open") return st === "open";
|
||||
return st === "merged";
|
||||
});
|
||||
}, [prs, filter]);
|
||||
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await refresh();
|
||||
setRefreshing(false);
|
||||
};
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await refresh();
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
const onMerge = (pr: DashboardPR) => {
|
||||
const blockers = pr.mergeability?.blockers ?? [];
|
||||
Alert.alert(
|
||||
`Merge #${pr.number}?`,
|
||||
blockers.length ? `Blockers: ${blockers.join(', ')}` : `Squash-merge "${pr.title ?? pr.number}".`,
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Merge',
|
||||
style: 'default',
|
||||
onPress: async () => {
|
||||
setMerging(prKey(pr));
|
||||
try {
|
||||
await merge(pr);
|
||||
} catch (e) {
|
||||
Alert.alert('Merge failed', e instanceof Error ? e.message : 'Unknown error');
|
||||
} finally {
|
||||
setMerging(null);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
const onMerge = (pr: DashboardPR) => {
|
||||
const blockers = pr.mergeability?.blockers ?? [];
|
||||
Alert.alert(
|
||||
`Merge #${pr.number}?`,
|
||||
blockers.length ? `Blockers: ${blockers.join(", ")}` : `Squash-merge "${pr.title ?? pr.number}".`,
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Merge",
|
||||
style: "default",
|
||||
onPress: async () => {
|
||||
setMerging(prKey(pr));
|
||||
try {
|
||||
await merge(pr);
|
||||
} catch (e) {
|
||||
Alert.alert("Merge failed", e instanceof Error ? e.message : "Unknown error");
|
||||
} finally {
|
||||
setMerging(null);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
if (!configured) {
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<View style={{ height: insets.top }} />
|
||||
<EmptyState icon="git-pull-request" title="No server" message="Connect to AO in Settings." />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (!configured) {
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<View style={{ height: insets.top }} />
|
||||
<EmptyState icon="git-pull-request" title="No server" message="Connect to AO in Settings." />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const counts = {
|
||||
open: prs.filter((p) => (p.pr.state ?? 'open') === 'open').length,
|
||||
merged: prs.filter((p) => p.pr.state === 'merged').length,
|
||||
all: prs.length,
|
||||
};
|
||||
const counts = {
|
||||
open: prs.filter((p) => (p.pr.state ?? "open") === "open").length,
|
||||
merged: prs.filter((p) => p.pr.state === "merged").length,
|
||||
all: prs.length,
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<View style={{ height: insets.top }} />
|
||||
<ScreenHeader title="Pull Requests" right={<ConnectionPill status={connection} />} />
|
||||
<ProjectSwitcher />
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<View style={{ height: insets.top }} />
|
||||
<ScreenHeader title="Pull Requests" right={<ConnectionPill status={connection} />} />
|
||||
<ProjectSwitcher />
|
||||
|
||||
<View style={styles.filters}>
|
||||
{(['open', 'merged', 'all'] as Filter[]).map((f) => (
|
||||
<Pill
|
||||
key={f}
|
||||
label={`${f[0].toUpperCase() + f.slice(1)} ${counts[f]}`}
|
||||
active={filter === f}
|
||||
onPress={() => setFilter(f)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<View style={styles.filters}>
|
||||
{(["open", "merged", "all"] as Filter[]).map((f) => (
|
||||
<Pill
|
||||
key={f}
|
||||
label={`${f[0].toUpperCase() + f.slice(1)} ${counts[f]}`}
|
||||
active={filter === f}
|
||||
onPress={() => setFilter(f)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
contentContainerStyle={{ paddingBottom: 110, paddingTop: 4 }}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.blue} />
|
||||
}
|
||||
>
|
||||
{filtered.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="git-pull-request"
|
||||
title="No pull requests"
|
||||
message={filter === 'open' ? 'No open PRs right now.' : 'Nothing here yet.'}
|
||||
/>
|
||||
) : (
|
||||
filtered.map(({ pr, session }) => (
|
||||
<PRCard
|
||||
key={`${pr.owner}/${pr.repo}#${pr.number}`}
|
||||
pr={pr}
|
||||
session={session}
|
||||
merging={merging === prKey(pr)}
|
||||
onMerge={() => onMerge(pr)}
|
||||
onOpenSession={() =>
|
||||
router.push({
|
||||
pathname: '/session/[id]',
|
||||
params: { id: session.id, projectId: session.projectId },
|
||||
})
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
<ScrollView
|
||||
contentContainerStyle={{ paddingBottom: 110, paddingTop: 4 }}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.blue} />}
|
||||
>
|
||||
{filtered.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="git-pull-request"
|
||||
title="No pull requests"
|
||||
message={filter === "open" ? "No open PRs right now." : "Nothing here yet."}
|
||||
/>
|
||||
) : (
|
||||
filtered.map(({ pr, session }) => (
|
||||
<PRCard
|
||||
key={`${pr.owner}/${pr.repo}#${pr.number}`}
|
||||
pr={pr}
|
||||
session={session}
|
||||
merging={merging === prKey(pr)}
|
||||
onMerge={() => onMerge(pr)}
|
||||
onOpenSession={() =>
|
||||
router.push({
|
||||
pathname: "/session/[id]",
|
||||
params: { id: session.id, projectId: session.projectId },
|
||||
})
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function PRCard({
|
||||
pr,
|
||||
session,
|
||||
merging,
|
||||
onMerge,
|
||||
onOpenSession,
|
||||
pr,
|
||||
session,
|
||||
merging,
|
||||
onMerge,
|
||||
onOpenSession,
|
||||
}: {
|
||||
pr: DashboardPR;
|
||||
session: DashboardSession;
|
||||
merging: boolean;
|
||||
onMerge: () => void;
|
||||
onOpenSession: () => void;
|
||||
pr: DashboardPR;
|
||||
session: DashboardSession;
|
||||
merging: boolean;
|
||||
onMerge: () => void;
|
||||
onOpenSession: () => void;
|
||||
}) {
|
||||
const mergeable = pr.mergeability?.mergeable && (pr.state ?? 'open') === 'open';
|
||||
const ci = pr.ciStatus;
|
||||
const review = pr.reviewDecision;
|
||||
const mergeable = pr.mergeability?.mergeable && (pr.state ?? "open") === "open";
|
||||
const ci = pr.ciStatus;
|
||||
const review = pr.reviewDecision;
|
||||
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<View style={styles.cardTop}>
|
||||
<Text style={styles.repo} numberOfLines={1}>
|
||||
{pr.repo ? `${pr.owner}/${pr.repo}` : session.projectId}
|
||||
</Text>
|
||||
<View style={{ flex: 1 }} />
|
||||
{pr.state === 'merged' ? (
|
||||
<Chip label="merged" color={theme.green} tint={theme.tintGreen} icon="git-merge" />
|
||||
) : pr.state === 'closed' ? (
|
||||
<Chip label="closed" color={theme.red} tint={theme.tintRed} />
|
||||
) : (
|
||||
<Text style={styles.num}>#{pr.number}</Text>
|
||||
)}
|
||||
</View>
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<View style={styles.cardTop}>
|
||||
<Text style={styles.repo} numberOfLines={1}>
|
||||
{pr.repo ? `${pr.owner}/${pr.repo}` : session.projectId}
|
||||
</Text>
|
||||
<View style={{ flex: 1 }} />
|
||||
{pr.state === "merged" ? (
|
||||
<Chip label="merged" color={theme.green} tint={theme.tintGreen} icon="git-merge" />
|
||||
) : pr.state === "closed" ? (
|
||||
<Chip label="closed" color={theme.red} tint={theme.tintRed} />
|
||||
) : (
|
||||
<Text style={styles.num}>#{pr.number}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Text style={styles.title} numberOfLines={2}>
|
||||
{pr.title ?? `Pull request #${pr.number}`}
|
||||
</Text>
|
||||
<Text style={styles.title} numberOfLines={2}>
|
||||
{pr.title ?? `Pull request #${pr.number}`}
|
||||
</Text>
|
||||
|
||||
<View style={styles.chips}>
|
||||
{ci && ci !== 'none'
|
||||
? (() => {
|
||||
const c = ciVisual(ci);
|
||||
return (
|
||||
<Chip
|
||||
label={c.label}
|
||||
color={c.color}
|
||||
tint={c.tint}
|
||||
icon={c.icon}
|
||||
/>
|
||||
);
|
||||
})()
|
||||
: null}
|
||||
{review === 'approved' ? (
|
||||
<Chip label="approved" color={theme.green} tint={theme.tintGreen} icon="check" />
|
||||
) : review === 'changes_requested' ? (
|
||||
<Chip label="changes req." color={theme.amber} tint={theme.tintAmber} icon="edit-3" />
|
||||
) : null}
|
||||
{pr.additions !== undefined && pr.deletions !== undefined ? (
|
||||
<View style={styles.diffChip}>
|
||||
<Text style={[styles.diffText, { color: theme.green }]}>+{pr.additions}</Text>
|
||||
<Text style={[styles.diffText, { color: theme.red }]}>−{pr.deletions}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{pr.unresolvedThreads ? (
|
||||
<Chip label={`${pr.unresolvedThreads} threads`} color={theme.amber} tint={theme.tintAmber} icon="message-square" />
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.chips}>
|
||||
{ci && ci !== "none"
|
||||
? (() => {
|
||||
const c = ciVisual(ci);
|
||||
return <Chip label={c.label} color={c.color} tint={c.tint} icon={c.icon} />;
|
||||
})()
|
||||
: null}
|
||||
{review === "approved" ? (
|
||||
<Chip label="approved" color={theme.green} tint={theme.tintGreen} icon="check" />
|
||||
) : review === "changes_requested" ? (
|
||||
<Chip label="changes req." color={theme.amber} tint={theme.tintAmber} icon="edit-3" />
|
||||
) : null}
|
||||
{pr.additions !== undefined && pr.deletions !== undefined ? (
|
||||
<View style={styles.diffChip}>
|
||||
<Text style={[styles.diffText, { color: theme.green }]}>+{pr.additions}</Text>
|
||||
<Text style={[styles.diffText, { color: theme.red }]}>−{pr.deletions}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{pr.unresolvedThreads ? (
|
||||
<Chip
|
||||
label={`${pr.unresolvedThreads} threads`}
|
||||
color={theme.amber}
|
||||
tint={theme.tintAmber}
|
||||
icon="message-square"
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Button title="Session" variant="ghost" icon="terminal" onPress={onOpenSession} style={styles.flexBtn} />
|
||||
{pr.url ? (
|
||||
<Button title="Open" variant="ghost" icon="external-link" onPress={() => Linking.openURL(pr.url!)} style={styles.flexBtn} />
|
||||
) : null}
|
||||
{mergeable ? (
|
||||
<Button title="Merge" icon="git-merge" loading={merging} onPress={onMerge} style={styles.flexBtn} />
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
<View style={styles.actions}>
|
||||
<Button title="Session" variant="ghost" icon="terminal" onPress={onOpenSession} style={styles.flexBtn} />
|
||||
{pr.url ? (
|
||||
<Button
|
||||
title="Open"
|
||||
variant="ghost"
|
||||
icon="external-link"
|
||||
onPress={() => Linking.openURL(pr.url!)}
|
||||
style={styles.flexBtn}
|
||||
/>
|
||||
) : null}
|
||||
{mergeable ? (
|
||||
<Button title="Merge" icon="git-merge" loading={merging} onPress={onMerge} style={styles.flexBtn} />
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: { flex: 1, backgroundColor: theme.bgBase },
|
||||
filters: { flexDirection: 'row', gap: 8, paddingHorizontal: 16, paddingBottom: 12 },
|
||||
card: {
|
||||
backgroundColor: theme.bgElevated,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.borderSubtle,
|
||||
padding: 14,
|
||||
marginHorizontal: 12,
|
||||
marginVertical: 5,
|
||||
},
|
||||
cardTop: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 },
|
||||
repo: { color: theme.textTertiary, fontSize: 12, fontFamily: theme.fontMono },
|
||||
num: { color: theme.textSecondary, fontSize: 13, fontWeight: '700', fontFamily: theme.fontMono },
|
||||
title: { color: theme.textPrimary, fontSize: 15, fontWeight: '500', lineHeight: 20 },
|
||||
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 12 },
|
||||
diffChip: { flexDirection: 'row', gap: 6, alignItems: 'center', paddingHorizontal: 4 },
|
||||
diffText: { fontSize: 11, fontWeight: '700', fontFamily: theme.fontMono },
|
||||
actions: { flexDirection: 'row', gap: 8, marginTop: 14 },
|
||||
flexBtn: { flex: 1, paddingVertical: 10 },
|
||||
screen: { flex: 1, backgroundColor: theme.bgBase },
|
||||
filters: { flexDirection: "row", gap: 8, paddingHorizontal: 16, paddingBottom: 12 },
|
||||
card: {
|
||||
backgroundColor: theme.bgElevated,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.borderSubtle,
|
||||
padding: 14,
|
||||
marginHorizontal: 12,
|
||||
marginVertical: 5,
|
||||
},
|
||||
cardTop: { flexDirection: "row", alignItems: "center", marginBottom: 8 },
|
||||
repo: { color: theme.textTertiary, fontSize: 12, fontFamily: theme.fontMono },
|
||||
num: { color: theme.textSecondary, fontSize: 13, fontWeight: "700", fontFamily: theme.fontMono },
|
||||
title: { color: theme.textPrimary, fontSize: 15, fontWeight: "500", lineHeight: 20 },
|
||||
chips: { flexDirection: "row", flexWrap: "wrap", gap: 6, marginTop: 12 },
|
||||
diffChip: { flexDirection: "row", gap: 6, alignItems: "center", paddingHorizontal: 4 },
|
||||
diffText: { fontSize: 11, fontWeight: "700", fontFamily: theme.fontMono },
|
||||
actions: { flexDirection: "row", gap: 8, marginTop: 14 },
|
||||
flexBtn: { flex: 1, paddingVertical: 10 },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,224 +1,235 @@
|
|||
import { Feather } from '@expo/vector-icons';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { pingServer } from '../../lib/api';
|
||||
import { DEFAULT_CONFIG, loadConfig, saveConfig, type ServerConfig } from '../../lib/config';
|
||||
import { useApp } from '../../lib/store';
|
||||
import { theme } from '../../lib/theme';
|
||||
import { Button, ConnectionPill, ScreenHeader } from '../../lib/ui';
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { pingServer } from "../../lib/api";
|
||||
import { DEFAULT_CONFIG, loadConfig, saveConfig, type ServerConfig } from "../../lib/config";
|
||||
import { useApp } from "../../lib/store";
|
||||
import { theme } from "../../lib/theme";
|
||||
import { Button, ConnectionPill, ScreenHeader } from "../../lib/ui";
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { reloadConfig, projects, connection } = useApp();
|
||||
const [cfg, setCfg] = useState<ServerConfig>(DEFAULT_CONFIG);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
const insets = useSafeAreaInsets();
|
||||
const { reloadConfig, projects, connection } = useApp();
|
||||
const [cfg, setCfg] = useState<ServerConfig>(DEFAULT_CONFIG);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig().then((c) => {
|
||||
setCfg(c);
|
||||
setLoaded(true);
|
||||
});
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
loadConfig().then((c) => {
|
||||
setCfg(c);
|
||||
setLoaded(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const set = (k: keyof ServerConfig) => (v: string) =>
|
||||
setCfg((prev) => ({ ...prev, [k]: v }));
|
||||
const set = (k: keyof ServerConfig) => (v: string) => setCfg((prev) => ({ ...prev, [k]: v }));
|
||||
|
||||
async function test() {
|
||||
setTesting(true);
|
||||
setResult(null);
|
||||
try {
|
||||
await saveConfig(cfg);
|
||||
const count = await pingServer(cfg);
|
||||
setResult({ ok: true, msg: `Connected — ${count} session(s) found.` });
|
||||
await reloadConfig();
|
||||
} catch (e) {
|
||||
setResult({ ok: false, msg: e instanceof Error ? e.message : 'Could not reach server.' });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
async function test() {
|
||||
setTesting(true);
|
||||
setResult(null);
|
||||
try {
|
||||
await saveConfig(cfg);
|
||||
const count = await pingServer(cfg);
|
||||
setResult({ ok: true, msg: `Connected — ${count} session(s) found.` });
|
||||
await reloadConfig();
|
||||
} catch (e) {
|
||||
setResult({ ok: false, msg: e instanceof Error ? e.message : "Could not reach server." });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
await saveConfig(cfg);
|
||||
await reloadConfig();
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 1800);
|
||||
}
|
||||
async function save() {
|
||||
await saveConfig(cfg);
|
||||
await reloadConfig();
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 1800);
|
||||
}
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator color={theme.blue} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (!loaded) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator color={theme.blue} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={{ flex: 1, backgroundColor: theme.bgBase }}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<View style={{ height: insets.top }} />
|
||||
<ScreenHeader title="Settings" right={<ConnectionPill status={connection} />} />
|
||||
<ScrollView
|
||||
style={styles.screen}
|
||||
contentContainerStyle={{ padding: 16, paddingBottom: 120 }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Text style={styles.sectionTitle}>SERVER</Text>
|
||||
<Text style={styles.intro}>
|
||||
Point the app at your AO server — your PC's Tailscale name / 100.x address (or LAN IP on
|
||||
the same Wi-Fi).
|
||||
</Text>
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={{ flex: 1, backgroundColor: theme.bgBase }}
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
>
|
||||
<View style={{ height: insets.top }} />
|
||||
<ScreenHeader title="Settings" right={<ConnectionPill status={connection} />} />
|
||||
<ScrollView
|
||||
style={styles.screen}
|
||||
contentContainerStyle={{ padding: 16, paddingBottom: 120 }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Text style={styles.sectionTitle}>SERVER</Text>
|
||||
<Text style={styles.intro}>
|
||||
Point the app at your AO server — your PC's Tailscale name / 100.x address (or LAN IP on the same Wi-Fi).
|
||||
</Text>
|
||||
|
||||
<Field
|
||||
label="HOST"
|
||||
value={cfg.host}
|
||||
onChangeText={set('host')}
|
||||
placeholder="my-pc.tailXXXX.ts.net or 192.168.x.x"
|
||||
autoCapitalize="none"
|
||||
keyboardType="url"
|
||||
/>
|
||||
<View style={styles.row}>
|
||||
<View style={{ flex: 1, marginRight: 8 }}>
|
||||
<Field label="API PORT" value={cfg.httpPort} onChangeText={set('httpPort')} keyboardType="number-pad" />
|
||||
</View>
|
||||
<View style={{ flex: 1, marginLeft: 8 }}>
|
||||
<Field label="TERMINAL PORT" value={cfg.muxPort} onChangeText={set('muxPort')} keyboardType="number-pad" />
|
||||
</View>
|
||||
</View>
|
||||
<Field
|
||||
label="HOST"
|
||||
value={cfg.host}
|
||||
onChangeText={set("host")}
|
||||
placeholder="my-pc.tailXXXX.ts.net or 192.168.x.x"
|
||||
autoCapitalize="none"
|
||||
keyboardType="url"
|
||||
/>
|
||||
<View style={styles.row}>
|
||||
<View style={{ flex: 1, marginRight: 8 }}>
|
||||
<Field label="API PORT" value={cfg.httpPort} onChangeText={set("httpPort")} keyboardType="number-pad" />
|
||||
</View>
|
||||
<View style={{ flex: 1, marginLeft: 8 }}>
|
||||
<Field label="TERMINAL PORT" value={cfg.muxPort} onChangeText={set("muxPort")} keyboardType="number-pad" />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.toggleRow}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.toggleLabel}>Use TLS (https / wss)</Text>
|
||||
<Text style={styles.toggleHint}>On only if AO is served over HTTPS (e.g. a Tailscale funnel).</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={!!cfg.secure}
|
||||
onValueChange={(v) => setCfg((prev) => ({ ...prev, secure: v }))}
|
||||
trackColor={{ true: theme.blue, false: theme.borderStrong }}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.toggleRow}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.toggleLabel}>Use TLS (https / wss)</Text>
|
||||
<Text style={styles.toggleHint}>On only if AO is served over HTTPS (e.g. a Tailscale funnel).</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={!!cfg.secure}
|
||||
onValueChange={(v) => setCfg((prev) => ({ ...prev, secure: v }))}
|
||||
trackColor={{ true: theme.blue, false: theme.borderStrong }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Button title="Test connection" variant="ghost" icon="activity" loading={testing} onPress={test} style={{ marginTop: 4 }} />
|
||||
{result && (
|
||||
<View style={[styles.resultBox, { borderColor: result.ok ? theme.tintGreen : theme.tintRed }]}>
|
||||
<Feather
|
||||
name={result.ok ? 'check-circle' : 'alert-circle'}
|
||||
size={15}
|
||||
color={result.ok ? theme.green : theme.red}
|
||||
/>
|
||||
<Text style={[styles.result, { color: result.ok ? theme.green : theme.red }]}>{result.msg}</Text>
|
||||
</View>
|
||||
)}
|
||||
<Button title={saved ? 'Saved ✓' : 'Save'} icon={saved ? undefined : 'save'} onPress={save} disabled={!cfg.host.trim()} style={{ marginTop: 12 }} />
|
||||
<Button
|
||||
title="Test connection"
|
||||
variant="ghost"
|
||||
icon="activity"
|
||||
loading={testing}
|
||||
onPress={test}
|
||||
style={{ marginTop: 4 }}
|
||||
/>
|
||||
{result && (
|
||||
<View style={[styles.resultBox, { borderColor: result.ok ? theme.tintGreen : theme.tintRed }]}>
|
||||
<Feather
|
||||
name={result.ok ? "check-circle" : "alert-circle"}
|
||||
size={15}
|
||||
color={result.ok ? theme.green : theme.red}
|
||||
/>
|
||||
<Text style={[styles.result, { color: result.ok ? theme.green : theme.red }]}>{result.msg}</Text>
|
||||
</View>
|
||||
)}
|
||||
<Button
|
||||
title={saved ? "Saved ✓" : "Save"}
|
||||
icon={saved ? undefined : "save"}
|
||||
onPress={save}
|
||||
disabled={!cfg.host.trim()}
|
||||
style={{ marginTop: 12 }}
|
||||
/>
|
||||
|
||||
<Text style={[styles.sectionTitle, { marginTop: 32 }]}>PROJECTS</Text>
|
||||
{projects.length === 0 ? (
|
||||
<Text style={styles.intro}>No projects found. Add a project from the AO dashboard.</Text>
|
||||
) : (
|
||||
projects.map((p) => (
|
||||
<View key={p.id} style={styles.projRow}>
|
||||
<Feather name="folder" size={16} color={theme.textTertiary} />
|
||||
<Text style={styles.projName}>{p.name}</Text>
|
||||
{p.sessionPrefix ? <Text style={styles.projPrefix}>{p.sessionPrefix}</Text> : null}
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
<Text style={[styles.sectionTitle, { marginTop: 32 }]}>PROJECTS</Text>
|
||||
{projects.length === 0 ? (
|
||||
<Text style={styles.intro}>No projects found. Add a project from the AO dashboard.</Text>
|
||||
) : (
|
||||
projects.map((p) => (
|
||||
<View key={p.id} style={styles.projRow}>
|
||||
<Feather name="folder" size={16} color={theme.textTertiary} />
|
||||
<Text style={styles.projName}>{p.name}</Text>
|
||||
{p.sessionPrefix ? <Text style={styles.projPrefix}>{p.sessionPrefix}</Text> : null}
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
function Field(props: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChangeText: (v: string) => void;
|
||||
placeholder?: string;
|
||||
autoCapitalize?: 'none' | 'sentences';
|
||||
keyboardType?: 'default' | 'url' | 'number-pad';
|
||||
label: string;
|
||||
value: string;
|
||||
onChangeText: (v: string) => void;
|
||||
placeholder?: string;
|
||||
autoCapitalize?: "none" | "sentences";
|
||||
keyboardType?: "default" | "url" | "number-pad";
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>{props.label}</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={props.value}
|
||||
onChangeText={props.onChangeText}
|
||||
placeholder={props.placeholder}
|
||||
placeholderTextColor={theme.textTertiary}
|
||||
autoCapitalize={props.autoCapitalize}
|
||||
autoCorrect={false}
|
||||
keyboardType={props.keyboardType}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
return (
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>{props.label}</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={props.value}
|
||||
onChangeText={props.onChangeText}
|
||||
placeholder={props.placeholder}
|
||||
placeholderTextColor={theme.textTertiary}
|
||||
autoCapitalize={props.autoCapitalize}
|
||||
autoCorrect={false}
|
||||
keyboardType={props.keyboardType}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: { flex: 1, backgroundColor: theme.bgBase },
|
||||
center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: theme.bgBase },
|
||||
sectionTitle: { color: theme.textTertiary, fontSize: 11, letterSpacing: 1.2, fontWeight: '700', marginBottom: 10 },
|
||||
intro: { color: theme.textSecondary, fontSize: 13, lineHeight: 19, marginBottom: 18 },
|
||||
field: { marginBottom: 16 },
|
||||
row: { flexDirection: 'row' },
|
||||
label: { color: theme.textTertiary, fontSize: 10, letterSpacing: 1, marginBottom: 6, fontWeight: '600' },
|
||||
input: {
|
||||
backgroundColor: theme.bgElevated,
|
||||
borderColor: theme.borderDefault,
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
color: theme.textPrimary,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 12,
|
||||
fontSize: 14,
|
||||
},
|
||||
resultBox: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
marginTop: 12,
|
||||
padding: 12,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
backgroundColor: theme.bgElevated,
|
||||
},
|
||||
result: { fontSize: 13, lineHeight: 18, flex: 1 },
|
||||
toggleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
paddingVertical: 6,
|
||||
marginBottom: 8,
|
||||
},
|
||||
toggleLabel: { color: theme.textPrimary, fontSize: 14, fontWeight: '600' },
|
||||
toggleHint: { color: theme.textTertiary, fontSize: 12, marginTop: 2, lineHeight: 16 },
|
||||
projRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
paddingVertical: 13,
|
||||
paddingHorizontal: 14,
|
||||
backgroundColor: theme.bgElevated,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.borderSubtle,
|
||||
marginBottom: 8,
|
||||
},
|
||||
projName: { color: theme.textPrimary, fontSize: 14, fontWeight: '600', flex: 1 },
|
||||
projPrefix: { color: theme.textTertiary, fontSize: 12, fontFamily: theme.fontMono },
|
||||
screen: { flex: 1, backgroundColor: theme.bgBase },
|
||||
center: { flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: theme.bgBase },
|
||||
sectionTitle: { color: theme.textTertiary, fontSize: 11, letterSpacing: 1.2, fontWeight: "700", marginBottom: 10 },
|
||||
intro: { color: theme.textSecondary, fontSize: 13, lineHeight: 19, marginBottom: 18 },
|
||||
field: { marginBottom: 16 },
|
||||
row: { flexDirection: "row" },
|
||||
label: { color: theme.textTertiary, fontSize: 10, letterSpacing: 1, marginBottom: 6, fontWeight: "600" },
|
||||
input: {
|
||||
backgroundColor: theme.bgElevated,
|
||||
borderColor: theme.borderDefault,
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
color: theme.textPrimary,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 12,
|
||||
fontSize: 14,
|
||||
},
|
||||
resultBox: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
marginTop: 12,
|
||||
padding: 12,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
backgroundColor: theme.bgElevated,
|
||||
},
|
||||
result: { fontSize: 13, lineHeight: 18, flex: 1 },
|
||||
toggleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
paddingVertical: 6,
|
||||
marginBottom: 8,
|
||||
},
|
||||
toggleLabel: { color: theme.textPrimary, fontSize: 14, fontWeight: "600" },
|
||||
toggleHint: { color: theme.textTertiary, fontSize: 12, marginTop: 2, lineHeight: 16 },
|
||||
projRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
paddingVertical: 13,
|
||||
paddingHorizontal: 14,
|
||||
backgroundColor: theme.bgElevated,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.borderSubtle,
|
||||
marginBottom: 8,
|
||||
},
|
||||
projName: { color: theme.textPrimary, fontSize: 14, fontWeight: "600", flex: 1 },
|
||||
projPrefix: { color: theme.textTertiary, fontSize: 12, fontFamily: theme.fontMono },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,34 +1,28 @@
|
|||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { AppProvider } from '../lib/store';
|
||||
import { theme } from '../lib/theme';
|
||||
import { Stack } from "expo-router";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
import { AppProvider } from "../lib/store";
|
||||
import { theme } from "../lib/theme";
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<AppProvider>
|
||||
<StatusBar style="light" />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: theme.bgSurface },
|
||||
headerTintColor: theme.textPrimary,
|
||||
headerTitleStyle: { fontWeight: '700' },
|
||||
headerShadowVisible: false,
|
||||
contentStyle: { backgroundColor: theme.bgBase },
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="session/[id]"
|
||||
options={{ title: 'Terminal', headerBackTitle: 'Back' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="spawn"
|
||||
options={{ presentation: 'modal', title: 'New agent' }}
|
||||
/>
|
||||
</Stack>
|
||||
</AppProvider>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<AppProvider>
|
||||
<StatusBar style="light" />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: theme.bgSurface },
|
||||
headerTintColor: theme.textPrimary,
|
||||
headerTitleStyle: { fontWeight: "700" },
|
||||
headerShadowVisible: false,
|
||||
contentStyle: { backgroundColor: theme.bgBase },
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="session/[id]" options={{ title: "Terminal", headerBackTitle: "Back" }} />
|
||||
<Stack.Screen name="spawn" options={{ presentation: "modal", title: "New agent" }} />
|
||||
</Stack>
|
||||
</AppProvider>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,13 @@
|
|||
import { Feather } from '@expo/vector-icons';
|
||||
import { XtermJsWebView, type XtermWebViewHandle } from '@fressh/react-native-xtermjs-webview';
|
||||
import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router';
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Keyboard,
|
||||
Platform,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { killSession, sendMessage } from '../../lib/api';
|
||||
import { isConfigured, loadConfig, type ServerConfig } from '../../lib/config';
|
||||
import { MuxClient, type MuxStatus } from '../../lib/mux';
|
||||
import { theme } from '../../lib/theme';
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { XtermJsWebView, type XtermWebViewHandle } from "@fressh/react-native-xtermjs-webview";
|
||||
import { useLocalSearchParams, useNavigation, useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { Alert, Keyboard, Platform, Pressable, StyleSheet, Text, TextInput, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { killSession, sendMessage } from "../../lib/api";
|
||||
import { isConfigured, loadConfig, type ServerConfig } from "../../lib/config";
|
||||
import { MuxClient, type MuxStatus } from "../../lib/mux";
|
||||
import { theme } from "../../lib/theme";
|
||||
|
||||
const FONT_SIZE = 12;
|
||||
|
||||
|
|
@ -174,497 +165,493 @@ true;
|
|||
|
||||
// Keys a phone keyboard lacks — sent straight to the PTY as escape sequences.
|
||||
const EXTRA_KEYS: { label: string; seq: string }[] = [
|
||||
{ label: 'esc', seq: '\x1b' },
|
||||
{ label: 'tab', seq: '\t' },
|
||||
{ label: '^C', seq: '\x03' },
|
||||
{ label: '←', seq: '\x1b[D' },
|
||||
{ label: '↑', seq: '\x1b[A' },
|
||||
{ label: '↓', seq: '\x1b[B' },
|
||||
{ label: '→', seq: '\x1b[C' },
|
||||
{ label: '↵', seq: '\r' },
|
||||
{ label: "esc", seq: "\x1b" },
|
||||
{ label: "tab", seq: "\t" },
|
||||
{ label: "^C", seq: "\x03" },
|
||||
{ label: "←", seq: "\x1b[D" },
|
||||
{ label: "↑", seq: "\x1b[A" },
|
||||
{ label: "↓", seq: "\x1b[B" },
|
||||
{ label: "→", seq: "\x1b[C" },
|
||||
{ label: "↵", seq: "\r" },
|
||||
];
|
||||
|
||||
// Named keys a hardware/Bluetooth keyboard emits (key.length > 1) mapped to the
|
||||
// bytes the PTY expects. Single-char keys are sent as-is.
|
||||
const NAMED_KEYS: Record<string, string> = {
|
||||
Backspace: '\x7f',
|
||||
Enter: '\r',
|
||||
'\n': '\r',
|
||||
Space: ' ',
|
||||
Tab: '\t',
|
||||
Escape: '\x1b',
|
||||
ArrowUp: '\x1b[A',
|
||||
ArrowDown: '\x1b[B',
|
||||
ArrowRight: '\x1b[C',
|
||||
ArrowLeft: '\x1b[D',
|
||||
Backspace: "\x7f",
|
||||
Enter: "\r",
|
||||
"\n": "\r",
|
||||
Space: " ",
|
||||
Tab: "\t",
|
||||
Escape: "\x1b",
|
||||
ArrowUp: "\x1b[A",
|
||||
ArrowDown: "\x1b[B",
|
||||
ArrowRight: "\x1b[C",
|
||||
ArrowLeft: "\x1b[D",
|
||||
};
|
||||
|
||||
const statusLabel: Record<MuxStatus, string> = {
|
||||
connecting: 'connecting…',
|
||||
open: 'live',
|
||||
closed: 'disconnected',
|
||||
error: 'error',
|
||||
connecting: "connecting…",
|
||||
open: "live",
|
||||
closed: "disconnected",
|
||||
error: "error",
|
||||
};
|
||||
const statusColors: Record<MuxStatus, string> = {
|
||||
connecting: theme.attention,
|
||||
open: theme.green,
|
||||
closed: theme.textTertiary,
|
||||
error: theme.red,
|
||||
connecting: theme.attention,
|
||||
open: theme.green,
|
||||
closed: theme.textTertiary,
|
||||
error: theme.red,
|
||||
};
|
||||
|
||||
export default function TerminalScreen() {
|
||||
const params = useLocalSearchParams<{ id: string; projectId?: string }>();
|
||||
const id = String(params.id);
|
||||
const projectId = params.projectId ? String(params.projectId) : undefined;
|
||||
const router = useRouter();
|
||||
const navigation = useNavigation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const params = useLocalSearchParams<{ id: string; projectId?: string }>();
|
||||
const id = String(params.id);
|
||||
const projectId = params.projectId ? String(params.projectId) : undefined;
|
||||
const router = useRouter();
|
||||
const navigation = useNavigation();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const xtermRef = useRef<XtermWebViewHandle | null>(null);
|
||||
const muxRef = useRef<MuxClient | null>(null);
|
||||
const openedRef = useRef(false);
|
||||
// Last grid size reported by the WebView's FitAddon, so we can send it to the
|
||||
// PTY the moment the terminal opens (dims may arrive before or after open).
|
||||
const lastDimsRef = useRef<{ cols: number; rows: number } | null>(null);
|
||||
// The REAL keyboard input. The WebView can't show/control a keyboard reliably,
|
||||
// so this hidden RN TextInput is what raises the keyboard and captures typing,
|
||||
// which we forward to the PTY over the mux. Focus it to type, blur it to hide.
|
||||
const kbInputRef = useRef<TextInput | null>(null);
|
||||
const xtermRef = useRef<XtermWebViewHandle | null>(null);
|
||||
const muxRef = useRef<MuxClient | null>(null);
|
||||
const openedRef = useRef(false);
|
||||
// Last grid size reported by the WebView's FitAddon, so we can send it to the
|
||||
// PTY the moment the terminal opens (dims may arrive before or after open).
|
||||
const lastDimsRef = useRef<{ cols: number; rows: number } | null>(null);
|
||||
// The REAL keyboard input. The WebView can't show/control a keyboard reliably,
|
||||
// so this hidden RN TextInput is what raises the keyboard and captures typing,
|
||||
// which we forward to the PTY over the mux. Focus it to type, blur it to hide.
|
||||
const kbInputRef = useRef<TextInput | null>(null);
|
||||
|
||||
const [cfg, setCfg] = useState<ServerConfig | null>(null);
|
||||
const [status, setStatus] = useState<MuxStatus>('connecting');
|
||||
const [size, setSize] = useState<{ cols: number; rows: number } | null>(null);
|
||||
const [banner, setBanner] = useState<string | null>(null);
|
||||
const [kbHeight, setKbHeight] = useState(0); // iOS: space to reserve for keyboard
|
||||
const [kbVisible, setKbVisible] = useState(false); // both platforms
|
||||
const [compose, setCompose] = useState(false); // high-level "send message" bar
|
||||
const [msg, setMsg] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [cfg, setCfg] = useState<ServerConfig | null>(null);
|
||||
const [status, setStatus] = useState<MuxStatus>("connecting");
|
||||
const [size, setSize] = useState<{ cols: number; rows: number } | null>(null);
|
||||
const [banner, setBanner] = useState<string | null>(null);
|
||||
const [kbHeight, setKbHeight] = useState(0); // iOS: space to reserve for keyboard
|
||||
const [kbVisible, setKbVisible] = useState(false); // both platforms
|
||||
const [compose, setCompose] = useState(false); // high-level "send message" bar
|
||||
const [msg, setMsg] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
// iOS doesn't resize the layout when the keyboard opens, so the key bar would
|
||||
// hide behind it — reserve kbHeight so the bar rides above the keyboard.
|
||||
// (Android's adjustResize shrinks the window for us, so no height needed there.)
|
||||
useEffect(() => {
|
||||
const isIOS = Platform.OS === 'ios';
|
||||
const showEvt = isIOS ? 'keyboardWillShow' : 'keyboardDidShow';
|
||||
const hideEvt = isIOS ? 'keyboardWillHide' : 'keyboardDidHide';
|
||||
const show = Keyboard.addListener(showEvt, (e) => {
|
||||
setKbVisible(true);
|
||||
if (isIOS) setKbHeight(e.endCoordinates.height);
|
||||
});
|
||||
const hide = Keyboard.addListener(hideEvt, () => {
|
||||
setKbVisible(false);
|
||||
setKbHeight(0);
|
||||
});
|
||||
// willShow can report a height that still includes the accessory bar we hid,
|
||||
// leaving a gap. didShow reports the actual final frame — use it to correct.
|
||||
const didShow = isIOS
|
||||
? Keyboard.addListener('keyboardDidShow', (e) => setKbHeight(e.endCoordinates.height))
|
||||
: null;
|
||||
// Backup: guarantee the reserved space collapses even if willHide is missed.
|
||||
const didHide = Keyboard.addListener('keyboardDidHide', () => {
|
||||
setKbVisible(false);
|
||||
setKbHeight(0);
|
||||
});
|
||||
return () => {
|
||||
show.remove();
|
||||
hide.remove();
|
||||
didShow?.remove();
|
||||
didHide.remove();
|
||||
};
|
||||
}, []);
|
||||
// iOS doesn't resize the layout when the keyboard opens, so the key bar would
|
||||
// hide behind it — reserve kbHeight so the bar rides above the keyboard.
|
||||
// (Android's adjustResize shrinks the window for us, so no height needed there.)
|
||||
useEffect(() => {
|
||||
const isIOS = Platform.OS === "ios";
|
||||
const showEvt = isIOS ? "keyboardWillShow" : "keyboardDidShow";
|
||||
const hideEvt = isIOS ? "keyboardWillHide" : "keyboardDidHide";
|
||||
const show = Keyboard.addListener(showEvt, (e) => {
|
||||
setKbVisible(true);
|
||||
if (isIOS) setKbHeight(e.endCoordinates.height);
|
||||
});
|
||||
const hide = Keyboard.addListener(hideEvt, () => {
|
||||
setKbVisible(false);
|
||||
setKbHeight(0);
|
||||
});
|
||||
// willShow can report a height that still includes the accessory bar we hid,
|
||||
// leaving a gap. didShow reports the actual final frame — use it to correct.
|
||||
const didShow = isIOS ? Keyboard.addListener("keyboardDidShow", (e) => setKbHeight(e.endCoordinates.height)) : null;
|
||||
// Backup: guarantee the reserved space collapses even if willHide is missed.
|
||||
const didHide = Keyboard.addListener("keyboardDidHide", () => {
|
||||
setKbVisible(false);
|
||||
setKbHeight(0);
|
||||
});
|
||||
return () => {
|
||||
show.remove();
|
||||
hide.remove();
|
||||
didShow?.remove();
|
||||
didHide.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Header shows just the short id; Kill lives in our own status bar below so we
|
||||
// fully control its shape/alignment (iOS draws its own box behind header
|
||||
// buttons, which fights any custom background).
|
||||
useLayoutEffect(() => {
|
||||
navigation.setOptions({
|
||||
title: id.length > 22 ? `${id.slice(0, 20)}…` : id,
|
||||
});
|
||||
}, [navigation, id]);
|
||||
// Header shows just the short id; Kill lives in our own status bar below so we
|
||||
// fully control its shape/alignment (iOS draws its own box behind header
|
||||
// buttons, which fights any custom background).
|
||||
useLayoutEffect(() => {
|
||||
navigation.setOptions({
|
||||
title: id.length > 22 ? `${id.slice(0, 20)}…` : id,
|
||||
});
|
||||
}, [navigation, id]);
|
||||
|
||||
// Load config, then connect the mux socket.
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
(async () => {
|
||||
const config = await loadConfig();
|
||||
if (disposed) return;
|
||||
setCfg(config);
|
||||
if (!isConfigured(config)) return;
|
||||
// Load config, then connect the mux socket.
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
(async () => {
|
||||
const config = await loadConfig();
|
||||
if (disposed) return;
|
||||
setCfg(config);
|
||||
if (!isConfigured(config)) return;
|
||||
|
||||
const mux = new MuxClient(config, {
|
||||
onStatus: (s) => setStatus(s),
|
||||
onTerminalData: (tid, bytes) => {
|
||||
if (tid === id) xtermRef.current?.write(bytes);
|
||||
},
|
||||
onTerminalExited: (tid, code) => {
|
||||
if (tid === id) setBanner(`Session exited (code ${code})`);
|
||||
},
|
||||
onTerminalError: (tid, msg) => {
|
||||
if (tid === id) setBanner(msg);
|
||||
},
|
||||
});
|
||||
muxRef.current = mux;
|
||||
mux.connect();
|
||||
})();
|
||||
return () => {
|
||||
disposed = true;
|
||||
muxRef.current?.disconnect();
|
||||
muxRef.current = null;
|
||||
};
|
||||
}, [id]);
|
||||
const mux = new MuxClient(config, {
|
||||
onStatus: (s) => setStatus(s),
|
||||
onTerminalData: (tid, bytes) => {
|
||||
if (tid === id) xtermRef.current?.write(bytes);
|
||||
},
|
||||
onTerminalExited: (tid, code) => {
|
||||
if (tid === id) setBanner(`Session exited (code ${code})`);
|
||||
},
|
||||
onTerminalError: (tid, msg) => {
|
||||
if (tid === id) setBanner(msg);
|
||||
},
|
||||
});
|
||||
muxRef.current = mux;
|
||||
mux.connect();
|
||||
})();
|
||||
return () => {
|
||||
disposed = true;
|
||||
muxRef.current?.disconnect();
|
||||
muxRef.current = null;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
// The WebView's FitAddon measures the real cell size and reports the resulting
|
||||
// cols/rows back through fressh's debug→logger.log channel. We forward those
|
||||
// exact dims to the PTY so the display and the PTY always agree, regardless of
|
||||
// font, DPR, or accessibility text scale.
|
||||
const applyDims = useCallback(
|
||||
(cols: number, rows: number) => {
|
||||
lastDimsRef.current = { cols, rows };
|
||||
setSize((prev) =>
|
||||
prev && prev.cols === cols && prev.rows === rows ? prev : { cols, rows },
|
||||
);
|
||||
if (openedRef.current) muxRef.current?.resize(id, cols, rows, projectId);
|
||||
},
|
||||
[id, projectId],
|
||||
);
|
||||
// The WebView's FitAddon measures the real cell size and reports the resulting
|
||||
// cols/rows back through fressh's debug→logger.log channel. We forward those
|
||||
// exact dims to the PTY so the display and the PTY always agree, regardless of
|
||||
// font, DPR, or accessibility text scale.
|
||||
const applyDims = useCallback(
|
||||
(cols: number, rows: number) => {
|
||||
lastDimsRef.current = { cols, rows };
|
||||
setSize((prev) => (prev && prev.cols === cols && prev.rows === rows ? prev : { cols, rows }));
|
||||
if (openedRef.current) muxRef.current?.resize(id, cols, rows, projectId);
|
||||
},
|
||||
[id, projectId],
|
||||
);
|
||||
|
||||
// fressh routes WebView {type:'debug'} messages to logger.log(prefix, message).
|
||||
// We piggyback on it for the FRESSH_DIMS report (using a custom onMessage would
|
||||
// clobber fressh's own bridge).
|
||||
const logger = useMemo(
|
||||
() => ({
|
||||
log: (...args: unknown[]) => {
|
||||
const m = args[args.length - 1];
|
||||
if (typeof m === 'string' && m.startsWith('FRESSH_DIMS ')) {
|
||||
const parts = m.split(' ');
|
||||
const cols = parseInt(parts[1], 10);
|
||||
const rows = parseInt(parts[2], 10);
|
||||
if (cols > 0 && rows > 0) applyDims(cols, rows);
|
||||
}
|
||||
},
|
||||
}),
|
||||
[applyDims],
|
||||
);
|
||||
// fressh routes WebView {type:'debug'} messages to logger.log(prefix, message).
|
||||
// We piggyback on it for the FRESSH_DIMS report (using a custom onMessage would
|
||||
// clobber fressh's own bridge).
|
||||
const logger = useMemo(
|
||||
() => ({
|
||||
log: (...args: unknown[]) => {
|
||||
const m = args[args.length - 1];
|
||||
if (typeof m === "string" && m.startsWith("FRESSH_DIMS ")) {
|
||||
const parts = m.split(" ");
|
||||
const cols = parseInt(parts[1], 10);
|
||||
const rows = parseInt(parts[2], 10);
|
||||
if (cols > 0 && rows > 0) applyDims(cols, rows);
|
||||
}
|
||||
},
|
||||
}),
|
||||
[applyDims],
|
||||
);
|
||||
|
||||
const onInitialized = useCallback(() => {
|
||||
// Guard against a second open if the WebView re-fires onInitialized (e.g.
|
||||
// remount on orientation change) — that would attach the PTY twice.
|
||||
if (openedRef.current) return;
|
||||
openedRef.current = true;
|
||||
muxRef.current?.openTerminal(id, projectId);
|
||||
// If the FitAddon already reported dims before open, send them to the PTY now.
|
||||
const d = lastDimsRef.current;
|
||||
if (d) muxRef.current?.resize(id, d.cols, d.rows, projectId);
|
||||
}, [id, projectId]);
|
||||
const onInitialized = useCallback(() => {
|
||||
// Guard against a second open if the WebView re-fires onInitialized (e.g.
|
||||
// remount on orientation change) — that would attach the PTY twice.
|
||||
if (openedRef.current) return;
|
||||
openedRef.current = true;
|
||||
muxRef.current?.openTerminal(id, projectId);
|
||||
// If the FitAddon already reported dims before open, send them to the PTY now.
|
||||
const d = lastDimsRef.current;
|
||||
if (d) muxRef.current?.resize(id, d.cols, d.rows, projectId);
|
||||
}, [id, projectId]);
|
||||
|
||||
const onData = useCallback(
|
||||
(data: string) => {
|
||||
muxRef.current?.sendInput(id, data, projectId);
|
||||
},
|
||||
[id, projectId],
|
||||
);
|
||||
const onData = useCallback(
|
||||
(data: string) => {
|
||||
muxRef.current?.sendInput(id, data, projectId);
|
||||
},
|
||||
[id, projectId],
|
||||
);
|
||||
|
||||
const sendKey = useCallback(
|
||||
(seq: string) => {
|
||||
muxRef.current?.sendInput(id, seq, projectId);
|
||||
},
|
||||
[id, projectId],
|
||||
);
|
||||
const sendKey = useCallback(
|
||||
(seq: string) => {
|
||||
muxRef.current?.sendInput(id, seq, projectId);
|
||||
},
|
||||
[id, projectId],
|
||||
);
|
||||
|
||||
// Show/hide the keyboard by focusing/blurring our RN input (fully reliable,
|
||||
// unlike the WebView's keyboard).
|
||||
const toggleKeyboard = useCallback(() => {
|
||||
if (kbVisible) kbInputRef.current?.blur();
|
||||
else kbInputRef.current?.focus();
|
||||
}, [kbVisible]);
|
||||
// Show/hide the keyboard by focusing/blurring our RN input (fully reliable,
|
||||
// unlike the WebView's keyboard).
|
||||
const toggleKeyboard = useCallback(() => {
|
||||
if (kbVisible) kbInputRef.current?.blur();
|
||||
else kbInputRef.current?.focus();
|
||||
}, [kbVisible]);
|
||||
|
||||
// Each key press in the hidden input -> the matching byte(s) to the PTY.
|
||||
const onKeyPress = useCallback(
|
||||
(e: { nativeEvent: { key: string } }) => {
|
||||
const k = e.nativeEvent.key;
|
||||
const seq = NAMED_KEYS[k] ?? (k.length === 1 ? k : null);
|
||||
if (seq !== null) muxRef.current?.sendInput(id, seq, projectId);
|
||||
},
|
||||
[id, projectId],
|
||||
);
|
||||
// Each key press in the hidden input -> the matching byte(s) to the PTY.
|
||||
const onKeyPress = useCallback(
|
||||
(e: { nativeEvent: { key: string } }) => {
|
||||
const k = e.nativeEvent.key;
|
||||
const seq = NAMED_KEYS[k] ?? (k.length === 1 ? k : null);
|
||||
if (seq !== null) muxRef.current?.sendInput(id, seq, projectId);
|
||||
},
|
||||
[id, projectId],
|
||||
);
|
||||
|
||||
// High-level message to the agent (AO's /send) — distinct from raw keystrokes.
|
||||
const sendPrompt = useCallback(async () => {
|
||||
const text = msg.trim();
|
||||
if (!text) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const config = cfg ?? (await loadConfig());
|
||||
await sendMessage(config, id, text);
|
||||
setMsg('');
|
||||
setCompose(false);
|
||||
} catch (e) {
|
||||
setBanner(`Send failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}, [msg, cfg, id]);
|
||||
// High-level message to the agent (AO's /send) — distinct from raw keystrokes.
|
||||
const sendPrompt = useCallback(async () => {
|
||||
const text = msg.trim();
|
||||
if (!text) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const config = cfg ?? (await loadConfig());
|
||||
await sendMessage(config, id, text);
|
||||
setMsg("");
|
||||
setCompose(false);
|
||||
} catch (e) {
|
||||
setBanner(`Send failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}, [msg, cfg, id]);
|
||||
|
||||
const confirmKill = useCallback(() => {
|
||||
const doKill = async () => {
|
||||
try {
|
||||
const config = cfg ?? (await loadConfig());
|
||||
await killSession(config, id);
|
||||
router.back();
|
||||
} catch (e) {
|
||||
setBanner(`Kill failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
};
|
||||
if (Platform.OS === 'web') {
|
||||
doKill();
|
||||
return;
|
||||
}
|
||||
Alert.alert('Kill session?', `This stops ${id}.`, [
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{ text: 'Kill', style: 'destructive', onPress: doKill },
|
||||
]);
|
||||
}, [cfg, id, router]);
|
||||
const confirmKill = useCallback(() => {
|
||||
const doKill = async () => {
|
||||
try {
|
||||
const config = cfg ?? (await loadConfig());
|
||||
await killSession(config, id);
|
||||
router.back();
|
||||
} catch (e) {
|
||||
setBanner(`Kill failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
};
|
||||
if (Platform.OS === "web") {
|
||||
doKill();
|
||||
return;
|
||||
}
|
||||
Alert.alert("Kill session?", `This stops ${id}.`, [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{ text: "Kill", style: "destructive", onPress: doKill },
|
||||
]);
|
||||
}, [cfg, id, router]);
|
||||
|
||||
const xtermOptions = useMemo(
|
||||
() => ({
|
||||
fontSize: FONT_SIZE,
|
||||
cursorBlink: true,
|
||||
scrollback: 5000,
|
||||
// Move more rows per swipe so touch scrolling feels responsive.
|
||||
scrollSensitivity: 3,
|
||||
fastScrollSensitivity: 8,
|
||||
theme: {
|
||||
background: theme.term,
|
||||
foreground: theme.textPrimary,
|
||||
cursor: theme.orange,
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const xtermOptions = useMemo(
|
||||
() => ({
|
||||
fontSize: FONT_SIZE,
|
||||
cursorBlink: true,
|
||||
scrollback: 5000,
|
||||
// Move more rows per swipe so touch scrolling feels responsive.
|
||||
scrollSensitivity: 3,
|
||||
fastScrollSensitivity: 8,
|
||||
theme: {
|
||||
background: theme.term,
|
||||
foreground: theme.textPrimary,
|
||||
cursor: theme.orange,
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const webViewOptions = useMemo(
|
||||
() => ({
|
||||
// Removes the extra "< > Done" / autofill bar iOS shows above the keyboard.
|
||||
hideKeyboardAccessoryView: true,
|
||||
// Custom drag/momentum scroll + input hardening (see TERMINAL_ENHANCE_JS).
|
||||
// Prepend the platform flag the enhance script branches on for scrolling.
|
||||
injectedJavaScript: `var IS_ANDROID=${Platform.OS === 'android'};\n${TERMINAL_ENHANCE_JS}`,
|
||||
androidLayerType: 'hardware' as const,
|
||||
nestedScrollEnabled: true,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const webViewOptions = useMemo(
|
||||
() => ({
|
||||
// Removes the extra "< > Done" / autofill bar iOS shows above the keyboard.
|
||||
hideKeyboardAccessoryView: true,
|
||||
// Custom drag/momentum scroll + input hardening (see TERMINAL_ENHANCE_JS).
|
||||
// Prepend the platform flag the enhance script branches on for scrolling.
|
||||
injectedJavaScript: `var IS_ANDROID=${Platform.OS === "android"};\n${TERMINAL_ENHANCE_JS}`,
|
||||
androidLayerType: "hardware" as const,
|
||||
nestedScrollEnabled: true,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
if (cfg && !isConfigured(cfg)) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.bannerText}>No server configured.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (cfg && !isConfigured(cfg)) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.bannerText}>No server configured.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// The composer and key bar sit directly atop each other, so they share one
|
||||
// bottom inset: reserve room above the keyboard, else the home-indicator inset.
|
||||
const bottomPad = kbHeight > 0 ? 8 : insets.bottom > 0 ? insets.bottom : 8;
|
||||
// The composer and key bar sit directly atop each other, so they share one
|
||||
// bottom inset: reserve room above the keyboard, else the home-indicator inset.
|
||||
const bottomPad = kbHeight > 0 ? 8 : insets.bottom > 0 ? insets.bottom : 8;
|
||||
|
||||
return (
|
||||
<View style={[styles.screen, Platform.OS === 'ios' && { paddingBottom: kbHeight }]}>
|
||||
<TextInput
|
||||
ref={kbInputRef}
|
||||
value=""
|
||||
onKeyPress={onKeyPress}
|
||||
onChangeText={() => {}}
|
||||
blurOnSubmit={false}
|
||||
multiline={false}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
keyboardAppearance="dark"
|
||||
caretHidden
|
||||
style={styles.kbInput}
|
||||
/>
|
||||
<View style={styles.statusBar}>
|
||||
<View style={[styles.statusDot, { backgroundColor: statusColors[status] }]} />
|
||||
<Text style={styles.statusText}>{statusLabel[status]}</Text>
|
||||
{size && (
|
||||
<Text style={styles.dims}>
|
||||
{size.cols}×{size.rows}
|
||||
</Text>
|
||||
)}
|
||||
<Pressable
|
||||
hitSlop={8}
|
||||
onPress={confirmKill}
|
||||
style={({ pressed }) => [styles.killBtn, pressed && { opacity: 0.7 }]}
|
||||
>
|
||||
<Feather name="x" size={12} color={theme.red} />
|
||||
<Text style={styles.killText}>Kill</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
return (
|
||||
<View style={[styles.screen, Platform.OS === "ios" && { paddingBottom: kbHeight }]}>
|
||||
<TextInput
|
||||
ref={kbInputRef}
|
||||
value=""
|
||||
onKeyPress={onKeyPress}
|
||||
onChangeText={() => {}}
|
||||
blurOnSubmit={false}
|
||||
multiline={false}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
keyboardAppearance="dark"
|
||||
caretHidden
|
||||
style={styles.kbInput}
|
||||
/>
|
||||
<View style={styles.statusBar}>
|
||||
<View style={[styles.statusDot, { backgroundColor: statusColors[status] }]} />
|
||||
<Text style={styles.statusText}>{statusLabel[status]}</Text>
|
||||
{size && (
|
||||
<Text style={styles.dims}>
|
||||
{size.cols}×{size.rows}
|
||||
</Text>
|
||||
)}
|
||||
<Pressable
|
||||
hitSlop={8}
|
||||
onPress={confirmKill}
|
||||
style={({ pressed }) => [styles.killBtn, pressed && { opacity: 0.7 }]}
|
||||
>
|
||||
<Feather name="x" size={12} color={theme.red} />
|
||||
<Text style={styles.killText}>Kill</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{banner && (
|
||||
<Pressable onPress={() => setBanner(null)} style={styles.banner}>
|
||||
<Text style={styles.bannerText}>{banner} (tap to dismiss)</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{banner && (
|
||||
<Pressable onPress={() => setBanner(null)} style={styles.banner}>
|
||||
<Text style={styles.bannerText}>{banner} (tap to dismiss)</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
<View style={styles.termWrap}>
|
||||
<XtermJsWebView
|
||||
ref={xtermRef}
|
||||
autoFit={false}
|
||||
xtermOptions={xtermOptions}
|
||||
webViewOptions={webViewOptions}
|
||||
logger={logger}
|
||||
onInitialized={onInitialized}
|
||||
onData={onData}
|
||||
style={{ flex: 1, backgroundColor: theme.bgBase }}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.termWrap}>
|
||||
<XtermJsWebView
|
||||
ref={xtermRef}
|
||||
autoFit={false}
|
||||
xtermOptions={xtermOptions}
|
||||
webViewOptions={webViewOptions}
|
||||
logger={logger}
|
||||
onInitialized={onInitialized}
|
||||
onData={onData}
|
||||
style={{ flex: 1, backgroundColor: theme.bgBase }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{compose && (
|
||||
<View style={[styles.composer, { paddingBottom: bottomPad }]}>
|
||||
<TextInput
|
||||
style={styles.composerInput}
|
||||
value={msg}
|
||||
onChangeText={setMsg}
|
||||
placeholder="Message the agent…"
|
||||
placeholderTextColor={theme.textTertiary}
|
||||
autoFocus
|
||||
multiline
|
||||
keyboardAppearance="dark"
|
||||
onSubmitEditing={sendPrompt}
|
||||
/>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.sendBtn, pressed && { opacity: 0.8 }, !msg.trim() && { opacity: 0.4 }]}
|
||||
onPress={sendPrompt}
|
||||
disabled={!msg.trim() || sending}
|
||||
>
|
||||
<Feather name="send" size={16} color="#06101f" />
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
{compose && (
|
||||
<View style={[styles.composer, { paddingBottom: bottomPad }]}>
|
||||
<TextInput
|
||||
style={styles.composerInput}
|
||||
value={msg}
|
||||
onChangeText={setMsg}
|
||||
placeholder="Message the agent…"
|
||||
placeholderTextColor={theme.textTertiary}
|
||||
autoFocus
|
||||
multiline
|
||||
keyboardAppearance="dark"
|
||||
onSubmitEditing={sendPrompt}
|
||||
/>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.sendBtn, pressed && { opacity: 0.8 }, !msg.trim() && { opacity: 0.4 }]}
|
||||
onPress={sendPrompt}
|
||||
disabled={!msg.trim() || sending}
|
||||
>
|
||||
<Feather name="send" size={16} color="#06101f" />
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={[styles.keys, { paddingBottom: bottomPad }]}>
|
||||
{EXTRA_KEYS.map((k) => (
|
||||
<Pressable
|
||||
key={k.label}
|
||||
style={({ pressed }) => [styles.key, pressed && styles.keyPressed]}
|
||||
onPress={() => sendKey(k.seq)}
|
||||
>
|
||||
<Text style={styles.keyText}>{k.label}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
{/* Compose a high-level message to the agent. */}
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.key, compose && styles.keyToggle, pressed && styles.keyPressed]}
|
||||
onPress={() => setCompose((c) => !c)}
|
||||
>
|
||||
<Feather name="message-square" size={15} color={compose ? theme.blue : theme.textPrimary} />
|
||||
</Pressable>
|
||||
{/* Show/hide the keyboard (replaces the OS "Done" button we removed). */}
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.key, styles.keyToggle, pressed && styles.keyPressed]}
|
||||
onPress={toggleKeyboard}
|
||||
>
|
||||
<Text style={styles.keyText}>{kbVisible ? '⌨▾' : '⌨▴'}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
<View style={[styles.keys, { paddingBottom: bottomPad }]}>
|
||||
{EXTRA_KEYS.map((k) => (
|
||||
<Pressable
|
||||
key={k.label}
|
||||
style={({ pressed }) => [styles.key, pressed && styles.keyPressed]}
|
||||
onPress={() => sendKey(k.seq)}
|
||||
>
|
||||
<Text style={styles.keyText}>{k.label}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
{/* Compose a high-level message to the agent. */}
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.key, compose && styles.keyToggle, pressed && styles.keyPressed]}
|
||||
onPress={() => setCompose((c) => !c)}
|
||||
>
|
||||
<Feather name="message-square" size={15} color={compose ? theme.blue : theme.textPrimary} />
|
||||
</Pressable>
|
||||
{/* Show/hide the keyboard (replaces the OS "Done" button we removed). */}
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.key, styles.keyToggle, pressed && styles.keyPressed]}
|
||||
onPress={toggleKeyboard}
|
||||
>
|
||||
<Text style={styles.keyText}>{kbVisible ? "⌨▾" : "⌨▴"}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: { flex: 1, backgroundColor: theme.bgBase },
|
||||
center: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: theme.bgBase,
|
||||
},
|
||||
statusBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 6,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.borderSubtle,
|
||||
},
|
||||
statusDot: { width: 8, height: 8, borderRadius: 4, marginRight: 8 },
|
||||
statusText: { color: theme.textSecondary, fontSize: 12, flex: 1 },
|
||||
dims: { color: theme.textTertiary, fontSize: 11, fontFamily: theme.fontMono },
|
||||
banner: {
|
||||
backgroundColor: theme.bgElevated,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.borderDefault,
|
||||
},
|
||||
bannerText: { color: theme.attention, fontSize: 12 },
|
||||
termWrap: { flex: 1, backgroundColor: theme.bgBase },
|
||||
keys: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 6,
|
||||
paddingHorizontal: 8,
|
||||
paddingTop: 8,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.borderSubtle,
|
||||
backgroundColor: theme.bgSurface,
|
||||
},
|
||||
key: {
|
||||
backgroundColor: theme.bgElevated,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.borderDefault,
|
||||
borderRadius: 6,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 12,
|
||||
minWidth: 44,
|
||||
alignItems: 'center',
|
||||
},
|
||||
keyPressed: { backgroundColor: theme.accentTint, borderColor: theme.accent },
|
||||
keyToggle: { borderColor: theme.accent, marginLeft: 'auto' },
|
||||
kbInput: { position: 'absolute', width: 1, height: 1, top: 0, left: 0, opacity: 0 },
|
||||
keyText: { color: theme.textPrimary, fontFamily: theme.fontMono, fontSize: 14 },
|
||||
killBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
backgroundColor: theme.tintRed,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 11,
|
||||
paddingVertical: 4,
|
||||
marginLeft: 12,
|
||||
},
|
||||
killText: { color: theme.red, fontWeight: '700', fontSize: 12 },
|
||||
composer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
gap: 8,
|
||||
paddingHorizontal: 10,
|
||||
paddingTop: 8,
|
||||
backgroundColor: theme.bgSurface,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.borderSubtle,
|
||||
},
|
||||
composerInput: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.bgElevated,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.borderDefault,
|
||||
borderRadius: 10,
|
||||
color: theme.textPrimary,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 9,
|
||||
fontSize: 14,
|
||||
maxHeight: 110,
|
||||
},
|
||||
sendBtn: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
backgroundColor: theme.blue,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
screen: { flex: 1, backgroundColor: theme.bgBase },
|
||||
center: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: theme.bgBase,
|
||||
},
|
||||
statusBar: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 6,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.borderSubtle,
|
||||
},
|
||||
statusDot: { width: 8, height: 8, borderRadius: 4, marginRight: 8 },
|
||||
statusText: { color: theme.textSecondary, fontSize: 12, flex: 1 },
|
||||
dims: { color: theme.textTertiary, fontSize: 11, fontFamily: theme.fontMono },
|
||||
banner: {
|
||||
backgroundColor: theme.bgElevated,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.borderDefault,
|
||||
},
|
||||
bannerText: { color: theme.attention, fontSize: 12 },
|
||||
termWrap: { flex: 1, backgroundColor: theme.bgBase },
|
||||
keys: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: 6,
|
||||
paddingHorizontal: 8,
|
||||
paddingTop: 8,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.borderSubtle,
|
||||
backgroundColor: theme.bgSurface,
|
||||
},
|
||||
key: {
|
||||
backgroundColor: theme.bgElevated,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.borderDefault,
|
||||
borderRadius: 6,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 12,
|
||||
minWidth: 44,
|
||||
alignItems: "center",
|
||||
},
|
||||
keyPressed: { backgroundColor: theme.accentTint, borderColor: theme.accent },
|
||||
keyToggle: { borderColor: theme.accent, marginLeft: "auto" },
|
||||
kbInput: { position: "absolute", width: 1, height: 1, top: 0, left: 0, opacity: 0 },
|
||||
keyText: { color: theme.textPrimary, fontFamily: theme.fontMono, fontSize: 14 },
|
||||
killBtn: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
backgroundColor: theme.tintRed,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 11,
|
||||
paddingVertical: 4,
|
||||
marginLeft: 12,
|
||||
},
|
||||
killText: { color: theme.red, fontWeight: "700", fontSize: 12 },
|
||||
composer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-end",
|
||||
gap: 8,
|
||||
paddingHorizontal: 10,
|
||||
paddingTop: 8,
|
||||
backgroundColor: theme.bgSurface,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.borderSubtle,
|
||||
},
|
||||
composerInput: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.bgElevated,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.borderDefault,
|
||||
borderRadius: 10,
|
||||
color: theme.textPrimary,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 9,
|
||||
fontSize: 14,
|
||||
maxHeight: 110,
|
||||
},
|
||||
sendBtn: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
backgroundColor: theme.blue,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,115 +1,98 @@
|
|||
import { useRouter } from 'expo-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useApp } from '../lib/store';
|
||||
import { theme } from '../lib/theme';
|
||||
import { Button, Pill } from '../lib/ui';
|
||||
import { useRouter } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, Text, TextInput, View } from "react-native";
|
||||
import { useApp } from "../lib/store";
|
||||
import { theme } from "../lib/theme";
|
||||
import { Button, Pill } from "../lib/ui";
|
||||
|
||||
export default function SpawnModal() {
|
||||
const router = useRouter();
|
||||
const { projects, activeProjectId, spawn } = useApp();
|
||||
const [projectId, setProjectId] = useState<string | null>(null);
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
const { projects, activeProjectId, spawn } = useApp();
|
||||
const [projectId, setProjectId] = useState<string | null>(null);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Default to the active project, else the only project.
|
||||
useEffect(() => {
|
||||
if (projectId) return;
|
||||
if (activeProjectId !== 'all') setProjectId(activeProjectId);
|
||||
else if (projects.length === 1) setProjectId(projects[0].id);
|
||||
}, [activeProjectId, projects, projectId]);
|
||||
// Default to the active project, else the only project.
|
||||
useEffect(() => {
|
||||
if (projectId) return;
|
||||
if (activeProjectId !== "all") setProjectId(activeProjectId);
|
||||
else if (projects.length === 1) setProjectId(projects[0].id);
|
||||
}, [activeProjectId, projects, projectId]);
|
||||
|
||||
const onSpawn = async () => {
|
||||
if (!projectId) {
|
||||
setError('Pick a project first.');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await spawn(prompt.trim() || undefined, projectId);
|
||||
router.back();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to spawn agent.');
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const onSpawn = async () => {
|
||||
if (!projectId) {
|
||||
setError("Pick a project first.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await spawn(prompt.trim() || undefined, projectId);
|
||||
router.back();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to spawn agent.");
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.screen}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<ScrollView contentContainerStyle={{ padding: 16 }} keyboardShouldPersistTaps="handled">
|
||||
<Text style={styles.lead}>
|
||||
Spawn a worker agent. It gets its own git worktree and branch, then starts on the task you
|
||||
give it.
|
||||
</Text>
|
||||
return (
|
||||
<KeyboardAvoidingView style={styles.screen} behavior={Platform.OS === "ios" ? "padding" : undefined}>
|
||||
<ScrollView contentContainerStyle={{ padding: 16 }} keyboardShouldPersistTaps="handled">
|
||||
<Text style={styles.lead}>
|
||||
Spawn a worker agent. It gets its own git worktree and branch, then starts on the task you give it.
|
||||
</Text>
|
||||
|
||||
<Text style={styles.label}>PROJECT</Text>
|
||||
<View style={styles.projects}>
|
||||
{projects.map((p) => (
|
||||
<Pill
|
||||
key={p.id}
|
||||
label={p.name}
|
||||
active={projectId === p.id}
|
||||
onPress={() => setProjectId(p.id)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<Text style={styles.label}>PROJECT</Text>
|
||||
<View style={styles.projects}>
|
||||
{projects.map((p) => (
|
||||
<Pill key={p.id} label={p.name} active={projectId === p.id} onPress={() => setProjectId(p.id)} />
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Text style={[styles.label, { marginTop: 20 }]}>TASK (OPTIONAL)</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={prompt}
|
||||
onChangeText={setPrompt}
|
||||
placeholder="e.g. Fix the flaky login test and open a PR"
|
||||
placeholderTextColor={theme.textTertiary}
|
||||
multiline
|
||||
autoCapitalize="sentences"
|
||||
/>
|
||||
<Text style={[styles.label, { marginTop: 20 }]}>TASK (OPTIONAL)</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={prompt}
|
||||
onChangeText={setPrompt}
|
||||
placeholder="e.g. Fix the flaky login test and open a PR"
|
||||
placeholderTextColor={theme.textTertiary}
|
||||
multiline
|
||||
autoCapitalize="sentences"
|
||||
/>
|
||||
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
|
||||
<Button
|
||||
title="Spawn agent"
|
||||
icon="zap"
|
||||
loading={busy}
|
||||
onPress={onSpawn}
|
||||
disabled={!projectId}
|
||||
style={{ marginTop: 20 }}
|
||||
/>
|
||||
<Button title="Cancel" variant="ghost" onPress={() => router.back()} style={{ marginTop: 10 }} />
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
<Button
|
||||
title="Spawn agent"
|
||||
icon="zap"
|
||||
loading={busy}
|
||||
onPress={onSpawn}
|
||||
disabled={!projectId}
|
||||
style={{ marginTop: 20 }}
|
||||
/>
|
||||
<Button title="Cancel" variant="ghost" onPress={() => router.back()} style={{ marginTop: 10 }} />
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: { flex: 1, backgroundColor: theme.bgBase },
|
||||
lead: { color: theme.textSecondary, fontSize: 14, lineHeight: 20, marginBottom: 22 },
|
||||
label: { color: theme.textTertiary, fontSize: 10, letterSpacing: 1, fontWeight: '700', marginBottom: 10 },
|
||||
projects: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
|
||||
input: {
|
||||
backgroundColor: theme.bgElevated,
|
||||
borderColor: theme.borderDefault,
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
color: theme.textPrimary,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 12,
|
||||
fontSize: 14,
|
||||
minHeight: 96,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
error: { color: theme.red, fontSize: 13, marginTop: 14 },
|
||||
screen: { flex: 1, backgroundColor: theme.bgBase },
|
||||
lead: { color: theme.textSecondary, fontSize: 14, lineHeight: 20, marginBottom: 22 },
|
||||
label: { color: theme.textTertiary, fontSize: 10, letterSpacing: 1, fontWeight: "700", marginBottom: 10 },
|
||||
projects: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
|
||||
input: {
|
||||
backgroundColor: theme.bgElevated,
|
||||
borderColor: theme.borderDefault,
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
color: theme.textPrimary,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 12,
|
||||
fontSize: 14,
|
||||
minHeight: 96,
|
||||
textAlignVertical: "top",
|
||||
},
|
||||
error: { color: theme.red, fontSize: 13, marginTop: 14 },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,27 +1,27 @@
|
|||
{
|
||||
"cli": {
|
||||
"version": ">= 20.0.0",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal",
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true,
|
||||
"android": {
|
||||
"buildType": "app-bundle"
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
"cli": {
|
||||
"version": ">= 20.0.0",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal",
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true,
|
||||
"android": {
|
||||
"buildType": "app-bundle"
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Static image imports (Metro resolves these to an asset reference at runtime,
|
||||
// which React Native's <Image source> accepts as a number).
|
||||
declare module '*.png' {
|
||||
const content: number;
|
||||
export default content;
|
||||
declare module "*.png" {
|
||||
const content: number;
|
||||
export default content;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,35 +1,30 @@
|
|||
import { ScrollView, StyleSheet } from 'react-native';
|
||||
import { useApp } from './store';
|
||||
import { Pill } from './ui';
|
||||
import { ScrollView, StyleSheet } from "react-native";
|
||||
import { useApp } from "./store";
|
||||
import { Pill } from "./ui";
|
||||
|
||||
// Horizontal pill row to scope the view to one project (or All). Only renders
|
||||
// when there's more than one project — single-project users never see clutter.
|
||||
export function ProjectSwitcher() {
|
||||
const { projects, activeProjectId, setActiveProject } = useApp();
|
||||
if (projects.length <= 1) return null;
|
||||
const { projects, activeProjectId, setActiveProject } = useApp();
|
||||
if (projects.length <= 1) return null;
|
||||
|
||||
const items = [{ id: 'all', name: 'All' }, ...projects];
|
||||
const items = [{ id: "all", name: "All" }, ...projects];
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={styles.row}
|
||||
>
|
||||
{items.map((p) => (
|
||||
<Pill
|
||||
key={p.id}
|
||||
label={p.name}
|
||||
active={activeProjectId === p.id}
|
||||
onPress={() => setActiveProject(p.id)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
);
|
||||
return (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={styles.row}
|
||||
>
|
||||
{items.map((p) => (
|
||||
<Pill key={p.id} label={p.name} active={activeProjectId === p.id} onPress={() => setActiveProject(p.id)} />
|
||||
))}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scroll: { flexGrow: 0 },
|
||||
row: { paddingHorizontal: 16, paddingBottom: 12, gap: 8, alignItems: 'center' },
|
||||
scroll: { flexGrow: 0 },
|
||||
row: { paddingHorizontal: 16, paddingBottom: 12, gap: 8, alignItems: "center" },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,111 +1,105 @@
|
|||
import { Feather } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { sessionTitle, type DashboardSession } from './api';
|
||||
import { ciColor, statusVisual, theme } from './theme';
|
||||
import { Dot } from './ui';
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { useRouter } from "expo-router";
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { sessionTitle, type DashboardSession } from "./api";
|
||||
import { ciColor, statusVisual, theme } from "./theme";
|
||||
import { Dot } from "./ui";
|
||||
|
||||
export function SessionCard({
|
||||
session,
|
||||
showProject = false,
|
||||
}: {
|
||||
session: DashboardSession;
|
||||
showProject?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const v = statusVisual(session.status);
|
||||
const pr = session.pr ?? session.prs?.[0];
|
||||
const title = sessionTitle(session);
|
||||
export function SessionCard({ session, showProject = false }: { session: DashboardSession; showProject?: boolean }) {
|
||||
const router = useRouter();
|
||||
const v = statusVisual(session.status);
|
||||
const pr = session.pr ?? session.prs?.[0];
|
||||
const title = sessionTitle(session);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: '/session/[id]',
|
||||
params: { id: session.id, projectId: session.projectId },
|
||||
})
|
||||
}
|
||||
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
|
||||
>
|
||||
<View style={styles.top}>
|
||||
<Dot color={v.color} breathing={v.breathing} size={8} />
|
||||
<Text style={[styles.status, { color: v.color }]}>{v.label}</Text>
|
||||
<View style={{ flex: 1 }} />
|
||||
{showProject ? <Text style={styles.project}>{session.projectId}</Text> : null}
|
||||
<Text style={styles.id}>{session.id}</Text>
|
||||
</View>
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: "/session/[id]",
|
||||
params: { id: session.id, projectId: session.projectId },
|
||||
})
|
||||
}
|
||||
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
|
||||
>
|
||||
<View style={styles.top}>
|
||||
<Dot color={v.color} breathing={v.breathing} size={8} />
|
||||
<Text style={[styles.status, { color: v.color }]}>{v.label}</Text>
|
||||
<View style={{ flex: 1 }} />
|
||||
{showProject ? <Text style={styles.project}>{session.projectId}</Text> : null}
|
||||
<Text style={styles.id}>{session.id}</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.title} numberOfLines={2}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={styles.title} numberOfLines={2}>
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
<View style={styles.meta}>
|
||||
{session.branch ? (
|
||||
<View style={styles.metaItem}>
|
||||
<Feather name="git-branch" size={11} color={theme.textTertiary} />
|
||||
<Text style={styles.branch} numberOfLines={1}>
|
||||
{session.branch}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{pr?.number ? (
|
||||
<View style={[styles.prChip, { borderColor: ciColor(pr.ciStatus) }]}>
|
||||
<Dot color={ciColor(pr.ciStatus)} size={6} />
|
||||
<Text style={styles.prText}>#{pr.number}</Text>
|
||||
{pr.additions !== undefined && pr.deletions !== undefined ? (
|
||||
<Text style={styles.diff}>
|
||||
<Text style={{ color: theme.green }}>+{pr.additions}</Text>{' '}
|
||||
<Text style={{ color: theme.red }}>−{pr.deletions}</Text>
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
<View style={{ flex: 1 }} />
|
||||
<Feather name="terminal" size={15} color={theme.textTertiary} />
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
<View style={styles.meta}>
|
||||
{session.branch ? (
|
||||
<View style={styles.metaItem}>
|
||||
<Feather name="git-branch" size={11} color={theme.textTertiary} />
|
||||
<Text style={styles.branch} numberOfLines={1}>
|
||||
{session.branch}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{pr?.number ? (
|
||||
<View style={[styles.prChip, { borderColor: ciColor(pr.ciStatus) }]}>
|
||||
<Dot color={ciColor(pr.ciStatus)} size={6} />
|
||||
<Text style={styles.prText}>#{pr.number}</Text>
|
||||
{pr.additions !== undefined && pr.deletions !== undefined ? (
|
||||
<Text style={styles.diff}>
|
||||
<Text style={{ color: theme.green }}>+{pr.additions}</Text>{" "}
|
||||
<Text style={{ color: theme.red }}>−{pr.deletions}</Text>
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
<View style={{ flex: 1 }} />
|
||||
<Feather name="terminal" size={15} color={theme.textTertiary} />
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: theme.bgElevated,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.borderSubtle,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 13,
|
||||
marginHorizontal: 12,
|
||||
marginVertical: 5,
|
||||
},
|
||||
cardPressed: { backgroundColor: theme.bgElevatedHover, borderColor: theme.borderDefault },
|
||||
top: { flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 8 },
|
||||
status: { fontSize: 12, fontWeight: '600' },
|
||||
project: {
|
||||
color: theme.textTertiary,
|
||||
fontSize: 11,
|
||||
fontFamily: theme.fontMono,
|
||||
marginRight: 8,
|
||||
},
|
||||
id: { color: theme.textTertiary, fontSize: 11, fontFamily: theme.fontMono },
|
||||
title: { color: theme.textPrimary, fontSize: 15, fontWeight: '500', lineHeight: 20 },
|
||||
meta: { flexDirection: 'row', alignItems: 'center', gap: 10, marginTop: 10 },
|
||||
metaItem: { flexDirection: 'row', alignItems: 'center', gap: 4, flexShrink: 1 },
|
||||
branch: {
|
||||
color: theme.textTertiary,
|
||||
fontSize: 12,
|
||||
fontFamily: theme.fontMono,
|
||||
flexShrink: 1,
|
||||
},
|
||||
prChip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
paddingHorizontal: 7,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 6,
|
||||
borderWidth: 1,
|
||||
},
|
||||
prText: { color: theme.textSecondary, fontSize: 11, fontWeight: '700', fontFamily: theme.fontMono },
|
||||
diff: { fontSize: 10, fontFamily: theme.fontMono },
|
||||
card: {
|
||||
backgroundColor: theme.bgElevated,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.borderSubtle,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 13,
|
||||
marginHorizontal: 12,
|
||||
marginVertical: 5,
|
||||
},
|
||||
cardPressed: { backgroundColor: theme.bgElevatedHover, borderColor: theme.borderDefault },
|
||||
top: { flexDirection: "row", alignItems: "center", gap: 6, marginBottom: 8 },
|
||||
status: { fontSize: 12, fontWeight: "600" },
|
||||
project: {
|
||||
color: theme.textTertiary,
|
||||
fontSize: 11,
|
||||
fontFamily: theme.fontMono,
|
||||
marginRight: 8,
|
||||
},
|
||||
id: { color: theme.textTertiary, fontSize: 11, fontFamily: theme.fontMono },
|
||||
title: { color: theme.textPrimary, fontSize: 15, fontWeight: "500", lineHeight: 20 },
|
||||
meta: { flexDirection: "row", alignItems: "center", gap: 10, marginTop: 10 },
|
||||
metaItem: { flexDirection: "row", alignItems: "center", gap: 4, flexShrink: 1 },
|
||||
branch: {
|
||||
color: theme.textTertiary,
|
||||
fontSize: 12,
|
||||
fontFamily: theme.fontMono,
|
||||
flexShrink: 1,
|
||||
},
|
||||
prChip: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
paddingHorizontal: 7,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 6,
|
||||
borderWidth: 1,
|
||||
},
|
||||
prText: { color: theme.textSecondary, fontSize: 11, fontWeight: "700", fontFamily: theme.fontMono },
|
||||
diff: { fontSize: 10, fontFamily: theme.fontMono },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,267 +1,244 @@
|
|||
import { httpBase, type ServerConfig } from './config';
|
||||
import type { AttentionLevel } from './theme';
|
||||
import { httpBase, type ServerConfig } from "./config";
|
||||
import type { AttentionLevel } from "./theme";
|
||||
|
||||
// ---- Types (subset of AO's DashboardSession we use on the phone) ------------
|
||||
|
||||
export type 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;
|
||||
changedFiles?: number;
|
||||
ciStatus?: 'pending' | 'passing' | 'failing' | 'none';
|
||||
reviewDecision?: 'approved' | 'changes_requested' | 'pending' | 'none';
|
||||
mergeability?: {
|
||||
mergeable?: boolean;
|
||||
ciPassing?: boolean;
|
||||
approved?: boolean;
|
||||
noConflicts?: boolean;
|
||||
blockers?: string[];
|
||||
};
|
||||
unresolvedThreads?: number;
|
||||
number: number;
|
||||
url: string;
|
||||
title?: string;
|
||||
owner?: string;
|
||||
repo?: string;
|
||||
branch?: string;
|
||||
baseBranch?: string;
|
||||
isDraft?: boolean;
|
||||
state?: "open" | "merged" | "closed";
|
||||
additions?: number;
|
||||
deletions?: number;
|
||||
changedFiles?: number;
|
||||
ciStatus?: "pending" | "passing" | "failing" | "none";
|
||||
reviewDecision?: "approved" | "changes_requested" | "pending" | "none";
|
||||
mergeability?: {
|
||||
mergeable?: boolean;
|
||||
ciPassing?: boolean;
|
||||
approved?: boolean;
|
||||
noConflicts?: boolean;
|
||||
blockers?: string[];
|
||||
};
|
||||
unresolvedThreads?: number;
|
||||
};
|
||||
|
||||
export type DashboardSession = {
|
||||
id: string;
|
||||
projectId: string;
|
||||
status: string | null;
|
||||
attentionLevel?: AttentionLevel | string | null;
|
||||
activity?: string | null;
|
||||
branch: string | null;
|
||||
issueId: string | null;
|
||||
issueUrl?: string | null;
|
||||
issueLabel?: string | null;
|
||||
issueTitle: string | null;
|
||||
userPrompt: string | null;
|
||||
displayName: string | null;
|
||||
summary: string | null;
|
||||
createdAt: string;
|
||||
lastActivityAt: string;
|
||||
pr?: DashboardPR | null;
|
||||
prs?: DashboardPR[];
|
||||
metadata?: Record<string, string>;
|
||||
id: string;
|
||||
projectId: string;
|
||||
status: string | null;
|
||||
attentionLevel?: AttentionLevel | string | null;
|
||||
activity?: string | null;
|
||||
branch: string | null;
|
||||
issueId: string | null;
|
||||
issueUrl?: string | null;
|
||||
issueLabel?: string | null;
|
||||
issueTitle: string | null;
|
||||
userPrompt: string | null;
|
||||
displayName: string | null;
|
||||
summary: string | null;
|
||||
createdAt: string;
|
||||
lastActivityAt: string;
|
||||
pr?: DashboardPR | null;
|
||||
prs?: DashboardPR[];
|
||||
metadata?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type OrchestratorLink = {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
status?: string | null;
|
||||
activity?: string | null;
|
||||
runtimeState?: string | null;
|
||||
hasRuntime?: boolean;
|
||||
isTerminal?: boolean;
|
||||
isRestorable?: boolean;
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
status?: string | null;
|
||||
activity?: string | null;
|
||||
runtimeState?: string | null;
|
||||
hasRuntime?: boolean;
|
||||
isTerminal?: boolean;
|
||||
isRestorable?: boolean;
|
||||
};
|
||||
|
||||
export type ProjectInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
sessionPrefix?: string;
|
||||
id: string;
|
||||
name: string;
|
||||
sessionPrefix?: string;
|
||||
};
|
||||
|
||||
export type DashboardStats = {
|
||||
totalSessions?: number;
|
||||
workingSessions?: number;
|
||||
openPRs?: number;
|
||||
needsReview?: number;
|
||||
totalSessions?: number;
|
||||
workingSessions?: number;
|
||||
openPRs?: number;
|
||||
needsReview?: number;
|
||||
};
|
||||
|
||||
export type SessionsResponse = {
|
||||
sessions: DashboardSession[];
|
||||
orchestrators: OrchestratorLink[];
|
||||
orchestratorId: string | null;
|
||||
stats: DashboardStats;
|
||||
sessions: DashboardSession[];
|
||||
orchestrators: OrchestratorLink[];
|
||||
orchestratorId: string | null;
|
||||
stats: DashboardStats;
|
||||
};
|
||||
|
||||
// ---- Low-level fetch with friendly errors ----------------------------------
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 12000;
|
||||
|
||||
async function req(
|
||||
cfg: ServerConfig,
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
const url = `${httpBase(cfg)}${path}`;
|
||||
// Without a timeout a sleeping/unreachable host (common over Tailscale) hangs
|
||||
// the call for the OS TCP timeout (~75-120s), freezing Kill/send and the poll.
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) },
|
||||
});
|
||||
} catch (e) {
|
||||
if ((e as { name?: string })?.name === 'AbortError') {
|
||||
throw new Error('Request timed out — is the server reachable?', { cause: e });
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
if (!res.ok) {
|
||||
let detail = '';
|
||||
try {
|
||||
detail = (await res.json())?.error ?? '';
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
return res;
|
||||
async function req(cfg: ServerConfig, path: string, init?: RequestInit): Promise<Response> {
|
||||
const url = `${httpBase(cfg)}${path}`;
|
||||
// Without a timeout a sleeping/unreachable host (common over Tailscale) hangs
|
||||
// the call for the OS TCP timeout (~75-120s), freezing Kill/send and the poll.
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
|
||||
});
|
||||
} catch (e) {
|
||||
if ((e as { name?: string })?.name === "AbortError") {
|
||||
throw new Error("Request timed out — is the server reachable?", { cause: e });
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
if (!res.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
detail = (await res.json())?.error ?? "";
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// ---- Reads ------------------------------------------------------------------
|
||||
|
||||
export async function getProjects(cfg: ServerConfig): Promise<ProjectInfo[]> {
|
||||
const res = await req(cfg, '/api/projects');
|
||||
const data = await res.json();
|
||||
return Array.isArray(data?.projects) ? data.projects : [];
|
||||
const res = await req(cfg, "/api/projects");
|
||||
const data = await res.json();
|
||||
return Array.isArray(data?.projects) ? data.projects : [];
|
||||
}
|
||||
|
||||
export async function getSessions(
|
||||
cfg: ServerConfig,
|
||||
projectId?: string,
|
||||
): Promise<SessionsResponse> {
|
||||
const q = projectId && projectId !== 'all' ? `?project=${encodeURIComponent(projectId)}` : '?project=all';
|
||||
const res = await req(cfg, `/api/sessions${q}`);
|
||||
const data = await res.json();
|
||||
return {
|
||||
sessions: Array.isArray(data?.sessions) ? data.sessions : [],
|
||||
orchestrators: Array.isArray(data?.orchestrators) ? data.orchestrators : [],
|
||||
orchestratorId: data?.orchestratorId ?? null,
|
||||
stats: data?.stats ?? {},
|
||||
};
|
||||
export async function getSessions(cfg: ServerConfig, projectId?: string): Promise<SessionsResponse> {
|
||||
const q = projectId && projectId !== "all" ? `?project=${encodeURIComponent(projectId)}` : "?project=all";
|
||||
const res = await req(cfg, `/api/sessions${q}`);
|
||||
const data = await res.json();
|
||||
return {
|
||||
sessions: Array.isArray(data?.sessions) ? data.sessions : [],
|
||||
orchestrators: Array.isArray(data?.orchestrators) ? data.orchestrators : [],
|
||||
orchestratorId: data?.orchestratorId ?? null,
|
||||
stats: data?.stats ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Writes / actions -------------------------------------------------------
|
||||
|
||||
export async function killSession(cfg: ServerConfig, id: string): Promise<void> {
|
||||
await req(cfg, `/api/sessions/${encodeURIComponent(id)}/kill`, { method: 'POST' });
|
||||
await req(cfg, `/api/sessions/${encodeURIComponent(id)}/kill`, { method: "POST" });
|
||||
}
|
||||
|
||||
export async function restoreSession(cfg: ServerConfig, id: string): Promise<void> {
|
||||
await req(cfg, `/api/sessions/${encodeURIComponent(id)}/restore`, { method: 'POST' });
|
||||
await req(cfg, `/api/sessions/${encodeURIComponent(id)}/restore`, { method: "POST" });
|
||||
}
|
||||
|
||||
export async function sendMessage(
|
||||
cfg: ServerConfig,
|
||||
id: string,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
await req(cfg, `/api/sessions/${encodeURIComponent(id)}/send`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
export async function sendMessage(cfg: ServerConfig, id: string, message: string): Promise<void> {
|
||||
await req(cfg, `/api/sessions/${encodeURIComponent(id)}/send`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function spawnSession(
|
||||
cfg: ServerConfig,
|
||||
opts: { projectId: string; prompt?: string; issueId?: string },
|
||||
cfg: ServerConfig,
|
||||
opts: { projectId: string; prompt?: string; issueId?: string },
|
||||
): Promise<DashboardSession> {
|
||||
const res = await req(cfg, '/api/spawn', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(opts),
|
||||
});
|
||||
const data = await res.json();
|
||||
return data?.session;
|
||||
const res = await req(cfg, "/api/spawn", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(opts),
|
||||
});
|
||||
const data = await res.json();
|
||||
return data?.session;
|
||||
}
|
||||
|
||||
export async function launchOrchestrator(
|
||||
cfg: ServerConfig,
|
||||
projectId: string,
|
||||
clean = false,
|
||||
cfg: ServerConfig,
|
||||
projectId: string,
|
||||
clean = false,
|
||||
): Promise<OrchestratorLink> {
|
||||
const res = await req(cfg, '/api/orchestrators', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ projectId, clean }),
|
||||
});
|
||||
const data = await res.json();
|
||||
return data?.orchestrator;
|
||||
const res = await req(cfg, "/api/orchestrators", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ projectId, clean }),
|
||||
});
|
||||
const data = await res.json();
|
||||
return data?.orchestrator;
|
||||
}
|
||||
|
||||
export async function mergePR(
|
||||
cfg: ServerConfig,
|
||||
pr: DashboardPR,
|
||||
): Promise<void> {
|
||||
const params: string[] = [];
|
||||
if (pr.owner) params.push(`owner=${encodeURIComponent(pr.owner)}`);
|
||||
if (pr.repo) params.push(`repo=${encodeURIComponent(pr.repo)}`);
|
||||
const q = params.length ? `?${params.join('&')}` : '';
|
||||
await req(cfg, `/api/prs/${pr.number}/merge${q}`, { method: 'POST' });
|
||||
export async function mergePR(cfg: ServerConfig, pr: DashboardPR): Promise<void> {
|
||||
const params: string[] = [];
|
||||
if (pr.owner) params.push(`owner=${encodeURIComponent(pr.owner)}`);
|
||||
if (pr.repo) params.push(`repo=${encodeURIComponent(pr.repo)}`);
|
||||
const q = params.length ? `?${params.join("&")}` : "";
|
||||
await req(cfg, `/api/prs/${pr.number}/merge${q}`, { method: "POST" });
|
||||
}
|
||||
|
||||
// Quick reachability probe for the Settings "Test connection" button.
|
||||
export async function pingServer(cfg: ServerConfig): Promise<number> {
|
||||
const res = await req(cfg, '/api/sessions?project=all');
|
||||
const data = await res.json();
|
||||
return Array.isArray(data?.sessions) ? data.sessions.length : 0;
|
||||
const res = await req(cfg, "/api/sessions?project=all");
|
||||
const data = await res.json();
|
||||
return Array.isArray(data?.sessions) ? data.sessions.length : 0;
|
||||
}
|
||||
|
||||
// ---- Derived helpers --------------------------------------------------------
|
||||
|
||||
const TERMINAL_STATUSES = new Set([
|
||||
'killed',
|
||||
'terminated',
|
||||
'done',
|
||||
'cleanup',
|
||||
'errored',
|
||||
'merged',
|
||||
]);
|
||||
const TERMINAL_STATUSES = new Set(["killed", "terminated", "done", "cleanup", "errored", "merged"]);
|
||||
|
||||
export function isTerminalStatus(status?: string | null): boolean {
|
||||
return !!status && TERMINAL_STATUSES.has(status);
|
||||
return !!status && TERMINAL_STATUSES.has(status);
|
||||
}
|
||||
|
||||
// Fallback attention bucket when the server didn't compute attentionLevel.
|
||||
export function attentionOf(s: DashboardSession): AttentionLevel {
|
||||
if (s.attentionLevel) return s.attentionLevel as AttentionLevel;
|
||||
const pr = s.pr ?? s.prs?.[0];
|
||||
if (s.status === 'merged' || s.status === 'done' || isTerminalStatus(s.status)) return 'done';
|
||||
if (pr?.mergeability?.mergeable || s.status === 'mergeable' || s.status === 'approved') return 'merge';
|
||||
if (s.status === 'needs_input' || s.status === 'stuck' || s.status === 'errored') return 'respond';
|
||||
if (pr?.ciStatus === 'failing' || pr?.reviewDecision === 'changes_requested' || s.status === 'ci_failed' || s.status === 'changes_requested') return 'review';
|
||||
if (s.status === 'pr_open' || s.status === 'review_pending') return 'pending';
|
||||
return 'working';
|
||||
if (s.attentionLevel) return s.attentionLevel as AttentionLevel;
|
||||
const pr = s.pr ?? s.prs?.[0];
|
||||
if (s.status === "merged" || s.status === "done" || isTerminalStatus(s.status)) return "done";
|
||||
if (pr?.mergeability?.mergeable || s.status === "mergeable" || s.status === "approved") return "merge";
|
||||
if (s.status === "needs_input" || s.status === "stuck" || s.status === "errored") return "respond";
|
||||
if (
|
||||
pr?.ciStatus === "failing" ||
|
||||
pr?.reviewDecision === "changes_requested" ||
|
||||
s.status === "ci_failed" ||
|
||||
s.status === "changes_requested"
|
||||
)
|
||||
return "review";
|
||||
if (s.status === "pr_open" || s.status === "review_pending") return "pending";
|
||||
return "working";
|
||||
}
|
||||
|
||||
export function sessionTitle(s: DashboardSession): string {
|
||||
return (
|
||||
s.displayName ||
|
||||
s.issueTitle ||
|
||||
s.userPrompt ||
|
||||
s.summary ||
|
||||
s.id
|
||||
);
|
||||
return s.displayName || s.issueTitle || s.userPrompt || s.summary || s.id;
|
||||
}
|
||||
|
||||
// All PRs across sessions, de-duplicated by number+repo.
|
||||
export function collectPRs(
|
||||
sessions: DashboardSession[],
|
||||
): { pr: DashboardPR; session: DashboardSession }[] {
|
||||
const seen = new Set<string>();
|
||||
const out: { pr: DashboardPR; session: DashboardSession }[] = [];
|
||||
for (const s of sessions) {
|
||||
const list = s.prs && s.prs.length ? s.prs : s.pr ? [s.pr] : [];
|
||||
for (const pr of list) {
|
||||
// Real GitHub/GitLab PR numbers are >= 1; 0/missing signals a placeholder.
|
||||
if (!pr || !pr.number || pr.number <= 0) continue;
|
||||
const key = `${pr.owner ?? ''}/${pr.repo ?? ''}#${pr.number}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push({ pr, session: s });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
export function collectPRs(sessions: DashboardSession[]): { pr: DashboardPR; session: DashboardSession }[] {
|
||||
const seen = new Set<string>();
|
||||
const out: { pr: DashboardPR; session: DashboardSession }[] = [];
|
||||
for (const s of sessions) {
|
||||
const list = s.prs && s.prs.length ? s.prs : s.pr ? [s.pr] : [];
|
||||
for (const pr of list) {
|
||||
// Real GitHub/GitLab PR numbers are >= 1; 0/missing signals a placeholder.
|
||||
if (!pr || !pr.number || pr.number <= 0) continue;
|
||||
const key = `${pr.owner ?? ""}/${pr.repo ?? ""}#${pr.number}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push({ pr, session: s });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,72 +1,75 @@
|
|||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
// The user points the app at their AO server (over Tailscale). We store just the
|
||||
// host + ports; HTTP and WS URLs are derived from them.
|
||||
export type ServerConfig = {
|
||||
host: string; // e.g. "100.101.102.103" or "my-pc.tail1234.ts.net"
|
||||
httpPort: string; // AO Next.js REST API, default 3000
|
||||
muxPort: string; // AO direct-terminal-ws mux, default 14801
|
||||
secure?: boolean; // use https/wss instead of http/ws (TLS / Tailscale funnel)
|
||||
host: string; // e.g. "100.101.102.103" or "my-pc.tail1234.ts.net"
|
||||
httpPort: string; // AO Next.js REST API, default 3000
|
||||
muxPort: string; // AO direct-terminal-ws mux, default 14801
|
||||
secure?: boolean; // use https/wss instead of http/ws (TLS / Tailscale funnel)
|
||||
};
|
||||
|
||||
export const DEFAULT_CONFIG: ServerConfig = {
|
||||
host: '',
|
||||
httpPort: '3000',
|
||||
muxPort: '14801',
|
||||
secure: false,
|
||||
host: "",
|
||||
httpPort: "3000",
|
||||
muxPort: "14801",
|
||||
secure: false,
|
||||
};
|
||||
|
||||
// Strip a pasted scheme (http://, ws://, …) and trailing slashes so we never
|
||||
// build a double-scheme URL like "http://https://host".
|
||||
function cleanHost(host: string): string {
|
||||
return host.trim().replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').replace(/\/+$/, '');
|
||||
return host
|
||||
.trim()
|
||||
.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "")
|
||||
.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
const KEY = 'ao.serverConfig';
|
||||
const KEY = "ao.serverConfig";
|
||||
|
||||
export async function loadConfig(): Promise<ServerConfig> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(KEY);
|
||||
if (!raw) return DEFAULT_CONFIG;
|
||||
return { ...DEFAULT_CONFIG, ...JSON.parse(raw) };
|
||||
} catch {
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(KEY);
|
||||
if (!raw) return DEFAULT_CONFIG;
|
||||
return { ...DEFAULT_CONFIG, ...JSON.parse(raw) };
|
||||
} catch {
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveConfig(cfg: ServerConfig): Promise<void> {
|
||||
await AsyncStorage.setItem(KEY, JSON.stringify(cfg));
|
||||
await AsyncStorage.setItem(KEY, JSON.stringify(cfg));
|
||||
}
|
||||
|
||||
export function httpBase(cfg: ServerConfig): string {
|
||||
return `${cfg.secure ? 'https' : 'http'}://${cleanHost(cfg.host)}:${cfg.httpPort}`;
|
||||
return `${cfg.secure ? "https" : "http"}://${cleanHost(cfg.host)}:${cfg.httpPort}`;
|
||||
}
|
||||
|
||||
export function muxUrl(cfg: ServerConfig): string {
|
||||
return `${cfg.secure ? 'wss' : 'ws'}://${cleanHost(cfg.host)}:${cfg.muxPort}/mux`;
|
||||
return `${cfg.secure ? "wss" : "ws"}://${cleanHost(cfg.host)}:${cfg.muxPort}/mux`;
|
||||
}
|
||||
|
||||
export function isConfigured(cfg: ServerConfig): boolean {
|
||||
return cleanHost(cfg.host).length > 0;
|
||||
return cleanHost(cfg.host).length > 0;
|
||||
}
|
||||
|
||||
// Small reactive hook so screens re-render when the config changes.
|
||||
export function useServerConfig() {
|
||||
const [config, setConfig] = useState<ServerConfig | null>(null);
|
||||
const [config, setConfig] = useState<ServerConfig | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setConfig(await loadConfig());
|
||||
}, []);
|
||||
const reload = useCallback(async () => {
|
||||
setConfig(await loadConfig());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, [reload]);
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
const update = useCallback(async (cfg: ServerConfig) => {
|
||||
await saveConfig(cfg);
|
||||
setConfig(cfg);
|
||||
}, []);
|
||||
const update = useCallback(async (cfg: ServerConfig) => {
|
||||
await saveConfig(cfg);
|
||||
setConfig(cfg);
|
||||
}, []);
|
||||
|
||||
return { config, update, reload };
|
||||
return { config, update, reload };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,53 +1,48 @@
|
|||
import { muxUrl, type ServerConfig } from './config';
|
||||
import { muxUrl, type ServerConfig } from "./config";
|
||||
|
||||
// Mirrors AO's mux-protocol.ts (the bits we use).
|
||||
export type SessionPatch = {
|
||||
id: string;
|
||||
status: string;
|
||||
activity: string | null;
|
||||
attentionLevel: string;
|
||||
lastActivityAt: string;
|
||||
id: string;
|
||||
status: string;
|
||||
activity: string | null;
|
||||
attentionLevel: string;
|
||||
lastActivityAt: string;
|
||||
};
|
||||
|
||||
export type MuxStatus = 'connecting' | 'open' | 'closed' | 'error';
|
||||
export type MuxStatus = "connecting" | "open" | "closed" | "error";
|
||||
|
||||
type Handlers = {
|
||||
onStatus?: (s: MuxStatus, detail?: string) => void;
|
||||
onTerminalData?: (id: string, bytes: Uint8Array) => void;
|
||||
onTerminalOpened?: (id: string) => void;
|
||||
onTerminalExited?: (id: string, code: number) => void;
|
||||
onTerminalError?: (id: string, message: string) => void;
|
||||
onSessions?: (sessions: SessionPatch[]) => void;
|
||||
onStatus?: (s: MuxStatus, detail?: string) => void;
|
||||
onTerminalData?: (id: string, bytes: Uint8Array) => void;
|
||||
onTerminalOpened?: (id: string) => void;
|
||||
onTerminalExited?: (id: string, code: number) => void;
|
||||
onTerminalError?: (id: string, message: string) => void;
|
||||
onSessions?: (sessions: SessionPatch[]) => void;
|
||||
};
|
||||
|
||||
// Encode a JS string (already UTF-8 decoded by the server) back to UTF-8 bytes
|
||||
// for xterm. Prefer the native TextEncoder; fall back to a manual encoder if a
|
||||
// runtime ever lacks it, so the terminal never hard-crashes on a missing global.
|
||||
const nativeEncoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null;
|
||||
const nativeEncoder = typeof TextEncoder !== "undefined" ? new TextEncoder() : null;
|
||||
|
||||
function utf8Encode(str: string): Uint8Array {
|
||||
if (nativeEncoder) return nativeEncoder.encode(str);
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
let c = str.charCodeAt(i);
|
||||
if (c < 0x80) {
|
||||
out.push(c);
|
||||
} else if (c < 0x800) {
|
||||
out.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f));
|
||||
} else if (c >= 0xd800 && c <= 0xdbff && i + 1 < str.length) {
|
||||
const c2 = str.charCodeAt(++i);
|
||||
c = 0x10000 + ((c & 0x3ff) << 10) + (c2 & 0x3ff);
|
||||
out.push(
|
||||
0xf0 | (c >> 18),
|
||||
0x80 | ((c >> 12) & 0x3f),
|
||||
0x80 | ((c >> 6) & 0x3f),
|
||||
0x80 | (c & 0x3f),
|
||||
);
|
||||
} else {
|
||||
out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
|
||||
}
|
||||
}
|
||||
return new Uint8Array(out);
|
||||
if (nativeEncoder) return nativeEncoder.encode(str);
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
let c = str.charCodeAt(i);
|
||||
if (c < 0x80) {
|
||||
out.push(c);
|
||||
} else if (c < 0x800) {
|
||||
out.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f));
|
||||
} else if (c >= 0xd800 && c <= 0xdbff && i + 1 < str.length) {
|
||||
const c2 = str.charCodeAt(++i);
|
||||
c = 0x10000 + ((c & 0x3ff) << 10) + (c2 & 0x3ff);
|
||||
out.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 0x3f), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
|
||||
} else {
|
||||
out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
|
||||
}
|
||||
}
|
||||
return new Uint8Array(out);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -55,170 +50,170 @@ function utf8Encode(str: string): Uint8Array {
|
|||
* snapshots and per-session terminal I/O. Auto-reconnects with backoff.
|
||||
*/
|
||||
export class MuxClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private cfg: ServerConfig;
|
||||
private handlers: Handlers;
|
||||
private closedByUser = false;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private backoff = 1000;
|
||||
// Terminals we want open, so we can re-open them after a reconnect. Maps the
|
||||
// session id -> its projectId so the re-open carries projectId too (the server
|
||||
// may need it to locate the right session across projects).
|
||||
private openTerminals = new Map<string, string | undefined>();
|
||||
private subscribed = false;
|
||||
private ws: WebSocket | null = null;
|
||||
private cfg: ServerConfig;
|
||||
private handlers: Handlers;
|
||||
private closedByUser = false;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private backoff = 1000;
|
||||
// Terminals we want open, so we can re-open them after a reconnect. Maps the
|
||||
// session id -> its projectId so the re-open carries projectId too (the server
|
||||
// may need it to locate the right session across projects).
|
||||
private openTerminals = new Map<string, string | undefined>();
|
||||
private subscribed = false;
|
||||
|
||||
constructor(cfg: ServerConfig, handlers: Handlers) {
|
||||
this.cfg = cfg;
|
||||
this.handlers = handlers;
|
||||
}
|
||||
constructor(cfg: ServerConfig, handlers: Handlers) {
|
||||
this.cfg = cfg;
|
||||
this.handlers = handlers;
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.closedByUser = false;
|
||||
this.open();
|
||||
}
|
||||
connect() {
|
||||
this.closedByUser = false;
|
||||
this.open();
|
||||
}
|
||||
|
||||
private open() {
|
||||
this.handlers.onStatus?.('connecting');
|
||||
let ws: WebSocket;
|
||||
try {
|
||||
ws = new WebSocket(muxUrl(this.cfg));
|
||||
} catch (e) {
|
||||
this.handlers.onStatus?.('error', String(e));
|
||||
this.scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
this.ws = ws;
|
||||
private open() {
|
||||
this.handlers.onStatus?.("connecting");
|
||||
let ws: WebSocket;
|
||||
try {
|
||||
ws = new WebSocket(muxUrl(this.cfg));
|
||||
} catch (e) {
|
||||
this.handlers.onStatus?.("error", String(e));
|
||||
this.scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
this.ws = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
this.backoff = 1000;
|
||||
this.handlers.onStatus?.('open');
|
||||
if (this.subscribed) this.send({ ch: 'subscribe', topics: ['sessions', 'notifications'] });
|
||||
// Re-open any terminals that were active before a reconnect (with projectId).
|
||||
for (const [id, projectId] of this.openTerminals) {
|
||||
this.send({ ch: 'terminal', id, type: 'open', projectId });
|
||||
}
|
||||
this.pingTimer = setInterval(() => {
|
||||
this.send({ ch: 'system', type: 'ping' });
|
||||
}, 20000);
|
||||
};
|
||||
ws.onopen = () => {
|
||||
this.backoff = 1000;
|
||||
this.handlers.onStatus?.("open");
|
||||
if (this.subscribed) this.send({ ch: "subscribe", topics: ["sessions", "notifications"] });
|
||||
// Re-open any terminals that were active before a reconnect (with projectId).
|
||||
for (const [id, projectId] of this.openTerminals) {
|
||||
this.send({ ch: "terminal", id, type: "open", projectId });
|
||||
}
|
||||
this.pingTimer = setInterval(() => {
|
||||
this.send({ ch: "system", type: "ping" });
|
||||
}, 20000);
|
||||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
let msg: unknown;
|
||||
try {
|
||||
msg = JSON.parse(typeof ev.data === 'string' ? ev.data : '');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
this.handle(msg);
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
let msg: unknown;
|
||||
try {
|
||||
msg = JSON.parse(typeof ev.data === "string" ? ev.data : "");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
this.handle(msg);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
this.handlers.onStatus?.('error');
|
||||
};
|
||||
ws.onerror = () => {
|
||||
this.handlers.onStatus?.("error");
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
this.clearPing();
|
||||
if (this.closedByUser) {
|
||||
this.handlers.onStatus?.('closed');
|
||||
return;
|
||||
}
|
||||
this.handlers.onStatus?.('closed');
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
}
|
||||
ws.onclose = () => {
|
||||
this.clearPing();
|
||||
if (this.closedByUser) {
|
||||
this.handlers.onStatus?.("closed");
|
||||
return;
|
||||
}
|
||||
this.handlers.onStatus?.("closed");
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
}
|
||||
|
||||
private handle(raw: unknown) {
|
||||
if (!raw || typeof raw !== 'object') return;
|
||||
const msg = raw as {
|
||||
ch?: string;
|
||||
type?: string;
|
||||
sessions?: SessionPatch[];
|
||||
id?: string;
|
||||
data?: string;
|
||||
code?: number;
|
||||
message?: string;
|
||||
};
|
||||
if (msg.ch === 'sessions' && msg.type === 'snapshot') {
|
||||
this.handlers.onSessions?.(msg.sessions ?? []);
|
||||
} else if (msg.ch === 'terminal') {
|
||||
const id = msg.id ?? '';
|
||||
switch (msg.type) {
|
||||
case 'data':
|
||||
this.handlers.onTerminalData?.(id, utf8Encode(String(msg.data ?? '')));
|
||||
break;
|
||||
case 'opened':
|
||||
this.handlers.onTerminalOpened?.(id);
|
||||
break;
|
||||
case 'exited':
|
||||
this.handlers.onTerminalExited?.(id, msg.code ?? 0);
|
||||
break;
|
||||
case 'error':
|
||||
this.handlers.onTerminalError?.(id, msg.message ?? 'terminal error');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
private handle(raw: unknown) {
|
||||
if (!raw || typeof raw !== "object") return;
|
||||
const msg = raw as {
|
||||
ch?: string;
|
||||
type?: string;
|
||||
sessions?: SessionPatch[];
|
||||
id?: string;
|
||||
data?: string;
|
||||
code?: number;
|
||||
message?: string;
|
||||
};
|
||||
if (msg.ch === "sessions" && msg.type === "snapshot") {
|
||||
this.handlers.onSessions?.(msg.sessions ?? []);
|
||||
} else if (msg.ch === "terminal") {
|
||||
const id = msg.id ?? "";
|
||||
switch (msg.type) {
|
||||
case "data":
|
||||
this.handlers.onTerminalData?.(id, utf8Encode(String(msg.data ?? "")));
|
||||
break;
|
||||
case "opened":
|
||||
this.handlers.onTerminalOpened?.(id);
|
||||
break;
|
||||
case "exited":
|
||||
this.handlers.onTerminalExited?.(id, msg.code ?? 0);
|
||||
break;
|
||||
case "error":
|
||||
this.handlers.onTerminalError?.(id, msg.message ?? "terminal error");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (this.closedByUser) return;
|
||||
this.clearReconnect();
|
||||
this.reconnectTimer = setTimeout(() => this.open(), this.backoff);
|
||||
this.backoff = Math.min(this.backoff * 2, 15000);
|
||||
}
|
||||
private scheduleReconnect() {
|
||||
if (this.closedByUser) return;
|
||||
this.clearReconnect();
|
||||
this.reconnectTimer = setTimeout(() => this.open(), this.backoff);
|
||||
this.backoff = Math.min(this.backoff * 2, 15000);
|
||||
}
|
||||
|
||||
private clearReconnect() {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
private clearReconnect() {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private clearPing() {
|
||||
if (this.pingTimer) {
|
||||
clearInterval(this.pingTimer);
|
||||
this.pingTimer = null;
|
||||
}
|
||||
}
|
||||
private clearPing() {
|
||||
if (this.pingTimer) {
|
||||
clearInterval(this.pingTimer);
|
||||
this.pingTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private send(obj: unknown) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(obj));
|
||||
}
|
||||
}
|
||||
private send(obj: unknown) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(obj));
|
||||
}
|
||||
}
|
||||
|
||||
subscribeSessions() {
|
||||
this.subscribed = true;
|
||||
this.send({ ch: 'subscribe', topics: ['sessions', 'notifications'] });
|
||||
}
|
||||
subscribeSessions() {
|
||||
this.subscribed = true;
|
||||
this.send({ ch: "subscribe", topics: ["sessions", "notifications"] });
|
||||
}
|
||||
|
||||
openTerminal(id: string, projectId?: string) {
|
||||
this.openTerminals.set(id, projectId);
|
||||
this.send({ ch: 'terminal', id, type: 'open', projectId });
|
||||
}
|
||||
openTerminal(id: string, projectId?: string) {
|
||||
this.openTerminals.set(id, projectId);
|
||||
this.send({ ch: "terminal", id, type: "open", projectId });
|
||||
}
|
||||
|
||||
sendInput(id: string, data: string, projectId?: string) {
|
||||
this.send({ ch: 'terminal', id, type: 'data', data, projectId });
|
||||
}
|
||||
sendInput(id: string, data: string, projectId?: string) {
|
||||
this.send({ ch: "terminal", id, type: "data", data, projectId });
|
||||
}
|
||||
|
||||
resize(id: string, cols: number, rows: number, projectId?: string) {
|
||||
this.send({ ch: 'terminal', id, type: 'resize', cols, rows, projectId });
|
||||
}
|
||||
resize(id: string, cols: number, rows: number, projectId?: string) {
|
||||
this.send({ ch: "terminal", id, type: "resize", cols, rows, projectId });
|
||||
}
|
||||
|
||||
closeTerminal(id: string, projectId?: string) {
|
||||
this.openTerminals.delete(id);
|
||||
this.send({ ch: 'terminal', id, type: 'close', projectId });
|
||||
}
|
||||
closeTerminal(id: string, projectId?: string) {
|
||||
this.openTerminals.delete(id);
|
||||
this.send({ ch: "terminal", id, type: "close", projectId });
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.closedByUser = true;
|
||||
this.clearReconnect();
|
||||
this.clearPing();
|
||||
try {
|
||||
this.ws?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this.ws = null;
|
||||
}
|
||||
disconnect() {
|
||||
this.closedByUser = true;
|
||||
this.clearReconnect();
|
||||
this.clearPing();
|
||||
try {
|
||||
this.ws?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,346 +1,331 @@
|
|||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import {
|
||||
collectPRs,
|
||||
getProjects,
|
||||
getSessions,
|
||||
killSession,
|
||||
launchOrchestrator as apiLaunchOrchestrator,
|
||||
mergePR as apiMergePR,
|
||||
restoreSession,
|
||||
sendMessage,
|
||||
spawnSession,
|
||||
type DashboardPR,
|
||||
type DashboardSession,
|
||||
type DashboardStats,
|
||||
type OrchestratorLink,
|
||||
type ProjectInfo,
|
||||
} from './api';
|
||||
import { isConfigured, loadConfig, type ServerConfig } from './config';
|
||||
import { MuxClient, type MuxStatus, type SessionPatch } from './mux';
|
||||
collectPRs,
|
||||
getProjects,
|
||||
getSessions,
|
||||
killSession,
|
||||
launchOrchestrator as apiLaunchOrchestrator,
|
||||
mergePR as apiMergePR,
|
||||
restoreSession,
|
||||
sendMessage,
|
||||
spawnSession,
|
||||
type DashboardPR,
|
||||
type DashboardSession,
|
||||
type DashboardStats,
|
||||
type OrchestratorLink,
|
||||
type ProjectInfo,
|
||||
} from "./api";
|
||||
import { isConfigured, loadConfig, type ServerConfig } from "./config";
|
||||
import { MuxClient, type MuxStatus, type SessionPatch } from "./mux";
|
||||
|
||||
const ACTIVE_PROJECT_KEY = 'ao.activeProject';
|
||||
const ACTIVE_PROJECT_KEY = "ao.activeProject";
|
||||
|
||||
type AppState = {
|
||||
config: ServerConfig | null;
|
||||
configured: boolean;
|
||||
projects: ProjectInfo[];
|
||||
sessions: DashboardSession[];
|
||||
orchestrators: OrchestratorLink[];
|
||||
orchestratorId: string | null;
|
||||
stats: DashboardStats;
|
||||
activeProjectId: string; // 'all' or a projectId
|
||||
connection: MuxStatus;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
// actions
|
||||
reloadConfig: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
setActiveProject: (id: string) => void;
|
||||
spawn: (prompt?: string, projectId?: string) => Promise<void>;
|
||||
launchConductor: (projectId: string, clean?: boolean) => Promise<OrchestratorLink>;
|
||||
merge: (pr: DashboardPR) => Promise<void>;
|
||||
kill: (id: string) => Promise<void>;
|
||||
restore: (id: string) => Promise<void>;
|
||||
send: (id: string, message: string) => Promise<void>;
|
||||
config: ServerConfig | null;
|
||||
configured: boolean;
|
||||
projects: ProjectInfo[];
|
||||
sessions: DashboardSession[];
|
||||
orchestrators: OrchestratorLink[];
|
||||
orchestratorId: string | null;
|
||||
stats: DashboardStats;
|
||||
activeProjectId: string; // 'all' or a projectId
|
||||
connection: MuxStatus;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
// actions
|
||||
reloadConfig: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
setActiveProject: (id: string) => void;
|
||||
spawn: (prompt?: string, projectId?: string) => Promise<void>;
|
||||
launchConductor: (projectId: string, clean?: boolean) => Promise<OrchestratorLink>;
|
||||
merge: (pr: DashboardPR) => Promise<void>;
|
||||
kill: (id: string) => Promise<void>;
|
||||
restore: (id: string) => Promise<void>;
|
||||
send: (id: string, message: string) => Promise<void>;
|
||||
};
|
||||
|
||||
const AppContext = createContext<AppState | null>(null);
|
||||
|
||||
export function useApp(): AppState {
|
||||
const ctx = useContext(AppContext);
|
||||
if (!ctx) throw new Error('useApp must be used within <AppProvider>');
|
||||
return ctx;
|
||||
const ctx = useContext(AppContext);
|
||||
if (!ctx) throw new Error("useApp must be used within <AppProvider>");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// Convenience selectors -------------------------------------------------------
|
||||
|
||||
export function useVisibleSessions(): DashboardSession[] {
|
||||
const { sessions, activeProjectId } = useApp();
|
||||
return useMemo(
|
||||
() =>
|
||||
activeProjectId === 'all'
|
||||
? sessions
|
||||
: sessions.filter((s) => s.projectId === activeProjectId),
|
||||
[sessions, activeProjectId],
|
||||
);
|
||||
const { sessions, activeProjectId } = useApp();
|
||||
return useMemo(
|
||||
() => (activeProjectId === "all" ? sessions : sessions.filter((s) => s.projectId === activeProjectId)),
|
||||
[sessions, activeProjectId],
|
||||
);
|
||||
}
|
||||
|
||||
export function usePRs() {
|
||||
const sessions = useVisibleSessions();
|
||||
return useMemo(() => collectPRs(sessions), [sessions]);
|
||||
const sessions = useVisibleSessions();
|
||||
return useMemo(() => collectPRs(sessions), [sessions]);
|
||||
}
|
||||
|
||||
// Provider --------------------------------------------------------------------
|
||||
|
||||
export function AppProvider({ children }: { children: ReactNode }) {
|
||||
const [config, setConfig] = useState<ServerConfig | null>(null);
|
||||
const [projects, setProjects] = useState<ProjectInfo[]>([]);
|
||||
const [rawSessions, setRawSessions] = useState<DashboardSession[]>([]);
|
||||
const [patches, setPatches] = useState<Map<string, SessionPatch>>(new Map());
|
||||
const [orchestrators, setOrchestrators] = useState<OrchestratorLink[]>([]);
|
||||
const [orchestratorId, setOrchestratorId] = useState<string | null>(null);
|
||||
const [stats, setStats] = useState<DashboardStats>({});
|
||||
const [activeProjectId, setActiveProjectId] = useState<string>('all');
|
||||
const [connection, setConnection] = useState<MuxStatus>('closed');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [config, setConfig] = useState<ServerConfig | null>(null);
|
||||
const [projects, setProjects] = useState<ProjectInfo[]>([]);
|
||||
const [rawSessions, setRawSessions] = useState<DashboardSession[]>([]);
|
||||
const [patches, setPatches] = useState<Map<string, SessionPatch>>(new Map());
|
||||
const [orchestrators, setOrchestrators] = useState<OrchestratorLink[]>([]);
|
||||
const [orchestratorId, setOrchestratorId] = useState<string | null>(null);
|
||||
const [stats, setStats] = useState<DashboardStats>({});
|
||||
const [activeProjectId, setActiveProjectId] = useState<string>("all");
|
||||
const [connection, setConnection] = useState<MuxStatus>("closed");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const muxRef = useRef<MuxClient | null>(null);
|
||||
const cfgRef = useRef<ServerConfig | null>(null);
|
||||
const muxRef = useRef<MuxClient | null>(null);
|
||||
const cfgRef = useRef<ServerConfig | null>(null);
|
||||
|
||||
// Load persisted active project once.
|
||||
useEffect(() => {
|
||||
AsyncStorage.getItem(ACTIVE_PROJECT_KEY).then((v) => {
|
||||
if (v) setActiveProjectId(v);
|
||||
});
|
||||
}, []);
|
||||
// Load persisted active project once.
|
||||
useEffect(() => {
|
||||
AsyncStorage.getItem(ACTIVE_PROJECT_KEY).then((v) => {
|
||||
if (v) setActiveProjectId(v);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const reloadConfig = useCallback(async () => {
|
||||
const c = await loadConfig();
|
||||
cfgRef.current = c;
|
||||
setConfig(c);
|
||||
}, []);
|
||||
const reloadConfig = useCallback(async () => {
|
||||
const c = await loadConfig();
|
||||
cfgRef.current = c;
|
||||
setConfig(c);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
reloadConfig();
|
||||
}, [reloadConfig]);
|
||||
useEffect(() => {
|
||||
reloadConfig();
|
||||
}, [reloadConfig]);
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
const c = cfgRef.current;
|
||||
if (!c || !isConfigured(c)) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [projs, sess] = await Promise.all([
|
||||
getProjects(c).catch(() => [] as ProjectInfo[]),
|
||||
getSessions(c, 'all'),
|
||||
]);
|
||||
setProjects(projs);
|
||||
setRawSessions(sess.sessions);
|
||||
setOrchestrators(sess.orchestrators);
|
||||
setOrchestratorId(sess.orchestratorId);
|
||||
setStats(sess.stats);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
const fetchAll = useCallback(async () => {
|
||||
const c = cfgRef.current;
|
||||
if (!c || !isConfigured(c)) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [projs, sess] = await Promise.all([getProjects(c).catch(() => [] as ProjectInfo[]), getSessions(c, "all")]);
|
||||
setProjects(projs);
|
||||
setRawSessions(sess.sessions);
|
||||
setOrchestrators(sess.orchestrators);
|
||||
setOrchestratorId(sess.orchestratorId);
|
||||
setStats(sess.stats);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// (Re)connect the live mux socket whenever the config changes.
|
||||
useEffect(() => {
|
||||
muxRef.current?.disconnect();
|
||||
muxRef.current = null;
|
||||
setPatches(new Map());
|
||||
if (!config || !isConfigured(config)) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
fetchAll();
|
||||
const mux = new MuxClient(config, {
|
||||
onStatus: (s) => setConnection(s),
|
||||
onSessions: (snapshot) => {
|
||||
setPatches((prev) => {
|
||||
// Only allocate a new Map (→ re-render) when something actually changed.
|
||||
// The server re-sends an identical snapshot every 3s when idle.
|
||||
let changed = false;
|
||||
const next = new Map(prev);
|
||||
for (const p of snapshot) {
|
||||
const old = prev.get(p.id);
|
||||
if (
|
||||
!old ||
|
||||
old.status !== p.status ||
|
||||
old.activity !== p.activity ||
|
||||
old.attentionLevel !== p.attentionLevel ||
|
||||
old.lastActivityAt !== p.lastActivityAt
|
||||
) {
|
||||
next.set(p.id, p);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
},
|
||||
});
|
||||
muxRef.current = mux;
|
||||
mux.connect();
|
||||
mux.subscribeSessions();
|
||||
// (Re)connect the live mux socket whenever the config changes.
|
||||
useEffect(() => {
|
||||
muxRef.current?.disconnect();
|
||||
muxRef.current = null;
|
||||
setPatches(new Map());
|
||||
if (!config || !isConfigured(config)) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
fetchAll();
|
||||
const mux = new MuxClient(config, {
|
||||
onStatus: (s) => setConnection(s),
|
||||
onSessions: (snapshot) => {
|
||||
setPatches((prev) => {
|
||||
// Only allocate a new Map (→ re-render) when something actually changed.
|
||||
// The server re-sends an identical snapshot every 3s when idle.
|
||||
let changed = false;
|
||||
const next = new Map(prev);
|
||||
for (const p of snapshot) {
|
||||
const old = prev.get(p.id);
|
||||
if (
|
||||
!old ||
|
||||
old.status !== p.status ||
|
||||
old.activity !== p.activity ||
|
||||
old.attentionLevel !== p.attentionLevel ||
|
||||
old.lastActivityAt !== p.lastActivityAt
|
||||
) {
|
||||
next.set(p.id, p);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
},
|
||||
});
|
||||
muxRef.current = mux;
|
||||
mux.connect();
|
||||
mux.subscribeSessions();
|
||||
|
||||
// REST safety-net poll (full fields the patch stream doesn't carry).
|
||||
const poll = setInterval(fetchAll, 12000);
|
||||
return () => {
|
||||
clearInterval(poll);
|
||||
mux.disconnect();
|
||||
muxRef.current = null;
|
||||
};
|
||||
}, [config, fetchAll]);
|
||||
// REST safety-net poll (full fields the patch stream doesn't carry).
|
||||
const poll = setInterval(fetchAll, 12000);
|
||||
return () => {
|
||||
clearInterval(poll);
|
||||
mux.disconnect();
|
||||
muxRef.current = null;
|
||||
};
|
||||
}, [config, fetchAll]);
|
||||
|
||||
// Merge live patches over the REST snapshot. A patch always carries the live
|
||||
// status/activity/attention, so it is authoritative for those fields (using
|
||||
// `??` would keep a stale `activity` when the agent went idle → activity:null).
|
||||
const sessions = useMemo(() => {
|
||||
if (patches.size === 0) return rawSessions;
|
||||
const known = new Set(rawSessions.map((s) => s.id));
|
||||
const merged = rawSessions.map((s) => {
|
||||
const p = patches.get(s.id);
|
||||
if (!p) return s;
|
||||
return {
|
||||
...s,
|
||||
status: p.status,
|
||||
activity: p.activity,
|
||||
attentionLevel: p.attentionLevel,
|
||||
lastActivityAt: p.lastActivityAt,
|
||||
};
|
||||
});
|
||||
// Sessions the server pushed over the mux but that aren't in the REST list
|
||||
// yet (e.g. just spawned by the orchestrator) — surface a minimal card now
|
||||
// instead of waiting up to 12s for the next poll to fill in full details.
|
||||
const fallbackProject = projects.length === 1 ? projects[0].id : '';
|
||||
const extras: DashboardSession[] = [];
|
||||
for (const [pid, p] of patches) {
|
||||
if (known.has(pid)) continue;
|
||||
extras.push({
|
||||
id: p.id,
|
||||
projectId: fallbackProject,
|
||||
status: p.status,
|
||||
attentionLevel: p.attentionLevel,
|
||||
activity: p.activity,
|
||||
branch: null,
|
||||
issueId: null,
|
||||
issueTitle: null,
|
||||
userPrompt: null,
|
||||
displayName: null,
|
||||
summary: null,
|
||||
createdAt: '',
|
||||
lastActivityAt: p.lastActivityAt,
|
||||
pr: null,
|
||||
prs: [],
|
||||
});
|
||||
}
|
||||
return extras.length ? [...merged, ...extras] : merged;
|
||||
}, [rawSessions, patches, projects]);
|
||||
// Merge live patches over the REST snapshot. A patch always carries the live
|
||||
// status/activity/attention, so it is authoritative for those fields (using
|
||||
// `??` would keep a stale `activity` when the agent went idle → activity:null).
|
||||
const sessions = useMemo(() => {
|
||||
if (patches.size === 0) return rawSessions;
|
||||
const known = new Set(rawSessions.map((s) => s.id));
|
||||
const merged = rawSessions.map((s) => {
|
||||
const p = patches.get(s.id);
|
||||
if (!p) return s;
|
||||
return {
|
||||
...s,
|
||||
status: p.status,
|
||||
activity: p.activity,
|
||||
attentionLevel: p.attentionLevel,
|
||||
lastActivityAt: p.lastActivityAt,
|
||||
};
|
||||
});
|
||||
// Sessions the server pushed over the mux but that aren't in the REST list
|
||||
// yet (e.g. just spawned by the orchestrator) — surface a minimal card now
|
||||
// instead of waiting up to 12s for the next poll to fill in full details.
|
||||
const fallbackProject = projects.length === 1 ? projects[0].id : "";
|
||||
const extras: DashboardSession[] = [];
|
||||
for (const [pid, p] of patches) {
|
||||
if (known.has(pid)) continue;
|
||||
extras.push({
|
||||
id: p.id,
|
||||
projectId: fallbackProject,
|
||||
status: p.status,
|
||||
attentionLevel: p.attentionLevel,
|
||||
activity: p.activity,
|
||||
branch: null,
|
||||
issueId: null,
|
||||
issueTitle: null,
|
||||
userPrompt: null,
|
||||
displayName: null,
|
||||
summary: null,
|
||||
createdAt: "",
|
||||
lastActivityAt: p.lastActivityAt,
|
||||
pr: null,
|
||||
prs: [],
|
||||
});
|
||||
}
|
||||
return extras.length ? [...merged, ...extras] : merged;
|
||||
}, [rawSessions, patches, projects]);
|
||||
|
||||
const setActiveProject = useCallback((id: string) => {
|
||||
setActiveProjectId(id);
|
||||
AsyncStorage.setItem(ACTIVE_PROJECT_KEY, id).catch(() => {});
|
||||
}, []);
|
||||
const setActiveProject = useCallback((id: string) => {
|
||||
setActiveProjectId(id);
|
||||
AsyncStorage.setItem(ACTIVE_PROJECT_KEY, id).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Pick a sensible project for actions that need one (spawn / conductor).
|
||||
const targetProject = useCallback((): string | null => {
|
||||
if (activeProjectId !== 'all') return activeProjectId;
|
||||
if (projects.length === 1) return projects[0].id;
|
||||
return null;
|
||||
}, [activeProjectId, projects]);
|
||||
// Pick a sensible project for actions that need one (spawn / conductor).
|
||||
const targetProject = useCallback((): string | null => {
|
||||
if (activeProjectId !== "all") return activeProjectId;
|
||||
if (projects.length === 1) return projects[0].id;
|
||||
return null;
|
||||
}, [activeProjectId, projects]);
|
||||
|
||||
const spawn = useCallback(
|
||||
async (prompt?: string, projectId?: string) => {
|
||||
const c = cfgRef.current;
|
||||
const proj = projectId ?? targetProject();
|
||||
if (!c || !proj) throw new Error('Pick a project first');
|
||||
await spawnSession(c, { projectId: proj, prompt });
|
||||
await fetchAll();
|
||||
},
|
||||
[targetProject, fetchAll],
|
||||
);
|
||||
const spawn = useCallback(
|
||||
async (prompt?: string, projectId?: string) => {
|
||||
const c = cfgRef.current;
|
||||
const proj = projectId ?? targetProject();
|
||||
if (!c || !proj) throw new Error("Pick a project first");
|
||||
await spawnSession(c, { projectId: proj, prompt });
|
||||
await fetchAll();
|
||||
},
|
||||
[targetProject, fetchAll],
|
||||
);
|
||||
|
||||
const launchConductor = useCallback(
|
||||
async (projectId: string, clean = false) => {
|
||||
const c = cfgRef.current!;
|
||||
const link = await apiLaunchOrchestrator(c, projectId, clean);
|
||||
await fetchAll();
|
||||
return link;
|
||||
},
|
||||
[fetchAll],
|
||||
);
|
||||
const launchConductor = useCallback(
|
||||
async (projectId: string, clean = false) => {
|
||||
const c = cfgRef.current!;
|
||||
const link = await apiLaunchOrchestrator(c, projectId, clean);
|
||||
await fetchAll();
|
||||
return link;
|
||||
},
|
||||
[fetchAll],
|
||||
);
|
||||
|
||||
const merge = useCallback(
|
||||
async (pr: DashboardPR) => {
|
||||
await apiMergePR(cfgRef.current!, pr);
|
||||
await fetchAll();
|
||||
},
|
||||
[fetchAll],
|
||||
);
|
||||
const merge = useCallback(
|
||||
async (pr: DashboardPR) => {
|
||||
await apiMergePR(cfgRef.current!, pr);
|
||||
await fetchAll();
|
||||
},
|
||||
[fetchAll],
|
||||
);
|
||||
|
||||
const kill = useCallback(
|
||||
async (id: string) => {
|
||||
await killSession(cfgRef.current!, id);
|
||||
await fetchAll();
|
||||
},
|
||||
[fetchAll],
|
||||
);
|
||||
const kill = useCallback(
|
||||
async (id: string) => {
|
||||
await killSession(cfgRef.current!, id);
|
||||
await fetchAll();
|
||||
},
|
||||
[fetchAll],
|
||||
);
|
||||
|
||||
const restore = useCallback(
|
||||
async (id: string) => {
|
||||
await restoreSession(cfgRef.current!, id);
|
||||
await fetchAll();
|
||||
},
|
||||
[fetchAll],
|
||||
);
|
||||
const restore = useCallback(
|
||||
async (id: string) => {
|
||||
await restoreSession(cfgRef.current!, id);
|
||||
await fetchAll();
|
||||
},
|
||||
[fetchAll],
|
||||
);
|
||||
|
||||
const send = useCallback(async (id: string, message: string) => {
|
||||
await sendMessage(cfgRef.current!, id, message);
|
||||
}, []);
|
||||
const send = useCallback(async (id: string, message: string) => {
|
||||
await sendMessage(cfgRef.current!, id, message);
|
||||
}, []);
|
||||
|
||||
// Memoized so the provider doesn't hand every useApp() consumer a brand-new
|
||||
// object (→ re-render) on each render. Re-renders now track real state changes.
|
||||
const value = useMemo<AppState>(
|
||||
() => ({
|
||||
config,
|
||||
configured: !!config && isConfigured(config),
|
||||
projects,
|
||||
sessions,
|
||||
orchestrators,
|
||||
orchestratorId,
|
||||
stats,
|
||||
activeProjectId,
|
||||
connection,
|
||||
loading,
|
||||
error,
|
||||
reloadConfig,
|
||||
refresh: fetchAll,
|
||||
setActiveProject,
|
||||
spawn,
|
||||
launchConductor,
|
||||
merge,
|
||||
kill,
|
||||
restore,
|
||||
send,
|
||||
}),
|
||||
[
|
||||
config,
|
||||
projects,
|
||||
sessions,
|
||||
orchestrators,
|
||||
orchestratorId,
|
||||
stats,
|
||||
activeProjectId,
|
||||
connection,
|
||||
loading,
|
||||
error,
|
||||
reloadConfig,
|
||||
fetchAll,
|
||||
setActiveProject,
|
||||
spawn,
|
||||
launchConductor,
|
||||
merge,
|
||||
kill,
|
||||
restore,
|
||||
send,
|
||||
],
|
||||
);
|
||||
// Memoized so the provider doesn't hand every useApp() consumer a brand-new
|
||||
// object (→ re-render) on each render. Re-renders now track real state changes.
|
||||
const value = useMemo<AppState>(
|
||||
() => ({
|
||||
config,
|
||||
configured: !!config && isConfigured(config),
|
||||
projects,
|
||||
sessions,
|
||||
orchestrators,
|
||||
orchestratorId,
|
||||
stats,
|
||||
activeProjectId,
|
||||
connection,
|
||||
loading,
|
||||
error,
|
||||
reloadConfig,
|
||||
refresh: fetchAll,
|
||||
setActiveProject,
|
||||
spawn,
|
||||
launchConductor,
|
||||
merge,
|
||||
kill,
|
||||
restore,
|
||||
send,
|
||||
}),
|
||||
[
|
||||
config,
|
||||
projects,
|
||||
sessions,
|
||||
orchestrators,
|
||||
orchestratorId,
|
||||
stats,
|
||||
activeProjectId,
|
||||
connection,
|
||||
loading,
|
||||
error,
|
||||
reloadConfig,
|
||||
fetchAll,
|
||||
setActiveProject,
|
||||
spawn,
|
||||
launchConductor,
|
||||
merge,
|
||||
kill,
|
||||
restore,
|
||||
send,
|
||||
],
|
||||
);
|
||||
|
||||
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
|
||||
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,70 +6,60 @@
|
|||
// red = failing / stuck / crashed
|
||||
// green = mergeable / passed / done
|
||||
export const theme = {
|
||||
// Surfaces (no box-in-box; the card is the only bordered surface)
|
||||
bgBase: '#0a0b0d',
|
||||
bgSide: '#08090b',
|
||||
bgColumn: '#0e0f12',
|
||||
bgSurface: '#121317', // headers, tab bar, key bar — flush chrome
|
||||
bgElevated: '#15171b', // cards & inputs
|
||||
bgElevatedHover: '#191b20',
|
||||
bgSubtle: 'rgba(255,255,255,0.04)',
|
||||
term: '#0c0d10',
|
||||
// Surfaces (no box-in-box; the card is the only bordered surface)
|
||||
bgBase: "#0a0b0d",
|
||||
bgSide: "#08090b",
|
||||
bgColumn: "#0e0f12",
|
||||
bgSurface: "#121317", // headers, tab bar, key bar — flush chrome
|
||||
bgElevated: "#15171b", // cards & inputs
|
||||
bgElevatedHover: "#191b20",
|
||||
bgSubtle: "rgba(255,255,255,0.04)",
|
||||
term: "#0c0d10",
|
||||
|
||||
textPrimary: '#f4f5f7',
|
||||
textSecondary: '#9ba1aa',
|
||||
textTertiary: '#646a73',
|
||||
textFaint: '#444951',
|
||||
textPrimary: "#f4f5f7",
|
||||
textSecondary: "#9ba1aa",
|
||||
textTertiary: "#646a73",
|
||||
textFaint: "#444951",
|
||||
|
||||
borderSubtle: 'rgba(255,255,255,0.06)',
|
||||
borderDefault: 'rgba(255,255,255,0.10)',
|
||||
borderStrong: 'rgba(255,255,255,0.16)',
|
||||
borderSubtle: "rgba(255,255,255,0.06)",
|
||||
borderDefault: "rgba(255,255,255,0.10)",
|
||||
borderStrong: "rgba(255,255,255,0.16)",
|
||||
|
||||
// Semantic — rationed
|
||||
blue: '#4d8dff',
|
||||
orange: '#f59f4c',
|
||||
amber: '#e8c14a',
|
||||
red: '#ef6b6b',
|
||||
green: '#74b98a',
|
||||
// Semantic — rationed
|
||||
blue: "#4d8dff",
|
||||
orange: "#f59f4c",
|
||||
amber: "#e8c14a",
|
||||
red: "#ef6b6b",
|
||||
green: "#74b98a",
|
||||
|
||||
// Faint tints for chip / glow backgrounds
|
||||
tintBlue: 'rgba(77,141,255,0.14)',
|
||||
tintOrange: 'rgba(245,159,76,0.14)',
|
||||
tintAmber: 'rgba(232,193,74,0.14)',
|
||||
tintRed: 'rgba(239,107,107,0.14)',
|
||||
tintGreen: 'rgba(116,185,138,0.14)',
|
||||
// Faint tints for chip / glow backgrounds
|
||||
tintBlue: "rgba(77,141,255,0.14)",
|
||||
tintOrange: "rgba(245,159,76,0.14)",
|
||||
tintAmber: "rgba(232,193,74,0.14)",
|
||||
tintRed: "rgba(239,107,107,0.14)",
|
||||
tintGreen: "rgba(116,185,138,0.14)",
|
||||
|
||||
// Back-compat aliases (older screens referenced these names)
|
||||
accent: '#4d8dff',
|
||||
accentHover: '#6ba0ff',
|
||||
accentTint: 'rgba(77,141,255,0.14)',
|
||||
attention: '#e8c14a',
|
||||
cyan: '#f59f4c',
|
||||
// Back-compat aliases (older screens referenced these names)
|
||||
accent: "#4d8dff",
|
||||
accentHover: "#6ba0ff",
|
||||
accentTint: "rgba(77,141,255,0.14)",
|
||||
attention: "#e8c14a",
|
||||
cyan: "#f59f4c",
|
||||
|
||||
fontMono: 'JetBrains Mono, Menlo, ui-monospace, monospace',
|
||||
fontMono: "JetBrains Mono, Menlo, ui-monospace, monospace",
|
||||
} as const;
|
||||
|
||||
// AO's attention levels, in urgency order. Drives the board sections.
|
||||
export type AttentionLevel =
|
||||
| 'merge'
|
||||
| 'action'
|
||||
| 'respond'
|
||||
| 'review'
|
||||
| 'pending'
|
||||
| 'working'
|
||||
| 'done';
|
||||
export type AttentionLevel = "merge" | "action" | "respond" | "review" | "pending" | "working" | "done";
|
||||
|
||||
export const attentionMeta: Record<
|
||||
string,
|
||||
{ label: string; color: string; tint: string; order: number }
|
||||
> = {
|
||||
merge: { label: 'Ready to merge', color: theme.green, tint: theme.tintGreen, order: 0 },
|
||||
action: { label: 'Needs you', color: theme.amber, tint: theme.tintAmber, order: 1 },
|
||||
respond: { label: 'Needs you', color: theme.amber, tint: theme.tintAmber, order: 1 },
|
||||
review: { label: 'Review', color: theme.red, tint: theme.tintRed, order: 2 },
|
||||
pending: { label: 'In review', color: theme.textTertiary, tint: theme.bgSubtle, order: 3 },
|
||||
working: { label: 'Working', color: theme.orange, tint: theme.tintOrange, order: 4 },
|
||||
done: { label: 'Done', color: theme.textTertiary, tint: theme.bgSubtle, order: 5 },
|
||||
export const attentionMeta: Record<string, { label: string; color: string; tint: string; order: number }> = {
|
||||
merge: { label: "Ready to merge", color: theme.green, tint: theme.tintGreen, order: 0 },
|
||||
action: { label: "Needs you", color: theme.amber, tint: theme.tintAmber, order: 1 },
|
||||
respond: { label: "Needs you", color: theme.amber, tint: theme.tintAmber, order: 1 },
|
||||
review: { label: "Review", color: theme.red, tint: theme.tintRed, order: 2 },
|
||||
pending: { label: "In review", color: theme.textTertiary, tint: theme.bgSubtle, order: 3 },
|
||||
working: { label: "Working", color: theme.orange, tint: theme.tintOrange, order: 4 },
|
||||
done: { label: "Done", color: theme.textTertiary, tint: theme.bgSubtle, order: 5 },
|
||||
};
|
||||
|
||||
export type StatusVisual = { color: string; label: string; breathing?: boolean };
|
||||
|
|
@ -77,71 +67,69 @@ export type StatusVisual = { color: string; label: string; breathing?: boolean }
|
|||
// One status → one dot color + short label. Mirrors AO's getStatusSpec so the
|
||||
// phone speaks the same visual language as the dashboard.
|
||||
export function statusVisual(status?: string | null): StatusVisual {
|
||||
switch (status) {
|
||||
case 'spawning':
|
||||
return { color: theme.blue, label: 'Starting' };
|
||||
case 'working':
|
||||
return { color: theme.orange, label: 'Working', breathing: true };
|
||||
case 'detecting':
|
||||
return { color: theme.orange, label: 'Detecting', breathing: true };
|
||||
case 'needs_input':
|
||||
return { color: theme.amber, label: 'Needs input' };
|
||||
case 'changes_requested':
|
||||
return { color: theme.amber, label: 'Changes req.' };
|
||||
case 'stuck':
|
||||
return { color: theme.red, label: 'Stuck' };
|
||||
case 'errored':
|
||||
return { color: theme.red, label: 'Crashed' };
|
||||
case 'ci_failed':
|
||||
return { color: theme.red, label: 'CI failed' };
|
||||
case 'pr_open':
|
||||
return { color: theme.textSecondary, label: 'PR open' };
|
||||
case 'review_pending':
|
||||
return { color: theme.textSecondary, label: 'In review' };
|
||||
case 'approved':
|
||||
return { color: theme.green, label: 'Approved' };
|
||||
case 'mergeable':
|
||||
return { color: theme.green, label: 'Mergeable' };
|
||||
case 'merged':
|
||||
return { color: theme.green, label: 'Merged' };
|
||||
case 'done':
|
||||
return { color: theme.green, label: 'Done' };
|
||||
case 'idle':
|
||||
return { color: theme.textTertiary, label: 'Idle' };
|
||||
case 'cleanup':
|
||||
return { color: theme.textTertiary, label: 'Cleanup' };
|
||||
case 'killed':
|
||||
case 'terminated':
|
||||
return { color: theme.textFaint, label: 'Terminated' };
|
||||
default:
|
||||
return { color: theme.textTertiary, label: status ?? 'unknown' };
|
||||
}
|
||||
switch (status) {
|
||||
case "spawning":
|
||||
return { color: theme.blue, label: "Starting" };
|
||||
case "working":
|
||||
return { color: theme.orange, label: "Working", breathing: true };
|
||||
case "detecting":
|
||||
return { color: theme.orange, label: "Detecting", breathing: true };
|
||||
case "needs_input":
|
||||
return { color: theme.amber, label: "Needs input" };
|
||||
case "changes_requested":
|
||||
return { color: theme.amber, label: "Changes req." };
|
||||
case "stuck":
|
||||
return { color: theme.red, label: "Stuck" };
|
||||
case "errored":
|
||||
return { color: theme.red, label: "Crashed" };
|
||||
case "ci_failed":
|
||||
return { color: theme.red, label: "CI failed" };
|
||||
case "pr_open":
|
||||
return { color: theme.textSecondary, label: "PR open" };
|
||||
case "review_pending":
|
||||
return { color: theme.textSecondary, label: "In review" };
|
||||
case "approved":
|
||||
return { color: theme.green, label: "Approved" };
|
||||
case "mergeable":
|
||||
return { color: theme.green, label: "Mergeable" };
|
||||
case "merged":
|
||||
return { color: theme.green, label: "Merged" };
|
||||
case "done":
|
||||
return { color: theme.green, label: "Done" };
|
||||
case "idle":
|
||||
return { color: theme.textTertiary, label: "Idle" };
|
||||
case "cleanup":
|
||||
return { color: theme.textTertiary, label: "Cleanup" };
|
||||
case "killed":
|
||||
case "terminated":
|
||||
return { color: theme.textFaint, label: "Terminated" };
|
||||
default:
|
||||
return { color: theme.textTertiary, label: status ?? "unknown" };
|
||||
}
|
||||
}
|
||||
|
||||
// Back-compat: older screens import statusColor.
|
||||
export function statusColor(status?: string | null): string {
|
||||
return statusVisual(status).color;
|
||||
return statusVisual(status).color;
|
||||
}
|
||||
|
||||
// Single source of truth for CI status → color (used by the PR chip border and
|
||||
// the PRs list). Keeps the SessionCard and PRs screen from forking the mapping.
|
||||
export function ciColor(ci?: string | null): string {
|
||||
if (ci === 'failing') return theme.red;
|
||||
if (ci === 'passing') return theme.green;
|
||||
return theme.textTertiary;
|
||||
if (ci === "failing") return theme.red;
|
||||
if (ci === "passing") return theme.green;
|
||||
return theme.textTertiary;
|
||||
}
|
||||
|
||||
export type CiVisual = {
|
||||
color: string;
|
||||
tint: string;
|
||||
icon: 'check-circle' | 'x-circle' | 'clock';
|
||||
label: string;
|
||||
color: string;
|
||||
tint: string;
|
||||
icon: "check-circle" | "x-circle" | "clock";
|
||||
label: string;
|
||||
};
|
||||
|
||||
export function ciVisual(ci?: string | null): CiVisual {
|
||||
if (ci === 'passing')
|
||||
return { color: theme.green, tint: theme.tintGreen, icon: 'check-circle', label: 'CI passing' };
|
||||
if (ci === 'failing')
|
||||
return { color: theme.red, tint: theme.tintRed, icon: 'x-circle', label: 'CI failing' };
|
||||
return { color: theme.textSecondary, tint: theme.bgSubtle, icon: 'clock', label: `CI ${ci}` };
|
||||
if (ci === "passing") return { color: theme.green, tint: theme.tintGreen, icon: "check-circle", label: "CI passing" };
|
||||
if (ci === "failing") return { color: theme.red, tint: theme.tintRed, icon: "x-circle", label: "CI failing" };
|
||||
return { color: theme.textSecondary, tint: theme.bgSubtle, icon: "clock", label: `CI ${ci}` };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,363 +1,339 @@
|
|||
import { Feather } from '@expo/vector-icons';
|
||||
import { memo, useEffect, useRef, type ReactNode } from 'react';
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { memo, useEffect, useRef, type ReactNode } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
Image,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
type StyleProp,
|
||||
type TextStyle,
|
||||
type ViewStyle,
|
||||
} from 'react-native';
|
||||
import { statusVisual, theme } from './theme';
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
Image,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
type StyleProp,
|
||||
type TextStyle,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { statusVisual, theme } from "./theme";
|
||||
// AO mascot glyph (transparent) shown beside each screen heading.
|
||||
import MASCOT from '../assets/mascot.png';
|
||||
import MASCOT from "../assets/mascot.png";
|
||||
|
||||
// A gently breathing dot — the only motion in the UI, reserved for "working".
|
||||
// Memoized so an unrelated parent re-render doesn't tear down and restart the
|
||||
// Animated loop (which causes a visible flicker and per-tick allocations).
|
||||
export const Dot = memo(function Dot({
|
||||
color,
|
||||
size = 9,
|
||||
breathing = false,
|
||||
color,
|
||||
size = 9,
|
||||
breathing = false,
|
||||
}: {
|
||||
color: string;
|
||||
size?: number;
|
||||
breathing?: boolean;
|
||||
color: string;
|
||||
size?: number;
|
||||
breathing?: boolean;
|
||||
}) {
|
||||
const pulse = useRef(new Animated.Value(1)).current;
|
||||
useEffect(() => {
|
||||
if (!breathing) return;
|
||||
const loop = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulse, { toValue: 0.35, duration: 1200, useNativeDriver: true }),
|
||||
Animated.timing(pulse, { toValue: 1, duration: 1200, useNativeDriver: true }),
|
||||
]),
|
||||
);
|
||||
loop.start();
|
||||
return () => loop.stop();
|
||||
}, [breathing, pulse]);
|
||||
const pulse = useRef(new Animated.Value(1)).current;
|
||||
useEffect(() => {
|
||||
if (!breathing) return;
|
||||
const loop = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulse, { toValue: 0.35, duration: 1200, useNativeDriver: true }),
|
||||
Animated.timing(pulse, { toValue: 1, duration: 1200, useNativeDriver: true }),
|
||||
]),
|
||||
);
|
||||
loop.start();
|
||||
return () => loop.stop();
|
||||
}, [breathing, pulse]);
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
backgroundColor: color,
|
||||
opacity: breathing ? pulse : 1,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Animated.View
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
backgroundColor: color,
|
||||
opacity: breathing ? pulse : 1,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
// A selectable pill — used by the project switcher, PR filters, and spawn picker
|
||||
// so the active/inactive color logic lives in exactly one place.
|
||||
export function Pill({
|
||||
label,
|
||||
active,
|
||||
onPress,
|
||||
style,
|
||||
textStyle,
|
||||
label,
|
||||
active,
|
||||
onPress,
|
||||
style,
|
||||
textStyle,
|
||||
}: {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
textStyle?: StyleProp<TextStyle>;
|
||||
label: string;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
textStyle?: StyleProp<TextStyle>;
|
||||
}) {
|
||||
return (
|
||||
<Pressable onPress={onPress} style={[s.pill, active && s.pillActive, style]}>
|
||||
<Text style={[s.pillText, active && s.pillTextActive, textStyle]}>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
return (
|
||||
<Pressable onPress={onPress} style={[s.pill, active && s.pillActive, style]}>
|
||||
<Text style={[s.pillText, active && s.pillTextActive, textStyle]}>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }: { status?: string | null }) {
|
||||
const v = statusVisual(status);
|
||||
return (
|
||||
<View style={s.badge}>
|
||||
<Dot color={v.color} breathing={v.breathing} size={8} />
|
||||
<Text style={[s.badgeText, { color: v.color }]}>{v.label}</Text>
|
||||
</View>
|
||||
);
|
||||
const v = statusVisual(status);
|
||||
return (
|
||||
<View style={s.badge}>
|
||||
<Dot color={v.color} breathing={v.breathing} size={8} />
|
||||
<Text style={[s.badgeText, { color: v.color }]}>{v.label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function Chip({
|
||||
label,
|
||||
color = theme.textSecondary,
|
||||
tint = theme.bgSubtle,
|
||||
mono = false,
|
||||
icon,
|
||||
label,
|
||||
color = theme.textSecondary,
|
||||
tint = theme.bgSubtle,
|
||||
mono = false,
|
||||
icon,
|
||||
}: {
|
||||
label: string;
|
||||
color?: string;
|
||||
tint?: string;
|
||||
mono?: boolean;
|
||||
icon?: keyof typeof Feather.glyphMap;
|
||||
label: string;
|
||||
color?: string;
|
||||
tint?: string;
|
||||
mono?: boolean;
|
||||
icon?: keyof typeof Feather.glyphMap;
|
||||
}) {
|
||||
return (
|
||||
<View style={[s.chip, { backgroundColor: tint }]}>
|
||||
{icon ? <Feather name={icon} size={11} color={color} style={{ marginRight: 4 }} /> : null}
|
||||
<Text
|
||||
style={[
|
||||
s.chipText,
|
||||
{ color },
|
||||
mono && { fontFamily: theme.fontMono, fontSize: 11 },
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
return (
|
||||
<View style={[s.chip, { backgroundColor: tint }]}>
|
||||
{icon ? <Feather name={icon} size={11} color={color} style={{ marginRight: 4 }} /> : null}
|
||||
<Text style={[s.chipText, { color }, mono && { fontFamily: theme.fontMono, fontSize: 11 }]} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function Card({
|
||||
children,
|
||||
onPress,
|
||||
style,
|
||||
children,
|
||||
onPress,
|
||||
style,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
onPress?: () => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
children: ReactNode;
|
||||
onPress?: () => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}) {
|
||||
if (!onPress) return <View style={[s.card, style]}>{children}</View>;
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={({ pressed }) => [s.card, pressed && s.cardPressed, style]}
|
||||
>
|
||||
{children}
|
||||
</Pressable>
|
||||
);
|
||||
if (!onPress) return <View style={[s.card, style]}>{children}</View>;
|
||||
return (
|
||||
<Pressable onPress={onPress} style={({ pressed }) => [s.card, pressed && s.cardPressed, style]}>
|
||||
{children}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionHeader({
|
||||
label,
|
||||
color,
|
||||
count,
|
||||
}: {
|
||||
label: string;
|
||||
color: string;
|
||||
count?: number;
|
||||
}) {
|
||||
return (
|
||||
<View style={s.sectionHeader}>
|
||||
<View style={[s.sectionBar, { backgroundColor: color }]} />
|
||||
<Text style={s.sectionLabel}>{label.toUpperCase()}</Text>
|
||||
{count !== undefined ? <Text style={s.sectionCount}>{count}</Text> : null}
|
||||
</View>
|
||||
);
|
||||
export function SectionHeader({ label, color, count }: { label: string; color: string; count?: number }) {
|
||||
return (
|
||||
<View style={s.sectionHeader}>
|
||||
<View style={[s.sectionBar, { backgroundColor: color }]} />
|
||||
<Text style={s.sectionLabel}>{label.toUpperCase()}</Text>
|
||||
{count !== undefined ? <Text style={s.sectionCount}>{count}</Text> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScreenHeader({
|
||||
title,
|
||||
subtitle,
|
||||
right,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
right?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<View style={s.screenHeader}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<View style={s.titleRow}>
|
||||
<Text style={s.screenTitle}>{title}</Text>
|
||||
<Image source={MASCOT} style={s.mascot} resizeMode="contain" />
|
||||
</View>
|
||||
{subtitle ? <Text style={s.screenSubtitle} numberOfLines={1}>{subtitle}</Text> : null}
|
||||
</View>
|
||||
{right}
|
||||
</View>
|
||||
);
|
||||
export function ScreenHeader({ title, subtitle, right }: { title: string; subtitle?: string; right?: ReactNode }) {
|
||||
return (
|
||||
<View style={s.screenHeader}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<View style={s.titleRow}>
|
||||
<Text style={s.screenTitle}>{title}</Text>
|
||||
<Image source={MASCOT} style={s.mascot} resizeMode="contain" />
|
||||
</View>
|
||||
{subtitle ? (
|
||||
<Text style={s.screenSubtitle} numberOfLines={1}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{right}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function Button({
|
||||
title,
|
||||
onPress,
|
||||
variant = 'primary',
|
||||
loading = false,
|
||||
disabled = false,
|
||||
icon,
|
||||
style,
|
||||
title,
|
||||
onPress,
|
||||
variant = "primary",
|
||||
loading = false,
|
||||
disabled = false,
|
||||
icon,
|
||||
style,
|
||||
}: {
|
||||
title: string;
|
||||
onPress: () => void;
|
||||
variant?: 'primary' | 'ghost' | 'danger';
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
icon?: keyof typeof Feather.glyphMap;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
title: string;
|
||||
onPress: () => void;
|
||||
variant?: "primary" | "ghost" | "danger";
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
icon?: keyof typeof Feather.glyphMap;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}) {
|
||||
const isPrimary = variant === 'primary';
|
||||
const isDanger = variant === 'danger';
|
||||
const fg = isPrimary ? '#06101f' : isDanger ? theme.red : theme.blue;
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={disabled || loading}
|
||||
style={({ pressed }) => [
|
||||
s.btn,
|
||||
isPrimary && s.btnPrimary,
|
||||
!isPrimary && s.btnGhost,
|
||||
isDanger && s.btnDanger,
|
||||
(disabled || loading) && { opacity: 0.5 },
|
||||
pressed && { opacity: 0.8 },
|
||||
style,
|
||||
]}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color={fg} size="small" />
|
||||
) : (
|
||||
<View style={s.btnInner}>
|
||||
{icon ? <Feather name={icon} size={15} color={fg} style={{ marginRight: 7 }} /> : null}
|
||||
<Text style={[s.btnText, { color: fg }]}>{title}</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
const isPrimary = variant === "primary";
|
||||
const isDanger = variant === "danger";
|
||||
const fg = isPrimary ? "#06101f" : isDanger ? theme.red : theme.blue;
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={disabled || loading}
|
||||
style={({ pressed }) => [
|
||||
s.btn,
|
||||
isPrimary && s.btnPrimary,
|
||||
!isPrimary && s.btnGhost,
|
||||
isDanger && s.btnDanger,
|
||||
(disabled || loading) && { opacity: 0.5 },
|
||||
pressed && { opacity: 0.8 },
|
||||
style,
|
||||
]}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color={fg} size="small" />
|
||||
) : (
|
||||
<View style={s.btnInner}>
|
||||
{icon ? <Feather name={icon} size={15} color={fg} style={{ marginRight: 7 }} /> : null}
|
||||
<Text style={[s.btnText, { color: fg }]}>{title}</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConnectionPill({ status }: { status: string }) {
|
||||
const color =
|
||||
status === 'open' ? theme.green : status === 'connecting' ? theme.amber : theme.textFaint;
|
||||
const label =
|
||||
status === 'open' ? 'live' : status === 'connecting' ? 'connecting' : 'offline';
|
||||
return (
|
||||
<View style={s.connPill}>
|
||||
<Dot color={color} size={6} breathing={status === 'connecting'} />
|
||||
<Text style={s.connText}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
const color = status === "open" ? theme.green : status === "connecting" ? theme.amber : theme.textFaint;
|
||||
const label = status === "open" ? "live" : status === "connecting" ? "connecting" : "offline";
|
||||
return (
|
||||
<View style={s.connPill}>
|
||||
<Dot color={color} size={6} breathing={status === "connecting"} />
|
||||
<Text style={s.connText}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon = 'inbox',
|
||||
title,
|
||||
message,
|
||||
action,
|
||||
icon = "inbox",
|
||||
title,
|
||||
message,
|
||||
action,
|
||||
}: {
|
||||
icon?: keyof typeof Feather.glyphMap;
|
||||
title: string;
|
||||
message?: string;
|
||||
action?: ReactNode;
|
||||
icon?: keyof typeof Feather.glyphMap;
|
||||
title: string;
|
||||
message?: string;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<View style={s.empty}>
|
||||
<View style={s.emptyIcon}>
|
||||
<Feather name={icon} size={26} color={theme.textTertiary} />
|
||||
</View>
|
||||
<Text style={s.emptyTitle}>{title}</Text>
|
||||
{message ? <Text style={s.emptyMsg}>{message}</Text> : null}
|
||||
{action ? <View style={{ marginTop: 18 }}>{action}</View> : null}
|
||||
</View>
|
||||
);
|
||||
return (
|
||||
<View style={s.empty}>
|
||||
<View style={s.emptyIcon}>
|
||||
<Feather name={icon} size={26} color={theme.textTertiary} />
|
||||
</View>
|
||||
<Text style={s.emptyTitle}>{title}</Text>
|
||||
{message ? <Text style={s.emptyMsg}>{message}</Text> : null}
|
||||
{action ? <View style={{ marginTop: 18 }}>{action}</View> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
badge: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||||
badgeText: { fontSize: 12, fontWeight: '600' },
|
||||
badge: { flexDirection: "row", alignItems: "center", gap: 6 },
|
||||
badgeText: { fontSize: 12, fontWeight: "600" },
|
||||
|
||||
pill: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 7,
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.borderDefault,
|
||||
backgroundColor: theme.bgElevated,
|
||||
},
|
||||
pillActive: { backgroundColor: theme.tintBlue, borderColor: theme.blue },
|
||||
pillText: { color: theme.textSecondary, fontSize: 13, fontWeight: '600' },
|
||||
pillTextActive: { color: theme.blue },
|
||||
pill: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 7,
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.borderDefault,
|
||||
backgroundColor: theme.bgElevated,
|
||||
},
|
||||
pillActive: { backgroundColor: theme.tintBlue, borderColor: theme.blue },
|
||||
pillText: { color: theme.textSecondary, fontSize: 13, fontWeight: "600" },
|
||||
pillTextActive: { color: theme.blue },
|
||||
|
||||
chip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 6,
|
||||
},
|
||||
chipText: { fontSize: 11, fontWeight: '600' },
|
||||
chip: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 6,
|
||||
},
|
||||
chipText: { fontSize: 11, fontWeight: "600" },
|
||||
|
||||
card: {
|
||||
backgroundColor: theme.bgElevated,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.borderSubtle,
|
||||
padding: 14,
|
||||
},
|
||||
cardPressed: { backgroundColor: theme.bgElevatedHover, borderColor: theme.borderDefault },
|
||||
card: {
|
||||
backgroundColor: theme.bgElevated,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.borderSubtle,
|
||||
padding: 14,
|
||||
},
|
||||
cardPressed: { backgroundColor: theme.bgElevatedHover, borderColor: theme.borderDefault },
|
||||
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 20,
|
||||
paddingBottom: 10,
|
||||
gap: 9,
|
||||
},
|
||||
sectionBar: { width: 3, height: 13, borderRadius: 2 },
|
||||
sectionLabel: {
|
||||
color: theme.textSecondary,
|
||||
fontSize: 11,
|
||||
letterSpacing: 1.2,
|
||||
fontWeight: '700',
|
||||
flex: 1,
|
||||
},
|
||||
sectionCount: {
|
||||
color: theme.textTertiary,
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
fontFamily: theme.fontMono,
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 20,
|
||||
paddingBottom: 10,
|
||||
gap: 9,
|
||||
},
|
||||
sectionBar: { width: 3, height: 13, borderRadius: 2 },
|
||||
sectionLabel: {
|
||||
color: theme.textSecondary,
|
||||
fontSize: 11,
|
||||
letterSpacing: 1.2,
|
||||
fontWeight: "700",
|
||||
flex: 1,
|
||||
},
|
||||
sectionCount: {
|
||||
color: theme.textTertiary,
|
||||
fontSize: 12,
|
||||
fontWeight: "700",
|
||||
fontFamily: theme.fontMono,
|
||||
},
|
||||
|
||||
screenHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 10,
|
||||
gap: 12,
|
||||
},
|
||||
titleRow: { flexDirection: 'row', alignItems: 'center', gap: 9 },
|
||||
mascot: { width: 30, height: 26, marginTop: 3 },
|
||||
screenTitle: { color: theme.textPrimary, fontSize: 26, fontWeight: '800', letterSpacing: -0.5 },
|
||||
screenSubtitle: { color: theme.textTertiary, fontSize: 12, marginTop: 1 },
|
||||
screenHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 10,
|
||||
gap: 12,
|
||||
},
|
||||
titleRow: { flexDirection: "row", alignItems: "center", gap: 9 },
|
||||
mascot: { width: 30, height: 26, marginTop: 3 },
|
||||
screenTitle: { color: theme.textPrimary, fontSize: 26, fontWeight: "800", letterSpacing: -0.5 },
|
||||
screenSubtitle: { color: theme.textTertiary, fontSize: 12, marginTop: 1 },
|
||||
|
||||
btn: { borderRadius: 10, paddingVertical: 13, paddingHorizontal: 16, alignItems: 'center' },
|
||||
btnInner: { flexDirection: 'row', alignItems: 'center' },
|
||||
btnPrimary: { backgroundColor: theme.blue },
|
||||
btnGhost: { borderWidth: 1, borderColor: theme.borderStrong, backgroundColor: theme.bgElevated },
|
||||
btnDanger: { borderColor: theme.tintRed, backgroundColor: theme.tintRed },
|
||||
btnText: { fontSize: 15, fontWeight: '700' },
|
||||
btn: { borderRadius: 10, paddingVertical: 13, paddingHorizontal: 16, alignItems: "center" },
|
||||
btnInner: { flexDirection: "row", alignItems: "center" },
|
||||
btnPrimary: { backgroundColor: theme.blue },
|
||||
btnGhost: { borderWidth: 1, borderColor: theme.borderStrong, backgroundColor: theme.bgElevated },
|
||||
btnDanger: { borderColor: theme.tintRed, backgroundColor: theme.tintRed },
|
||||
btnText: { fontSize: 15, fontWeight: "700" },
|
||||
|
||||
connPill: { flexDirection: 'row', alignItems: 'center', gap: 5 },
|
||||
connText: { color: theme.textTertiary, fontSize: 11, fontWeight: '600' },
|
||||
connPill: { flexDirection: "row", alignItems: "center", gap: 5 },
|
||||
connText: { color: theme.textTertiary, fontSize: 11, fontWeight: "600" },
|
||||
|
||||
empty: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 40, minHeight: 320 },
|
||||
emptyIcon: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 18,
|
||||
backgroundColor: theme.bgElevated,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.borderSubtle,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 18,
|
||||
},
|
||||
emptyTitle: { color: theme.textPrimary, fontSize: 17, fontWeight: '700', textAlign: 'center' },
|
||||
emptyMsg: {
|
||||
color: theme.textSecondary,
|
||||
fontSize: 13,
|
||||
lineHeight: 20,
|
||||
textAlign: 'center',
|
||||
marginTop: 8,
|
||||
maxWidth: 300,
|
||||
},
|
||||
empty: { flex: 1, alignItems: "center", justifyContent: "center", padding: 40, minHeight: 320 },
|
||||
emptyIcon: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 18,
|
||||
backgroundColor: theme.bgElevated,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.borderSubtle,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginBottom: 18,
|
||||
},
|
||||
emptyTitle: { color: theme.textPrimary, fontSize: 17, fontWeight: "700", textAlign: "center" },
|
||||
emptyMsg: {
|
||||
color: theme.textSecondary,
|
||||
fontSize: 13,
|
||||
lineHeight: 20,
|
||||
textAlign: "center",
|
||||
marginTop: 8,
|
||||
maxWidth: 300,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,38 +1,38 @@
|
|||
{
|
||||
"name": "ao-mobile",
|
||||
"version": "1.0.0",
|
||||
"main": "expo-router/entry",
|
||||
"packageManager": "npm@10.9.2",
|
||||
"dependencies": {
|
||||
"@fressh/react-native-xtermjs-webview": "^0.0.8",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"expo": "^54.0.35",
|
||||
"expo-build-properties": "~1.0.10",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-linking": "~8.0.12",
|
||||
"expo-router": "~6.0.24",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-safe-area-context": "~5.6.2",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-web": "^0.21.2",
|
||||
"react-native-webview": "13.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.1.10",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"overrides": {
|
||||
"postcss": "^8.5.10",
|
||||
"uuid": "^11.1.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"private": true
|
||||
"name": "ao-mobile",
|
||||
"version": "1.0.0",
|
||||
"main": "expo-router/entry",
|
||||
"packageManager": "npm@10.9.2",
|
||||
"dependencies": {
|
||||
"@fressh/react-native-xtermjs-webview": "^0.0.8",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"expo": "^54.0.35",
|
||||
"expo-build-properties": "~1.0.10",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-linking": "~8.0.12",
|
||||
"expo-router": "~6.0.24",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-safe-area-context": "~5.6.2",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-web": "^0.21.2",
|
||||
"react-native-webview": "13.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.1.10",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"overrides": {
|
||||
"postcss": "^8.5.10",
|
||||
"uuid": "^11.1.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
}
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue