Hermes Agent commited on
Commit
4e89d90
·
1 Parent(s): 81fbcdd

Clean up all legacy OmniRoute and OpenCode references across configuration, gateway, reverse proxy, index landing, and mobile app directory

Browse files
README.md CHANGED
@@ -8,14 +8,12 @@ app_port: 4096
8
  pinned: false
9
  ---
10
 
11
- # Opencode CLI Server
12
 
13
- This Hugging Face Space hosts the backend `opencode serve` API server on port 4096.
14
 
15
- ## Connection Setup for Mobile App
16
 
17
- To connect your OpenCode mobile client to this server:
18
- 1. Open the mobile app and go to **Settings**.
19
- 2. Under **Server URL**, enter the Space's direct domain:
20
- `https://jishnupg-opencode-cli.hf.space` (or your corresponding Space URL)
21
- 3. Set your **Auth Type** and credentials if you configured `OPENCODE_SERVER_USERNAME`/`OPENCODE_SERVER_PASSWORD` secrets.
 
8
  pinned: false
9
  ---
10
 
11
+ # Multi-Service Container Hub
12
 
13
+ This Hugging Face Space hosts a multi-service container hub on port 4096.
14
 
15
+ ## Services
16
 
17
+ 1. **Open WebUI** (`/`): Feature-rich AI chat interface.
18
+ 2. **Jellyfin Media Server** (`/jellyfin/`): Media streaming server.
19
+ 3. **Telegram Stream Proxy** (`/tg-stream/`): High-speed MTProto video range streamer for Telegram Drive.
 
 
entrypoint.sh CHANGED
@@ -10,20 +10,9 @@ git config --global --add safe.directory '*' 2>/dev/null || true
10
 
11
  # /data is the persistent HF dataset bucket mount
12
  echo "[INIT] Setting up /data directories..."
13
- mkdir -p /data/share/opencode 2>/dev/null || echo "[WARN] Could not create /data/share/opencode"
14
- mkdir -p /data/config/opencode 2>/dev/null || echo "[WARN] Could not create /data/config/opencode"
15
- mkdir -p /data/cache/opencode 2>/dev/null || echo "[WARN] Could not create /data/cache/opencode"
16
- mkdir -p /data/state/opencode 2>/dev/null || echo "[WARN] Could not create /data/state/opencode"
17
  mkdir -p /data/open-webui 2>/dev/null || echo "[WARN] Could not create /data/open-webui"
18
- mkdir -p /data/omniroute 2>/dev/null || echo "[WARN] Could not create /data/omniroute"
19
  mkdir -p /data/jellyfin/data /data/jellyfin/config /data/jellyfin/cache /data/jellyfin/log /data/jellyfin/media/Movies /data/jellyfin/media/TVShows 2>/dev/null || true
20
 
21
- # Symlink OmniRoute data directory to /data/omniroute for 100% persistence
22
- if [ ! -L "/root/.omniroute" ]; then
23
- rm -rf /root/.omniroute 2>/dev/null || true
24
- ln -sf /data/omniroute /root/.omniroute
25
- fi
26
-
27
  # Start Telegram Direct Range Stream Proxy in background
28
  echo "[INIT] Starting Telegram Direct Stream Proxy on port 8080..."
29
  python3 /tg_streamer.py &
@@ -54,16 +43,15 @@ chmod -R 777 /root/.cache /data/cache 2>/dev/null || true
54
  sleep 2
55
 
56
  # Pre-configure & Start Open WebUI on port 8098 (Mounted at / via FastAPI proxy)
57
- echo "[INIT] Starting Open WebUI on port 8098 pre-configured with OmniRoute..."
58
  if command -v open-webui >/dev/null 2>&1; then
59
- # Tell Open WebUI its public-facing URL (required to avoid "Backend Required" error)
60
- export WEBUI_URL="https://jishnupg-opencode-cli.hf.space"
61
- export OPENAI_API_BASE_URL="http://127.0.0.1:20128/v1"
62
- export OPENAI_API_KEY="omniroute"
63
  export WEBUI_SECRET_KEY="${WEBUI_SECRET_KEY:-opencode_webui_jwt_secret_2026}"
64
- export ENABLE_OLLAMA_API="false"
65
- export ENABLE_OPENAI_API="true"
66
- # Ensure OmniRoute is NOT registered as an OpenAPI tool server (fixes 500 ContentTypeError crash)
67
  export TOOL_SERVERS=""
68
  export OPENAPI_TOOL_SERVERS=""
69
  export PORT=8098
@@ -73,7 +61,7 @@ if command -v open-webui >/dev/null 2>&1; then
73
  rm -rf /root/.open-webui 2>/dev/null || true
74
  ln -sf /data/open-webui /root/.open-webui
75
  fi
76
- # Sanitize any stale OpenAPI tool server entries in webui.db
77
  python3 -c "
78
  import sqlite3, os
79
  db_p = '/data/open-webui/webui.db'
@@ -104,10 +92,6 @@ fi
104
  echo "[DISK] /data usage:"
105
  df -h /data 2>/dev/null || echo "[WARN] Could not check /data disk space"
106
 
107
- # Remove stale SQLite WAL/lock files to prevent "database is locked" on startup
108
- rm -f /data/omniroute/*.sqlite-wal /data/omniroute/*.sqlite-shm /data/omniroute/*.lock 2>/dev/null || true
109
- rm -f /root/.omniroute/*.sqlite-wal /root/.omniroute/*.sqlite-shm /root/.omniroute/*.lock 2>/dev/null || true
110
-
111
  echo "============================================"
112
  echo "=== Launching FastAPI Gateway Proxy on Port 4096 ==="
113
  echo "============================================"
 
10
 
11
  # /data is the persistent HF dataset bucket mount
12
  echo "[INIT] Setting up /data directories..."
 
 
 
 
13
  mkdir -p /data/open-webui 2>/dev/null || echo "[WARN] Could not create /data/open-webui"
 
14
  mkdir -p /data/jellyfin/data /data/jellyfin/config /data/jellyfin/cache /data/jellyfin/log /data/jellyfin/media/Movies /data/jellyfin/media/TVShows 2>/dev/null || true
15
 
 
 
 
 
 
 
16
  # Start Telegram Direct Range Stream Proxy in background
17
  echo "[INIT] Starting Telegram Direct Stream Proxy on port 8080..."
18
  python3 /tg_streamer.py &
 
43
  sleep 2
44
 
45
  # Pre-configure & Start Open WebUI on port 8098 (Mounted at / via FastAPI proxy)
46
+ echo "[INIT] Starting Open WebUI on port 8098..."
47
  if command -v open-webui >/dev/null 2>&1; then
48
+ # Tell Open WebUI its public-facing URL
49
+ export WEBUI_URL="${WEBUI_URL:-https://jishnupg-opencode-cli.hf.space}"
50
+ export OPENAI_API_BASE_URL="${OPENAI_API_BASE_URL:-https://api.openai.com/v1}"
51
+ export OPENAI_API_KEY="${OPENAI_API_KEY:-}"
52
  export WEBUI_SECRET_KEY="${WEBUI_SECRET_KEY:-opencode_webui_jwt_secret_2026}"
53
+ export ENABLE_OLLAMA_API="${ENABLE_OLLAMA_API:-false}"
54
+ export ENABLE_OPENAI_API="${ENABLE_OPENAI_API:-true}"
 
55
  export TOOL_SERVERS=""
56
  export OPENAPI_TOOL_SERVERS=""
57
  export PORT=8098
 
61
  rm -rf /root/.open-webui 2>/dev/null || true
62
  ln -sf /data/open-webui /root/.open-webui
63
  fi
64
+ # Sanitize any stale tool server entries in webui.db
65
  python3 -c "
66
  import sqlite3, os
67
  db_p = '/data/open-webui/webui.db'
 
92
  echo "[DISK] /data usage:"
93
  df -h /data 2>/dev/null || echo "[WARN] Could not check /data disk space"
94
 
 
 
 
 
95
  echo "============================================"
96
  echo "=== Launching FastAPI Gateway Proxy on Port 4096 ==="
97
  echo "============================================"
gateway/utils.py CHANGED
@@ -20,8 +20,6 @@ logger = logging.getLogger("GatewayUtils")
20
 
21
  # Port definitions
22
  WEBUI_PORT = int(os.environ.get("WEBUI_PORT", 8098))
23
- OMNIROUTE_PORT = int(os.environ.get("OMNIROUTE_PORT", 20128))
24
- OPENCODE_PORT = int(os.environ.get("OPENCODE_PORT", 4097))
25
  JELLYFIN_PORT = int(os.environ.get("JELLYFIN_PORT", 8096))
26
  TG_PORT = int(os.environ.get("TG_PORT", 8080))
27
  GATEWAY_PORT = int(os.environ.get("PORT", 4096))
@@ -29,16 +27,14 @@ GATEWAY_PORT = int(os.environ.get("PORT", 4096))
29
  PUBLIC_HOST = os.environ.get("PUBLIC_HOST", "jishnupg-opencode-cli.hf.space")
30
  PUBLIC_ORIGIN = f"https://{PUBLIC_HOST}"
31
 
32
- _INTERNAL_PORTS = {GATEWAY_PORT, WEBUI_PORT, OMNIROUTE_PORT, OPENCODE_PORT, JELLYFIN_PORT, TG_PORT}
33
  _PORT_STRIP_RE = re.compile(r"(https?://[^/:]+):(" + "|".join(str(p) for p in _INTERNAL_PORTS) + r")")
34
 
35
  _PORT_PREFIX_MAP = {
36
- GATEWAY_PORT: "",
37
- OMNIROUTE_PORT: "/omniroute",
38
- WEBUI_PORT: "/openwebui",
39
- OPENCODE_PORT: "/server",
40
- JELLYFIN_PORT: "/jellyfin",
41
- TG_PORT: "/tg-stream",
42
  }
43
 
44
  # Shared Async HTTPX Client Instance
 
20
 
21
  # Port definitions
22
  WEBUI_PORT = int(os.environ.get("WEBUI_PORT", 8098))
 
 
23
  JELLYFIN_PORT = int(os.environ.get("JELLYFIN_PORT", 8096))
24
  TG_PORT = int(os.environ.get("TG_PORT", 8080))
25
  GATEWAY_PORT = int(os.environ.get("PORT", 4096))
 
27
  PUBLIC_HOST = os.environ.get("PUBLIC_HOST", "jishnupg-opencode-cli.hf.space")
28
  PUBLIC_ORIGIN = f"https://{PUBLIC_HOST}"
29
 
30
+ _INTERNAL_PORTS = {GATEWAY_PORT, WEBUI_PORT, JELLYFIN_PORT, TG_PORT}
31
  _PORT_STRIP_RE = re.compile(r"(https?://[^/:]+):(" + "|".join(str(p) for p in _INTERNAL_PORTS) + r")")
32
 
33
  _PORT_PREFIX_MAP = {
34
+ GATEWAY_PORT: "",
35
+ WEBUI_PORT: "/openwebui",
36
+ JELLYFIN_PORT: "/jellyfin",
37
+ TG_PORT: "/tg-stream",
 
 
38
  }
39
 
40
  # Shared Async HTTPX Client Instance
index.html CHANGED
@@ -150,62 +150,47 @@
150
 
151
  <div class="grid">
152
  <!-- Open WebUI Card -->
153
- <a href="/openwebui/" class="card">
154
  <div>
155
  <div class="card-header">
156
  <span class="icon">🤖</span>
157
  <span class="badge">Online</span>
158
  </div>
159
  <h2>Open WebUI</h2>
160
- <p>Feature-rich AI chat interface connected to OmniRoute models.</p>
161
  </div>
162
  <div class="card-footer">
163
  Launch Open WebUI &rarr;
164
  </div>
165
  </a>
166
 
167
- <!-- OmniRoute Card -->
168
- <a href="/omniroute/" class="card">
169
- <div>
170
- <div class="card-header">
171
- <span class="icon">⚡</span>
172
- <span class="badge">Online</span>
173
- </div>
174
- <h2>OmniRoute AI Gateway</h2>
175
- <p>AI model router dashboard and multi-provider management.</p>
176
- </div>
177
- <div class="card-footer">
178
- Launch OmniRoute &rarr;
179
- </div>
180
- </a>
181
-
182
- <!-- OpenCode Server Card -->
183
- <a href="/server/" class="card">
184
  <div>
185
  <div class="card-header">
186
- <span class="icon">💻</span>
187
  <span class="badge">Online</span>
188
  </div>
189
- <h2>OpenCode Server</h2>
190
- <p>Backend API server for OpenCode mobile client and remote CLI.</p>
191
  </div>
192
  <div class="card-footer">
193
- Launch Server API &rarr;
194
  </div>
195
  </a>
196
 
197
- <!-- Jellyfin Card -->
198
- <a href="/jellyfin/" class="card">
199
  <div>
200
  <div class="card-header">
201
- <span class="icon">🎬</span>
202
  <span class="badge">Online</span>
203
  </div>
204
- <h2>Jellyfin Media Server</h2>
205
- <p>Stream movies, TV shows, and Telegram Drive media files.</p>
206
  </div>
207
  <div class="card-footer">
208
- Launch Jellyfin &rarr;
209
  </div>
210
  </a>
211
  </div>
 
150
 
151
  <div class="grid">
152
  <!-- Open WebUI Card -->
153
+ <a href="/" class="card">
154
  <div>
155
  <div class="card-header">
156
  <span class="icon">🤖</span>
157
  <span class="badge">Online</span>
158
  </div>
159
  <h2>Open WebUI</h2>
160
+ <p>Feature-rich AI chat interface connected to AI model endpoints.</p>
161
  </div>
162
  <div class="card-footer">
163
  Launch Open WebUI &rarr;
164
  </div>
165
  </a>
166
 
167
+ <!-- Jellyfin Card -->
168
+ <a href="/jellyfin/" class="card">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  <div>
170
  <div class="card-header">
171
+ <span class="icon">🎬</span>
172
  <span class="badge">Online</span>
173
  </div>
174
+ <h2>Jellyfin Media Server</h2>
175
+ <p>Stream movies, TV shows, and Telegram Drive media files.</p>
176
  </div>
177
  <div class="card-footer">
178
+ Launch Jellyfin &rarr;
179
  </div>
180
  </a>
181
 
182
+ <!-- TG Streamer Card -->
183
+ <a href="/tg-stream/health" class="card">
184
  <div>
185
  <div class="card-header">
186
+ <span class="icon">📡</span>
187
  <span class="badge">Online</span>
188
  </div>
189
+ <h2>Telegram Streamer</h2>
190
+ <p>High-speed MTProto 5G range streaming proxy for Telegram Drive.</p>
191
  </div>
192
  <div class="card-footer">
193
+ Check Status &rarr;
194
  </div>
195
  </a>
196
  </div>
mobile/app.json DELETED
@@ -1,22 +0,0 @@
1
- {
2
- "expo": {
3
- "name": "OpenCode Mobile",
4
- "slug": "opencode-mobile",
5
- "version": "1.0.0",
6
- "orientation": "portrait",
7
- "scheme": "opencode",
8
- "platforms": ["android"],
9
- "android": {
10
- "package": "com.opencode.mobile",
11
- "adaptiveIcon": {
12
- "backgroundColor": "#1a1b26"
13
- }
14
- },
15
- "plugins": [
16
- "expo-asset",
17
- "expo-font",
18
- "expo-router",
19
- "expo-secure-store"
20
- ]
21
- }
22
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
mobile/app/(tabs)/_layout.tsx DELETED
@@ -1,69 +0,0 @@
1
- import { Tabs } from "expo-router";
2
- import { Home, MessageSquare, FolderOpen, Search, Terminal } from "lucide-react-native";
3
- import { getTheme } from "../../lib/storage";
4
- import { themes } from "../../constants/themes";
5
-
6
- export default function TabLayout() {
7
- const theme = themes[getTheme()];
8
-
9
- const icons = {
10
- index: Home,
11
- sessions: MessageSquare,
12
- files: FolderOpen,
13
- search: Search,
14
- terminal: Terminal,
15
- };
16
-
17
- return (
18
- <Tabs
19
- screenOptions={{
20
- headerShown: false,
21
- tabBarStyle: {
22
- backgroundColor: theme.colors.bgSecondary,
23
- borderTopColor: theme.colors.border,
24
- height: 60,
25
- paddingBottom: 8,
26
- paddingTop: 8,
27
- },
28
- tabBarActiveTintColor: theme.colors.primary,
29
- tabBarInactiveTintColor: theme.colors.textMuted,
30
- }}
31
- >
32
- <Tabs.Screen
33
- name="index"
34
- options={{
35
- title: "Home",
36
- tabBarIcon: ({ color, size }) => <icons.index size={size} color={color} />,
37
- }}
38
- />
39
- <Tabs.Screen
40
- name="sessions"
41
- options={{
42
- title: "Sessions",
43
- tabBarIcon: ({ color, size }) => <icons.sessions size={size} color={color} />,
44
- }}
45
- />
46
- <Tabs.Screen
47
- name="files"
48
- options={{
49
- title: "Files",
50
- tabBarIcon: ({ color, size }) => <icons.files size={size} color={color} />,
51
- }}
52
- />
53
- <Tabs.Screen
54
- name="search"
55
- options={{
56
- title: "Search",
57
- tabBarIcon: ({ color, size }) => <icons.search size={size} color={color} />,
58
- }}
59
- />
60
- <Tabs.Screen
61
- name="terminal"
62
- options={{
63
- title: "Terminal",
64
- tabBarIcon: ({ color, size }) => <icons.terminal size={size} color={color} />,
65
- }}
66
- />
67
- </Tabs>
68
- );
69
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
mobile/app/(tabs)/files.tsx DELETED
@@ -1,153 +0,0 @@
1
- import { View, Text, TextInput, TouchableOpacity, FlatList, StyleSheet } from "react-native";
2
- import { useState } from "react";
3
- import { useSafeAreaInsets } from "react-native-safe-area-context";
4
- import { Search, ChevronRight, Folder, File, ArrowLeft } from "lucide-react-native";
5
- import { useListFiles } from "../../hooks/useApi";
6
- import { getTheme } from "../../lib/storage";
7
- import { themes } from "../../constants/themes";
8
-
9
- export default function FilesScreen() {
10
- const insets = useSafeAreaInsets();
11
- const theme = themes[getTheme()];
12
- const [currentDir, setCurrentDir] = useState(".");
13
- const [search, setSearch] = useState("");
14
- const { data: files, isLoading } = useListFiles(currentDir);
15
-
16
- const filtered = (files || []).filter(
17
- (f) => !search || f.name.toLowerCase().includes(search.toLowerCase())
18
- );
19
-
20
- const dirs = currentDir === "." ? [] : currentDir.split("/");
21
- const goBack = () => {
22
- if (dirs.length <= 1) {
23
- setCurrentDir(".");
24
- } else {
25
- setCurrentDir(dirs.slice(0, -1).join("/"));
26
- }
27
- };
28
-
29
- return (
30
- <View style={[styles.container, { backgroundColor: theme.colors.bg }]}>
31
- <View style={[styles.header, { paddingTop: insets.top + 12 }]}>
32
- {currentDir !== "." && (
33
- <TouchableOpacity onPress={goBack} style={styles.backBtn}>
34
- <ArrowLeft size={20} color={theme.colors.text} />
35
- </TouchableOpacity>
36
- )}
37
- <Text style={[styles.title, { color: theme.colors.text }]} numberOfLines={1}>
38
- {currentDir === "." ? "Files" : dirs[dirs.length - 1]}
39
- </Text>
40
- </View>
41
-
42
- <View style={styles.breadcrumb}>
43
- {dirs.map((d, i) => (
44
- <TouchableOpacity
45
- key={i}
46
- onPress={() => setCurrentDir(dirs.slice(0, i + 1).join("/"))}
47
- style={styles.breadcrumbItem}
48
- >
49
- <Text style={[styles.breadcrumbText, { color: theme.colors.primary }]}>{d}</Text>
50
- <ChevronRight size={12} color={theme.colors.textMuted} />
51
- </TouchableOpacity>
52
- ))}
53
- {dirs.length === 0 && (
54
- <Text style={[styles.breadcrumbText, { color: theme.colors.textMuted }]}>root</Text>
55
- )}
56
- </View>
57
-
58
- <View style={[styles.searchBar, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
59
- <Search size={16} color={theme.colors.textMuted} />
60
- <TextInput
61
- style={[styles.searchInput, { color: theme.colors.text }]}
62
- placeholder="Filter files..."
63
- placeholderTextColor={theme.colors.textMuted}
64
- value={search}
65
- onChangeText={setSearch}
66
- />
67
- </View>
68
-
69
- <FlatList
70
- data={filtered}
71
- keyExtractor={(item) => item.path}
72
- contentContainerStyle={{ paddingBottom: insets.bottom + 100, paddingHorizontal: 20 }}
73
- showsVerticalScrollIndicator={false}
74
- ListEmptyComponent={
75
- <View style={styles.empty}>
76
- <Folder size={40} color={theme.colors.textMuted} />
77
- <Text style={[styles.emptyText, { color: theme.colors.textMuted }]}>
78
- {isLoading ? "Loading..." : "Empty"}
79
- </Text>
80
- </View>
81
- }
82
- renderItem={({ item }) => (
83
- <TouchableOpacity
84
- style={[styles.fileItem, { backgroundColor: theme.colors.surface }]}
85
- onPress={() => {
86
- if (item.type === "directory") {
87
- setCurrentDir(item.path);
88
- }
89
- }}
90
- >
91
- {item.type === "directory" ? (
92
- <Folder size={18} color={theme.colors.warning} />
93
- ) : (
94
- <File size={18} color={theme.colors.textMuted} />
95
- )}
96
- <Text style={[styles.fileName, { color: theme.colors.text }]} numberOfLines={1}>
97
- {item.name}
98
- </Text>
99
- {item.type === "directory" && (
100
- <ChevronRight size={16} color={theme.colors.textMuted} />
101
- )}
102
- </TouchableOpacity>
103
- )}
104
- />
105
- </View>
106
- );
107
- }
108
-
109
- const styles = StyleSheet.create({
110
- container: { flex: 1 },
111
- header: {
112
- flexDirection: "row",
113
- alignItems: "center",
114
- gap: 10,
115
- paddingHorizontal: 20,
116
- paddingBottom: 12,
117
- },
118
- backBtn: { padding: 4 },
119
- title: { fontSize: 28, fontWeight: "700", letterSpacing: -0.5, flex: 1 },
120
- breadcrumb: {
121
- flexDirection: "row",
122
- alignItems: "center",
123
- flexWrap: "wrap",
124
- paddingHorizontal: 20,
125
- marginBottom: 12,
126
- gap: 4,
127
- },
128
- breadcrumbItem: { flexDirection: "row", alignItems: "center", gap: 4 },
129
- breadcrumbText: { fontSize: 13 },
130
- searchBar: {
131
- flexDirection: "row",
132
- alignItems: "center",
133
- gap: 8,
134
- marginHorizontal: 20,
135
- marginBottom: 16,
136
- paddingHorizontal: 14,
137
- paddingVertical: 10,
138
- borderRadius: 10,
139
- borderWidth: 1,
140
- },
141
- searchInput: { flex: 1, fontSize: 14, padding: 0 },
142
- fileItem: {
143
- flexDirection: "row",
144
- alignItems: "center",
145
- gap: 10,
146
- padding: 12,
147
- borderRadius: 10,
148
- marginBottom: 4,
149
- },
150
- fileName: { flex: 1, fontSize: 14 },
151
- empty: { alignItems: "center", marginTop: 80, gap: 12 },
152
- emptyText: { fontSize: 14 },
153
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
mobile/app/(tabs)/index.tsx DELETED
@@ -1,140 +0,0 @@
1
- import { View, Text, TouchableOpacity, ScrollView, StyleSheet } from "react-native";
2
- import { useRouter } from "expo-router";
3
- import { useSafeAreaInsets } from "react-native-safe-area-context";
4
- import { Plus, Wifi, WifiOff, Settings } from "lucide-react-native";
5
- import { useSessions, useCreateSession } from "../../hooks/useApi";
6
- import { useConnection } from "../../hooks/useConnection";
7
- import { getTheme } from "../../lib/storage";
8
- import { themes } from "../../constants/themes";
9
-
10
- export default function HomeScreen() {
11
- const router = useRouter();
12
- const insets = useSafeAreaInsets();
13
- const theme = themes[getTheme()];
14
- const { isConnected, state: connState } = useConnection();
15
- const { data: sessions } = useSessions();
16
- const createSession = useCreateSession();
17
-
18
- const handleNewSession = async () => {
19
- try {
20
- const session = await createSession.mutateAsync({ title: "New Chat", directory: "default" });
21
- router.push(`/session/${session.id}`);
22
- } catch {}
23
- };
24
-
25
- const recentSessions = (sessions || []).slice(0, 5);
26
-
27
- return (
28
- <View style={[styles.container, { backgroundColor: theme.colors.bg }]}>
29
- <View style={[styles.header, { paddingTop: insets.top + 12 }]}>
30
- <Text style={[styles.title, { color: theme.colors.text }]}>OpenCode</Text>
31
- <TouchableOpacity onPress={() => router.push("/settings")}>
32
- <Settings size={22} color={theme.colors.textMuted} />
33
- </TouchableOpacity>
34
- </View>
35
-
36
- <ScrollView
37
- style={styles.scroll}
38
- contentContainerStyle={{ paddingBottom: insets.bottom + 100 }}
39
- showsVerticalScrollIndicator={false}
40
- >
41
- <View
42
- style={[
43
- styles.statusCard,
44
- {
45
- backgroundColor: isConnected ? theme.colors.bgTertiary : theme.colors.surface,
46
- borderColor: isConnected ? theme.colors.success : theme.colors.danger,
47
- },
48
- ]}
49
- >
50
- {isConnected ? (
51
- <Wifi size={18} color={theme.colors.success} />
52
- ) : (
53
- <WifiOff size={18} color={theme.colors.danger} />
54
- )}
55
- <Text style={[styles.statusText, { color: theme.colors.text }]}>
56
- {isConnected ? "Connected" : connState === "reconnecting" ? "Reconnecting..." : "Disconnected"}
57
- </Text>
58
- </View>
59
-
60
- <TouchableOpacity
61
- style={[styles.newSessionBtn, { backgroundColor: theme.colors.primary }]}
62
- onPress={handleNewSession}
63
- activeOpacity={0.8}
64
- >
65
- <Plus size={20} color={theme.colors.primaryText} />
66
- <Text style={[styles.newSessionText, { color: theme.colors.primaryText }]}>
67
- New Session
68
- </Text>
69
- </TouchableOpacity>
70
-
71
- {recentSessions.length > 0 && (
72
- <View style={styles.section}>
73
- <Text style={[styles.sectionTitle, { color: theme.colors.textSecondary }]}>
74
- Recent Sessions
75
- </Text>
76
- {recentSessions.map((s) => (
77
- <TouchableOpacity
78
- key={s.id}
79
- style={[styles.sessionItem, { backgroundColor: theme.colors.surface }]}
80
- onPress={() => router.push(`/session/${s.id}`)}
81
- >
82
- <Text style={[styles.sessionTitle, { color: theme.colors.text }]} numberOfLines={1}>
83
- {s.title || "Untitled"}
84
- </Text>
85
- <Text style={[styles.sessionTime, { color: theme.colors.textMuted }]}>
86
- {new Date(s.updatedAt).toLocaleDateString()}
87
- </Text>
88
- </TouchableOpacity>
89
- ))}
90
- </View>
91
- )}
92
- </ScrollView>
93
- </View>
94
- );
95
- }
96
-
97
- const styles = StyleSheet.create({
98
- container: { flex: 1 },
99
- header: {
100
- flexDirection: "row",
101
- justifyContent: "space-between",
102
- alignItems: "center",
103
- paddingHorizontal: 20,
104
- paddingBottom: 12,
105
- },
106
- title: { fontSize: 28, fontWeight: "700", letterSpacing: -0.5 },
107
- scroll: { flex: 1, paddingHorizontal: 20 },
108
- statusCard: {
109
- flexDirection: "row",
110
- alignItems: "center",
111
- gap: 10,
112
- padding: 14,
113
- borderRadius: 12,
114
- marginBottom: 20,
115
- borderWidth: 1,
116
- },
117
- statusText: { fontSize: 14, fontWeight: "500" },
118
- newSessionBtn: {
119
- flexDirection: "row",
120
- alignItems: "center",
121
- justifyContent: "center",
122
- gap: 8,
123
- padding: 16,
124
- borderRadius: 14,
125
- marginBottom: 28,
126
- },
127
- newSessionText: { fontSize: 16, fontWeight: "600" },
128
- section: { marginBottom: 24 },
129
- sectionTitle: { fontSize: 13, fontWeight: "600", textTransform: "uppercase", letterSpacing: 0.5, marginBottom: 10 },
130
- sessionItem: {
131
- flexDirection: "row",
132
- justifyContent: "space-between",
133
- alignItems: "center",
134
- padding: 14,
135
- borderRadius: 10,
136
- marginBottom: 6,
137
- },
138
- sessionTitle: { fontSize: 15, fontWeight: "500", flex: 1, marginRight: 10 },
139
- sessionTime: { fontSize: 12 },
140
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
mobile/app/(tabs)/search.tsx DELETED
@@ -1,105 +0,0 @@
1
- import { View, Text, TextInput, FlatList, StyleSheet } from "react-native";
2
- import { useState } from "react";
3
- import { useSafeAreaInsets } from "react-native-safe-area-context";
4
- import { Search as SearchIcon } from "lucide-react-native";
5
- import { useFindFiles } from "../../hooks/useApi";
6
- import { getTheme } from "../../lib/storage";
7
- import { themes } from "../../constants/themes";
8
-
9
- export default function SearchScreen() {
10
- const insets = useSafeAreaInsets();
11
- const theme = themes[getTheme()];
12
- const [query, setQuery] = useState("");
13
- const { data: results, isLoading } = useFindFiles(query);
14
-
15
- return (
16
- <View style={[styles.container, { backgroundColor: theme.colors.bg }]}>
17
- <View style={[styles.header, { paddingTop: insets.top + 12 }]}>
18
- <Text style={[styles.title, { color: theme.colors.text }]}>Search</Text>
19
- </View>
20
-
21
- <View style={[styles.searchBar, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
22
- <SearchIcon size={16} color={theme.colors.textMuted} />
23
- <TextInput
24
- style={[styles.searchInput, { color: theme.colors.text }]}
25
- placeholder="Search files, code, text..."
26
- placeholderTextColor={theme.colors.textMuted}
27
- value={query}
28
- onChangeText={setQuery}
29
- autoFocus
30
- />
31
- </View>
32
-
33
- <FlatList
34
- data={results || []}
35
- keyExtractor={(_, i) => String(i)}
36
- contentContainerStyle={{ paddingBottom: insets.bottom + 100, paddingHorizontal: 20 }}
37
- showsVerticalScrollIndicator={false}
38
- ListEmptyComponent={
39
- <View style={styles.empty}>
40
- <SearchIcon size={40} color={theme.colors.textMuted} />
41
- <Text style={[styles.emptyText, { color: theme.colors.textMuted }]}>
42
- {query.length === 0
43
- ? "Type to search"
44
- : isLoading
45
- ? "Searching..."
46
- : "No results"}
47
- </Text>
48
- </View>
49
- }
50
- renderItem={({ item }) => (
51
- <View style={[styles.resultCard, { backgroundColor: theme.colors.surface }]}>
52
- <View style={styles.resultHeader}>
53
- <Text style={[styles.resultFile, { color: theme.colors.primary }]} numberOfLines={1}>
54
- {item.file}
55
- </Text>
56
- <Text style={[styles.resultLine, { color: theme.colors.textMuted }]}>
57
- L{item.line}
58
- </Text>
59
- </View>
60
- <Text style={[styles.resultMatch, { color: theme.colors.text }]} numberOfLines={2}>
61
- {item.context}
62
- </Text>
63
- </View>
64
- )}
65
- />
66
- </View>
67
- );
68
- }
69
-
70
- const styles = StyleSheet.create({
71
- container: { flex: 1 },
72
- header: {
73
- paddingHorizontal: 20,
74
- paddingBottom: 12,
75
- },
76
- title: { fontSize: 28, fontWeight: "700", letterSpacing: -0.5 },
77
- searchBar: {
78
- flexDirection: "row",
79
- alignItems: "center",
80
- gap: 8,
81
- marginHorizontal: 20,
82
- marginBottom: 16,
83
- paddingHorizontal: 14,
84
- paddingVertical: 10,
85
- borderRadius: 10,
86
- borderWidth: 1,
87
- },
88
- searchInput: { flex: 1, fontSize: 14, padding: 0 },
89
- resultCard: {
90
- padding: 12,
91
- borderRadius: 10,
92
- marginBottom: 6,
93
- },
94
- resultHeader: {
95
- flexDirection: "row",
96
- justifyContent: "space-between",
97
- alignItems: "center",
98
- marginBottom: 4,
99
- },
100
- resultFile: { fontSize: 13, fontWeight: "500", flex: 1, marginRight: 8 },
101
- resultLine: { fontSize: 12 },
102
- resultMatch: { fontSize: 13 },
103
- empty: { alignItems: "center", marginTop: 80, gap: 12 },
104
- emptyText: { fontSize: 14 },
105
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
mobile/app/(tabs)/sessions.tsx DELETED
@@ -1,128 +0,0 @@
1
- import { View, Text, TextInput, TouchableOpacity, FlatList, Alert, StyleSheet } from "react-native";
2
- import { useRouter } from "expo-router";
3
- import { useSafeAreaInsets } from "react-native-safe-area-context";
4
- import { Plus, Search, Trash2, MessageSquare } from "lucide-react-native";
5
- import { useState } from "react";
6
- import { useSessions, useCreateSession, useDeleteSession } from "../../hooks/useApi";
7
- import { getTheme } from "../../lib/storage";
8
- import { themes } from "../../constants/themes";
9
-
10
- export default function SessionsScreen() {
11
- const router = useRouter();
12
- const insets = useSafeAreaInsets();
13
- const theme = themes[getTheme()];
14
- const { data: sessions, isLoading } = useSessions();
15
- const createSession = useCreateSession();
16
- const deleteSession = useDeleteSession();
17
- const [search, setSearch] = useState("");
18
-
19
- const filtered = (sessions || []).filter(
20
- (s) => !search || (s.title || "").toLowerCase().includes(search.toLowerCase())
21
- );
22
-
23
- const handleCreate = async () => {
24
- try {
25
- const session = await createSession.mutateAsync({ title: "New Chat", directory: "default" });
26
- router.push(`/session/${session.id}`);
27
- } catch {}
28
- };
29
-
30
- const handleDelete = (id: string, title: string) => {
31
- Alert.alert("Delete Session", `Delete "${title || "Untitled"}"?`, [
32
- { text: "Cancel", style: "cancel" },
33
- { text: "Delete", style: "destructive", onPress: () => deleteSession.mutateAsync(id) },
34
- ]);
35
- };
36
-
37
- return (
38
- <View style={[styles.container, { backgroundColor: theme.colors.bg }]}>
39
- <View style={[styles.header, { paddingTop: insets.top + 12 }]}>
40
- <Text style={[styles.title, { color: theme.colors.text }]}>Sessions</Text>
41
- <TouchableOpacity onPress={handleCreate}>
42
- <Plus size={22} color={theme.colors.primary} />
43
- </TouchableOpacity>
44
- </View>
45
-
46
- <View style={[styles.searchBar, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
47
- <Search size={16} color={theme.colors.textMuted} />
48
- <TextInput
49
- style={[styles.searchInput, { color: theme.colors.text }]}
50
- placeholder="Search sessions..."
51
- placeholderTextColor={theme.colors.textMuted}
52
- value={search}
53
- onChangeText={setSearch}
54
- />
55
- </View>
56
-
57
- <FlatList
58
- data={filtered}
59
- keyExtractor={(item) => item.id}
60
- contentContainerStyle={{ paddingBottom: insets.bottom + 100, paddingHorizontal: 20 }}
61
- showsVerticalScrollIndicator={false}
62
- ListEmptyComponent={
63
- <View style={styles.empty}>
64
- <MessageSquare size={40} color={theme.colors.textMuted} />
65
- <Text style={[styles.emptyText, { color: theme.colors.textMuted }]}>
66
- {isLoading ? "Loading..." : "No sessions yet"}
67
- </Text>
68
- </View>
69
- }
70
- renderItem={({ item }) => (
71
- <TouchableOpacity
72
- style={[styles.sessionCard, { backgroundColor: theme.colors.surface }]}
73
- onPress={() => router.push(`/session/${item.id}`)}
74
- >
75
- <View style={styles.sessionInfo}>
76
- <Text style={[styles.sessionTitle, { color: theme.colors.text }]} numberOfLines={1}>
77
- {item.title || "Untitled"}
78
- </Text>
79
- <Text style={[styles.sessionDate, { color: theme.colors.textMuted }]}>
80
- {new Date(item.updatedAt).toLocaleString()}
81
- </Text>
82
- </View>
83
- <TouchableOpacity onPress={() => handleDelete(item.id, item.title)}>
84
- <Trash2 size={16} color={theme.colors.textMuted} />
85
- </TouchableOpacity>
86
- </TouchableOpacity>
87
- )}
88
- />
89
- </View>
90
- );
91
- }
92
-
93
- const styles = StyleSheet.create({
94
- container: { flex: 1 },
95
- header: {
96
- flexDirection: "row",
97
- justifyContent: "space-between",
98
- alignItems: "center",
99
- paddingHorizontal: 20,
100
- paddingBottom: 12,
101
- },
102
- title: { fontSize: 28, fontWeight: "700", letterSpacing: -0.5 },
103
- searchBar: {
104
- flexDirection: "row",
105
- alignItems: "center",
106
- gap: 8,
107
- marginHorizontal: 20,
108
- marginBottom: 16,
109
- paddingHorizontal: 14,
110
- paddingVertical: 10,
111
- borderRadius: 10,
112
- borderWidth: 1,
113
- },
114
- searchInput: { flex: 1, fontSize: 14, padding: 0 },
115
- sessionCard: {
116
- flexDirection: "row",
117
- alignItems: "center",
118
- justifyContent: "space-between",
119
- padding: 14,
120
- borderRadius: 10,
121
- marginBottom: 6,
122
- },
123
- sessionInfo: { flex: 1, marginRight: 12 },
124
- sessionTitle: { fontSize: 15, fontWeight: "500" },
125
- sessionDate: { fontSize: 12, marginTop: 2 },
126
- empty: { alignItems: "center", marginTop: 80, gap: 12 },
127
- emptyText: { fontSize: 14 },
128
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
mobile/app/(tabs)/terminal.tsx DELETED
@@ -1,37 +0,0 @@
1
- import { View, Text, StyleSheet } from "react-native";
2
- import { useSafeAreaInsets } from "react-native-safe-area-context";
3
- import { Terminal } from "lucide-react-native";
4
- import { getTheme } from "../../lib/storage";
5
- import { themes } from "../../constants/themes";
6
-
7
- export default function TerminalScreen() {
8
- const insets = useSafeAreaInsets();
9
- const theme = themes[getTheme()];
10
-
11
- return (
12
- <View style={[styles.container, { backgroundColor: theme.colors.bg }]}>
13
- <View style={[styles.header, { paddingTop: insets.top + 12 }]}>
14
- <Text style={[styles.title, { color: theme.colors.text }]}>Terminal</Text>
15
- </View>
16
- <View style={styles.empty}>
17
- <Terminal size={48} color={theme.colors.textMuted} />
18
- <Text style={[styles.emptyTitle, { color: theme.colors.text }]}>Terminal</Text>
19
- <Text style={[styles.emptyText, { color: theme.colors.textMuted }]}>
20
- Terminal sessions will appear here.
21
- </Text>
22
- </View>
23
- </View>
24
- );
25
- }
26
-
27
- const styles = StyleSheet.create({
28
- container: { flex: 1 },
29
- header: {
30
- paddingHorizontal: 20,
31
- paddingBottom: 12,
32
- },
33
- title: { fontSize: 28, fontWeight: "700", letterSpacing: -0.5 },
34
- empty: { flex: 1, alignItems: "center", justifyContent: "center", gap: 12 },
35
- emptyTitle: { fontSize: 18, fontWeight: "600" },
36
- emptyText: { fontSize: 14 },
37
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
mobile/app/_layout.tsx DELETED
@@ -1,27 +0,0 @@
1
- import { useEffect } from "react";
2
- import { Stack } from "expo-router";
3
- import { StatusBar } from "expo-status-bar";
4
- import { QueryProvider } from "../lib/query-provider";
5
- import { getTheme } from "../lib/storage";
6
- import { themes } from "../constants/themes";
7
-
8
- export default function RootLayout() {
9
- const theme = themes[getTheme()];
10
-
11
- useEffect(() => {
12
- const { SystemUI } = require("expo-system-ui");
13
- SystemUI.setBackgroundColorAsync(theme.colors.bg);
14
- }, []);
15
-
16
- return (
17
- <QueryProvider>
18
- <StatusBar style="light" />
19
- <Stack
20
- screenOptions={{
21
- headerShown: false,
22
- contentStyle: { backgroundColor: theme.colors.bg },
23
- }}
24
- />
25
- </QueryProvider>
26
- );
27
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
mobile/app/session/[id].tsx DELETED
@@ -1,271 +0,0 @@
1
- import { View, Text, TextInput, TouchableOpacity, FlatList, KeyboardAvoidingView, Platform, StyleSheet } from "react-native";
2
- import { useLocalSearchParams, useRouter } from "expo-router";
3
- import { useSafeAreaInsets } from "react-native-safe-area-context";
4
- import { useState, useEffect, useRef } from "react";
5
- import { ArrowLeft, Send, Loader, AlertTriangle } from "lucide-react-native";
6
- import { useMessages, useSession, useSendMessage, useReplyPermission, useReplyQuestion } from "../../hooks/useApi";
7
- import { useSSE, getMessagesFromEvents } from "../../hooks/useSSE";
8
- import { MarkdownRenderer } from "../../components/MarkdownRenderer";
9
- import { ToolTimeline } from "../../components/ToolTimeline";
10
- import { PermissionCard } from "../../components/PermissionCard";
11
- import { QuestionCard } from "../../components/QuestionCard";
12
- import { Message, MessagePart, ToolPart, PermissionRequest, QuestionRequest } from "../../constants/types";
13
- import { getTheme } from "../../lib/storage";
14
- import { themes } from "../../constants/themes";
15
-
16
- export default function ChatScreen() {
17
- const { id } = useLocalSearchParams<{ id: string }>();
18
- const router = useRouter();
19
- const insets = useSafeAreaInsets();
20
- const theme = themes[getTheme()];
21
- const [input, setInput] = useState("");
22
- const flatListRef = useRef<FlatList>(null);
23
-
24
- const { data: session } = useSession(id || "");
25
- const { data: serverMessages } = useMessages(id || "");
26
- const { connect, disconnect, streaming, events, error } = useSSE(id);
27
- const sendMessage = useSendMessage();
28
- const replyPermission = useReplyPermission();
29
- const replyQuestion = useReplyQuestion();
30
-
31
- // Build messages from server + SSE events
32
- const sseMessages = getMessagesFromEvents(events);
33
- const allMessages: Message[] = [...(serverMessages || []), ...sseMessages];
34
-
35
- // Deduplicate by id
36
- const seen = new Set<string>();
37
- const uniqueMessages = allMessages.filter((m) => {
38
- if (seen.has(m.id)) return false;
39
- seen.add(m.id);
40
- return true;
41
- });
42
-
43
- // Collect pending permissions and questions
44
- const pendingPermissions: PermissionRequest[] = [];
45
- const pendingQuestions: QuestionRequest[] = [];
46
- for (const ev of events) {
47
- if (ev.type === "permission.requested") {
48
- const perm = ev.properties as PermissionRequest;
49
- if (perm.state === "pending") pendingPermissions.push(perm);
50
- }
51
- if (ev.type === "question.asked") {
52
- const q = ev.properties as QuestionRequest;
53
- if (q.state === "pending") pendingQuestions.push(q);
54
- }
55
- }
56
-
57
- useEffect(() => {
58
- connect();
59
- return () => disconnect();
60
- }, [id]);
61
-
62
- const handleSend = () => {
63
- if (!input.trim() || !id) return;
64
- sendMessage.mutate({ sessionId: id, content: input.trim() });
65
- setInput("");
66
- };
67
-
68
- useEffect(() => {
69
- if (uniqueMessages.length > 0 || events.length > 0) {
70
- setTimeout(() => flatListRef.current?.scrollToEnd({ animated: true }), 100);
71
- }
72
- }, [uniqueMessages.length, events.length]);
73
-
74
- const renderPart = (part: MessagePart, index: number) => {
75
- if (part.type === "text" && part.text) {
76
- return <MarkdownRenderer key={index} content={part.text} />;
77
- }
78
- if (part.type === "tool") {
79
- const toolPart = part as ToolPart;
80
- return (
81
- <ToolTimeline
82
- key={toolPart.callID || index}
83
- parts={[toolPart]}
84
- />
85
- );
86
- }
87
- if (part.type === "reasoning") {
88
- return (
89
- <View key={index} style={[styles.reasoning, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border }]}>
90
- <Text style={[styles.reasoningLabel, { color: theme.colors.textMuted }]}>Thinking...</Text>
91
- <Text style={[styles.reasoningText, { color: theme.colors.textMuted }]} numberOfLines={3}>
92
- {part.text}
93
- </Text>
94
- </View>
95
- );
96
- }
97
- return null;
98
- };
99
-
100
- const renderMessage = (item: Message) => {
101
- const isUser = item.role === "user";
102
-
103
- // Group consecutive tool parts
104
- const toolParts: ToolPart[] = [];
105
- const otherParts: MessagePart[] = [];
106
- for (const part of item.parts) {
107
- if (part.type === "tool") toolParts.push(part as ToolPart);
108
- else otherParts.push(part);
109
- }
110
-
111
- return (
112
- <View
113
- style={[
114
- styles.bubble,
115
- isUser
116
- ? { backgroundColor: theme.colors.primary, alignSelf: "flex-end", maxWidth: "85%" }
117
- : { backgroundColor: theme.colors.surface, alignSelf: "flex-start", maxWidth: "90%" },
118
- ]}
119
- >
120
- {otherParts.map((part, i) => renderPart(part, i))}
121
- {toolParts.length > 0 && <ToolTimeline parts={toolParts} />}
122
- </View>
123
- );
124
- };
125
-
126
- return (
127
- <KeyboardAvoidingView
128
- style={[styles.container, { backgroundColor: theme.colors.bg }]}
129
- behavior={Platform.OS === "ios" ? "padding" : undefined}
130
- keyboardVerticalOffset={Platform.OS === "ios" ? 0 : 0}
131
- >
132
- <View style={[styles.header, { paddingTop: insets.top + 8, backgroundColor: theme.colors.bgSecondary, borderBottomColor: theme.colors.border }]}>
133
- <TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
134
- <ArrowLeft size={20} color={theme.colors.text} />
135
- </TouchableOpacity>
136
- <Text style={[styles.headerTitle, { color: theme.colors.text }]} numberOfLines={1}>
137
- {session?.title || "Chat"}
138
- </Text>
139
- </View>
140
-
141
- <FlatList
142
- ref={flatListRef}
143
- data={uniqueMessages}
144
- keyExtractor={(item) => item.id}
145
- contentContainerStyle={{ padding: 20, paddingBottom: 20, gap: 12 }}
146
- showsVerticalScrollIndicator={false}
147
- onContentSizeChange={() => flatListRef.current?.scrollToEnd({ animated: true })}
148
- renderItem={({ item }) => renderMessage(item)}
149
- ListFooterComponent={
150
- <>
151
- {/* Pending permissions */}
152
- {pendingPermissions.map((perm) => (
153
- <PermissionCard
154
- key={perm.id}
155
- request={perm}
156
- onAllow={() => replyPermission.mutate({ sessionId: id!, requestId: perm.id, allow: true })}
157
- onDeny={() => replyPermission.mutate({ sessionId: id!, requestId: perm.id, allow: false })}
158
- />
159
- ))}
160
-
161
- {/* Pending questions */}
162
- {pendingQuestions.map((q) => (
163
- <QuestionCard
164
- key={q.id}
165
- request={q}
166
- onReply={(answer) => replyQuestion.mutate({ sessionId: id!, requestId: q.id, answer })}
167
- />
168
- ))}
169
-
170
- {/* Streaming indicator */}
171
- {streaming && (
172
- <View style={[styles.bubble, { backgroundColor: theme.colors.surface, alignSelf: "flex-start" }]}>
173
- <View style={styles.typing}>
174
- <Loader size={14} color={theme.colors.textMuted} />
175
- <Text style={[styles.typingText, { color: theme.colors.textMuted }]}>Thinking...</Text>
176
- </View>
177
- </View>
178
- )}
179
- </>
180
- }
181
- />
182
-
183
- {error && (
184
- <View style={[styles.errorBar, { backgroundColor: theme.colors.danger }]}>
185
- <AlertTriangle size={14} color="#fff" />
186
- <Text style={[styles.errorText, { color: "#fff" }]}>{error}</Text>
187
- </View>
188
- )}
189
-
190
- <View style={[styles.inputBar, { borderTopColor: theme.colors.border, backgroundColor: theme.colors.bgSecondary, paddingBottom: insets.bottom + 8 }]}>
191
- <TextInput
192
- style={[styles.textInput, { color: theme.colors.text, backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}
193
- placeholder="Type a message..."
194
- placeholderTextColor={theme.colors.textMuted}
195
- value={input}
196
- onChangeText={setInput}
197
- onSubmitEditing={handleSend}
198
- multiline
199
- maxLength={4000}
200
- />
201
- <TouchableOpacity
202
- style={[styles.sendBtn, { backgroundColor: input.trim() ? theme.colors.primary : theme.colors.surface }]}
203
- onPress={handleSend}
204
- disabled={!input.trim() || streaming}
205
- >
206
- <Send size={18} color={input.trim() ? theme.colors.primaryText : theme.colors.textMuted} />
207
- </TouchableOpacity>
208
- </View>
209
- </KeyboardAvoidingView>
210
- );
211
- }
212
-
213
- const styles = StyleSheet.create({
214
- container: { flex: 1 },
215
- header: {
216
- flexDirection: "row",
217
- alignItems: "center",
218
- gap: 10,
219
- paddingHorizontal: 16,
220
- paddingBottom: 10,
221
- borderBottomWidth: 1,
222
- },
223
- backBtn: { padding: 4 },
224
- headerTitle: { fontSize: 17, fontWeight: "600", flex: 1 },
225
- bubble: {
226
- padding: 14,
227
- borderRadius: 14,
228
- },
229
- reasoning: {
230
- padding: 10,
231
- borderRadius: 8,
232
- borderWidth: 1,
233
- marginTop: 4,
234
- },
235
- reasoningLabel: { fontSize: 11, fontWeight: "600", textTransform: "uppercase", marginBottom: 4 },
236
- reasoningText: { fontSize: 12, lineHeight: 18, fontStyle: "italic" },
237
- typing: { flexDirection: "row", alignItems: "center", gap: 6 },
238
- typingText: { fontSize: 14 },
239
- errorBar: {
240
- flexDirection: "row",
241
- alignItems: "center",
242
- gap: 8,
243
- paddingHorizontal: 16,
244
- paddingVertical: 8,
245
- },
246
- errorText: { fontSize: 13, flex: 1 },
247
- inputBar: {
248
- flexDirection: "row",
249
- alignItems: "flex-end",
250
- gap: 8,
251
- paddingHorizontal: 16,
252
- paddingTop: 10,
253
- borderTopWidth: 1,
254
- },
255
- textInput: {
256
- flex: 1,
257
- fontSize: 15,
258
- paddingHorizontal: 14,
259
- paddingVertical: 10,
260
- borderRadius: 12,
261
- borderWidth: 1,
262
- maxHeight: 120,
263
- },
264
- sendBtn: {
265
- width: 40,
266
- height: 40,
267
- borderRadius: 12,
268
- alignItems: "center",
269
- justifyContent: "center",
270
- },
271
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
mobile/app/settings.tsx DELETED
@@ -1,281 +0,0 @@
1
- import { View, Text, TextInput, TouchableOpacity, ScrollView, StyleSheet } from "react-native";
2
- import { useRouter } from "expo-router";
3
- import { useSafeAreaInsets } from "react-native-safe-area-context";
4
- import { useState, useEffect } from "react";
5
- import { ArrowLeft, Check, Shield, Globe, Key, Lock } from "lucide-react-native";
6
- import { getHealth } from "../lib/api";
7
- import {
8
- getServerUrl,
9
- setServerUrl as persistUrl,
10
- getAuthType,
11
- setAuthType,
12
- getAuthValue,
13
- setAuthValue,
14
- clearAuth,
15
- getTheme,
16
- setTheme as persistTheme,
17
- } from "../lib/storage";
18
- import type { AuthType } from "../lib/storage";
19
- import { themes } from "../constants/themes";
20
- import { ThemeName } from "../constants/types";
21
-
22
- const themeNames = Object.keys(themes) as ThemeName[];
23
-
24
- const authTypes: { type: AuthType; label: string; description: string; icon: typeof Shield }[] = [
25
- { type: "none", label: "None", description: "No authentication", icon: Globe },
26
- { type: "basic", label: "Basic Auth", description: "Username & password", icon: Lock },
27
- { type: "bearer", label: "Bearer Token", description: "Authorization header", icon: Key },
28
- { type: "apikey", label: "API Key", description: "X-API-Key header", icon: Shield },
29
- ];
30
-
31
- export default function SettingsScreen() {
32
- const router = useRouter();
33
- const insets = useSafeAreaInsets();
34
- const currentTheme = getTheme();
35
- const theme = themes[currentTheme];
36
-
37
- const [serverInput, setServerInput] = useState(getServerUrl());
38
- const [authType, setAuthTypeState] = useState<AuthType>(getAuthType());
39
- const [authValue, setAuthValueState] = useState(getAuthValue());
40
- const [connected, setConnected] = useState<boolean | null>(null);
41
- const [serverVersion, setServerVersion] = useState<string | null>(null);
42
-
43
- // Basic auth split
44
- const [basicUser, setBasicUser] = useState(() => {
45
- const val = getAuthValue();
46
- return getAuthType() === "basic" && val.includes(":") ? val.split(":")[0] : "";
47
- });
48
- const [basicPass, setBasicPass] = useState(() => {
49
- const val = getAuthValue();
50
- return getAuthType() === "basic" && val.includes(":") ? val.split(":").slice(1).join(":") : "";
51
- });
52
-
53
- const testConnection = async () => {
54
- persistUrl(serverInput);
55
-
56
- // Save auth
57
- if (authType === "basic") {
58
- setAuthValue(`${basicUser}:${basicPass}`);
59
- } else {
60
- setAuthValue(authValue);
61
- }
62
- setAuthType(authType);
63
-
64
- try {
65
- const health = await getHealth();
66
- setConnected(health.healthy);
67
- setServerVersion(health.version || null);
68
- } catch {
69
- setConnected(false);
70
- setServerVersion(null);
71
- }
72
- };
73
-
74
- const handleThemeSelect = (name: ThemeName) => {
75
- persistTheme(name);
76
- };
77
-
78
- return (
79
- <View style={[styles.container, { backgroundColor: theme.colors.bg }]}>
80
- <View style={[styles.header, { paddingTop: insets.top + 12, borderBottomColor: theme.colors.border }]}>
81
- <TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
82
- <ArrowLeft size={20} color={theme.colors.text} />
83
- </TouchableOpacity>
84
- <Text style={[styles.title, { color: theme.colors.text }]}>Settings</Text>
85
- </View>
86
-
87
- <ScrollView
88
- contentContainerStyle={{ padding: 20, paddingBottom: insets.bottom + 40 }}
89
- showsVerticalScrollIndicator={false}
90
- >
91
- {/* Server */}
92
- <Text style={[styles.sectionLabel, { color: theme.colors.textSecondary }]}>Server</Text>
93
- <View style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
94
- <Text style={[styles.label, { color: theme.colors.textMuted }]}>Server URL</Text>
95
- <TextInput
96
- style={[styles.input, { color: theme.colors.text, backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border }]}
97
- value={serverInput}
98
- onChangeText={setServerInput}
99
- placeholder="http://127.0.0.1:4096"
100
- placeholderTextColor={theme.colors.textMuted}
101
- autoCapitalize="none"
102
- autoCorrect={false}
103
- />
104
-
105
- {/* Auth Type */}
106
- <Text style={[styles.label, { color: theme.colors.textMuted, marginTop: 8 }]}>Authentication</Text>
107
- <View style={styles.authGrid}>
108
- {authTypes.map((a) => {
109
- const Icon = a.icon;
110
- const selected = authType === a.type;
111
- return (
112
- <TouchableOpacity
113
- key={a.type}
114
- style={[
115
- styles.authCard,
116
- {
117
- backgroundColor: selected ? theme.colors.bgTertiary : theme.colors.bg,
118
- borderColor: selected ? theme.colors.primary : theme.colors.border,
119
- },
120
- ]}
121
- onPress={() => setAuthTypeState(a.type)}
122
- >
123
- <Icon size={16} color={selected ? theme.colors.primary : theme.colors.textMuted} />
124
- <Text style={[styles.authLabel, { color: selected ? theme.colors.text : theme.colors.textMuted }]}>
125
- {a.label}
126
- </Text>
127
- </TouchableOpacity>
128
- );
129
- })}
130
- </View>
131
-
132
- {/* Auth Value Input */}
133
- {authType === "basic" && (
134
- <View style={{ gap: 8, marginTop: 8 }}>
135
- <TextInput
136
- style={[styles.input, { color: theme.colors.text, backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border }]}
137
- value={basicUser}
138
- onChangeText={setBasicUser}
139
- placeholder="Username"
140
- placeholderTextColor={theme.colors.textMuted}
141
- autoCapitalize="none"
142
- />
143
- <TextInput
144
- style={[styles.input, { color: theme.colors.text, backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border }]}
145
- value={basicPass}
146
- onChangeText={setBasicPass}
147
- placeholder="Password"
148
- placeholderTextColor={theme.colors.textMuted}
149
- secureTextEntry
150
- />
151
- </View>
152
- )}
153
- {authType === "bearer" && (
154
- <TextInput
155
- style={[styles.input, { color: theme.colors.text, backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border, marginTop: 8 }]}
156
- value={authValue}
157
- onChangeText={setAuthValueState}
158
- placeholder="Bearer token"
159
- placeholderTextColor={theme.colors.textMuted}
160
- autoCapitalize="none"
161
- secureTextEntry
162
- />
163
- )}
164
- {authType === "apikey" && (
165
- <TextInput
166
- style={[styles.input, { color: theme.colors.text, backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border, marginTop: 8 }]}
167
- value={authValue}
168
- onChangeText={setAuthValueState}
169
- placeholder="API key"
170
- placeholderTextColor={theme.colors.textMuted}
171
- autoCapitalize="none"
172
- secureTextEntry
173
- />
174
- )}
175
-
176
- <TouchableOpacity style={[styles.btn, { backgroundColor: theme.colors.primary }]} onPress={testConnection}>
177
- <Text style={[styles.btnText, { color: theme.colors.primaryText }]}>Test Connection</Text>
178
- </TouchableOpacity>
179
- {connected !== null && (
180
- <Text style={[styles.status, { color: connected ? theme.colors.success : theme.colors.danger }]}>
181
- {connected ? `Connected${serverVersion ? ` (v${serverVersion})` : ""}` : "Failed to connect"}
182
- </Text>
183
- )}
184
- </View>
185
-
186
- {/* Theme */}
187
- <Text style={[styles.sectionLabel, { color: theme.colors.textSecondary, marginTop: 28 }]}>Theme</Text>
188
- <View style={styles.themeGrid}>
189
- {themeNames.map((name) => {
190
- const t = themes[name];
191
- const selected = currentTheme === name;
192
- return (
193
- <TouchableOpacity
194
- key={name}
195
- style={[
196
- styles.themeCard,
197
- {
198
- backgroundColor: t.colors.bg,
199
- borderColor: selected ? theme.colors.primary : t.colors.border,
200
- borderWidth: selected ? 2 : 1,
201
- },
202
- ]}
203
- onPress={() => handleThemeSelect(name)}
204
- >
205
- <View style={styles.themePreview}>
206
- <View style={[styles.dot, { backgroundColor: t.colors.primary }]} />
207
- <View style={[styles.dot, { backgroundColor: t.colors.secondary }]} />
208
- <View style={[styles.dot, { backgroundColor: t.colors.danger }]} />
209
- </View>
210
- <Text style={[styles.themeName, { color: t.colors.text }]}>{t.label}</Text>
211
- {selected && <Check size={14} color={theme.colors.primary} />}
212
- </TouchableOpacity>
213
- );
214
- })}
215
- </View>
216
-
217
- {/* About */}
218
- <Text style={[styles.sectionLabel, { color: theme.colors.textSecondary, marginTop: 28 }]}>About</Text>
219
- <View style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
220
- <Text style={[styles.aboutText, { color: theme.colors.text }]}>OpenCode Mobile</Text>
221
- <Text style={[styles.aboutVersion, { color: theme.colors.textMuted }]}>v1.0.0</Text>
222
- </View>
223
- </ScrollView>
224
- </View>
225
- );
226
- }
227
-
228
- const styles = StyleSheet.create({
229
- container: { flex: 1 },
230
- header: {
231
- flexDirection: "row",
232
- alignItems: "center",
233
- gap: 10,
234
- paddingHorizontal: 20,
235
- paddingBottom: 12,
236
- borderBottomWidth: 1,
237
- },
238
- backBtn: { padding: 4 },
239
- title: { fontSize: 28, fontWeight: "700", letterSpacing: -0.5 },
240
- sectionLabel: { fontSize: 13, fontWeight: "600", textTransform: "uppercase", letterSpacing: 0.5, marginBottom: 10 },
241
- card: { padding: 16, borderRadius: 12, borderWidth: 1, gap: 10 },
242
- label: { fontSize: 13, fontWeight: "500" },
243
- input: {
244
- fontSize: 14,
245
- paddingHorizontal: 12,
246
- paddingVertical: 10,
247
- borderRadius: 8,
248
- borderWidth: 1,
249
- },
250
- btn: { paddingVertical: 12, borderRadius: 10, alignItems: "center", marginTop: 4 },
251
- btnText: { fontSize: 14, fontWeight: "600" },
252
- status: { fontSize: 13, textAlign: "center" },
253
- authGrid: {
254
- flexDirection: "row",
255
- flexWrap: "wrap",
256
- gap: 8,
257
- },
258
- authCard: {
259
- flexDirection: "row",
260
- alignItems: "center",
261
- gap: 6,
262
- paddingHorizontal: 12,
263
- paddingVertical: 8,
264
- borderRadius: 8,
265
- borderWidth: 1,
266
- },
267
- authLabel: { fontSize: 13, fontWeight: "500" },
268
- themeGrid: { gap: 8 },
269
- themeCard: {
270
- flexDirection: "row",
271
- alignItems: "center",
272
- gap: 12,
273
- padding: 14,
274
- borderRadius: 10,
275
- },
276
- themePreview: { flexDirection: "row", gap: 4 },
277
- dot: { width: 10, height: 10, borderRadius: 5 },
278
- themeName: { flex: 1, fontSize: 14, fontWeight: "500" },
279
- aboutText: { fontSize: 15, fontWeight: "600" },
280
- aboutVersion: { fontSize: 13 },
281
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
mobile/components/MarkdownRenderer.tsx DELETED
@@ -1,134 +0,0 @@
1
- import React from "react";
2
- import { View, Text, StyleSheet, ScrollView } from "react-native";
3
- import Markdown from "react-native-markdown-display";
4
- import { themes } from "../constants/themes";
5
- import { getTheme } from "../lib/storage";
6
-
7
- interface MarkdownRendererProps {
8
- content: string;
9
- }
10
-
11
- const codeStyle = (theme: { colors: Record<string, string> }) =>
12
- StyleSheet.create({
13
- body: {},
14
- heading1: { fontSize: 22, fontWeight: "700", color: theme.colors.text, marginTop: 16, marginBottom: 8 },
15
- heading2: { fontSize: 19, fontWeight: "700", color: theme.colors.text, marginTop: 14, marginBottom: 6 },
16
- heading3: { fontSize: 16, fontWeight: "600", color: theme.colors.text, marginTop: 12, marginBottom: 4 },
17
- paragraph: { fontSize: 15, lineHeight: 22, color: theme.colors.text, marginBottom: 8 },
18
- link: { color: theme.colors.primary, textDecorationLine: "none" },
19
- blockquote: {
20
- borderLeftWidth: 3,
21
- borderLeftColor: theme.colors.primary,
22
- paddingLeft: 12,
23
- marginLeft: 0,
24
- marginVertical: 8,
25
- opacity: 0.8,
26
- },
27
- code_inline: {
28
- fontFamily: "monospace",
29
- fontSize: 13,
30
- color: theme.colors.accent,
31
- backgroundColor: theme.colors.bgTertiary,
32
- paddingHorizontal: 5,
33
- paddingVertical: 2,
34
- borderRadius: 4,
35
- },
36
- code_block: {
37
- fontFamily: "monospace",
38
- fontSize: 13,
39
- color: theme.colors.text,
40
- backgroundColor: theme.colors.bgTertiary,
41
- padding: 12,
42
- borderRadius: 8,
43
- marginVertical: 8,
44
- borderWidth: 1,
45
- borderColor: theme.colors.border,
46
- },
47
- fence: {
48
- fontFamily: "monospace",
49
- fontSize: 13,
50
- color: theme.colors.text,
51
- backgroundColor: theme.colors.bgTertiary,
52
- padding: 12,
53
- borderRadius: 8,
54
- marginVertical: 8,
55
- borderWidth: 1,
56
- borderColor: theme.colors.border,
57
- },
58
- list_item: { fontSize: 15, lineHeight: 22, color: theme.colors.text, marginBottom: 4 },
59
- bullet_list: { marginVertical: 8 },
60
- ordered_list: { marginVertical: 8 },
61
- table: { borderWidth: 1, borderColor: theme.colors.border, marginVertical: 8 },
62
- table_header_cell: {
63
- backgroundColor: theme.colors.bgTertiary,
64
- padding: 8,
65
- borderBottomWidth: 1,
66
- borderBottomColor: theme.colors.border,
67
- },
68
- table_cell: { padding: 8, borderBottomWidth: 1, borderBottomColor: theme.colors.border },
69
- strong: { fontWeight: "700" },
70
- em: { fontStyle: "italic" },
71
- hr: { height: 1, backgroundColor: theme.colors.border, marginVertical: 16 },
72
- image: { marginVertical: 8 },
73
- });
74
-
75
- export function MarkdownRenderer({ content }: MarkdownRendererProps) {
76
- const theme = themes[getTheme()];
77
- const styles = codeStyle(theme);
78
-
79
- return (
80
- <Markdown style={styles}>{content}</Markdown>
81
- );
82
- }
83
-
84
- // ── Code block with language label ──
85
-
86
- interface CodeBlockProps {
87
- language?: string;
88
- children: string;
89
- }
90
-
91
- export function CodeBlock({ language, children }: CodeBlockProps) {
92
- const theme = themes[getTheme()];
93
-
94
- return (
95
- <View style={[codeStyles.container, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border }]}>
96
- {language ? (
97
- <View style={[codeStyles.header, { borderBottomColor: theme.colors.border }]}>
98
- <Text style={[codeStyles.language, { color: theme.colors.textMuted }]}>{language}</Text>
99
- </View>
100
- ) : null}
101
- <ScrollView horizontal showsHorizontalScrollIndicator={false}>
102
- <Text style={[codeStyles.code, { color: theme.colors.text }]} selectable>
103
- {children}
104
- </Text>
105
- </ScrollView>
106
- </View>
107
- );
108
- }
109
-
110
- const codeStyles = StyleSheet.create({
111
- container: {
112
- borderRadius: 8,
113
- borderWidth: 1,
114
- marginVertical: 8,
115
- overflow: "hidden",
116
- },
117
- header: {
118
- paddingHorizontal: 12,
119
- paddingVertical: 6,
120
- borderBottomWidth: 1,
121
- },
122
- language: {
123
- fontSize: 11,
124
- fontWeight: "600",
125
- textTransform: "uppercase",
126
- letterSpacing: 0.5,
127
- },
128
- code: {
129
- fontFamily: "monospace",
130
- fontSize: 13,
131
- lineHeight: 20,
132
- padding: 12,
133
- },
134
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
mobile/components/PermissionCard.tsx DELETED
@@ -1,113 +0,0 @@
1
- import React from "react";
2
- import { View, Text, TouchableOpacity, StyleSheet } from "react-native";
3
- import { Shield, Check, X } from "lucide-react-native";
4
- import { themes } from "../constants/themes";
5
- import { getTheme } from "../lib/storage";
6
- import type { PermissionRequest } from "../constants/types";
7
-
8
- interface PermissionCardProps {
9
- request: PermissionRequest;
10
- onAllow: () => void;
11
- onDeny: () => void;
12
- }
13
-
14
- export function PermissionCard({ request, onAllow, onDeny }: PermissionCardProps) {
15
- const theme = themes[getTheme()];
16
-
17
- return (
18
- <View style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
19
- <View style={styles.header}>
20
- <Shield size={16} color={theme.colors.warning} />
21
- <Text style={[styles.title, { color: theme.colors.text }]}>Permission Required</Text>
22
- </View>
23
-
24
- <Text style={[styles.permission, { color: theme.colors.textSecondary }]}>
25
- {request.permission}
26
- </Text>
27
-
28
- {request.description ? (
29
- <Text style={[styles.description, { color: theme.colors.textMuted }]}>
30
- {request.description}
31
- </Text>
32
- ) : null}
33
-
34
- <View style={styles.actions}>
35
- <TouchableOpacity
36
- style={[styles.denyBtn, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border }]}
37
- onPress={onDeny}
38
- activeOpacity={0.7}
39
- >
40
- <X size={16} color={theme.colors.danger} />
41
- <Text style={[styles.denyText, { color: theme.colors.danger }]}>Deny</Text>
42
- </TouchableOpacity>
43
-
44
- <TouchableOpacity
45
- style={[styles.allowBtn, { backgroundColor: theme.colors.success }]}
46
- onPress={onAllow}
47
- activeOpacity={0.7}
48
- >
49
- <Check size={16} color="#fff" />
50
- <Text style={[styles.allowText, { color: "#fff" }]}>Allow</Text>
51
- </TouchableOpacity>
52
- </View>
53
- </View>
54
- );
55
- }
56
-
57
- const styles = StyleSheet.create({
58
- card: {
59
- padding: 16,
60
- borderRadius: 12,
61
- borderWidth: 1,
62
- gap: 10,
63
- },
64
- header: {
65
- flexDirection: "row",
66
- alignItems: "center",
67
- gap: 8,
68
- },
69
- title: {
70
- fontSize: 15,
71
- fontWeight: "600",
72
- },
73
- permission: {
74
- fontSize: 14,
75
- fontWeight: "500",
76
- },
77
- description: {
78
- fontSize: 13,
79
- lineHeight: 18,
80
- },
81
- actions: {
82
- flexDirection: "row",
83
- gap: 10,
84
- marginTop: 4,
85
- },
86
- denyBtn: {
87
- flex: 1,
88
- flexDirection: "row",
89
- alignItems: "center",
90
- justifyContent: "center",
91
- gap: 6,
92
- paddingVertical: 10,
93
- borderRadius: 8,
94
- borderWidth: 1,
95
- },
96
- denyText: {
97
- fontSize: 14,
98
- fontWeight: "600",
99
- },
100
- allowBtn: {
101
- flex: 1,
102
- flexDirection: "row",
103
- alignItems: "center",
104
- justifyContent: "center",
105
- gap: 6,
106
- paddingVertical: 10,
107
- borderRadius: 8,
108
- },
109
- allowText: {
110
- fontSize: 14,
111
- fontWeight: "600",
112
- },
113
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
mobile/components/QuestionCard.tsx DELETED
@@ -1,176 +0,0 @@
1
- import React, { useState } from "react";
2
- import { View, Text, TouchableOpacity, TextInput, StyleSheet } from "react-native";
3
- import { HelpCircle, Send } from "lucide-react-native";
4
- import { themes } from "../constants/themes";
5
- import { getTheme } from "../lib/storage";
6
- import type { QuestionRequest } from "../constants/types";
7
-
8
- interface QuestionCardProps {
9
- request: QuestionRequest;
10
- onReply: (answer: string) => void;
11
- onReject?: () => void;
12
- }
13
-
14
- export function QuestionCard({ request, onReply, onReject }: QuestionCardProps) {
15
- const theme = themes[getTheme()];
16
- const [selected, setSelected] = useState<string | null>(null);
17
- const [customAnswer, setCustomAnswer] = useState("");
18
-
19
- const handleSubmit = () => {
20
- if (selected) {
21
- onReply(selected);
22
- } else if (customAnswer.trim()) {
23
- onReply(customAnswer.trim());
24
- }
25
- };
26
-
27
- const hasOptions = request.options && request.options.length > 0;
28
-
29
- return (
30
- <View style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
31
- <View style={styles.header}>
32
- <HelpCircle size={16} color={theme.colors.primary} />
33
- <Text style={[styles.title, { color: theme.colors.text }]}>Question</Text>
34
- </View>
35
-
36
- <Text style={[styles.question, { color: theme.colors.textSecondary }]}>
37
- {request.question}
38
- </Text>
39
-
40
- {hasOptions ? (
41
- <View style={styles.options}>
42
- {request.options!.map((option) => {
43
- const isSelected = selected === option;
44
- return (
45
- <TouchableOpacity
46
- key={option}
47
- style={[
48
- styles.option,
49
- {
50
- backgroundColor: isSelected ? theme.colors.bgTertiary : theme.colors.bg,
51
- borderColor: isSelected ? theme.colors.primary : theme.colors.border,
52
- },
53
- ]}
54
- onPress={() => setSelected(option)}
55
- activeOpacity={0.7}
56
- >
57
- <View
58
- style={[
59
- styles.radio,
60
- {
61
- borderColor: isSelected ? theme.colors.primary : theme.colors.textMuted,
62
- backgroundColor: isSelected ? theme.colors.primary : "transparent",
63
- },
64
- ]}
65
- />
66
- <Text
67
- style={[
68
- styles.optionText,
69
- { color: isSelected ? theme.colors.text : theme.colors.textSecondary },
70
- ]}
71
- >
72
- {option}
73
- </Text>
74
- </TouchableOpacity>
75
- );
76
- })}
77
- </View>
78
- ) : (
79
- <TextInput
80
- style={[styles.input, { color: theme.colors.text, backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border }]}
81
- value={customAnswer}
82
- onChangeText={setCustomAnswer}
83
- placeholder="Type your answer..."
84
- placeholderTextColor={theme.colors.textMuted}
85
- multiline
86
- />
87
- )}
88
-
89
- <TouchableOpacity
90
- style={[
91
- styles.submitBtn,
92
- {
93
- backgroundColor: selected || customAnswer.trim() ? theme.colors.primary : theme.colors.bgTertiary,
94
- },
95
- ]}
96
- onPress={handleSubmit}
97
- disabled={!selected && !customAnswer.trim()}
98
- activeOpacity={0.7}
99
- >
100
- <Send size={14} color={selected || customAnswer.trim() ? theme.colors.primaryText : theme.colors.textMuted} />
101
- <Text
102
- style={[
103
- styles.submitText,
104
- { color: selected || customAnswer.trim() ? theme.colors.primaryText : theme.colors.textMuted },
105
- ]}
106
- >
107
- Submit
108
- </Text>
109
- </TouchableOpacity>
110
- </View>
111
- );
112
- }
113
-
114
- const styles = StyleSheet.create({
115
- card: {
116
- padding: 16,
117
- borderRadius: 12,
118
- borderWidth: 1,
119
- gap: 10,
120
- },
121
- header: {
122
- flexDirection: "row",
123
- alignItems: "center",
124
- gap: 8,
125
- },
126
- title: {
127
- fontSize: 15,
128
- fontWeight: "600",
129
- },
130
- question: {
131
- fontSize: 14,
132
- lineHeight: 20,
133
- },
134
- options: {
135
- gap: 6,
136
- },
137
- option: {
138
- flexDirection: "row",
139
- alignItems: "center",
140
- gap: 10,
141
- padding: 12,
142
- borderRadius: 8,
143
- borderWidth: 1,
144
- },
145
- radio: {
146
- width: 16,
147
- height: 16,
148
- borderRadius: 8,
149
- borderWidth: 2,
150
- },
151
- optionText: {
152
- fontSize: 14,
153
- flex: 1,
154
- },
155
- input: {
156
- fontSize: 14,
157
- paddingHorizontal: 12,
158
- paddingVertical: 10,
159
- borderRadius: 8,
160
- borderWidth: 1,
161
- minHeight: 44,
162
- },
163
- submitBtn: {
164
- flexDirection: "row",
165
- alignItems: "center",
166
- justifyContent: "center",
167
- gap: 6,
168
- paddingVertical: 10,
169
- borderRadius: 8,
170
- marginTop: 4,
171
- },
172
- submitText: {
173
- fontSize: 14,
174
- fontWeight: "600",
175
- },
176
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
mobile/components/ToolTimeline.tsx DELETED
@@ -1,165 +0,0 @@
1
- import React from "react";
2
- import { View, Text, StyleSheet } from "react-native";
3
- import { Loader, Check, X, FileText, Search, Terminal, Globe, Wrench } from "lucide-react-native";
4
- import { themes } from "../constants/themes";
5
- import { getTheme } from "../lib/storage";
6
- import type { ToolPart } from "../constants/types";
7
-
8
- interface ToolTimelineProps {
9
- parts: ToolPart[];
10
- }
11
-
12
- const toolIcons: Record<string, typeof Wrench> = {
13
- read: FileText,
14
- write: FileText,
15
- edit: FileText,
16
- glob: Search,
17
- grep: Search,
18
- bash: Terminal,
19
- webfetch: Globe,
20
- websearch: Globe,
21
- };
22
-
23
- const toolLabels: Record<string, string> = {
24
- read: "Reading file",
25
- write: "Writing file",
26
- edit: "Editing file",
27
- glob: "Searching files",
28
- grep: "Searching content",
29
- bash: "Running command",
30
- webfetch: "Fetching URL",
31
- websearch: "Searching web",
32
- };
33
-
34
- export function ToolTimeline({ parts }: ToolTimelineProps) {
35
- const theme = themes[getTheme()];
36
-
37
- if (parts.length === 0) return null;
38
-
39
- return (
40
- <View style={styles.container}>
41
- {parts.map((part, index) => {
42
- const Icon = toolIcons[part.tool] || Wrench;
43
- const label = toolLabels[part.tool] || part.tool;
44
- const isRunning = part.state === "call";
45
- const isError = part.state === "error";
46
- const isDone = part.state === "result";
47
-
48
- return (
49
- <View key={part.callID || index} style={styles.row}>
50
- {/* Status indicator */}
51
- <View
52
- style={[
53
- styles.indicator,
54
- {
55
- backgroundColor: isRunning
56
- ? theme.colors.warning
57
- : isError
58
- ? theme.colors.danger
59
- : theme.colors.success,
60
- },
61
- ]}
62
- />
63
-
64
- {/* Content */}
65
- <View style={styles.content}>
66
- <View style={styles.header}>
67
- <Icon
68
- size={14}
69
- color={
70
- isRunning
71
- ? theme.colors.warning
72
- : isError
73
- ? theme.colors.danger
74
- : theme.colors.success
75
- }
76
- />
77
- <Text
78
- style={[
79
- styles.label,
80
- {
81
- color: isRunning
82
- ? theme.colors.text
83
- : theme.colors.textMuted,
84
- },
85
- ]}
86
- numberOfLines={1}
87
- >
88
- {label}
89
- </Text>
90
-
91
- {isRunning && <Loader size={12} color={theme.colors.warning} />}
92
- {isDone && <Check size={12} color={theme.colors.success} />}
93
- {isError && <X size={12} color={theme.colors.danger} />}
94
- </View>
95
-
96
- {/* Tool title (if provided) */}
97
- {part.title ? (
98
- <Text
99
- style={[styles.title, { color: theme.colors.textSecondary }]}
100
- numberOfLines={1}
101
- >
102
- {part.title}
103
- </Text>
104
- ) : null}
105
-
106
- {/* Show truncated input for certain tools */}
107
- {part.input && (part.tool === "bash" || part.tool === "webfetch") && (
108
- <Text
109
- style={[styles.detail, { color: theme.colors.textMuted }]}
110
- numberOfLines={1}
111
- >
112
- {part.tool === "bash"
113
- ? `$ ${part.input.command || JSON.stringify(part.input).slice(0, 60)}`
114
- : String(part.input.url || "").slice(0, 80)}
115
- </Text>
116
- )}
117
- </View>
118
- </View>
119
- );
120
- })}
121
- </View>
122
- );
123
- }
124
-
125
- const styles = StyleSheet.create({
126
- container: {
127
- gap: 2,
128
- marginVertical: 4,
129
- },
130
- row: {
131
- flexDirection: "row",
132
- alignItems: "flex-start",
133
- gap: 10,
134
- },
135
- indicator: {
136
- width: 6,
137
- height: 6,
138
- borderRadius: 3,
139
- marginTop: 6,
140
- },
141
- content: {
142
- flex: 1,
143
- gap: 2,
144
- },
145
- header: {
146
- flexDirection: "row",
147
- alignItems: "center",
148
- gap: 6,
149
- },
150
- label: {
151
- fontSize: 13,
152
- fontWeight: "500",
153
- flex: 1,
154
- },
155
- title: {
156
- fontSize: 12,
157
- marginLeft: 20,
158
- },
159
- detail: {
160
- fontSize: 12,
161
- fontFamily: "monospace",
162
- marginLeft: 20,
163
- opacity: 0.7,
164
- },
165
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
mobile/constants/themes.ts DELETED
@@ -1,132 +0,0 @@
1
- import { Theme, ThemeName } from "./types";
2
-
3
- export const themes: Record<ThemeName, Theme> = {
4
- tokyo: {
5
- name: "tokyo",
6
- label: "Tokyo Night",
7
- colors: {
8
- bg: "#1a1b26",
9
- bgSecondary: "#16161e",
10
- bgTertiary: "#24283b",
11
- surface: "#292e42",
12
- border: "#3b4261",
13
- text: "#c0caf5",
14
- textSecondary: "#a9b1d6",
15
- textMuted: "#565f89",
16
- primary: "#7aa2f7",
17
- primaryText: "#1a1b26",
18
- secondary: "#bb9af7",
19
- danger: "#f7768e",
20
- success: "#9ece6a",
21
- warning: "#e0af68",
22
- accent: "#7dcfff",
23
- },
24
- },
25
- dracula: {
26
- name: "dracula",
27
- label: "Dracula",
28
- colors: {
29
- bg: "#282a36",
30
- bgSecondary: "#21222c",
31
- bgTertiary: "#343746",
32
- surface: "#44475a",
33
- border: "#6272a4",
34
- text: "#f8f8f2",
35
- textSecondary: "#bd93f9",
36
- textMuted: "#6272a4",
37
- primary: "#bd93f9",
38
- primaryText: "#282a36",
39
- secondary: "#ff79c6",
40
- danger: "#ff5555",
41
- success: "#50fa7b",
42
- warning: "#f1fa8c",
43
- accent: "#8be9fd",
44
- },
45
- },
46
- nord: {
47
- name: "nord",
48
- label: "Nord",
49
- colors: {
50
- bg: "#2e3440",
51
- bgSecondary: "#3b4252",
52
- bgTertiary: "#434c5e",
53
- surface: "#4c566a",
54
- border: "#4c566a",
55
- text: "#eceff4",
56
- textSecondary: "#d8dee9",
57
- textMuted: "#7b88a1",
58
- primary: "#88c0d0",
59
- primaryText: "#2e3440",
60
- secondary: "#b48ead",
61
- danger: "#bf616a",
62
- success: "#a3be8c",
63
- warning: "#ebcb8b",
64
- accent: "#8fbcbb",
65
- },
66
- },
67
- gruvbox: {
68
- name: "gruvbox",
69
- label: "Gruvbox",
70
- colors: {
71
- bg: "#282828",
72
- bgSecondary: "#1d2021",
73
- bgTertiary: "#3c3836",
74
- surface: "#504945",
75
- border: "#665c54",
76
- text: "#ebdbb2",
77
- textSecondary: "#d5c4a1",
78
- textMuted: "#928374",
79
- primary: "#d79921",
80
- primaryText: "#282828",
81
- secondary: "#b8bb26",
82
- danger: "#cc241d",
83
- success: "#98971a",
84
- warning: "#d65d0e",
85
- accent: "#83a598",
86
- },
87
- },
88
- onedark: {
89
- name: "onedark",
90
- label: "One Dark",
91
- colors: {
92
- bg: "#282c34",
93
- bgSecondary: "#21252b",
94
- bgTertiary: "#333842",
95
- surface: "#3e4451",
96
- border: "#4b5263",
97
- text: "#abb2bf",
98
- textSecondary: "#c8ccd4",
99
- textMuted: "#636d83",
100
- primary: "#61afef",
101
- primaryText: "#282c34",
102
- secondary: "#c678dd",
103
- danger: "#e06c75",
104
- success: "#98c379",
105
- warning: "#e5c07b",
106
- accent: "#56b6c2",
107
- },
108
- },
109
- catppuccin: {
110
- name: "catppuccin",
111
- label: "Catppuccin Mocha",
112
- colors: {
113
- bg: "#1e1e2e",
114
- bgSecondary: "#181825",
115
- bgTertiary: "#313244",
116
- surface: "#45475a",
117
- border: "#585b70",
118
- text: "#cdd6f4",
119
- textSecondary: "#bac2de",
120
- textMuted: "#6c7086",
121
- primary: "#89b4fa",
122
- primaryText: "#1e1e2e",
123
- secondary: "#cba6f7",
124
- danger: "#f38ba8",
125
- success: "#a6e3a1",
126
- warning: "#f9e2af",
127
- accent: "#94e2d5",
128
- },
129
- },
130
- };
131
-
132
- export const defaultTheme: ThemeName = "tokyo";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
mobile/constants/types.ts DELETED
@@ -1,404 +0,0 @@
1
- // Types derived from real OpenCode serve OpenAPI spec (v1.18.3)
2
- // Endpoint prefix: /api/
3
-
4
- // ── Health ──
5
- export interface HealthResponse {
6
- healthy: boolean;
7
- version: string;
8
- }
9
-
10
- // ── Config ──
11
- export interface OpenCodeConfig {
12
- $schema?: string;
13
- theme?: string;
14
- provider?: Record<string, ProviderConfig>;
15
- model?: Record<string, string>;
16
- command?: Record<string, string>;
17
- plugin?: Record<string, unknown>;
18
- mcp?: Record<string, unknown>;
19
- share?: unknown;
20
- experimental?: unknown;
21
- }
22
-
23
- export interface ProviderConfig {
24
- api?: string;
25
- apiKey?: string;
26
- models?: Record<string, unknown>;
27
- options?: Record<string, unknown>;
28
- }
29
-
30
- // ── Provider ──
31
- export interface Provider {
32
- id: string;
33
- name: string;
34
- api?: string;
35
- auth?: ProviderAuth;
36
- models?: Model[];
37
- env?: Record<string, string>;
38
- }
39
-
40
- export interface ProviderAuth {
41
- type: "api_key" | "oauth";
42
- api_key?: { env?: string; header?: string };
43
- oauth?: { authorize_url: string; token_url: string; scopes: string[] };
44
- }
45
-
46
- // ── Model ──
47
- export interface Model {
48
- id: string;
49
- name: string;
50
- providerID: string;
51
- context?: number;
52
- maxTokens?: number;
53
- reasoning?: boolean;
54
- config?: Record<string, unknown>;
55
- }
56
-
57
- // ── Session ──
58
- export interface Session {
59
- id: string;
60
- parentID?: string;
61
- title: string;
62
- agent?: string;
63
- model?: string;
64
- status: "idle" | "busy" | "error";
65
- directory: string;
66
- createdAt: string;
67
- updatedAt: string;
68
- }
69
-
70
- export interface SessionStatus {
71
- busy: boolean;
72
- }
73
-
74
- /** Paginated list response from /api/session */
75
- export interface SessionListResponse {
76
- data: Session[];
77
- cursor?: {
78
- previous?: string;
79
- next?: string;
80
- };
81
- }
82
-
83
- /** Model list response from /api/model */
84
- export interface ModelListResponse {
85
- location?: {
86
- directory?: string;
87
- project?: { id: string; directory: string };
88
- };
89
- data: Model[];
90
- }
91
-
92
- // ── Message ──
93
- export interface Message {
94
- id: string;
95
- sessionID: string;
96
- role: "user" | "assistant" | "tool" | "system";
97
- parts: MessagePart[];
98
- createdAt: string;
99
- }
100
-
101
- export type MessagePart =
102
- | TextPart
103
- | ToolPart
104
- | StepPart
105
- | ReasoningPart
106
- | SourcePart;
107
-
108
- export interface TextPart {
109
- type: "text";
110
- text: string;
111
- }
112
-
113
- export interface ToolPart {
114
- type: "tool";
115
- tool: string;
116
- state: "call" | "result" | "error";
117
- callID: string;
118
- input?: Record<string, unknown>;
119
- output?: string;
120
- title?: string;
121
- metadata?: Record<string, unknown>;
122
- }
123
-
124
- export interface StepPart {
125
- type: "step";
126
- name: string;
127
- state: "pending" | "running" | "completed" | "error";
128
- }
129
-
130
- export interface ReasoningPart {
131
- type: "reasoning";
132
- text: string;
133
- }
134
-
135
- export interface SourcePart {
136
- type: "source";
137
- source: string;
138
- title?: string;
139
- }
140
-
141
- // ── Events (SSE) ──
142
- export type ServerEvent =
143
- | SessionCreatedEvent
144
- | SessionUpdatedEvent
145
- | SessionDeletedEvent
146
- | MessageCreatedEvent
147
- | MessageUpdatedEvent
148
- | MessagePartUpdatedEvent
149
- | MessagePartDeletedEvent
150
- | PermissionRequestedEvent
151
- | PermissionUpdatedEvent
152
- | QuestionAskedEvent
153
- | QuestionAnsweredEvent
154
- | FileChangedEvent
155
- | TodoUpdatedEvent
156
- | SyncEvent
157
- | ErrorEvent;
158
-
159
- export interface SessionCreatedEvent {
160
- type: "session.created";
161
- properties: Session;
162
- }
163
-
164
- export interface SessionUpdatedEvent {
165
- type: "session.updated";
166
- properties: Partial<Session> & { id: string };
167
- }
168
-
169
- export interface SessionDeletedEvent {
170
- type: "session.deleted";
171
- properties: { id: string };
172
- }
173
-
174
- export interface MessageCreatedEvent {
175
- type: "message.created";
176
- properties: Message;
177
- }
178
-
179
- export interface MessageUpdatedEvent {
180
- type: "message.updated";
181
- properties: Partial<Message> & { id: string };
182
- }
183
-
184
- export interface MessagePartUpdatedEvent {
185
- type: "message.part.updated";
186
- properties: {
187
- messageID: string;
188
- part: MessagePart;
189
- sessionID: string;
190
- };
191
- }
192
-
193
- export interface MessagePartDeletedEvent {
194
- type: "message.part.deleted";
195
- properties: {
196
- messageID: string;
197
- partID: string;
198
- sessionID: string;
199
- };
200
- }
201
-
202
- export interface PermissionRequestedEvent {
203
- type: "permission.requested";
204
- properties: PermissionRequest;
205
- }
206
-
207
- export interface PermissionUpdatedEvent {
208
- type: "permission.updated";
209
- properties: PermissionRequest;
210
- }
211
-
212
- export interface QuestionAskedEvent {
213
- type: "question.asked";
214
- properties: QuestionRequest;
215
- }
216
-
217
- export interface QuestionAnsweredEvent {
218
- type: "question.answered";
219
- properties: QuestionRequest;
220
- }
221
-
222
- export interface FileChangedEvent {
223
- type: "file.changed";
224
- properties: { path: string; status: string };
225
- }
226
-
227
- export interface TodoUpdatedEvent {
228
- type: "todo.updated";
229
- properties: { sessionID: string; todos: Todo[] };
230
- }
231
-
232
- export interface SyncEvent {
233
- type: "sync";
234
- properties: Record<string, unknown>;
235
- }
236
-
237
- export interface ErrorEvent {
238
- type: "error";
239
- properties: { message: string; code?: string };
240
- }
241
-
242
- // ── Permission ──
243
- export interface PermissionRequest {
244
- id: string;
245
- sessionID: string;
246
- messageID?: string;
247
- partID?: string;
248
- permission: string;
249
- description?: string;
250
- state: "pending" | "approved" | "denied";
251
- createdAt: string;
252
- }
253
-
254
- export interface SavedPermission {
255
- id: string;
256
- permission: string;
257
- pattern?: string;
258
- allow: boolean;
259
- }
260
-
261
- // ── Question ──
262
- export interface QuestionRequest {
263
- id: string;
264
- sessionID: string;
265
- question: string;
266
- options?: string[];
267
- state: "pending" | "answered" | "rejected";
268
- answer?: string;
269
- createdAt: string;
270
- }
271
-
272
- // ── Todo ──
273
- export interface Todo {
274
- id: string;
275
- content: string;
276
- status: "pending" | "in_progress" | "completed";
277
- metadata?: Record<string, unknown>;
278
- }
279
-
280
- // ── File ──
281
- export interface FileEntry {
282
- name: string;
283
- path: string;
284
- type: "file" | "directory";
285
- size?: number;
286
- modified?: string;
287
- }
288
-
289
- export interface FileContent {
290
- content: string;
291
- encoding?: string;
292
- }
293
-
294
- export interface FileStatus {
295
- path: string;
296
- status: string;
297
- }
298
-
299
- // ── Find/Search ──
300
- export interface FindResult {
301
- file: string;
302
- line: number;
303
- column: number;
304
- match: string;
305
- context: string;
306
- }
307
-
308
- export interface SymbolResult {
309
- name: string;
310
- kind: string;
311
- file: string;
312
- line: number;
313
- column: number;
314
- }
315
-
316
- // ── PTY ──
317
- export interface PTY {
318
- id: string;
319
- shell: string;
320
- cwd: string;
321
- pid?: number;
322
- createdAt: string;
323
- }
324
-
325
- // ── Command ──
326
- export interface Command {
327
- name: string;
328
- description?: string;
329
- category?: string;
330
- }
331
-
332
- // ── Skill ──
333
- export interface Skill {
334
- name: string;
335
- description?: string;
336
- location?: string;
337
- }
338
-
339
- // ── Agent ──
340
- export interface Agent {
341
- name: string;
342
- description?: string;
343
- tools?: string[];
344
- }
345
-
346
- // ── Diff ──
347
- export interface DiffEntry {
348
- path: string;
349
- status: string;
350
- additions: number;
351
- deletions: number;
352
- hunks?: DiffHunk[];
353
- }
354
-
355
- export interface DiffHunk {
356
- oldStart: number;
357
- newStart: number;
358
- oldLines: number;
359
- newLines: number;
360
- content: string;
361
- }
362
-
363
- // ── VCS ──
364
- export interface VCSStatus {
365
- branch: string;
366
- dirty: boolean;
367
- ahead: number;
368
- behind: number;
369
- }
370
-
371
- // ── Context helpers ──
372
- export interface SessionContext {
373
- files: string[];
374
- capabilities: string[];
375
- }
376
-
377
- export interface SessionHistory {
378
- messages: Message[];
379
- }
380
-
381
- // ── Theme ──
382
- export type ThemeName = "tokyo" | "dracula" | "nord" | "gruvbox" | "onedark" | "catppuccin";
383
-
384
- export interface Theme {
385
- name: ThemeName;
386
- label: string;
387
- colors: {
388
- bg: string;
389
- bgSecondary: string;
390
- bgTertiary: string;
391
- surface: string;
392
- border: string;
393
- text: string;
394
- textSecondary: string;
395
- textMuted: string;
396
- primary: string;
397
- primaryText: string;
398
- secondary: string;
399
- danger: string;
400
- success: string;
401
- warning: string;
402
- accent: string;
403
- };
404
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
mobile/hooks/useApi.ts DELETED
@@ -1,322 +0,0 @@
1
- import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
2
- import * as api from "../lib/api";
3
-
4
- // ── Health ──
5
-
6
- export function useHealth() {
7
- return useQuery({
8
- queryKey: ["health"],
9
- queryFn: api.getHealth,
10
- refetchInterval: 10000,
11
- retry: 3,
12
- });
13
- }
14
-
15
- // ── Config ──
16
-
17
- export function useConfig() {
18
- return useQuery({
19
- queryKey: ["config"],
20
- queryFn: api.getConfig,
21
- });
22
- }
23
-
24
- export function usePatchConfig() {
25
- const qc = useQueryClient();
26
- return useMutation({
27
- mutationFn: api.patchConfig,
28
- onSuccess: () => qc.invalidateQueries({ queryKey: ["config"] }),
29
- });
30
- }
31
-
32
- // ── Sessions ──
33
-
34
- export function useSessions() {
35
- return useQuery({
36
- queryKey: ["sessions"],
37
- queryFn: api.getSessions,
38
- });
39
- }
40
-
41
- export function useActiveSessions() {
42
- return useQuery({
43
- queryKey: ["sessions", "active"],
44
- queryFn: api.getActiveSessions,
45
- });
46
- }
47
-
48
- export function useSession(id: string) {
49
- return useQuery({
50
- queryKey: ["session", id],
51
- queryFn: () => api.getSession(id),
52
- enabled: !!id,
53
- });
54
- }
55
-
56
- export function useCreateSession() {
57
- const qc = useQueryClient();
58
- return useMutation({
59
- mutationFn: (options?: { title?: string; agent?: string; model?: string; directory?: string }) =>
60
- api.createSession(options),
61
- onSuccess: () => qc.invalidateQueries({ queryKey: ["sessions"] }),
62
- });
63
- }
64
-
65
- export function useDeleteSession() {
66
- const qc = useQueryClient();
67
- return useMutation({
68
- mutationFn: (id: string) => api.deleteSession(id),
69
- onSuccess: () => qc.invalidateQueries({ queryKey: ["sessions"] }),
70
- });
71
- }
72
-
73
- export function usePatchSession() {
74
- const qc = useQueryClient();
75
- return useMutation({
76
- mutationFn: ({ id, data }: { id: string; data: { title?: string; agent?: string; model?: string } }) =>
77
- api.patchSession(id, data),
78
- onSuccess: () => qc.invalidateQueries({ queryKey: ["sessions"] }),
79
- });
80
- }
81
-
82
- export function useSessionTodos(sessionId: string) {
83
- return useQuery({
84
- queryKey: ["todos", sessionId],
85
- queryFn: () => api.getSessionTodos(sessionId),
86
- enabled: !!sessionId,
87
- });
88
- }
89
-
90
- // ── Messages ──
91
-
92
- export function useMessages(sessionId: string) {
93
- return useQuery({
94
- queryKey: ["messages", sessionId],
95
- queryFn: () => api.getMessages(sessionId),
96
- enabled: !!sessionId,
97
- });
98
- }
99
-
100
- export function useSendMessage() {
101
- const qc = useQueryClient();
102
- return useMutation({
103
- mutationFn: ({ sessionId, content }: { sessionId: string; content: string }) =>
104
- api.sendMessage(sessionId, content),
105
- onSuccess: (_, vars) => {
106
- qc.invalidateQueries({ queryKey: ["messages", vars.sessionId] });
107
- qc.invalidateQueries({ queryKey: ["sessions"] });
108
- },
109
- });
110
- }
111
-
112
- export function useAbortSession() {
113
- return useMutation({
114
- mutationFn: (sessionId: string) => api.abortSession(sessionId),
115
- });
116
- }
117
-
118
- export function useCompactSession() {
119
- const qc = useQueryClient();
120
- return useMutation({
121
- mutationFn: (sessionId: string) => api.compactSession(sessionId),
122
- onSuccess: (_, sessionId) => {
123
- qc.invalidateQueries({ queryKey: ["messages", sessionId] });
124
- },
125
- });
126
- }
127
-
128
- export function useShareSession() {
129
- const qc = useQueryClient();
130
- return useMutation({
131
- mutationFn: (sessionId: string) => api.shareSession(sessionId),
132
- onSuccess: () => qc.invalidateQueries({ queryKey: ["sessions"] }),
133
- });
134
- }
135
-
136
- export function useForkSession() {
137
- const qc = useQueryClient();
138
- return useMutation({
139
- mutationFn: (sessionId: string) => api.forkSession(sessionId),
140
- onSuccess: () => qc.invalidateQueries({ queryKey: ["sessions"] }),
141
- });
142
- }
143
-
144
- // ── Session diff / revert ──
145
-
146
- export function useSessionDiff(sessionId: string) {
147
- return useQuery({
148
- queryKey: ["diff", sessionId],
149
- queryFn: () => api.getSessionDiff(sessionId),
150
- enabled: !!sessionId,
151
- });
152
- }
153
-
154
- // ── Permissions ──
155
-
156
- export function usePermissionRequests() {
157
- return useQuery({
158
- queryKey: ["permissions", "requests"],
159
- queryFn: api.getPermissionRequests,
160
- refetchInterval: 5000,
161
- });
162
- }
163
-
164
- export function useSavedPermissions() {
165
- return useQuery({
166
- queryKey: ["permissions", "saved"],
167
- queryFn: api.getSavedPermissions,
168
- });
169
- }
170
-
171
- export function useReplyPermission() {
172
- const qc = useQueryClient();
173
- return useMutation({
174
- mutationFn: ({
175
- sessionId,
176
- requestId,
177
- allow,
178
- }: {
179
- sessionId: string;
180
- requestId: string;
181
- allow: boolean;
182
- }) => api.replyPermission(sessionId, requestId, allow),
183
- onSuccess: () => {
184
- qc.invalidateQueries({ queryKey: ["permissions"] });
185
- },
186
- });
187
- }
188
-
189
- // ── Questions ──
190
-
191
- export function useQuestionRequests() {
192
- return useQuery({
193
- queryKey: ["questions", "requests"],
194
- queryFn: api.getQuestionRequests,
195
- refetchInterval: 5000,
196
- });
197
- }
198
-
199
- export function useReplyQuestion() {
200
- const qc = useQueryClient();
201
- return useMutation({
202
- mutationFn: ({
203
- sessionId,
204
- requestId,
205
- answer,
206
- }: {
207
- sessionId: string;
208
- requestId: string;
209
- answer: string;
210
- }) => api.replyQuestion(sessionId, requestId, answer),
211
- onSuccess: () => {
212
- qc.invalidateQueries({ queryKey: ["questions"] });
213
- },
214
- });
215
- }
216
-
217
- // ── Providers / Models ──
218
-
219
- export function useProviders() {
220
- return useQuery({
221
- queryKey: ["providers"],
222
- queryFn: api.getProviders,
223
- });
224
- }
225
-
226
- export function useModels() {
227
- return useQuery({
228
- queryKey: ["models"],
229
- queryFn: api.getModels,
230
- });
231
- }
232
-
233
- // ── Agent / Command / Skill ──
234
-
235
- export function useAgents() {
236
- return useQuery({
237
- queryKey: ["agents"],
238
- queryFn: api.getAgents,
239
- });
240
- }
241
-
242
- export function useCommands() {
243
- return useQuery({
244
- queryKey: ["commands"],
245
- queryFn: api.getCommands,
246
- });
247
- }
248
-
249
- export function useSkills() {
250
- return useQuery({
251
- queryKey: ["skills"],
252
- queryFn: api.getSkills,
253
- });
254
- }
255
-
256
- // ── Files ──
257
-
258
- export function useListFiles(dir: string = ".") {
259
- return useQuery({
260
- queryKey: ["files", dir],
261
- queryFn: () => api.listFiles(dir),
262
- });
263
- }
264
-
265
- export function useFileContent(path: string) {
266
- return useQuery({
267
- queryKey: ["fileContent", path],
268
- queryFn: () => api.readFile(path),
269
- enabled: !!path,
270
- });
271
- }
272
-
273
- export function useFindFiles(query: string, dir?: string) {
274
- return useQuery({
275
- queryKey: ["findFiles", query, dir],
276
- queryFn: () => api.findFiles(query, dir),
277
- enabled: query.length > 0,
278
- });
279
- }
280
-
281
- export function useFindSymbols(query: string) {
282
- return useQuery({
283
- queryKey: ["findSymbols", query],
284
- queryFn: () => api.findSymbols(query),
285
- enabled: query.length > 0,
286
- });
287
- }
288
-
289
- // ── VCS ──
290
-
291
- export function useVCSStatus() {
292
- return useQuery({
293
- queryKey: ["vcs"],
294
- queryFn: api.getVCSStatus,
295
- refetchInterval: 15000,
296
- });
297
- }
298
-
299
- // ── PTY ──
300
-
301
- export function usePTYs() {
302
- return useQuery({
303
- queryKey: ["pty"],
304
- queryFn: api.getPTYs,
305
- });
306
- }
307
-
308
- export function useCreatePTY() {
309
- const qc = useQueryClient();
310
- return useMutation({
311
- mutationFn: (shell?: string) => api.createPTY(shell),
312
- onSuccess: () => qc.invalidateQueries({ queryKey: ["pty"] }),
313
- });
314
- }
315
-
316
- export function useDeletePTY() {
317
- const qc = useQueryClient();
318
- return useMutation({
319
- mutationFn: (id: string) => api.deletePTY(id),
320
- onSuccess: () => qc.invalidateQueries({ queryKey: ["pty"] }),
321
- });
322
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
mobile/hooks/useConnection.ts DELETED
@@ -1,65 +0,0 @@
1
- import { useState, useEffect, useCallback, useRef } from "react";
2
- import {
3
- getConnectionManager,
4
- type ConnectionState,
5
- } from "../lib/connection-manager";
6
- import type { ServerEvent } from "../constants/types";
7
-
8
- export interface UseConnectionReturn {
9
- state: ConnectionState;
10
- connect: (sessionId?: string) => void;
11
- disconnect: () => void;
12
- switchSession: (sessionId: string | null) => void;
13
- isConnected: boolean;
14
- }
15
-
16
- export function useConnection(sessionId?: string): UseConnectionReturn {
17
- const [state, setState] = useState<ConnectionState>("disconnected");
18
- const manager = useRef(getConnectionManager());
19
-
20
- useEffect(() => {
21
- const unsub = manager.current.onStateChange(setState);
22
- return unsub;
23
- }, []);
24
-
25
- // Auto-connect on mount
26
- useEffect(() => {
27
- manager.current.connect(sessionId);
28
- return () => {
29
- // Don't disconnect on unmount — keep connection alive
30
- };
31
- }, [sessionId]);
32
-
33
- const connect = useCallback(
34
- (sid?: string) => manager.current.connect(sid ?? sessionId),
35
- [sessionId]
36
- );
37
-
38
- const disconnect = useCallback(() => manager.current.disconnect(), []);
39
-
40
- const switchSession = useCallback(
41
- (sid: string | null) => manager.current.switchSession(sid),
42
- []
43
- );
44
-
45
- return {
46
- state,
47
- connect,
48
- disconnect,
49
- switchSession,
50
- isConnected: state === "connected",
51
- };
52
- }
53
-
54
- export function useConnectionEvents(
55
- onEvent: (event: ServerEvent) => void
56
- ) {
57
- const callbackRef = useRef(onEvent);
58
- callbackRef.current = onEvent;
59
-
60
- useEffect(() => {
61
- const manager = getConnectionManager();
62
- const unsub = manager.onEvent((event) => callbackRef.current(event));
63
- return unsub;
64
- }, []);
65
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
mobile/hooks/useSSE.ts DELETED
@@ -1,156 +0,0 @@
1
- import { useState, useCallback, useRef, useEffect } from "react";
2
- import { subscribeEvents, parseSSELines } from "../lib/api";
3
- import type { ServerEvent, Message, MessagePart } from "../constants/types";
4
-
5
- export interface SSEState {
6
- connected: boolean;
7
- streaming: boolean;
8
- error: string | null;
9
- events: ServerEvent[];
10
- }
11
-
12
- export function useSSE(sessionId?: string) {
13
- const [state, setState] = useState<SSEState>({
14
- connected: false,
15
- streaming: false,
16
- error: null,
17
- events: [],
18
- });
19
-
20
- const abortRef = useRef<AbortController | null>(null);
21
- const bufferRef = useRef("");
22
- const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
23
-
24
- const connect = useCallback(async () => {
25
- setState((s) => ({ ...s, error: null }));
26
-
27
- try {
28
- const response = await subscribeEvents(sessionId);
29
- abortRef.current = new AbortController();
30
- setState((s) => ({ ...s, connected: true, streaming: true }));
31
-
32
- const reader = response.body?.getReader();
33
- if (!reader) throw new Error("No readable stream");
34
-
35
- const decoder = new TextDecoder();
36
-
37
- while (true) {
38
- const { done, value } = await reader.read();
39
- if (done) break;
40
-
41
- bufferRef.current += decoder.decode(value, { stream: true });
42
- const { events, remaining } = parseSSELines(bufferRef.current);
43
- bufferRef.current = remaining;
44
-
45
- if (events.length > 0) {
46
- setState((s) => ({
47
- ...s,
48
- events: [...s.events, ...events],
49
- }));
50
- }
51
- }
52
-
53
- setState((s) => ({ ...s, connected: false, streaming: false }));
54
- } catch (err: unknown) {
55
- if (err instanceof Error && err.name !== "AbortError") {
56
- setState((s) => ({
57
- ...s,
58
- connected: false,
59
- streaming: false,
60
- error: err.message,
61
- }));
62
- }
63
- }
64
- }, [sessionId]);
65
-
66
- const disconnect = useCallback(() => {
67
- abortRef.current?.abort();
68
- if (reconnectTimer.current) {
69
- clearTimeout(reconnectTimer.current);
70
- }
71
- setState({ connected: false, streaming: false, error: null, events: [] });
72
- }, []);
73
-
74
- const clearEvents = useCallback(() => {
75
- setState((s) => ({ ...s, events: [] }));
76
- }, []);
77
-
78
- // Auto-reconnect on disconnect
79
- useEffect(() => {
80
- if (!state.connected && !state.streaming && sessionId) {
81
- reconnectTimer.current = setTimeout(() => {
82
- connect();
83
- }, 3000);
84
- }
85
- return () => {
86
- if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
87
- };
88
- }, [state.connected, state.streaming, sessionId, connect]);
89
-
90
- // Cleanup on unmount
91
- useEffect(() => {
92
- return () => {
93
- abortRef.current?.abort();
94
- };
95
- }, []);
96
-
97
- return {
98
- ...state,
99
- connect,
100
- disconnect,
101
- clearEvents,
102
- };
103
- }
104
-
105
- // ── Derived helpers ──
106
-
107
- /** Extract the latest assistant message from events */
108
- export function getLatestAssistantMessage(events: ServerEvent[]): Message | null {
109
- for (let i = events.length - 1; i >= 0; i--) {
110
- const ev = events[i];
111
- if (ev.type === "message.created" || ev.type === "message.updated") {
112
- const msg = ev.properties as Message;
113
- if (msg.role === "assistant") return msg;
114
- }
115
- }
116
- return null;
117
- }
118
-
119
- /** Extract all messages from events in order */
120
- export function getMessagesFromEvents(events: ServerEvent[]): Message[] {
121
- const map = new Map<string, Message>();
122
-
123
- for (const ev of events) {
124
- if (ev.type === "message.created") {
125
- const msg = ev.properties as Message;
126
- map.set(msg.id, msg);
127
- } else if (ev.type === "message.updated") {
128
- const partial = ev.properties as Partial<Message> & { id: string };
129
- const existing = map.get(partial.id);
130
- if (existing) {
131
- map.set(partial.id, { ...existing, ...partial });
132
- }
133
- } else if (ev.type === "message.part.updated") {
134
- const { messageID, part } = ev.properties as {
135
- messageID: string;
136
- part: MessagePart;
137
- sessionID: string;
138
- };
139
- const existing = map.get(messageID);
140
- if (existing) {
141
- const partIdx = existing.parts.findIndex(
142
- (p) => JSON.stringify(p) === JSON.stringify(part)
143
- );
144
- const newParts = [...existing.parts];
145
- if (partIdx >= 0) {
146
- newParts[partIdx] = part;
147
- } else {
148
- newParts.push(part);
149
- }
150
- map.set(messageID, { ...existing, parts: newParts });
151
- }
152
- }
153
- }
154
-
155
- return Array.from(map.values());
156
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
mobile/lib/api.ts DELETED
@@ -1,434 +0,0 @@
1
- import { getServerUrl, getAuthHeader, setServerUrl as persistServerUrl, setCredentials, clearAuth, setAuthType, setAuthValue } from "./storage";
2
- import type { AuthType } from "./storage";
3
- import type {
4
- HealthResponse,
5
- Session,
6
- SessionStatus,
7
- SessionListResponse,
8
- Message,
9
- MessagePart,
10
- OpenCodeConfig,
11
- Provider,
12
- Model,
13
- ModelListResponse,
14
- FileEntry,
15
- FileContent,
16
- FindResult,
17
- SymbolResult,
18
- PTY,
19
- Command,
20
- Skill,
21
- Agent,
22
- DiffEntry,
23
- VCSStatus,
24
- PermissionRequest,
25
- SavedPermission,
26
- QuestionRequest,
27
- Todo,
28
- ServerEvent,
29
- } from "../constants/types";
30
-
31
- // ── Base request helper ──
32
-
33
- async function request<T>(
34
- path: string,
35
- options?: RequestInit
36
- ): Promise<T> {
37
- const base = getServerUrl();
38
- const authHeaders = getAuthHeader();
39
- const headers: Record<string, string> = {
40
- "Content-Type": "application/json",
41
- ...authHeaders,
42
- ...((options?.headers as Record<string, string>) || {}),
43
- };
44
-
45
- const res = await fetch(`${base}${path}`, {
46
- ...options,
47
- headers,
48
- });
49
-
50
- if (!res.ok) {
51
- const text = await res.text();
52
- throw new Error(text || `HTTP ${res.status} ${res.statusText}`);
53
- }
54
-
55
- const contentType = res.headers.get("content-type") || "";
56
- if (contentType.includes("application/json")) {
57
- return res.json();
58
- }
59
- return res.text() as unknown as T;
60
- }
61
-
62
- /** Extract data array from { location, data } or { data } response */
63
- function extractData<T>(response: { data?: T[] } | T[]): T[] {
64
- if (Array.isArray(response)) return response;
65
- return response.data || [];
66
- }
67
-
68
- // ── Server URL / Credentials ──
69
-
70
- export function setServerUrl(url: string) {
71
- persistServerUrl(url);
72
- }
73
-
74
- export function setServerCredentials(username: string, password: string) {
75
- setCredentials(username, password);
76
- }
77
-
78
- // ── Health ──
79
-
80
- export function getHealth(): Promise<HealthResponse> {
81
- return request("/api/health");
82
- }
83
-
84
- // ── Config ── (root endpoint, not /api/)
85
-
86
- export function getConfig(): Promise<OpenCodeConfig> {
87
- return request("/config");
88
- }
89
-
90
- export function patchConfig(config: Partial<OpenCodeConfig>): Promise<void> {
91
- return request("/config", { method: "PATCH", body: JSON.stringify(config) });
92
- }
93
-
94
- // ── Sessions ──
95
-
96
- export function getSessions(): Promise<Session[]> {
97
- return request("/session");
98
- }
99
-
100
- export function getActiveSessions(): Promise<Session[]> {
101
- return request("/session/active");
102
- }
103
-
104
- export function getSession(id: string): Promise<Session> {
105
- return request(`/session/${id}`);
106
- }
107
-
108
- /** Get session via /api/ — returns {data: Session} */
109
- export function getSessionApi(id: string): Promise<Session> {
110
- return request<{ data: Session }>(`/api/session/${id}`).then((r) => r.data);
111
- }
112
-
113
- export function createSession(options?: {
114
- title?: string;
115
- agent?: string;
116
- model?: string;
117
- directory?: string;
118
- }): Promise<Session> {
119
- return request("/session", {
120
- method: "POST",
121
- body: JSON.stringify(options || {}),
122
- });
123
- }
124
-
125
- export function deleteSession(id: string): Promise<void> {
126
- return request(`/session/${id}`, { method: "DELETE" });
127
- }
128
-
129
- export function patchSession(
130
- id: string,
131
- data: { title?: string; agent?: string; model?: string }
132
- ): Promise<Session> {
133
- return request(`/session/${id}`, {
134
- method: "PATCH",
135
- body: JSON.stringify(data),
136
- });
137
- }
138
-
139
- export function getSessionStatus(id: string): Promise<SessionStatus> {
140
- return request(`/session/${id}`);
141
- }
142
-
143
- export function getSessionChildren(id: string): Promise<Session[]> {
144
- return request<Session[]>(`/session/${id}/children`).catch(() => []);
145
- }
146
-
147
- export function getSessionTodos(id: string): Promise<Todo[]> {
148
- return request(`/session/${id}/todo`);
149
- }
150
-
151
- // ── Messages ──
152
-
153
- export function getMessages(sessionId: string): Promise<Message[]> {
154
- return request(`/session/${sessionId}/message`);
155
- }
156
-
157
- export function getMessage(sessionId: string, messageId: string): Promise<Message> {
158
- return request(`/session/${sessionId}/message/${messageId}`);
159
- }
160
-
161
- export function deleteMessage(sessionId: string, messageId: string): Promise<void> {
162
- return request(`/session/${sessionId}/message/${messageId}`, {
163
- method: "DELETE",
164
- });
165
- }
166
-
167
- // ── Send message (triggers SSE response) ──
168
-
169
- export interface MessageResponse {
170
- info: {
171
- id: string;
172
- parentID?: string;
173
- role: string;
174
- sessionID: string;
175
- modelID?: string;
176
- providerID?: string;
177
- time?: { created: number; completed?: number };
178
- finish?: string;
179
- };
180
- parts: MessagePart[];
181
- }
182
-
183
- export function sendMessage(
184
- sessionId: string,
185
- content: string
186
- ): Promise<MessageResponse> {
187
- return request(`/session/${sessionId}/message`, {
188
- method: "POST",
189
- body: JSON.stringify({
190
- parts: [{ type: "text", text: content }],
191
- }),
192
- });
193
- }
194
-
195
- // ── Session actions ──
196
-
197
- export function abortSession(sessionId: string): Promise<void> {
198
- return request(`/session/${sessionId}/abort`, { method: "POST" });
199
- }
200
-
201
- export function initSession(sessionId: string): Promise<void> {
202
- return request(`/session/${sessionId}/init`, { method: "POST" });
203
- }
204
-
205
- export function compactSession(sessionId: string): Promise<void> {
206
- return request(`/session/${sessionId}/compact`, { method: "POST" });
207
- }
208
-
209
- export function summarizeSession(sessionId: string): Promise<void> {
210
- return request(`/session/${sessionId}/summarize`, { method: "POST" });
211
- }
212
-
213
- export function shareSession(sessionId: string): Promise<{ url?: string }> {
214
- return request(`/session/${sessionId}/share`, { method: "POST" });
215
- }
216
-
217
- export function forkSession(sessionId: string): Promise<Session> {
218
- return request(`/session/${sessionId}/fork`, { method: "POST" });
219
- }
220
-
221
- // ── Session diff / revert ──
222
-
223
- export function getSessionDiff(sessionId: string): Promise<DiffEntry[]> {
224
- return request(`/session/${sessionId}/diff`);
225
- }
226
-
227
- export function revertSessionStage(sessionId: string): Promise<void> {
228
- return request(`/session/${sessionId}/revert`, { method: "POST" });
229
- }
230
-
231
- export function revertSessionClear(sessionId: string): Promise<void> {
232
- return request(`/session/${sessionId}/unrevert`, { method: "POST" });
233
- }
234
-
235
- // ── Session history / context ──
236
-
237
- export function getSessionHistory(sessionId: string): Promise<{ messages: Message[] }> {
238
- return request(`/session/${sessionId}/history`);
239
- }
240
-
241
- export function getSessionContext(sessionId: string): Promise<{ files: string[]; capabilities: string[] }> {
242
- return request(`/session/${sessionId}/context`);
243
- }
244
-
245
- // ── Permissions ──
246
-
247
- export function getPermissionRequests(): Promise<PermissionRequest[]> {
248
- return request("/permission");
249
- }
250
-
251
- export function getSavedPermissions(): Promise<SavedPermission[]> {
252
- return request("/permission/saved");
253
- }
254
-
255
- export function deleteSavedPermission(id: string): Promise<void> {
256
- return request(`/permission/saved/${id}`, { method: "DELETE" });
257
- }
258
-
259
- export function replyPermission(
260
- sessionId: string,
261
- requestId: string,
262
- allow: boolean
263
- ): Promise<void> {
264
- return request(`/session/${sessionId}/permissions/${requestId}`, {
265
- method: "POST",
266
- body: JSON.stringify({ allow }),
267
- });
268
- }
269
-
270
- // ── Questions ──
271
-
272
- export function getQuestionRequests(): Promise<QuestionRequest[]> {
273
- return request("/question");
274
- }
275
-
276
- export function getSessionQuestions(sessionId: string): Promise<QuestionRequest[]> {
277
- return request(`/session/${sessionId}/question`);
278
- }
279
-
280
- export function replyQuestion(
281
- sessionId: string,
282
- requestId: string,
283
- answer: string
284
- ): Promise<void> {
285
- return request(`/question/${requestId}/reply`, {
286
- method: "POST",
287
- body: JSON.stringify({ answer }),
288
- });
289
- }
290
-
291
- export function rejectQuestion(
292
- sessionId: string,
293
- requestId: string
294
- ): Promise<void> {
295
- return request(`/question/${requestId}/reject`, {
296
- method: "POST",
297
- });
298
- }
299
-
300
- // ── Providers ──
301
-
302
- export function getProviders(): Promise<Provider[]> {
303
- return request<{ data: Provider[] }>("/api/provider").then((r) => r.data);
304
- }
305
-
306
- export function getProvider(id: string): Promise<Provider> {
307
- return request(`/api/provider/${id}`);
308
- }
309
-
310
- // ── Models ──
311
-
312
- export function getModels(): Promise<Model[]> {
313
- return request<{ data: Model[] }>("/api/model").then((r) => r.data);
314
- }
315
-
316
- // ── Agent / Command / Skill ──
317
-
318
- export function getAgents(): Promise<Agent[]> {
319
- return request<{ data: Agent[] }>("/api/agent").then((r) => r.data);
320
- }
321
-
322
- export function getCommands(): Promise<Command[]> {
323
- return request<{ data: Command[] }>("/api/command").then((r) => r.data);
324
- }
325
-
326
- export function getSkills(): Promise<Skill[]> {
327
- return request<{ data: Skill[] }>("/api/skill").then((r) => r.data);
328
- }
329
-
330
- // ── Files ──
331
-
332
- export function listFiles(dir: string = "."): Promise<FileEntry[]> {
333
- return request<{ data: FileEntry[] }>(`/api/fs/list?path=${encodeURIComponent(dir)}`).then((r) => r.data);
334
- }
335
-
336
- export function readFile(filePath: string): Promise<FileContent> {
337
- return request(`/file/content?path=${encodeURIComponent(filePath)}`);
338
- }
339
-
340
- export function findFiles(query: string, dir?: string): Promise<FindResult[]> {
341
- const params = new URLSearchParams({ query });
342
- if (dir) params.set("path", dir);
343
- return request(`/find/file?${params.toString()}`);
344
- }
345
-
346
- export function findSymbols(query: string): Promise<SymbolResult[]> {
347
- return request(`/find/symbol?query=${encodeURIComponent(query)}`);
348
- }
349
-
350
- // ── VCS ──
351
-
352
- export function getVCSStatus(): Promise<VCSStatus> {
353
- return request("/api/vcs/status");
354
- }
355
-
356
- // ── PTY ──
357
-
358
- export function getPTYs(): Promise<PTY[]> {
359
- return request<{ data: PTY[] }>("/api/pty").then((r) => r.data);
360
- }
361
-
362
- export function createPTY(shell?: string): Promise<PTY> {
363
- return request("/api/pty", {
364
- method: "POST",
365
- body: JSON.stringify({ shell }),
366
- });
367
- }
368
-
369
- export function getPTY(id: string): Promise<PTY> {
370
- return request(`/api/pty/${id}`);
371
- }
372
-
373
- export function deletePTY(id: string): Promise<void> {
374
- return request(`/api/pty/${id}`, { method: "DELETE" });
375
- }
376
-
377
- export function getPTYConnectToken(id: string): Promise<{ token: string }> {
378
- return request(`/api/pty/${id}/connect-token`, { method: "POST" });
379
- }
380
-
381
- // ── SSE Event Stream (returns raw Response for manual parsing) ──
382
-
383
- export async function subscribeEvents(
384
- sessionId?: string
385
- ): Promise<Response> {
386
- const base = getServerUrl();
387
- const authHeaders = getAuthHeader();
388
- const url = sessionId
389
- ? `${base}/session/${sessionId}/event`
390
- : `${base}/event`;
391
-
392
- const res = await fetch(url, {
393
- headers: { Accept: "text/event-stream", ...authHeaders },
394
- });
395
-
396
- if (!res.ok) {
397
- throw new Error(`SSE connection failed: ${res.status}`);
398
- }
399
-
400
- return res;
401
- }
402
-
403
- // ── SSE Parser helper ──
404
-
405
- export function parseSSELines(
406
- buffer: string
407
- ): { events: ServerEvent[]; remaining: string } {
408
- const events: ServerEvent[] = [];
409
- const lines = buffer.split("\n");
410
- const remaining = lines.pop() || "";
411
-
412
- let eventType = "";
413
- let eventData = "";
414
-
415
- for (const line of lines) {
416
- if (line.startsWith("event: ")) {
417
- eventType = line.slice(7).trim();
418
- } else if (line.startsWith("data: ")) {
419
- eventData = line.slice(6);
420
- } else if (line === "") {
421
- if (eventType && eventData) {
422
- try {
423
- events.push(JSON.parse(eventData) as ServerEvent);
424
- } catch {
425
- // skip malformed
426
- }
427
- }
428
- eventType = "";
429
- eventData = "";
430
- }
431
- }
432
-
433
- return { events, remaining };
434
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
mobile/lib/connection-manager.ts DELETED
@@ -1,285 +0,0 @@
1
- import { getHealth, subscribeEvents, parseSSELines } from "./api";
2
- import { getServerUrl, getCredentials } from "./storage";
3
- import type { ServerEvent } from "../constants/types";
4
-
5
- export type ConnectionState =
6
- | "disconnected"
7
- | "connecting"
8
- | "connected"
9
- | "reconnecting"
10
- | "error";
11
-
12
- export type ConnectionListener = (state: ConnectionState) => void;
13
- export type EventListener = (event: ServerEvent) => void;
14
-
15
- interface ConnectionManagerOptions {
16
- /** Max reconnect attempts before giving up (default: Infinity) */
17
- maxRetries?: number;
18
- /** Base delay in ms for exponential backoff (default: 1000) */
19
- baseDelay?: number;
20
- /** Max delay in ms (default: 30000) */
21
- maxDelay?: number;
22
- /** Health check interval in ms (default: 15000) */
23
- heartbeatInterval?: number;
24
- /** Timeout for health check in ms (default: 5000) */
25
- healthTimeout?: number;
26
- }
27
-
28
- export class ConnectionManager {
29
- private state: ConnectionState = "disconnected";
30
- private retryCount = 0;
31
- private maxRetries: number;
32
- private baseDelay: number;
33
- private maxDelay: number;
34
- private heartbeatMs: number;
35
- private healthTimeout: number;
36
-
37
- private stateListeners: Set<ConnectionListener> = new Set();
38
- private eventListeners: Set<EventListener> = new Set();
39
-
40
- private heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
41
- private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
42
- private abortController: AbortController | null = null;
43
- private sseBuffer = "";
44
-
45
- /** Currently active SSE session subscription (null = global events) */
46
- private activeSessionId: string | null = null;
47
-
48
- constructor(options?: ConnectionManagerOptions) {
49
- this.maxRetries = options?.maxRetries ?? Infinity;
50
- this.baseDelay = options?.baseDelay ?? 1000;
51
- this.maxDelay = options?.maxDelay ?? 30000;
52
- this.heartbeatMs = options?.heartbeatInterval ?? 15000;
53
- this.healthTimeout = options?.healthTimeout ?? 5000;
54
- }
55
-
56
- // ── State ──
57
-
58
- getState(): ConnectionState {
59
- return this.state;
60
- }
61
-
62
- isConnected(): boolean {
63
- return this.state === "connected";
64
- }
65
-
66
- // ── Listeners ──
67
-
68
- onStateChange(listener: ConnectionListener): () => void {
69
- this.stateListeners.add(listener);
70
- return () => this.stateListeners.delete(listener);
71
- }
72
-
73
- onEvent(listener: EventListener): () => void {
74
- this.eventListeners.add(listener);
75
- return () => this.eventListeners.delete(listener);
76
- }
77
-
78
- private setState(newState: ConnectionState) {
79
- if (this.state === newState) return;
80
- this.state = newState;
81
- this.stateListeners.forEach((fn) => fn(newState));
82
- }
83
-
84
- private emitEvent(event: ServerEvent) {
85
- this.eventListeners.forEach((fn) => fn(event));
86
- }
87
-
88
- // ── Connect ──
89
-
90
- async connect(sessionId?: string) {
91
- if (this.state === "connected" || this.state === "connecting") return;
92
-
93
- this.activeSessionId = sessionId ?? null;
94
- this.setState("connecting");
95
-
96
- try {
97
- await this.checkHealth();
98
- this.retryCount = 0;
99
- this.setState("connected");
100
- this.startHeartbeat();
101
- this.connectSSE();
102
- } catch {
103
- this.setState("error");
104
- this.scheduleReconnect();
105
- }
106
- }
107
-
108
- disconnect() {
109
- this.stopHeartbeat();
110
- this.stopReconnect();
111
- this.abortController?.abort();
112
- this.abortController = null;
113
- this.sseBuffer = "";
114
- this.retryCount = 0;
115
- this.setState("disconnected");
116
- }
117
-
118
- // ── Health check ──
119
-
120
- private async checkHealth(): Promise<void> {
121
- const controller = new AbortController();
122
- const timeout = setTimeout(() => controller.abort(), this.healthTimeout);
123
-
124
- try {
125
- const result = await Promise.race([
126
- getHealth(),
127
- new Promise<never>((_, reject) => {
128
- controller.signal.addEventListener("abort", () =>
129
- reject(new Error("Health check timeout"))
130
- );
131
- }),
132
- ]);
133
-
134
- clearTimeout(timeout);
135
-
136
- if (!result.healthy) {
137
- throw new Error("Server reports unhealthy");
138
- }
139
- } catch (err) {
140
- clearTimeout(timeout);
141
- throw err;
142
- }
143
- }
144
-
145
- // ── Heartbeat ──
146
-
147
- private startHeartbeat() {
148
- this.stopHeartbeat();
149
- this.heartbeatTimer = setInterval(async () => {
150
- try {
151
- await this.checkHealth();
152
- // If we were in error state, recover
153
- if (this.state === "error") {
154
- this.retryCount = 0;
155
- this.setState("connected");
156
- this.connectSSE();
157
- }
158
- } catch {
159
- if (this.state === "connected") {
160
- this.setState("error");
161
- this.scheduleReconnect();
162
- }
163
- }
164
- }, this.heartbeatMs);
165
- }
166
-
167
- private stopHeartbeat() {
168
- if (this.heartbeatTimer) {
169
- clearInterval(this.heartbeatTimer);
170
- this.heartbeatTimer = null;
171
- }
172
- }
173
-
174
- // ── SSE ──
175
-
176
- private connectSSE() {
177
- this.abortController?.abort();
178
- this.abortController = new AbortController();
179
- this.sseBuffer = "";
180
-
181
- const doConnect = async () => {
182
- try {
183
- const response = await subscribeEvents(this.activeSessionId ?? undefined);
184
- const reader = response.body?.getReader();
185
- if (!reader) throw new Error("No readable stream");
186
-
187
- const decoder = new TextDecoder();
188
-
189
- while (true) {
190
- if (this.abortController?.signal.aborted) break;
191
-
192
- const { done, value } = await reader.read();
193
- if (done) break;
194
-
195
- this.sseBuffer += decoder.decode(value, { stream: true });
196
- const { events, remaining } = parseSSELines(this.sseBuffer);
197
- this.sseBuffer = remaining;
198
-
199
- for (const event of events) {
200
- this.emitEvent(event);
201
- }
202
- }
203
-
204
- // SSE ended normally — try reconnect
205
- if (this.state === "connected") {
206
- this.setState("error");
207
- this.scheduleReconnect();
208
- }
209
- } catch (err) {
210
- if (
211
- this.abortController?.signal.aborted ||
212
- this.state === "disconnected"
213
- ) {
214
- return;
215
- }
216
-
217
- console.warn("[ConnectionManager] SSE error:", err);
218
- if (this.state === "connected") {
219
- this.setState("error");
220
- this.scheduleReconnect();
221
- }
222
- }
223
- };
224
-
225
- doConnect();
226
- }
227
-
228
- // ── Reconnect with exponential backoff ──
229
-
230
- private scheduleReconnect() {
231
- if (this.retryCount >= this.maxRetries) {
232
- this.setState("disconnected");
233
- return;
234
- }
235
-
236
- this.setState("reconnecting");
237
-
238
- const delay = Math.min(
239
- this.baseDelay * Math.pow(2, this.retryCount),
240
- this.maxDelay
241
- );
242
- this.retryCount++;
243
-
244
- this.reconnectTimer = setTimeout(() => {
245
- this.connectSSE();
246
- // Also check health
247
- this.checkHealth()
248
- .then(() => {
249
- this.retryCount = 0;
250
- this.setState("connected");
251
- })
252
- .catch(() => {
253
- // Will retry again
254
- });
255
- }, delay);
256
- }
257
-
258
- private stopReconnect() {
259
- if (this.reconnectTimer) {
260
- clearTimeout(this.reconnectTimer);
261
- this.reconnectTimer = null;
262
- }
263
- }
264
-
265
- // ── Switch session subscription ──
266
-
267
- switchSession(sessionId: string | null) {
268
- if (this.activeSessionId === sessionId) return;
269
- this.activeSessionId = sessionId;
270
- if (this.state === "connected") {
271
- this.connectSSE();
272
- }
273
- }
274
- }
275
-
276
- // ── Singleton ──
277
-
278
- let instance: ConnectionManager | null = null;
279
-
280
- export function getConnectionManager(options?: ConnectionManagerOptions): ConnectionManager {
281
- if (!instance) {
282
- instance = new ConnectionManager(options);
283
- }
284
- return instance;
285
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
mobile/lib/query-provider.tsx DELETED
@@ -1,17 +0,0 @@
1
- import React from "react";
2
- import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
3
-
4
- const queryClient = new QueryClient({
5
- defaultOptions: {
6
- queries: {
7
- staleTime: 5000,
8
- refetchOnWindowFocus: false,
9
- },
10
- },
11
- });
12
-
13
- export function QueryProvider({ children }: { children: React.ReactNode }) {
14
- return (
15
- <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
16
- );
17
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
mobile/lib/storage.ts DELETED
@@ -1,134 +0,0 @@
1
- import AsyncStorage from "@react-native-async-storage/async-storage";
2
- import { ThemeName } from "../constants/types";
3
-
4
- export const storageKeys = {
5
- serverUrl: "opencode_server_url",
6
- authType: "opencode_auth_type",
7
- authValue: "opencode_auth_value",
8
- theme: "opencode_theme",
9
- lastSessionId: "opencode_last_session_id",
10
- } as const;
11
-
12
- export type AuthType = "none" | "basic" | "bearer" | "apikey";
13
-
14
- const cache: Record<string, string | null> = {};
15
-
16
- async function get(key: string): Promise<string | null> {
17
- if (cache[key] !== undefined) return cache[key];
18
- const val = await AsyncStorage.getItem(key);
19
- cache[key] = val;
20
- return val;
21
- }
22
-
23
- async function set(key: string, value: string): Promise<void> {
24
- cache[key] = value;
25
- await AsyncStorage.setItem(key, value);
26
- }
27
-
28
- async function del(key: string): Promise<void> {
29
- cache[key] = null;
30
- await AsyncStorage.removeItem(key);
31
- }
32
-
33
- function getSync(key: string): string | null {
34
- if (cache[key] !== undefined) return cache[key];
35
- return null;
36
- }
37
-
38
- // Preload all keys on module load
39
- const _loaded = (async () => {
40
- const entries = await AsyncStorage.multiGet(Object.values(storageKeys));
41
- for (const [k, v] of entries) {
42
- cache[k] = v;
43
- }
44
- })();
45
-
46
- // ── Server URL ──
47
-
48
- export function getServerUrl(): string {
49
- return getSync(storageKeys.serverUrl) || "http://127.0.0.1:4096";
50
- }
51
-
52
- export async function setServerUrl(url: string): Promise<void> {
53
- await set(storageKeys.serverUrl, url);
54
- }
55
-
56
- // ── Auth ──
57
-
58
- export function getAuthType(): AuthType {
59
- return (getSync(storageKeys.authType) as AuthType) || "none";
60
- }
61
-
62
- export async function setAuthType(type: AuthType): Promise<void> {
63
- await set(storageKeys.authType, type);
64
- }
65
-
66
- export function getAuthValue(): string {
67
- return getSync(storageKeys.authValue) || "";
68
- }
69
-
70
- export async function setAuthValue(value: string): Promise<void> {
71
- await set(storageKeys.authValue, value);
72
- }
73
-
74
- export function getCredentials(): { username: string; password: string } {
75
- const val = getAuthValue();
76
- if (getAuthType() === "basic" && val.includes(":")) {
77
- const [username, ...rest] = val.split(":");
78
- return { username, password: rest.join(":") };
79
- }
80
- return { username: "", password: "" };
81
- }
82
-
83
- export async function setCredentials(username: string, password: string): Promise<void> {
84
- await setAuthType("basic");
85
- await setAuthValue(`${username}:${password}`);
86
- }
87
-
88
- export async function clearAuth(): Promise<void> {
89
- await del(storageKeys.authType);
90
- await del(storageKeys.authValue);
91
- }
92
-
93
- export function getAuthHeader(): Record<string, string> {
94
- const type = getAuthType();
95
- const value = getAuthValue();
96
-
97
- if (!value) return {};
98
-
99
- switch (type) {
100
- case "basic": {
101
- const creds = getCredentials();
102
- if (creds.username) {
103
- return { Authorization: `Basic ${btoa(`${creds.username}:${creds.password}`)}` };
104
- }
105
- return {};
106
- }
107
- case "bearer":
108
- return { Authorization: `Bearer ${value}` };
109
- case "apikey":
110
- return { "X-API-Key": value };
111
- default:
112
- return {};
113
- }
114
- }
115
-
116
- // ── Theme ──
117
-
118
- export function getTheme(): ThemeName {
119
- return (getSync(storageKeys.theme) as ThemeName) || "tokyo";
120
- }
121
-
122
- export async function setTheme(theme: ThemeName): Promise<void> {
123
- await set(storageKeys.theme, theme);
124
- }
125
-
126
- // ── Last session ──
127
-
128
- export function getLastSessionId(): string | undefined {
129
- return getSync(storageKeys.lastSessionId) || undefined;
130
- }
131
-
132
- export async function setLastSessionId(id: string): Promise<void> {
133
- await set(storageKeys.lastSessionId, id);
134
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
mobile/package-lock.json DELETED
The diff for this file is too large to render. See raw diff
 
mobile/package.json DELETED
@@ -1,38 +0,0 @@
1
- {
2
- "name": "opencode-mobile",
3
- "version": "1.0.0",
4
- "main": "expo-router/entry",
5
- "scripts": {
6
- "start": "expo start",
7
- "android": "expo start --android",
8
- "ios": "expo start --ios",
9
- "web": "expo start --web"
10
- },
11
- "dependencies": {
12
- "@react-native-async-storage/async-storage": "2.1.2",
13
- "@tanstack/react-query": "^5.60.6",
14
- "expo": "~53.0.27",
15
- "expo-asset": "~11.1.7",
16
- "expo-constants": "~17.1.8",
17
- "expo-font": "~13.3.2",
18
- "expo-linking": "~7.1.7",
19
- "expo-router": "~5.1.11",
20
- "expo-secure-store": "~14.2.4",
21
- "expo-splash-screen": "~0.30.10",
22
- "expo-status-bar": "~2.2.3",
23
- "expo-system-ui": "~5.0.11",
24
- "lucide-react-native": "^0.460.0",
25
- "react": "19.0.0",
26
- "react-native": "0.79.6",
27
- "react-native-gesture-handler": "~2.24.0",
28
- "react-native-markdown-display": "^7.0.2",
29
- "react-native-reanimated": "~3.17.4",
30
- "react-native-safe-area-context": "5.4.0",
31
- "react-native-screens": "~4.11.1",
32
- "react-native-svg": "15.11.2"
33
- },
34
- "devDependencies": {
35
- "@types/react": "~19.0.10",
36
- "typescript": "~5.8.3"
37
- }
38
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
mobile/tests/integration.test.ts DELETED
@@ -1,251 +0,0 @@
1
- /**
2
- * End-to-end integration test for OpenCode Mobile client
3
- * Run against a live opencode serve instance
4
- *
5
- * Usage: npx tsx mobile/tests/integration.test.ts
6
- */
7
-
8
- const BASE = "http://127.0.0.1:4096";
9
-
10
- interface TestResult {
11
- name: string;
12
- passed: boolean;
13
- error?: string;
14
- duration: number;
15
- }
16
-
17
- const results: TestResult[] = [];
18
-
19
- async function test(name: string, fn: () => Promise<void>) {
20
- const start = Date.now();
21
- try {
22
- await fn();
23
- results.push({ name, passed: true, duration: Date.now() - start });
24
- console.log(` ✓ ${name} (${Date.now() - start}ms)`);
25
- } catch (err: any) {
26
- results.push({
27
- name,
28
- passed: false,
29
- error: err.message,
30
- duration: Date.now() - start,
31
- });
32
- console.log(` ✗ ${name}: ${err.message}`);
33
- }
34
- }
35
-
36
- function assert(condition: boolean, msg: string) {
37
- if (!condition) throw new Error(msg);
38
- }
39
-
40
- async function api<T = any>(path: string, opts?: RequestInit): Promise<T> {
41
- const res = await fetch(`${BASE}${path}`, {
42
- headers: { "Content-Type": "application/json", ...opts?.headers },
43
- ...opts,
44
- });
45
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
46
- return res.json();
47
- }
48
-
49
- // ── Tests ──
50
-
51
- async function run() {
52
- console.log("\n=== OpenCode Integration Tests ===\n");
53
-
54
- let sessionId: string;
55
-
56
- // 1. Health
57
- await test("GET /api/health", async () => {
58
- const data = await api("/api/health");
59
- assert(data.healthy === true, "not healthy");
60
- });
61
-
62
- // 2. Config (root endpoint — /api/config returns HTML)
63
- await test("GET /config", async () => {
64
- const data = await api("/config");
65
- assert(typeof data === "object", "config is not object");
66
- });
67
-
68
- // 3. List sessions (root returns array)
69
- await test("GET /session (list)", async () => {
70
- const data = await api("/session");
71
- assert(Array.isArray(data), "not array");
72
- });
73
-
74
- // 4. List sessions via /api/ (returns {data, cursor})
75
- await test("GET /api/session (list)", async () => {
76
- const data = await api("/api/session");
77
- assert(Array.isArray(data.data), "data is not array");
78
- });
79
-
80
- // 5. Create session
81
- await test("POST /session (create)", async () => {
82
- const data = await api("/session", {
83
- method: "POST",
84
- body: JSON.stringify({ title: "Integration Test" }),
85
- });
86
- assert(data.id, "no id returned");
87
- sessionId = data.id;
88
- });
89
-
90
- // 6. Get session (root)
91
- await test("GET /session/{id}", async () => {
92
- const data = await api(`/session/${sessionId}`);
93
- assert(data.id === sessionId, "id mismatch");
94
- });
95
-
96
- // 7. Get session via /api/ (returns {data: Session})
97
- await test("GET /api/session/{id}", async () => {
98
- const data = await api(`/api/session/${sessionId}`);
99
- assert(data.data?.id === sessionId, "id mismatch in data");
100
- });
101
-
102
- // 8. List messages (empty)
103
- await test("GET /session/{id}/message (empty)", async () => {
104
- const data = await api(`/session/${sessionId}/message`);
105
- assert(Array.isArray(data), "not array");
106
- });
107
-
108
- // 9. Send message (requires {parts: [{type:'text', text:'...'}]})
109
- await test("POST /session/{id}/message (send)", async () => {
110
- const data = await api(`/session/${sessionId}/message`, {
111
- method: "POST",
112
- body: JSON.stringify({
113
- parts: [{ type: "text", text: "Say exactly: integration test ok" }],
114
- }),
115
- });
116
- assert(data.info || data.parts, "unexpected response shape");
117
- });
118
-
119
- // 10. SSE event stream (global endpoint)
120
- await test("GET /event (SSE)", async () => {
121
- const controller = new AbortController();
122
- const timeout = setTimeout(() => controller.abort(), 4000);
123
-
124
- const res = await fetch(`${BASE}/event`, {
125
- headers: { Accept: "text/event-stream" },
126
- signal: controller.signal,
127
- });
128
-
129
- clearTimeout(timeout);
130
- assert(res.ok, `HTTP ${res.status}`);
131
-
132
- const reader = res.body!.getReader();
133
- const decoder = new TextDecoder();
134
- let buffer = "";
135
- let eventCount = 0;
136
-
137
- const readTimeout = setTimeout(() => reader.cancel(), 3000);
138
-
139
- while (eventCount < 1) {
140
- const { done, value } = await reader.read();
141
- if (done) break;
142
- buffer += decoder.decode(value, { stream: true });
143
- const lines = buffer.split("\n");
144
- buffer = lines.pop() || "";
145
- for (const line of lines) {
146
- if (line.startsWith("data: ") || line.startsWith("event: ")) eventCount++;
147
- }
148
- }
149
-
150
- clearTimeout(readTimeout);
151
- reader.cancel();
152
- assert(eventCount > 0, `no events (got ${eventCount})`);
153
- });
154
-
155
- // 11. Providers
156
- await test("GET /api/provider", async () => {
157
- const data = await api("/api/provider");
158
- assert(Array.isArray(data.data), "data is not array");
159
- });
160
-
161
- // 12. Models
162
- await test("GET /api/model", async () => {
163
- const data = await api("/api/model");
164
- assert(Array.isArray(data.data), "data is not array");
165
- });
166
-
167
- // 13. Agents
168
- await test("GET /api/agent", async () => {
169
- const data = await api("/api/agent");
170
- assert(Array.isArray(data.data), "data is not array");
171
- });
172
-
173
- // 14. Commands
174
- await test("GET /api/command", async () => {
175
- const data = await api("/api/command");
176
- assert(Array.isArray(data.data), "data is not array");
177
- });
178
-
179
- // 15. Skills
180
- await test("GET /api/skill", async () => {
181
- const data = await api("/api/skill");
182
- assert(Array.isArray(data.data), "data is not array");
183
- });
184
-
185
- // 16. File list
186
- await test("GET /api/fs/list", async () => {
187
- const data = await api("/api/fs/list?path=.");
188
- assert(Array.isArray(data.data), "data is not array");
189
- assert(data.data.length > 0, "empty file list");
190
- });
191
-
192
- // 17. File content (root endpoint)
193
- await test("GET /file/content", async () => {
194
- const data = await api("/file/content?path=package.json");
195
- assert(data.content || typeof data === "string", "no content");
196
- });
197
-
198
- // 18. Find files (root endpoint)
199
- await test("GET /find/file", async () => {
200
- const data = await api("/find/file?query=package");
201
- assert(Array.isArray(data), "not array");
202
- });
203
-
204
- // 19. PTY list
205
- await test("GET /api/pty", async () => {
206
- const data = await api("/api/pty");
207
- assert(Array.isArray(data.data), "data is not array");
208
- });
209
-
210
- // 20. VCS status
211
- await test("GET /vcs/status", async () => {
212
- const data = await api("/vcs/status");
213
- assert(typeof data === "object", "not object");
214
- });
215
-
216
- // 21. Delete session
217
- await test("DELETE /session/{id}", async () => {
218
- await api(`/session/${sessionId}`, { method: "DELETE" });
219
- });
220
-
221
- // 22. Verify deleted
222
- await test("GET /session/{id} after delete", async () => {
223
- try {
224
- await api(`/session/${sessionId}`);
225
- assert(false, "should have thrown");
226
- } catch {
227
- // Expected
228
- }
229
- });
230
-
231
- // Summary
232
- console.log("\n=== Results ===\n");
233
- const passed = results.filter((r) => r.passed).length;
234
- const failed = results.filter((r) => !r.passed).length;
235
- console.log(` ${passed} passed, ${failed} failed out of ${results.length} tests`);
236
-
237
- if (failed > 0) {
238
- console.log("\n Failed tests:");
239
- results
240
- .filter((r) => !r.passed)
241
- .forEach((r) => console.log(` - ${r.name}: ${r.error}`));
242
- }
243
-
244
- console.log("");
245
- process.exit(failed > 0 ? 1 : 0);
246
- }
247
-
248
- run().catch((err) => {
249
- console.error("Fatal:", err);
250
- process.exit(1);
251
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
mobile/tsconfig.json DELETED
@@ -1,15 +0,0 @@
1
- {
2
- "extends": "expo/tsconfig.base",
3
- "compilerOptions": {
4
- "strict": true,
5
- "paths": {
6
- "@/*": [
7
- "./*"
8
- ]
9
- }
10
- },
11
- "include": [
12
- "**/*.ts",
13
- "**/*.tsx"
14
- ]
15
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
nginx.conf CHANGED
@@ -27,77 +27,7 @@ http {
27
  proxy_redirect ~^https?://[^/]+:4096/(.*)$ https://jishnupg-opencode-cli.hf.space/$1;
28
 
29
  # -------------------------------------------------------
30
- # 1. OMNIROUTE AI GATEWAY (/omniroute, /v1, /_next, /assets)
31
- # -------------------------------------------------------
32
- location /omniroute/ {
33
- proxy_pass http://127.0.0.1:20128/;
34
- proxy_set_header Upgrade $http_upgrade;
35
- proxy_set_header Connection "upgrade";
36
- proxy_redirect /login /omniroute/login;
37
- proxy_redirect /dashboard /omniroute/dashboard;
38
- proxy_set_header Accept-Encoding "";
39
- sub_filter_types text/javascript application/javascript application/json;
40
- sub_filter 'href="/' 'href="/omniroute/';
41
- sub_filter 'src="/' 'src="/omniroute/';
42
- sub_filter 'action="/' 'action="/omniroute/';
43
- sub_filter '="/_next/' '="/omniroute/_next/';
44
- sub_filter '"/_next/' '"/omniroute/_next/';
45
- sub_filter '="/assets/' '="/assets/';
46
- sub_filter_once off;
47
- }
48
-
49
- location = /omniroute {
50
- return 301 /omniroute/;
51
- }
52
-
53
- location /_next/ {
54
- proxy_pass http://127.0.0.1:20128/_next/;
55
- }
56
-
57
- location /assets/ {
58
- proxy_pass http://127.0.0.1:20128/assets/;
59
- }
60
-
61
- location /v1/ {
62
- proxy_pass http://127.0.0.1:20128/v1/;
63
- }
64
-
65
- # -------------------------------------------------------
66
- # 2. OPENCODE CLI SERVER (/server, /session, /project, etc.)
67
- # -------------------------------------------------------
68
- location /server/ {
69
- proxy_pass http://127.0.0.1:4097/;
70
- proxy_set_header Upgrade $http_upgrade;
71
- proxy_set_header Connection "upgrade";
72
- proxy_set_header Accept-Encoding "";
73
- sub_filter_types text/javascript application/javascript;
74
- sub_filter 'href="/' 'href="/server/';
75
- sub_filter 'src="/' 'src="/server/';
76
- sub_filter_once off;
77
- }
78
-
79
- location = /server {
80
- return 301 /server/;
81
- }
82
-
83
- location /session { proxy_pass http://127.0.0.1:4097; }
84
- location /project { proxy_pass http://127.0.0.1:4097; }
85
- location /config { proxy_pass http://127.0.0.1:4097; }
86
- location /permission { proxy_pass http://127.0.0.1:4097; }
87
- location /question { proxy_pass http://127.0.0.1:4097; }
88
- location /file { proxy_pass http://127.0.0.1:4097; }
89
- location /find { proxy_pass http://127.0.0.1:4097; }
90
- location /events { proxy_pass http://127.0.0.1:4097; proxy_buffering off; }
91
- location /event { proxy_pass http://127.0.0.1:4097; proxy_buffering off; }
92
-
93
- location /ws {
94
- proxy_pass http://127.0.0.1:4097;
95
- proxy_set_header Upgrade $http_upgrade;
96
- proxy_set_header Connection "upgrade";
97
- }
98
-
99
- # -------------------------------------------------------
100
- # 3. JELLYFIN MEDIA SERVER (/jellyfin)
101
  # -------------------------------------------------------
102
  location /jellyfin/ {
103
  proxy_pass http://127.0.0.1:8096/;
 
27
  proxy_redirect ~^https?://[^/]+:4096/(.*)$ https://jishnupg-opencode-cli.hf.space/$1;
28
 
29
  # -------------------------------------------------------
30
+ # 1. JELLYFIN MEDIA SERVER (/jellyfin)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  # -------------------------------------------------------
32
  location /jellyfin/ {
33
  proxy_pass http://127.0.0.1:8096/;