Spaces:
Paused
Paused
OpenCode Integration commited on
Commit ·
2c19166
1
Parent(s): fc72de6
feat: copy all files from Opencode-Cli
Browse files- Dockerfile +35 -0
- README.md +21 -0
- cleaner.py +88 -0
- docker-compose.yml +26 -0
- entrypoint.sh +155 -0
- mobile/app.json +22 -0
- mobile/app/(tabs)/_layout.tsx +69 -0
- mobile/app/(tabs)/files.tsx +153 -0
- mobile/app/(tabs)/index.tsx +140 -0
- mobile/app/(tabs)/search.tsx +105 -0
- mobile/app/(tabs)/sessions.tsx +128 -0
- mobile/app/(tabs)/terminal.tsx +37 -0
- mobile/app/_layout.tsx +27 -0
- mobile/app/session/[id].tsx +271 -0
- mobile/app/settings.tsx +281 -0
- mobile/components/MarkdownRenderer.tsx +134 -0
- mobile/components/PermissionCard.tsx +113 -0
- mobile/components/QuestionCard.tsx +176 -0
- mobile/components/ToolTimeline.tsx +165 -0
- mobile/constants/themes.ts +132 -0
- mobile/constants/types.ts +404 -0
- mobile/hooks/useApi.ts +322 -0
- mobile/hooks/useConnection.ts +65 -0
- mobile/hooks/useSSE.ts +156 -0
- mobile/lib/api.ts +434 -0
- mobile/lib/connection-manager.ts +285 -0
- mobile/lib/query-provider.tsx +17 -0
- mobile/lib/storage.ts +134 -0
- mobile/package-lock.json +0 -0
- mobile/package.json +38 -0
- mobile/tests/integration.test.ts +251 -0
- mobile/tsconfig.json +15 -0
Dockerfile
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM debian:bookworm-slim
|
| 2 |
+
|
| 3 |
+
# /data is mounted as a persistent HF dataset bucket (Jishnupg/Opencode-Cli-storage)
|
| 4 |
+
# All opencode data (sessions, DB, config) goes here so it survives container restarts
|
| 5 |
+
ENV XDG_DATA_HOME=/data/share
|
| 6 |
+
ENV XDG_CONFIG_HOME=/data/config
|
| 7 |
+
ENV XDG_CACHE_HOME=/data/cache
|
| 8 |
+
ENV XDG_STATE_HOME=/data/state
|
| 9 |
+
|
| 10 |
+
ARG OPENCODE_VERSION=1.18.3
|
| 11 |
+
|
| 12 |
+
# Install dependencies (including git for project initialization and python3 for DB self-healing)
|
| 13 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 14 |
+
curl \
|
| 15 |
+
ca-certificates \
|
| 16 |
+
git \
|
| 17 |
+
python3 \
|
| 18 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 19 |
+
|
| 20 |
+
# Download opencode binary
|
| 21 |
+
RUN curl -fsSL "https://github.com/anomalyco/opencode/releases/download/v${OPENCODE_VERSION}/opencode-linux-x64.tar.gz" \
|
| 22 |
+
| tar -xz -C /usr/local/bin opencode
|
| 23 |
+
|
| 24 |
+
# Working directory for projects
|
| 25 |
+
RUN mkdir -p /projects/default
|
| 26 |
+
|
| 27 |
+
COPY cleaner.py /cleaner.py
|
| 28 |
+
COPY entrypoint.sh /entrypoint.sh
|
| 29 |
+
RUN chmod +x /entrypoint.sh
|
| 30 |
+
|
| 31 |
+
WORKDIR /projects/default
|
| 32 |
+
|
| 33 |
+
EXPOSE 4096
|
| 34 |
+
|
| 35 |
+
ENTRYPOINT ["/entrypoint.sh"]
|
README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Opencode CLI
|
| 3 |
+
emoji: 🖥️
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: docker
|
| 7 |
+
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.
|
cleaner.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sqlite3
|
| 2 |
+
import time
|
| 3 |
+
import os
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
|
| 6 |
+
def log(msg):
|
| 7 |
+
print(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}", flush=True)
|
| 8 |
+
|
| 9 |
+
log("OpenCode Self-Healing Daemon: started")
|
| 10 |
+
|
| 11 |
+
# Priority order: /data is the persistent HF bucket mount
|
| 12 |
+
DB_PATHS = [
|
| 13 |
+
"/data/share/opencode/opencode.db",
|
| 14 |
+
"/projects/.opencode/share/opencode/opencode.db",
|
| 15 |
+
"/root/.local/share/opencode/opencode.db",
|
| 16 |
+
"/home/opencode/.local/share/opencode/opencode.db",
|
| 17 |
+
]
|
| 18 |
+
|
| 19 |
+
def is_corrupted(s):
|
| 20 |
+
"""Check if a string contains Unicode replacement characters from bad base64 decoding."""
|
| 21 |
+
return s and ("\ufffd" in s or "??#y" in s or "\xef\xbf\xbd" in s)
|
| 22 |
+
|
| 23 |
+
cycle = 0
|
| 24 |
+
while True:
|
| 25 |
+
time.sleep(3)
|
| 26 |
+
cycle += 1
|
| 27 |
+
|
| 28 |
+
for db_path in DB_PATHS:
|
| 29 |
+
if not os.path.exists(db_path):
|
| 30 |
+
continue
|
| 31 |
+
try:
|
| 32 |
+
conn = sqlite3.connect(db_path, timeout=5)
|
| 33 |
+
cursor = conn.cursor()
|
| 34 |
+
|
| 35 |
+
# Check if session table exists
|
| 36 |
+
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='session'")
|
| 37 |
+
if not cursor.fetchone():
|
| 38 |
+
conn.close()
|
| 39 |
+
continue
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# Log session count periodically
|
| 43 |
+
if cycle % 20 == 0: # Every ~60 seconds
|
| 44 |
+
cursor.execute("SELECT COUNT(*) FROM session")
|
| 45 |
+
count = cursor.fetchone()[0]
|
| 46 |
+
log(f"DB {db_path}: {count} sessions")
|
| 47 |
+
|
| 48 |
+
# Fix corrupted directory/path in session table
|
| 49 |
+
cursor.execute("SELECT id, directory, path FROM session")
|
| 50 |
+
for row_id, directory, path in cursor.fetchall():
|
| 51 |
+
need_update = False
|
| 52 |
+
new_directory = directory
|
| 53 |
+
new_path = path
|
| 54 |
+
|
| 55 |
+
if is_corrupted(directory):
|
| 56 |
+
new_directory = "/projects/default"
|
| 57 |
+
need_update = True
|
| 58 |
+
|
| 59 |
+
if is_corrupted(path):
|
| 60 |
+
new_path = None
|
| 61 |
+
need_update = True
|
| 62 |
+
|
| 63 |
+
if need_update:
|
| 64 |
+
log(f"[Healed] Session '{row_id}': dir={directory!r} -> {new_directory!r}, path={path!r} -> {new_path!r}")
|
| 65 |
+
cursor.execute(
|
| 66 |
+
"UPDATE session SET directory = ?, path = ? WHERE id = ?",
|
| 67 |
+
(new_directory, new_path, row_id)
|
| 68 |
+
)
|
| 69 |
+
conn.commit()
|
| 70 |
+
|
| 71 |
+
# Fix corrupted entries in project_directory table
|
| 72 |
+
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='project_directory'")
|
| 73 |
+
if cursor.fetchone():
|
| 74 |
+
cursor.execute("SELECT project_id, directory FROM project_directory")
|
| 75 |
+
for p_id, directory in cursor.fetchall():
|
| 76 |
+
if is_corrupted(directory):
|
| 77 |
+
log(f"[Healed] project_directory '{p_id}': deleting corrupted dir={directory!r}")
|
| 78 |
+
cursor.execute(
|
| 79 |
+
"DELETE FROM project_directory WHERE project_id = ? AND directory = ?",
|
| 80 |
+
(p_id, directory)
|
| 81 |
+
)
|
| 82 |
+
conn.commit()
|
| 83 |
+
|
| 84 |
+
conn.close()
|
| 85 |
+
except sqlite3.OperationalError:
|
| 86 |
+
pass # DB locked by opencode, retry next cycle
|
| 87 |
+
except Exception as e:
|
| 88 |
+
log(f"[Healer Error] {db_path}: {e}")
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: "3.8"
|
| 2 |
+
|
| 3 |
+
services:
|
| 4 |
+
opencode:
|
| 5 |
+
build: .
|
| 6 |
+
ports:
|
| 7 |
+
- "4096:4096"
|
| 8 |
+
environment:
|
| 9 |
+
# Set your API keys here
|
| 10 |
+
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
| 11 |
+
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
| 12 |
+
# Optional: enable HTTP basic auth
|
| 13 |
+
- OPENCODE_SERVER_USERNAME=${OPENCODE_SERVER_USERNAME:-}
|
| 14 |
+
- OPENCODE_SERVER_PASSWORD=${OPENCODE_SERVER_PASSWORD:-}
|
| 15 |
+
volumes:
|
| 16 |
+
# Mount a directory for projects to persist data
|
| 17 |
+
- opencode-data:/projects
|
| 18 |
+
restart: unless-stopped
|
| 19 |
+
healthcheck:
|
| 20 |
+
test: ["CMD", "curl", "-f", "http://localhost:4096/api/health"]
|
| 21 |
+
interval: 30s
|
| 22 |
+
timeout: 5s
|
| 23 |
+
retries: 3
|
| 24 |
+
|
| 25 |
+
volumes:
|
| 26 |
+
opencode-data:
|
entrypoint.sh
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/sh
|
| 2 |
+
|
| 3 |
+
echo "============================================"
|
| 4 |
+
echo "=== OpenCode Space starting up ==="
|
| 5 |
+
echo "Time: $(date)"
|
| 6 |
+
echo "============================================"
|
| 7 |
+
|
| 8 |
+
# Configure Git to trust all directories to prevent ownership errors
|
| 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 |
+
|
| 18 |
+
# Ensure config exists with correct model setting
|
| 19 |
+
# Model IDs MUST use "provider/model" format per OpenCode docs (e.g. "opencode/big-pickle")
|
| 20 |
+
echo "[CONFIG] Setting up default configuration..."
|
| 21 |
+
|
| 22 |
+
# Remove stale config that may have wrong model format
|
| 23 |
+
python3 -c "
|
| 24 |
+
import json, os
|
| 25 |
+
p = '/data/config/opencode/opencode.json'
|
| 26 |
+
stale_models = ['big-pickle', 'mimo-v2.5-free', 'opencode/mimo-v2.5-free']
|
| 27 |
+
try:
|
| 28 |
+
d = json.load(open(p)) if os.path.exists(p) else {}
|
| 29 |
+
if d.get('model') in stale_models:
|
| 30 |
+
print(f'[CONFIG] Removing stale model {d[\"model\"]!r}, will regenerate')
|
| 31 |
+
del d['model']
|
| 32 |
+
json.dump(d, open(p, 'w'), indent=2)
|
| 33 |
+
except Exception as e:
|
| 34 |
+
print(f'[CONFIG] Error normalizing: {e}')
|
| 35 |
+
" 2>/dev/null || true
|
| 36 |
+
|
| 37 |
+
python3 -c "
|
| 38 |
+
import json, os
|
| 39 |
+
p = '/data/config/opencode/opencode.json'
|
| 40 |
+
try:
|
| 41 |
+
d = json.load(open(p)) if os.path.exists(p) else {}
|
| 42 |
+
except Exception:
|
| 43 |
+
d = {}
|
| 44 |
+
d['\$schema'] = 'https://opencode.ai/config.json'
|
| 45 |
+
d['server'] = d.get('server', {'port': 4096, 'hostname': '0.0.0.0'})
|
| 46 |
+
# Model IDs require provider/model format: 'opencode/big-pickle' for Zen free models
|
| 47 |
+
if not os.environ.get('ANTHROPIC_API_KEY') and not os.environ.get('OPENAI_API_KEY'):
|
| 48 |
+
# Use opencode Zen free model with correct provider prefix
|
| 49 |
+
d['model'] = 'opencode/big-pickle'
|
| 50 |
+
elif not d.get('model'):
|
| 51 |
+
d['model'] = 'opencode/big-pickle'
|
| 52 |
+
json.dump(d, open(p, 'w'), indent=2)
|
| 53 |
+
print('[CONFIG] Wrote config with model:', d.get('model'))
|
| 54 |
+
" 2>/dev/null || true
|
| 55 |
+
echo "[CONFIG] Current configuration:"
|
| 56 |
+
cat /data/config/opencode/opencode.json 2>/dev/null || echo "{}"
|
| 57 |
+
|
| 58 |
+
# Log disk space
|
| 59 |
+
echo "[DISK] /data usage:"
|
| 60 |
+
df -h /data 2>/dev/null || echo "[WARN] Could not check /data disk space"
|
| 61 |
+
|
| 62 |
+
# Check existing database
|
| 63 |
+
echo "[DB] Checking database..."
|
| 64 |
+
DB_PATH="/data/share/opencode/opencode.db"
|
| 65 |
+
if [ -f "$DB_PATH" ]; then
|
| 66 |
+
echo "[DB] Found database at $DB_PATH ($(du -h "$DB_PATH" | cut -f1))"
|
| 67 |
+
# Count sessions
|
| 68 |
+
python3 -c "
|
| 69 |
+
import sqlite3
|
| 70 |
+
try:
|
| 71 |
+
conn = sqlite3.connect('$DB_PATH', timeout=5)
|
| 72 |
+
c = conn.cursor()
|
| 73 |
+
# List all tables
|
| 74 |
+
c.execute(\"SELECT name FROM sqlite_master WHERE type='table'\")
|
| 75 |
+
tables = [r[0] for r in c.fetchall()]
|
| 76 |
+
print(f'[DB] Tables: {tables}')
|
| 77 |
+
if 'session' in tables:
|
| 78 |
+
c.execute('PRAGMA table_info(session)')
|
| 79 |
+
cols = [r[1] for r in c.fetchall()]
|
| 80 |
+
print(f'[DB] Session columns: {cols}')
|
| 81 |
+
c.execute('SELECT COUNT(*) FROM session')
|
| 82 |
+
count = c.fetchone()[0]
|
| 83 |
+
print(f'[DB] Total sessions: {count}')
|
| 84 |
+
c.execute('SELECT id, title, directory FROM session LIMIT 5')
|
| 85 |
+
for row in c.fetchall():
|
| 86 |
+
print(f'[DB] Session: {row[0]} title={row[1]!r} dir={row[2]!r}')
|
| 87 |
+
conn.close()
|
| 88 |
+
except Exception as e:
|
| 89 |
+
print(f'[DB] Error: {e}')
|
| 90 |
+
" 2>/dev/null || echo "[DB] Could not read database"
|
| 91 |
+
else
|
| 92 |
+
echo "[DB] No database found"
|
| 93 |
+
fi
|
| 94 |
+
|
| 95 |
+
# Start the SQLite self-healing daemon in the background
|
| 96 |
+
echo "[INIT] Starting self-healing daemon..."
|
| 97 |
+
python3 /cleaner.py &
|
| 98 |
+
|
| 99 |
+
# Always ensure /projects/default exists
|
| 100 |
+
mkdir -p /projects/default
|
| 101 |
+
|
| 102 |
+
# Navigate to the default projects directory
|
| 103 |
+
cd /projects/default
|
| 104 |
+
|
| 105 |
+
# If GITHUB_REPO is not set, use the default OpenCode Drive repo
|
| 106 |
+
GITHUB_REPO="${GITHUB_REPO:-https://github.com/JishnuPG-tech/OpenCode-Drive.git}"
|
| 107 |
+
|
| 108 |
+
# Check current state
|
| 109 |
+
HAS_GIT=$(test -d ".git" && echo "yes" || echo "no")
|
| 110 |
+
HAS_REMOTE=$(git remote -v 2>/dev/null | grep -c origin || echo "0")
|
| 111 |
+
IS_EMPTY=$(test -z "$(ls -A . 2>/dev/null | grep -v '^\.')" && echo "yes" || echo "no")
|
| 112 |
+
|
| 113 |
+
echo "[GIT] State: has_git=$HAS_GIT has_remote=$HAS_REMOTE is_empty=$IS_EMPTY"
|
| 114 |
+
|
| 115 |
+
if [ "$HAS_GIT" = "no" ] && [ "$IS_EMPTY" = "yes" ]; then
|
| 116 |
+
echo "[GIT] Empty directory. Cloning $GITHUB_REPO ..."
|
| 117 |
+
git clone "$GITHUB_REPO" . 2>&1 || echo "[GIT] Clone FAILED"
|
| 118 |
+
elif [ "$HAS_GIT" = "yes" ] && [ "$HAS_REMOTE" -gt "0" ]; then
|
| 119 |
+
echo "[GIT] Repo exists. Pulling latest..."
|
| 120 |
+
git pull origin HEAD 2>/dev/null || echo "[GIT] Pull failed"
|
| 121 |
+
else
|
| 122 |
+
echo "[GIT] Using existing files."
|
| 123 |
+
fi
|
| 124 |
+
|
| 125 |
+
# Final safety: ensure a git repo exists
|
| 126 |
+
if [ ! -d .git ]; then
|
| 127 |
+
echo "[GIT] No .git found. Initializing..."
|
| 128 |
+
git init
|
| 129 |
+
git config user.email 'opencode@local.com'
|
| 130 |
+
git config user.name 'OpenCode'
|
| 131 |
+
git commit --allow-empty -m 'Initial commit'
|
| 132 |
+
fi
|
| 133 |
+
|
| 134 |
+
echo "[GIT] Files: $(ls -1 . 2>/dev/null | grep -v '^\.git$' | tr '\n' ', ')"
|
| 135 |
+
|
| 136 |
+
# Log key env vars
|
| 137 |
+
echo "[ENV] API keys: ANTHROPIC=$(if [ -n \"$ANTHROPIC_API_KEY\" ]; then echo SET; else echo NOT_SET; fi) OPENAI=$(if [ -n \"$OPENAI_API_KEY\" ]; then echo SET; else echo NOT_SET; fi)"
|
| 138 |
+
echo "[ENV] Auth: USER=$(if [ -n \"$OPENCODE_SERVER_USERNAME\" ]; then echo SET; else echo NOT_SET; fi) PASS=$(if [ -n \"$OPENCODE_SERVER_PASSWORD\" ]; then echo SET; else echo NOT_SET; fi)"
|
| 139 |
+
|
| 140 |
+
# Test connectivity to OpenCode Zen API
|
| 141 |
+
echo "[NET] Testing connectivity to opencode.ai/zen..."
|
| 142 |
+
ZEN_STATUS=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 "https://opencode.ai/zen/v1/models" 2>/dev/null || echo "000")
|
| 143 |
+
echo "[NET] OpenCode Zen API status: $ZEN_STATUS"
|
| 144 |
+
|
| 145 |
+
if [ "$ZEN_STATUS" = "200" ]; then
|
| 146 |
+
echo "[NET] OpenCode Zen API is reachable"
|
| 147 |
+
else
|
| 148 |
+
echo "[NET] WARNING: OpenCode Zen API may not be reachable from this container"
|
| 149 |
+
echo "[NET] This means free models may not work - AI responses will fail silently"
|
| 150 |
+
fi
|
| 151 |
+
|
| 152 |
+
echo "============================================"
|
| 153 |
+
echo "=== Launching OpenCode server ==="
|
| 154 |
+
echo "============================================"
|
| 155 |
+
exec opencode serve --port 4096 --hostname 0.0.0.0
|
mobile/app.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
mobile/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
}
|