Spaces:
Paused
Paused
Hermes Agent commited on
Commit ·
a6324bc
1
Parent(s): 051231e
Wipe all space files and initialize clean Space
Browse files- .env.example +0 -20
- .gitignore +0 -9
- Dockerfile +0 -64
- README.md +4 -3
- cleaner.py +0 -88
- entrypoint.sh +0 -328
- mobile/app.json +0 -22
- mobile/app/(tabs)/_layout.tsx +0 -69
- mobile/app/(tabs)/files.tsx +0 -153
- mobile/app/(tabs)/index.tsx +0 -140
- mobile/app/(tabs)/search.tsx +0 -105
- mobile/app/(tabs)/sessions.tsx +0 -128
- mobile/app/(tabs)/terminal.tsx +0 -37
- mobile/app/_layout.tsx +0 -27
- mobile/app/session/[id].tsx +0 -271
- mobile/app/settings.tsx +0 -281
- mobile/components/MarkdownRenderer.tsx +0 -134
- mobile/components/PermissionCard.tsx +0 -113
- mobile/components/QuestionCard.tsx +0 -176
- mobile/components/ToolTimeline.tsx +0 -165
- mobile/constants/themes.ts +0 -132
- mobile/constants/types.ts +0 -404
- mobile/hooks/useApi.ts +0 -322
- mobile/hooks/useConnection.ts +0 -65
- mobile/hooks/useSSE.ts +0 -156
- mobile/lib/api.ts +0 -434
- mobile/lib/connection-manager.ts +0 -285
- mobile/lib/query-provider.tsx +0 -17
- mobile/lib/storage.ts +0 -134
- mobile/package-lock.json +0 -0
- mobile/package.json +0 -38
- mobile/tests/integration.test.ts +0 -251
- mobile/tsconfig.json +0 -15
- sync_engine.py +0 -560
.env.example
DELETED
|
@@ -1,20 +0,0 @@
|
|
| 1 |
-
# API Keys (at least one required for AI features)
|
| 2 |
-
ANTHROPIC_API_KEY=
|
| 3 |
-
OPENAI_API_KEY=
|
| 4 |
-
|
| 5 |
-
# SSH password — used by Termius to log in through the reverse tunnel
|
| 6 |
-
# If unset, a random password is printed to Space logs on each startup
|
| 7 |
-
SSH_PASSWORD=
|
| 8 |
-
|
| 9 |
-
# Reverse SSH tunnel to Fly.io jump server
|
| 10 |
-
# JUMP_HOST : your Fly.io app hostname, e.g. opencode-jump.fly.dev
|
| 11 |
-
# JUMP_SSH_KEY : private ed25519 key (PEM format, single line with \n)
|
| 12 |
-
# generated with: ssh-keygen -t ed25519 -f jump_key -N ""
|
| 13 |
-
# paste the contents of jump_key (private) here
|
| 14 |
-
# The matching public key (jump_key.pub) goes into Fly.io secret JUMP_AUTHORIZED_KEY
|
| 15 |
-
JUMP_HOST=
|
| 16 |
-
JUMP_SSH_KEY=
|
| 17 |
-
|
| 18 |
-
# Optional: HTTP Basic Auth for the OpenCode server
|
| 19 |
-
OPENCODE_SERVER_USERNAME=
|
| 20 |
-
OPENCODE_SERVER_PASSWORD=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.gitignore
DELETED
|
@@ -1,9 +0,0 @@
|
|
| 1 |
-
__pycache__/
|
| 2 |
-
*.pyc
|
| 3 |
-
*.pyo
|
| 4 |
-
*.pyd
|
| 5 |
-
.python-version
|
| 6 |
-
.vscode/
|
| 7 |
-
.idea/
|
| 8 |
-
*.swp
|
| 9 |
-
.DS_Store
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Dockerfile
DELETED
|
@@ -1,64 +0,0 @@
|
|
| 1 |
-
## OpenCode-Serve · direct mode + terminal
|
| 2 |
-
## nginx on :7860 (HF exposed) proxies:
|
| 3 |
-
## /terminal → ttyd on :7681 (real PTY bash)
|
| 4 |
-
## / → opencode on :8080 (chat UI + API)
|
| 5 |
-
##
|
| 6 |
-
## SSH access: sshd on :22 (internal) reached via bore transparent TCP tunnel.
|
| 7 |
-
## bore.pub:PORT → raw TCP → sshd:22 (no SSH interception → full PTY support)
|
| 8 |
-
FROM debian:bookworm-slim
|
| 9 |
-
|
| 10 |
-
ENV DEBIAN_FRONTEND=noninteractive
|
| 11 |
-
|
| 12 |
-
# XDG dirs — now local (no /data volume required).
|
| 13 |
-
# sync_engine.py restores these from the HF Dataset on startup.
|
| 14 |
-
ENV XDG_DATA_HOME=/data/share
|
| 15 |
-
ENV XDG_CONFIG_HOME=/data/config
|
| 16 |
-
ENV XDG_CACHE_HOME=/data/cache
|
| 17 |
-
ENV XDG_STATE_HOME=/data/state
|
| 18 |
-
|
| 19 |
-
ENV PORT=7860
|
| 20 |
-
|
| 21 |
-
ARG OPENCODE_VERSION=1.18.3
|
| 22 |
-
ARG TTYD_VERSION=1.7.7
|
| 23 |
-
ARG BORE_VERSION=0.6.0
|
| 24 |
-
|
| 25 |
-
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 26 |
-
ca-certificates curl git gnupg python3 python3-pip nginx \
|
| 27 |
-
openssh-server \
|
| 28 |
-
&& curl -fsSL "https://github.com/anomalyco/opencode/releases/download/v${OPENCODE_VERSION}/opencode-linux-x64.tar.gz" \
|
| 29 |
-
| tar -xz -C /usr/local/bin opencode \
|
| 30 |
-
&& chmod +x /usr/local/bin/opencode \
|
| 31 |
-
&& curl -fsSL "https://github.com/tsl0922/ttyd/releases/download/${TTYD_VERSION}/ttyd.x86_64" \
|
| 32 |
-
-o /usr/local/bin/ttyd \
|
| 33 |
-
&& chmod +x /usr/local/bin/ttyd \
|
| 34 |
-
&& curl -fsSL "https://github.com/ekzhang/bore/releases/download/v${BORE_VERSION}/bore-v${BORE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
|
| 35 |
-
| tar -xz -C /usr/local/bin bore \
|
| 36 |
-
&& chmod +x /usr/local/bin/bore \
|
| 37 |
-
&& mkdir -p /var/run/sshd \
|
| 38 |
-
&& rm -rf /var/lib/apt/lists/*
|
| 39 |
-
|
| 40 |
-
# Install huggingface_hub for the sync engine
|
| 41 |
-
RUN pip3 install --quiet --no-cache-dir --break-system-packages "huggingface_hub>=0.23"
|
| 42 |
-
|
| 43 |
-
# Pre-create /data dirs so XDG paths always exist even without a volume mount.
|
| 44 |
-
# sync_engine.py will restore their contents from the HF Dataset at startup.
|
| 45 |
-
RUN mkdir -p \
|
| 46 |
-
/data/share/opencode \
|
| 47 |
-
/data/config/opencode \
|
| 48 |
-
/data/cache/opencode \
|
| 49 |
-
/data/state/opencode \
|
| 50 |
-
/data/logs \
|
| 51 |
-
/data/workspaces \
|
| 52 |
-
/projects/default
|
| 53 |
-
|
| 54 |
-
COPY cleaner.py /cleaner.py
|
| 55 |
-
COPY sync_engine.py /sync_engine.py
|
| 56 |
-
COPY entrypoint.sh /entrypoint.sh
|
| 57 |
-
RUN chmod +x /entrypoint.sh /sync_engine.py \
|
| 58 |
-
&& rm -f /etc/nginx/sites-enabled/default
|
| 59 |
-
|
| 60 |
-
WORKDIR /projects/default
|
| 61 |
-
|
| 62 |
-
EXPOSE 7860
|
| 63 |
-
|
| 64 |
-
ENTRYPOINT ["/entrypoint.sh"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
README.md
CHANGED
|
@@ -1,10 +1,11 @@
|
|
| 1 |
---
|
| 2 |
-
title: OpenCode
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: indigo
|
| 6 |
sdk: docker
|
| 7 |
-
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: OpenCode-Serve
|
| 3 |
+
emoji: 🚀
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: indigo
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
+
# OpenCode-Serve
|
| 11 |
+
Clean Hugging Face Space repository.
|
cleaner.py
DELETED
|
@@ -1,88 +0,0 @@
|
|
| 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}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
entrypoint.sh
DELETED
|
@@ -1,328 +0,0 @@
|
|
| 1 |
-
#!/bin/sh
|
| 2 |
-
# OpenCode-Serve entrypoint
|
| 3 |
-
#
|
| 4 |
-
# nginx :7860 (HF exposed)
|
| 5 |
-
# /terminal → ttyd :7681 — real PTY bash, mobile-optimised
|
| 6 |
-
# / → opencode :8080 — chat UI + REST API + SSE
|
| 7 |
-
# sshd :22 (internal only)
|
| 8 |
-
# ↑ exposed via bore transparent TCP tunnel
|
| 9 |
-
# Termius → bore.pub:PORT → raw TCP → sshd:22
|
| 10 |
-
# (no SSH interception = full PTY, arrow keys, Ctrl+C, vim)
|
| 11 |
-
#
|
| 12 |
-
set -u
|
| 13 |
-
|
| 14 |
-
echo "============================================"
|
| 15 |
-
echo "=== OpenCode-Serve starting ==="
|
| 16 |
-
echo "Time: $(date)"
|
| 17 |
-
echo "============================================"
|
| 18 |
-
|
| 19 |
-
# Prevent git ownership errors
|
| 20 |
-
git config --global --add safe.directory '*' 2>/dev/null || true
|
| 21 |
-
|
| 22 |
-
# ─── Data directories ───
|
| 23 |
-
echo "[INIT] Setting up /data directories..."
|
| 24 |
-
mkdir -p /data/share/opencode 2>/dev/null || echo "[WARN] Could not create /data/share/opencode"
|
| 25 |
-
mkdir -p /data/config/opencode 2>/dev/null || echo "[WARN] Could not create /data/config/opencode"
|
| 26 |
-
mkdir -p /data/cache/opencode 2>/dev/null || echo "[WARN] Could not create /data/cache/opencode"
|
| 27 |
-
mkdir -p /data/state/opencode 2>/dev/null || echo "[WARN] Could not create /data/state/opencode"
|
| 28 |
-
mkdir -p /data/workspaces /data/logs /root/.ssh 2>/dev/null || true
|
| 29 |
-
chmod 700 /root/.ssh
|
| 30 |
-
|
| 31 |
-
# ─── Restore persistent storage from HF Dataset ──────────────────────
|
| 32 |
-
echo "[RESTORE] Restoring workspace from HF Dataset..."
|
| 33 |
-
python3 /sync_engine.py restore 2>&1 | tee -a /data/logs/sync.log
|
| 34 |
-
echo "[RESTORE] Done."
|
| 35 |
-
|
| 36 |
-
# ─── OpenCode config ─────────────────────────────────────────────────
|
| 37 |
-
echo "[CONFIG] Setting up default configuration..."
|
| 38 |
-
python3 -c "
|
| 39 |
-
import json, os
|
| 40 |
-
p = '/data/config/opencode/opencode.json'
|
| 41 |
-
stale_models = ['big-pickle', 'mimo-v2.5-free', 'opencode/mimo-v2.5-free']
|
| 42 |
-
try:
|
| 43 |
-
d = json.load(open(p)) if os.path.exists(p) else {}
|
| 44 |
-
if d.get('model') in stale_models:
|
| 45 |
-
print(f'[CONFIG] Removing stale model {d[\"model\"]!r}, will regenerate')
|
| 46 |
-
del d['model']
|
| 47 |
-
json.dump(d, open(p, 'w'), indent=2)
|
| 48 |
-
except Exception as e:
|
| 49 |
-
print(f'[CONFIG] Error normalizing: {e}')
|
| 50 |
-
" 2>/dev/null || true
|
| 51 |
-
|
| 52 |
-
python3 -c "
|
| 53 |
-
import json, os
|
| 54 |
-
p = '/data/config/opencode/opencode.json'
|
| 55 |
-
try:
|
| 56 |
-
d = json.load(open(p)) if os.path.exists(p) else {}
|
| 57 |
-
except Exception:
|
| 58 |
-
d = {}
|
| 59 |
-
d['\$schema'] = 'https://opencode.ai/config.json'
|
| 60 |
-
d['server'] = {'port': 8080, 'hostname': '127.0.0.1'}
|
| 61 |
-
if not os.environ.get('ANTHROPIC_API_KEY') and not os.environ.get('OPENAI_API_KEY'):
|
| 62 |
-
d['model'] = 'opencode/big-pickle'
|
| 63 |
-
elif not d.get('model'):
|
| 64 |
-
d['model'] = 'opencode/big-pickle'
|
| 65 |
-
# Auto-load CLAUDE.md as persistent memory injected into every chat.
|
| 66 |
-
# OpenCode 1.18.3+: instructions must be an array of file paths (not inline
|
| 67 |
-
# text) — OpenCode reads each file and uses its content as system prompt.
|
| 68 |
-
claude_md = '/projects/default/CLAUDE.md'
|
| 69 |
-
try:
|
| 70 |
-
with open(claude_md) as f:
|
| 71 |
-
mem = f.read().strip()
|
| 72 |
-
if mem:
|
| 73 |
-
d['instructions'] = [claude_md] # array of file paths — NOT a string
|
| 74 |
-
print('[CONFIG] CLAUDE.md registered as instruction file (' + str(len(mem)) + ' chars)')
|
| 75 |
-
else:
|
| 76 |
-
d.pop('instructions', None)
|
| 77 |
-
except FileNotFoundError:
|
| 78 |
-
d.pop('instructions', None)
|
| 79 |
-
print('[CONFIG] No CLAUDE.md yet — create /projects/default/CLAUDE.md for persistent memory')
|
| 80 |
-
json.dump(d, open(p, 'w'), indent=2)
|
| 81 |
-
print('[CONFIG] Wrote config with model:', d.get('model'))
|
| 82 |
-
" 2>/dev/null || true
|
| 83 |
-
echo "[CONFIG] Current configuration:"
|
| 84 |
-
cat /data/config/opencode/opencode.json 2>/dev/null || echo "{}"
|
| 85 |
-
|
| 86 |
-
# ─── Detect and remove malformed SQLite databases ───
|
| 87 |
-
echo "[DB] Checking database integrity..."
|
| 88 |
-
DB_PATH="/data/share/opencode/opencode.db"
|
| 89 |
-
if [ -f "$DB_PATH" ]; then
|
| 90 |
-
echo "[DB] Found database at $DB_PATH"
|
| 91 |
-
python3 -c "
|
| 92 |
-
import sqlite3
|
| 93 |
-
try:
|
| 94 |
-
conn = sqlite3.connect('$DB_PATH', timeout=5)
|
| 95 |
-
row = conn.execute('PRAGMA integrity_check').fetchone()
|
| 96 |
-
conn.close()
|
| 97 |
-
print('[DB] Integrity:', row[0] if row else 'error')
|
| 98 |
-
except Exception as e:
|
| 99 |
-
print('[DB] Error:', e)
|
| 100 |
-
" 2>/dev/null || echo "[DB] Could not read database"
|
| 101 |
-
else
|
| 102 |
-
echo "[DB] No database found (fresh start)"
|
| 103 |
-
fi
|
| 104 |
-
|
| 105 |
-
# ─── nginx: minimal reverse proxy ───────────────────────────────────
|
| 106 |
-
cat > /etc/nginx/nginx.conf << 'NGINX_CONF'
|
| 107 |
-
events { worker_connections 1024; }
|
| 108 |
-
http {
|
| 109 |
-
include /etc/nginx/mime.types;
|
| 110 |
-
default_type application/octet-stream;
|
| 111 |
-
|
| 112 |
-
# WebSocket upgrade header — nginx 1.22 does NOT accept variables in
|
| 113 |
-
# proxy_buffering, so SSE and HTML are split into two location blocks.
|
| 114 |
-
map $http_upgrade $connection_upgrade {
|
| 115 |
-
websocket upgrade;
|
| 116 |
-
default "";
|
| 117 |
-
}
|
| 118 |
-
|
| 119 |
-
server {
|
| 120 |
-
listen 7860;
|
| 121 |
-
|
| 122 |
-
# Redirect bare root to the chat UI
|
| 123 |
-
location = / {
|
| 124 |
-
return 302 /server;
|
| 125 |
-
}
|
| 126 |
-
|
| 127 |
-
# Terminal (ttyd PTY — WebSocket)
|
| 128 |
-
location /terminal {
|
| 129 |
-
proxy_pass http://127.0.0.1:7681;
|
| 130 |
-
proxy_http_version 1.1;
|
| 131 |
-
proxy_set_header Upgrade $http_upgrade;
|
| 132 |
-
proxy_set_header Connection "upgrade";
|
| 133 |
-
proxy_set_header Host $host;
|
| 134 |
-
proxy_read_timeout 86400;
|
| 135 |
-
}
|
| 136 |
-
|
| 137 |
-
# OpenCode REST API + SSE streaming — buffering OFF so events
|
| 138 |
-
# reach the browser token-by-token without delay.
|
| 139 |
-
location /api/ {
|
| 140 |
-
proxy_pass http://127.0.0.1:8080;
|
| 141 |
-
proxy_http_version 1.1;
|
| 142 |
-
proxy_set_header Host $host;
|
| 143 |
-
proxy_set_header Connection "";
|
| 144 |
-
proxy_set_header Accept-Encoding "";
|
| 145 |
-
proxy_buffering off;
|
| 146 |
-
proxy_cache off;
|
| 147 |
-
proxy_read_timeout 86400;
|
| 148 |
-
}
|
| 149 |
-
|
| 150 |
-
# OpenCode SPA (HTML + assets) — buffering ON so sub_filter can
|
| 151 |
-
# inject the localhost redirect shim before the SPA module loads.
|
| 152 |
-
location / {
|
| 153 |
-
proxy_pass http://127.0.0.1:8080;
|
| 154 |
-
proxy_http_version 1.1;
|
| 155 |
-
proxy_set_header Upgrade $http_upgrade;
|
| 156 |
-
proxy_set_header Connection $connection_upgrade;
|
| 157 |
-
proxy_set_header Host $host;
|
| 158 |
-
proxy_set_header Accept-Encoding "";
|
| 159 |
-
proxy_cache off;
|
| 160 |
-
proxy_read_timeout 86400;
|
| 161 |
-
|
| 162 |
-
# Strip the upstream CSP (its hash only covers the theme-preload
|
| 163 |
-
# inline script) and replace with a policy that also allows our
|
| 164 |
-
# injected fix script.
|
| 165 |
-
proxy_hide_header Content-Security-Policy;
|
| 166 |
-
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src * data:;" always;
|
| 167 |
-
|
| 168 |
-
# Root cause of "AI never responds / instant stop":
|
| 169 |
-
# The SPA bundle hardcodes baseUrl:"http://localhost:4096" as its
|
| 170 |
-
# default SDK client. Any call that uses this default (before the
|
| 171 |
-
# SPA reads location.origin) hits an unreachable address.
|
| 172 |
-
# This shim patches window.fetch, XMLHttpRequest, and EventSource
|
| 173 |
-
# at the window level — BEFORE the SPA module executes — so ALL
|
| 174 |
-
# http://localhost:4096 traffic is silently redirected to the
|
| 175 |
-
# correct public origin. It also clears any stale localStorage URL.
|
| 176 |
-
sub_filter '</head>' '<script>!function(){var BAD="http://localhost:4096",OK=location.origin;if(BAD===OK)return;var F=window.fetch;window.fetch=function(u,o){if(typeof u==="string"&&u.startsWith(BAD))u=OK+u.slice(BAD.length);return F.call(this,u,o)};var X=XMLHttpRequest.prototype.open;XMLHttpRequest.prototype.open=function(){if(typeof arguments[1]==="string"&&arguments[1].startsWith(BAD))arguments[1]=OK+arguments[1].slice(BAD.length);return X.apply(this,arguments)};if(window.EventSource){var E=window.EventSource;window.EventSource=function(u,o){if(typeof u==="string"&&u.startsWith(BAD))u=OK+u.slice(BAD.length);return new E(u,o)};window.EventSource.prototype=E.prototype}try{var k="opencode.settings.dat:defaultServerUrl",s=localStorage.getItem(k);if(s&&s!==OK)localStorage.removeItem(k)}catch(e){}}();</script></head>';
|
| 177 |
-
sub_filter_once on;
|
| 178 |
-
}
|
| 179 |
-
}
|
| 180 |
-
}
|
| 181 |
-
NGINX_CONF
|
| 182 |
-
nginx
|
| 183 |
-
echo "[NGINX] Started."
|
| 184 |
-
|
| 185 |
-
# ─── SSH server ──────────────────────────────────────────────────────
|
| 186 |
-
echo "[SSH] Configuring sshd..."
|
| 187 |
-
|
| 188 |
-
cat > /etc/ssh/sshd_config << 'SSHD_CONF'
|
| 189 |
-
Port 22
|
| 190 |
-
PermitRootLogin yes
|
| 191 |
-
PasswordAuthentication yes
|
| 192 |
-
PubkeyAuthentication yes
|
| 193 |
-
AuthorizedKeysFile /root/.ssh/authorized_keys
|
| 194 |
-
ChallengeResponseAuthentication no
|
| 195 |
-
UsePAM no
|
| 196 |
-
X11Forwarding no
|
| 197 |
-
PrintMotd no
|
| 198 |
-
AcceptEnv LANG LC_*
|
| 199 |
-
Subsystem sftp /usr/lib/openssh/sftp-server
|
| 200 |
-
SSHD_CONF
|
| 201 |
-
|
| 202 |
-
if [ -n "${SSH_PASSWORD:-}" ]; then
|
| 203 |
-
echo "root:${SSH_PASSWORD}" | chpasswd
|
| 204 |
-
echo "[SSH] Password set from SSH_PASSWORD secret."
|
| 205 |
-
else
|
| 206 |
-
GENERATED_PW=$(head -c 16 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 16)
|
| 207 |
-
echo "root:${GENERATED_PW}" | chpasswd
|
| 208 |
-
echo "[SSH] ============================================"
|
| 209 |
-
echo "[SSH] No SSH_PASSWORD secret. Generated: ${GENERATED_PW}"
|
| 210 |
-
echo "[SSH] Set SSH_PASSWORD in Space Secrets to make it permanent."
|
| 211 |
-
echo "[SSH] ============================================"
|
| 212 |
-
fi
|
| 213 |
-
|
| 214 |
-
ssh-keygen -A 2>/dev/null || true
|
| 215 |
-
/usr/sbin/sshd
|
| 216 |
-
echo "[SSH] sshd running on port 22."
|
| 217 |
-
|
| 218 |
-
# ─── Transparent TCP tunnel via bore ─────────────────────────────────
|
| 219 |
-
# bore forwards raw TCP bytes without touching the SSH protocol.
|
| 220 |
-
# Termius → bore.pub:PORT → raw TCP → sshd:22
|
| 221 |
-
# The SSH handshake happens directly between Termius and sshd — no
|
| 222 |
-
# middleman, so PTY, arrow keys, Ctrl+C, vim all work perfectly.
|
| 223 |
-
#
|
| 224 |
-
BORE_LOG=/data/logs/bore.log
|
| 225 |
-
TUNNEL_HOST=""
|
| 226 |
-
TUNNEL_PORT=""
|
| 227 |
-
BORE_PID=""
|
| 228 |
-
|
| 229 |
-
echo "[TUNNEL] Starting transparent TCP tunnel via bore..."
|
| 230 |
-
> "${BORE_LOG}"
|
| 231 |
-
|
| 232 |
-
bore local 22 --to bore.pub > "${BORE_LOG}" 2>&1 &
|
| 233 |
-
BORE_PID=$!
|
| 234 |
-
|
| 235 |
-
# Wait up to 20s for bore to print the assigned port
|
| 236 |
-
for _i in $(seq 1 20); do
|
| 237 |
-
sleep 1
|
| 238 |
-
TUNNEL_PORT=$(grep -oE 'bore\.pub:[0-9]+' "${BORE_LOG}" 2>/dev/null \
|
| 239 |
-
| grep -oE '[0-9]+$' | head -1)
|
| 240 |
-
[ -n "${TUNNEL_PORT}" ] && break
|
| 241 |
-
done
|
| 242 |
-
|
| 243 |
-
if [ -n "${TUNNEL_PORT}" ] && kill -0 "${BORE_PID}" 2>/dev/null; then
|
| 244 |
-
TUNNEL_HOST="bore.pub"
|
| 245 |
-
fi
|
| 246 |
-
|
| 247 |
-
echo "[TUNNEL] ============================================"
|
| 248 |
-
if [ -n "${TUNNEL_HOST}" ]; then
|
| 249 |
-
echo "[TUNNEL] Tunnel type : bore (transparent TCP — no SSH interception)"
|
| 250 |
-
echo "[TUNNEL] Status : UP"
|
| 251 |
-
echo "[TUNNEL] ----"
|
| 252 |
-
echo "[TUNNEL] Termius host : ${TUNNEL_HOST}"
|
| 253 |
-
echo "[TUNNEL] Termius port : ${TUNNEL_PORT}"
|
| 254 |
-
echo "[TUNNEL] Termius user : root"
|
| 255 |
-
echo "[TUNNEL] Termius pass : (your SSH_PASSWORD secret)"
|
| 256 |
-
echo "[TUNNEL] ----"
|
| 257 |
-
echo "[TUNNEL] PTY support : FULL (arrow keys, Ctrl+C, vim, tab completion)"
|
| 258 |
-
echo "[TUNNEL] Tunnel log : ${BORE_LOG}"
|
| 259 |
-
echo "[TUNNEL] ============================================"
|
| 260 |
-
echo "bore.pub:${TUNNEL_PORT}" > /data/logs/tunnel-url.txt
|
| 261 |
-
|
| 262 |
-
# Auto-restart: if bore dies, reconnect and log the new port
|
| 263 |
-
(
|
| 264 |
-
_bore_pid="${BORE_PID}"
|
| 265 |
-
while true; do
|
| 266 |
-
sleep 10
|
| 267 |
-
if ! kill -0 "${_bore_pid}" 2>/dev/null; then
|
| 268 |
-
_ts="$(date -u '+%H:%M:%S')"
|
| 269 |
-
echo "[TUNNEL][${_ts}] bore dropped — reconnecting..." >> "${BORE_LOG}"
|
| 270 |
-
bore local 22 --to bore.pub >> "${BORE_LOG}" 2>&1 &
|
| 271 |
-
_bore_pid=$!
|
| 272 |
-
for _w in $(seq 1 15); do
|
| 273 |
-
sleep 1
|
| 274 |
-
_new_port=$(grep -oE 'bore\.pub:[0-9]+' "${BORE_LOG}" 2>/dev/null \
|
| 275 |
-
| grep -oE '[0-9]+$' | tail -1)
|
| 276 |
-
[ -n "${_new_port}" ] && break
|
| 277 |
-
done
|
| 278 |
-
if [ -n "${_new_port}" ]; then
|
| 279 |
-
echo "[TUNNEL][$(date -u '+%H:%M:%S')] Reconnected: bore.pub:${_new_port}" >> "${BORE_LOG}"
|
| 280 |
-
echo "bore.pub:${_new_port}" > /data/logs/tunnel-url.txt
|
| 281 |
-
fi
|
| 282 |
-
fi
|
| 283 |
-
done
|
| 284 |
-
) &
|
| 285 |
-
else
|
| 286 |
-
echo "[TUNNEL] FAILED — bore could not connect to bore.pub"
|
| 287 |
-
echo "[TUNNEL] Bore output:"
|
| 288 |
-
cat "${BORE_LOG}" 2>/dev/null | sed 's/^/[TUNNEL] /'
|
| 289 |
-
echo "[TUNNEL] Terminal at /terminal still works."
|
| 290 |
-
echo "[TUNNEL] ============================================"
|
| 291 |
-
fi
|
| 292 |
-
|
| 293 |
-
# ─── Start DB self-healing daemon ────────────────────────────────────
|
| 294 |
-
echo "[CLEANER] Starting self-healing daemon..."
|
| 295 |
-
python3 /cleaner.py &
|
| 296 |
-
|
| 297 |
-
# ─── Start background sync daemon ────────────────────────────────────
|
| 298 |
-
echo "[SYNC] Starting background sync daemon..."
|
| 299 |
-
python3 /sync_engine.py watch 2>&1 | tee -a /data/logs/sync.log &
|
| 300 |
-
echo "[SYNC] Sync daemon started. Logs: /data/logs/sync.log"
|
| 301 |
-
|
| 302 |
-
# ─── Ensure project dir exists ───────────────────────────────────────
|
| 303 |
-
mkdir -p /projects/default
|
| 304 |
-
cd /projects/default
|
| 305 |
-
[ -d .git ] || git init -q 2>/dev/null || true
|
| 306 |
-
|
| 307 |
-
# ─── ttyd: real PTY bash on :7681 ────────────────────────────────────
|
| 308 |
-
echo "[TERMINAL] ttyd on :7681 (base-path /terminal) ..."
|
| 309 |
-
nohup ttyd -p 7681 -i 0.0.0.0 \
|
| 310 |
-
-b /terminal \
|
| 311 |
-
-W \
|
| 312 |
-
-t fontSize=15 \
|
| 313 |
-
-t lineHeight=1.1 \
|
| 314 |
-
-t cursorBlink=true \
|
| 315 |
-
-t scrollback=2000 \
|
| 316 |
-
bash -l > /data/logs/ttyd.log 2>&1 &
|
| 317 |
-
|
| 318 |
-
# ─── Test OpenCode Zen API reachability ─────────────────────────────
|
| 319 |
-
echo "[NET] Testing OpenCode Zen API..."
|
| 320 |
-
ZEN_STATUS=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 "https://opencode.ai/zen/v1/models" 2>/dev/null || echo "000")
|
| 321 |
-
echo "[NET] OpenCode Zen API: $ZEN_STATUS"
|
| 322 |
-
if [ "$ZEN_STATUS" != "200" ]; then
|
| 323 |
-
echo "[NET] WARNING: Zen API unreachable — free model responses may fail"
|
| 324 |
-
fi
|
| 325 |
-
|
| 326 |
-
# ─── OpenCode on :8080 (nginx proxies / → here) ──────────────────────
|
| 327 |
-
echo "[OPENCODE] opencode serve on :8080 ..."
|
| 328 |
-
exec opencode serve --port 8080 --hostname 127.0.0.1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/app.json
DELETED
|
@@ -1,22 +0,0 @@
|
|
| 1 |
-
{
|
| 2 |
-
"expo": {
|
| 3 |
-
"name": "OpenCode Mobile",
|
| 4 |
-
"slug": "opencode-mobile",
|
| 5 |
-
"version": "1.0.0",
|
| 6 |
-
"orientation": "portrait",
|
| 7 |
-
"scheme": "opencode",
|
| 8 |
-
"platforms": ["android"],
|
| 9 |
-
"android": {
|
| 10 |
-
"package": "com.opencode.mobile",
|
| 11 |
-
"adaptiveIcon": {
|
| 12 |
-
"backgroundColor": "#1a1b26"
|
| 13 |
-
}
|
| 14 |
-
},
|
| 15 |
-
"plugins": [
|
| 16 |
-
"expo-asset",
|
| 17 |
-
"expo-font",
|
| 18 |
-
"expo-router",
|
| 19 |
-
"expo-secure-store"
|
| 20 |
-
]
|
| 21 |
-
}
|
| 22 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/app/(tabs)/_layout.tsx
DELETED
|
@@ -1,69 +0,0 @@
|
|
| 1 |
-
import { Tabs } from "expo-router";
|
| 2 |
-
import { Home, MessageSquare, FolderOpen, Search, Terminal } from "lucide-react-native";
|
| 3 |
-
import { getTheme } from "../../lib/storage";
|
| 4 |
-
import { themes } from "../../constants/themes";
|
| 5 |
-
|
| 6 |
-
export default function TabLayout() {
|
| 7 |
-
const theme = themes[getTheme()];
|
| 8 |
-
|
| 9 |
-
const icons = {
|
| 10 |
-
index: Home,
|
| 11 |
-
sessions: MessageSquare,
|
| 12 |
-
files: FolderOpen,
|
| 13 |
-
search: Search,
|
| 14 |
-
terminal: Terminal,
|
| 15 |
-
};
|
| 16 |
-
|
| 17 |
-
return (
|
| 18 |
-
<Tabs
|
| 19 |
-
screenOptions={{
|
| 20 |
-
headerShown: false,
|
| 21 |
-
tabBarStyle: {
|
| 22 |
-
backgroundColor: theme.colors.bgSecondary,
|
| 23 |
-
borderTopColor: theme.colors.border,
|
| 24 |
-
height: 60,
|
| 25 |
-
paddingBottom: 8,
|
| 26 |
-
paddingTop: 8,
|
| 27 |
-
},
|
| 28 |
-
tabBarActiveTintColor: theme.colors.primary,
|
| 29 |
-
tabBarInactiveTintColor: theme.colors.textMuted,
|
| 30 |
-
}}
|
| 31 |
-
>
|
| 32 |
-
<Tabs.Screen
|
| 33 |
-
name="index"
|
| 34 |
-
options={{
|
| 35 |
-
title: "Home",
|
| 36 |
-
tabBarIcon: ({ color, size }) => <icons.index size={size} color={color} />,
|
| 37 |
-
}}
|
| 38 |
-
/>
|
| 39 |
-
<Tabs.Screen
|
| 40 |
-
name="sessions"
|
| 41 |
-
options={{
|
| 42 |
-
title: "Sessions",
|
| 43 |
-
tabBarIcon: ({ color, size }) => <icons.sessions size={size} color={color} />,
|
| 44 |
-
}}
|
| 45 |
-
/>
|
| 46 |
-
<Tabs.Screen
|
| 47 |
-
name="files"
|
| 48 |
-
options={{
|
| 49 |
-
title: "Files",
|
| 50 |
-
tabBarIcon: ({ color, size }) => <icons.files size={size} color={color} />,
|
| 51 |
-
}}
|
| 52 |
-
/>
|
| 53 |
-
<Tabs.Screen
|
| 54 |
-
name="search"
|
| 55 |
-
options={{
|
| 56 |
-
title: "Search",
|
| 57 |
-
tabBarIcon: ({ color, size }) => <icons.search size={size} color={color} />,
|
| 58 |
-
}}
|
| 59 |
-
/>
|
| 60 |
-
<Tabs.Screen
|
| 61 |
-
name="terminal"
|
| 62 |
-
options={{
|
| 63 |
-
title: "Terminal",
|
| 64 |
-
tabBarIcon: ({ color, size }) => <icons.terminal size={size} color={color} />,
|
| 65 |
-
}}
|
| 66 |
-
/>
|
| 67 |
-
</Tabs>
|
| 68 |
-
);
|
| 69 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/app/(tabs)/files.tsx
DELETED
|
@@ -1,153 +0,0 @@
|
|
| 1 |
-
import { View, Text, TextInput, TouchableOpacity, FlatList, StyleSheet } from "react-native";
|
| 2 |
-
import { useState } from "react";
|
| 3 |
-
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
| 4 |
-
import { Search, ChevronRight, Folder, File, ArrowLeft } from "lucide-react-native";
|
| 5 |
-
import { useListFiles } from "../../hooks/useApi";
|
| 6 |
-
import { getTheme } from "../../lib/storage";
|
| 7 |
-
import { themes } from "../../constants/themes";
|
| 8 |
-
|
| 9 |
-
export default function FilesScreen() {
|
| 10 |
-
const insets = useSafeAreaInsets();
|
| 11 |
-
const theme = themes[getTheme()];
|
| 12 |
-
const [currentDir, setCurrentDir] = useState(".");
|
| 13 |
-
const [search, setSearch] = useState("");
|
| 14 |
-
const { data: files, isLoading } = useListFiles(currentDir);
|
| 15 |
-
|
| 16 |
-
const filtered = (files || []).filter(
|
| 17 |
-
(f) => !search || f.name.toLowerCase().includes(search.toLowerCase())
|
| 18 |
-
);
|
| 19 |
-
|
| 20 |
-
const dirs = currentDir === "." ? [] : currentDir.split("/");
|
| 21 |
-
const goBack = () => {
|
| 22 |
-
if (dirs.length <= 1) {
|
| 23 |
-
setCurrentDir(".");
|
| 24 |
-
} else {
|
| 25 |
-
setCurrentDir(dirs.slice(0, -1).join("/"));
|
| 26 |
-
}
|
| 27 |
-
};
|
| 28 |
-
|
| 29 |
-
return (
|
| 30 |
-
<View style={[styles.container, { backgroundColor: theme.colors.bg }]}>
|
| 31 |
-
<View style={[styles.header, { paddingTop: insets.top + 12 }]}>
|
| 32 |
-
{currentDir !== "." && (
|
| 33 |
-
<TouchableOpacity onPress={goBack} style={styles.backBtn}>
|
| 34 |
-
<ArrowLeft size={20} color={theme.colors.text} />
|
| 35 |
-
</TouchableOpacity>
|
| 36 |
-
)}
|
| 37 |
-
<Text style={[styles.title, { color: theme.colors.text }]} numberOfLines={1}>
|
| 38 |
-
{currentDir === "." ? "Files" : dirs[dirs.length - 1]}
|
| 39 |
-
</Text>
|
| 40 |
-
</View>
|
| 41 |
-
|
| 42 |
-
<View style={styles.breadcrumb}>
|
| 43 |
-
{dirs.map((d, i) => (
|
| 44 |
-
<TouchableOpacity
|
| 45 |
-
key={i}
|
| 46 |
-
onPress={() => setCurrentDir(dirs.slice(0, i + 1).join("/"))}
|
| 47 |
-
style={styles.breadcrumbItem}
|
| 48 |
-
>
|
| 49 |
-
<Text style={[styles.breadcrumbText, { color: theme.colors.primary }]}>{d}</Text>
|
| 50 |
-
<ChevronRight size={12} color={theme.colors.textMuted} />
|
| 51 |
-
</TouchableOpacity>
|
| 52 |
-
))}
|
| 53 |
-
{dirs.length === 0 && (
|
| 54 |
-
<Text style={[styles.breadcrumbText, { color: theme.colors.textMuted }]}>root</Text>
|
| 55 |
-
)}
|
| 56 |
-
</View>
|
| 57 |
-
|
| 58 |
-
<View style={[styles.searchBar, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
|
| 59 |
-
<Search size={16} color={theme.colors.textMuted} />
|
| 60 |
-
<TextInput
|
| 61 |
-
style={[styles.searchInput, { color: theme.colors.text }]}
|
| 62 |
-
placeholder="Filter files..."
|
| 63 |
-
placeholderTextColor={theme.colors.textMuted}
|
| 64 |
-
value={search}
|
| 65 |
-
onChangeText={setSearch}
|
| 66 |
-
/>
|
| 67 |
-
</View>
|
| 68 |
-
|
| 69 |
-
<FlatList
|
| 70 |
-
data={filtered}
|
| 71 |
-
keyExtractor={(item) => item.path}
|
| 72 |
-
contentContainerStyle={{ paddingBottom: insets.bottom + 100, paddingHorizontal: 20 }}
|
| 73 |
-
showsVerticalScrollIndicator={false}
|
| 74 |
-
ListEmptyComponent={
|
| 75 |
-
<View style={styles.empty}>
|
| 76 |
-
<Folder size={40} color={theme.colors.textMuted} />
|
| 77 |
-
<Text style={[styles.emptyText, { color: theme.colors.textMuted }]}>
|
| 78 |
-
{isLoading ? "Loading..." : "Empty"}
|
| 79 |
-
</Text>
|
| 80 |
-
</View>
|
| 81 |
-
}
|
| 82 |
-
renderItem={({ item }) => (
|
| 83 |
-
<TouchableOpacity
|
| 84 |
-
style={[styles.fileItem, { backgroundColor: theme.colors.surface }]}
|
| 85 |
-
onPress={() => {
|
| 86 |
-
if (item.type === "directory") {
|
| 87 |
-
setCurrentDir(item.path);
|
| 88 |
-
}
|
| 89 |
-
}}
|
| 90 |
-
>
|
| 91 |
-
{item.type === "directory" ? (
|
| 92 |
-
<Folder size={18} color={theme.colors.warning} />
|
| 93 |
-
) : (
|
| 94 |
-
<File size={18} color={theme.colors.textMuted} />
|
| 95 |
-
)}
|
| 96 |
-
<Text style={[styles.fileName, { color: theme.colors.text }]} numberOfLines={1}>
|
| 97 |
-
{item.name}
|
| 98 |
-
</Text>
|
| 99 |
-
{item.type === "directory" && (
|
| 100 |
-
<ChevronRight size={16} color={theme.colors.textMuted} />
|
| 101 |
-
)}
|
| 102 |
-
</TouchableOpacity>
|
| 103 |
-
)}
|
| 104 |
-
/>
|
| 105 |
-
</View>
|
| 106 |
-
);
|
| 107 |
-
}
|
| 108 |
-
|
| 109 |
-
const styles = StyleSheet.create({
|
| 110 |
-
container: { flex: 1 },
|
| 111 |
-
header: {
|
| 112 |
-
flexDirection: "row",
|
| 113 |
-
alignItems: "center",
|
| 114 |
-
gap: 10,
|
| 115 |
-
paddingHorizontal: 20,
|
| 116 |
-
paddingBottom: 12,
|
| 117 |
-
},
|
| 118 |
-
backBtn: { padding: 4 },
|
| 119 |
-
title: { fontSize: 28, fontWeight: "700", letterSpacing: -0.5, flex: 1 },
|
| 120 |
-
breadcrumb: {
|
| 121 |
-
flexDirection: "row",
|
| 122 |
-
alignItems: "center",
|
| 123 |
-
flexWrap: "wrap",
|
| 124 |
-
paddingHorizontal: 20,
|
| 125 |
-
marginBottom: 12,
|
| 126 |
-
gap: 4,
|
| 127 |
-
},
|
| 128 |
-
breadcrumbItem: { flexDirection: "row", alignItems: "center", gap: 4 },
|
| 129 |
-
breadcrumbText: { fontSize: 13 },
|
| 130 |
-
searchBar: {
|
| 131 |
-
flexDirection: "row",
|
| 132 |
-
alignItems: "center",
|
| 133 |
-
gap: 8,
|
| 134 |
-
marginHorizontal: 20,
|
| 135 |
-
marginBottom: 16,
|
| 136 |
-
paddingHorizontal: 14,
|
| 137 |
-
paddingVertical: 10,
|
| 138 |
-
borderRadius: 10,
|
| 139 |
-
borderWidth: 1,
|
| 140 |
-
},
|
| 141 |
-
searchInput: { flex: 1, fontSize: 14, padding: 0 },
|
| 142 |
-
fileItem: {
|
| 143 |
-
flexDirection: "row",
|
| 144 |
-
alignItems: "center",
|
| 145 |
-
gap: 10,
|
| 146 |
-
padding: 12,
|
| 147 |
-
borderRadius: 10,
|
| 148 |
-
marginBottom: 4,
|
| 149 |
-
},
|
| 150 |
-
fileName: { flex: 1, fontSize: 14 },
|
| 151 |
-
empty: { alignItems: "center", marginTop: 80, gap: 12 },
|
| 152 |
-
emptyText: { fontSize: 14 },
|
| 153 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/app/(tabs)/index.tsx
DELETED
|
@@ -1,140 +0,0 @@
|
|
| 1 |
-
import { View, Text, TouchableOpacity, ScrollView, StyleSheet } from "react-native";
|
| 2 |
-
import { useRouter } from "expo-router";
|
| 3 |
-
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
| 4 |
-
import { Plus, Wifi, WifiOff, Settings } from "lucide-react-native";
|
| 5 |
-
import { useSessions, useCreateSession } from "../../hooks/useApi";
|
| 6 |
-
import { useConnection } from "../../hooks/useConnection";
|
| 7 |
-
import { getTheme } from "../../lib/storage";
|
| 8 |
-
import { themes } from "../../constants/themes";
|
| 9 |
-
|
| 10 |
-
export default function HomeScreen() {
|
| 11 |
-
const router = useRouter();
|
| 12 |
-
const insets = useSafeAreaInsets();
|
| 13 |
-
const theme = themes[getTheme()];
|
| 14 |
-
const { isConnected, state: connState } = useConnection();
|
| 15 |
-
const { data: sessions } = useSessions();
|
| 16 |
-
const createSession = useCreateSession();
|
| 17 |
-
|
| 18 |
-
const handleNewSession = async () => {
|
| 19 |
-
try {
|
| 20 |
-
const session = await createSession.mutateAsync({ title: "New Chat", directory: "default" });
|
| 21 |
-
router.push(`/session/${session.id}`);
|
| 22 |
-
} catch {}
|
| 23 |
-
};
|
| 24 |
-
|
| 25 |
-
const recentSessions = (sessions || []).slice(0, 5);
|
| 26 |
-
|
| 27 |
-
return (
|
| 28 |
-
<View style={[styles.container, { backgroundColor: theme.colors.bg }]}>
|
| 29 |
-
<View style={[styles.header, { paddingTop: insets.top + 12 }]}>
|
| 30 |
-
<Text style={[styles.title, { color: theme.colors.text }]}>OpenCode</Text>
|
| 31 |
-
<TouchableOpacity onPress={() => router.push("/settings")}>
|
| 32 |
-
<Settings size={22} color={theme.colors.textMuted} />
|
| 33 |
-
</TouchableOpacity>
|
| 34 |
-
</View>
|
| 35 |
-
|
| 36 |
-
<ScrollView
|
| 37 |
-
style={styles.scroll}
|
| 38 |
-
contentContainerStyle={{ paddingBottom: insets.bottom + 100 }}
|
| 39 |
-
showsVerticalScrollIndicator={false}
|
| 40 |
-
>
|
| 41 |
-
<View
|
| 42 |
-
style={[
|
| 43 |
-
styles.statusCard,
|
| 44 |
-
{
|
| 45 |
-
backgroundColor: isConnected ? theme.colors.bgTertiary : theme.colors.surface,
|
| 46 |
-
borderColor: isConnected ? theme.colors.success : theme.colors.danger,
|
| 47 |
-
},
|
| 48 |
-
]}
|
| 49 |
-
>
|
| 50 |
-
{isConnected ? (
|
| 51 |
-
<Wifi size={18} color={theme.colors.success} />
|
| 52 |
-
) : (
|
| 53 |
-
<WifiOff size={18} color={theme.colors.danger} />
|
| 54 |
-
)}
|
| 55 |
-
<Text style={[styles.statusText, { color: theme.colors.text }]}>
|
| 56 |
-
{isConnected ? "Connected" : connState === "reconnecting" ? "Reconnecting..." : "Disconnected"}
|
| 57 |
-
</Text>
|
| 58 |
-
</View>
|
| 59 |
-
|
| 60 |
-
<TouchableOpacity
|
| 61 |
-
style={[styles.newSessionBtn, { backgroundColor: theme.colors.primary }]}
|
| 62 |
-
onPress={handleNewSession}
|
| 63 |
-
activeOpacity={0.8}
|
| 64 |
-
>
|
| 65 |
-
<Plus size={20} color={theme.colors.primaryText} />
|
| 66 |
-
<Text style={[styles.newSessionText, { color: theme.colors.primaryText }]}>
|
| 67 |
-
New Session
|
| 68 |
-
</Text>
|
| 69 |
-
</TouchableOpacity>
|
| 70 |
-
|
| 71 |
-
{recentSessions.length > 0 && (
|
| 72 |
-
<View style={styles.section}>
|
| 73 |
-
<Text style={[styles.sectionTitle, { color: theme.colors.textSecondary }]}>
|
| 74 |
-
Recent Sessions
|
| 75 |
-
</Text>
|
| 76 |
-
{recentSessions.map((s) => (
|
| 77 |
-
<TouchableOpacity
|
| 78 |
-
key={s.id}
|
| 79 |
-
style={[styles.sessionItem, { backgroundColor: theme.colors.surface }]}
|
| 80 |
-
onPress={() => router.push(`/session/${s.id}`)}
|
| 81 |
-
>
|
| 82 |
-
<Text style={[styles.sessionTitle, { color: theme.colors.text }]} numberOfLines={1}>
|
| 83 |
-
{s.title || "Untitled"}
|
| 84 |
-
</Text>
|
| 85 |
-
<Text style={[styles.sessionTime, { color: theme.colors.textMuted }]}>
|
| 86 |
-
{new Date(s.updatedAt).toLocaleDateString()}
|
| 87 |
-
</Text>
|
| 88 |
-
</TouchableOpacity>
|
| 89 |
-
))}
|
| 90 |
-
</View>
|
| 91 |
-
)}
|
| 92 |
-
</ScrollView>
|
| 93 |
-
</View>
|
| 94 |
-
);
|
| 95 |
-
}
|
| 96 |
-
|
| 97 |
-
const styles = StyleSheet.create({
|
| 98 |
-
container: { flex: 1 },
|
| 99 |
-
header: {
|
| 100 |
-
flexDirection: "row",
|
| 101 |
-
justifyContent: "space-between",
|
| 102 |
-
alignItems: "center",
|
| 103 |
-
paddingHorizontal: 20,
|
| 104 |
-
paddingBottom: 12,
|
| 105 |
-
},
|
| 106 |
-
title: { fontSize: 28, fontWeight: "700", letterSpacing: -0.5 },
|
| 107 |
-
scroll: { flex: 1, paddingHorizontal: 20 },
|
| 108 |
-
statusCard: {
|
| 109 |
-
flexDirection: "row",
|
| 110 |
-
alignItems: "center",
|
| 111 |
-
gap: 10,
|
| 112 |
-
padding: 14,
|
| 113 |
-
borderRadius: 12,
|
| 114 |
-
marginBottom: 20,
|
| 115 |
-
borderWidth: 1,
|
| 116 |
-
},
|
| 117 |
-
statusText: { fontSize: 14, fontWeight: "500" },
|
| 118 |
-
newSessionBtn: {
|
| 119 |
-
flexDirection: "row",
|
| 120 |
-
alignItems: "center",
|
| 121 |
-
justifyContent: "center",
|
| 122 |
-
gap: 8,
|
| 123 |
-
padding: 16,
|
| 124 |
-
borderRadius: 14,
|
| 125 |
-
marginBottom: 28,
|
| 126 |
-
},
|
| 127 |
-
newSessionText: { fontSize: 16, fontWeight: "600" },
|
| 128 |
-
section: { marginBottom: 24 },
|
| 129 |
-
sectionTitle: { fontSize: 13, fontWeight: "600", textTransform: "uppercase", letterSpacing: 0.5, marginBottom: 10 },
|
| 130 |
-
sessionItem: {
|
| 131 |
-
flexDirection: "row",
|
| 132 |
-
justifyContent: "space-between",
|
| 133 |
-
alignItems: "center",
|
| 134 |
-
padding: 14,
|
| 135 |
-
borderRadius: 10,
|
| 136 |
-
marginBottom: 6,
|
| 137 |
-
},
|
| 138 |
-
sessionTitle: { fontSize: 15, fontWeight: "500", flex: 1, marginRight: 10 },
|
| 139 |
-
sessionTime: { fontSize: 12 },
|
| 140 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/app/(tabs)/search.tsx
DELETED
|
@@ -1,105 +0,0 @@
|
|
| 1 |
-
import { View, Text, TextInput, FlatList, StyleSheet } from "react-native";
|
| 2 |
-
import { useState } from "react";
|
| 3 |
-
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
| 4 |
-
import { Search as SearchIcon } from "lucide-react-native";
|
| 5 |
-
import { useFindFiles } from "../../hooks/useApi";
|
| 6 |
-
import { getTheme } from "../../lib/storage";
|
| 7 |
-
import { themes } from "../../constants/themes";
|
| 8 |
-
|
| 9 |
-
export default function SearchScreen() {
|
| 10 |
-
const insets = useSafeAreaInsets();
|
| 11 |
-
const theme = themes[getTheme()];
|
| 12 |
-
const [query, setQuery] = useState("");
|
| 13 |
-
const { data: results, isLoading } = useFindFiles(query);
|
| 14 |
-
|
| 15 |
-
return (
|
| 16 |
-
<View style={[styles.container, { backgroundColor: theme.colors.bg }]}>
|
| 17 |
-
<View style={[styles.header, { paddingTop: insets.top + 12 }]}>
|
| 18 |
-
<Text style={[styles.title, { color: theme.colors.text }]}>Search</Text>
|
| 19 |
-
</View>
|
| 20 |
-
|
| 21 |
-
<View style={[styles.searchBar, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
|
| 22 |
-
<SearchIcon size={16} color={theme.colors.textMuted} />
|
| 23 |
-
<TextInput
|
| 24 |
-
style={[styles.searchInput, { color: theme.colors.text }]}
|
| 25 |
-
placeholder="Search files, code, text..."
|
| 26 |
-
placeholderTextColor={theme.colors.textMuted}
|
| 27 |
-
value={query}
|
| 28 |
-
onChangeText={setQuery}
|
| 29 |
-
autoFocus
|
| 30 |
-
/>
|
| 31 |
-
</View>
|
| 32 |
-
|
| 33 |
-
<FlatList
|
| 34 |
-
data={results || []}
|
| 35 |
-
keyExtractor={(_, i) => String(i)}
|
| 36 |
-
contentContainerStyle={{ paddingBottom: insets.bottom + 100, paddingHorizontal: 20 }}
|
| 37 |
-
showsVerticalScrollIndicator={false}
|
| 38 |
-
ListEmptyComponent={
|
| 39 |
-
<View style={styles.empty}>
|
| 40 |
-
<SearchIcon size={40} color={theme.colors.textMuted} />
|
| 41 |
-
<Text style={[styles.emptyText, { color: theme.colors.textMuted }]}>
|
| 42 |
-
{query.length === 0
|
| 43 |
-
? "Type to search"
|
| 44 |
-
: isLoading
|
| 45 |
-
? "Searching..."
|
| 46 |
-
: "No results"}
|
| 47 |
-
</Text>
|
| 48 |
-
</View>
|
| 49 |
-
}
|
| 50 |
-
renderItem={({ item }) => (
|
| 51 |
-
<View style={[styles.resultCard, { backgroundColor: theme.colors.surface }]}>
|
| 52 |
-
<View style={styles.resultHeader}>
|
| 53 |
-
<Text style={[styles.resultFile, { color: theme.colors.primary }]} numberOfLines={1}>
|
| 54 |
-
{item.file}
|
| 55 |
-
</Text>
|
| 56 |
-
<Text style={[styles.resultLine, { color: theme.colors.textMuted }]}>
|
| 57 |
-
L{item.line}
|
| 58 |
-
</Text>
|
| 59 |
-
</View>
|
| 60 |
-
<Text style={[styles.resultMatch, { color: theme.colors.text }]} numberOfLines={2}>
|
| 61 |
-
{item.context}
|
| 62 |
-
</Text>
|
| 63 |
-
</View>
|
| 64 |
-
)}
|
| 65 |
-
/>
|
| 66 |
-
</View>
|
| 67 |
-
);
|
| 68 |
-
}
|
| 69 |
-
|
| 70 |
-
const styles = StyleSheet.create({
|
| 71 |
-
container: { flex: 1 },
|
| 72 |
-
header: {
|
| 73 |
-
paddingHorizontal: 20,
|
| 74 |
-
paddingBottom: 12,
|
| 75 |
-
},
|
| 76 |
-
title: { fontSize: 28, fontWeight: "700", letterSpacing: -0.5 },
|
| 77 |
-
searchBar: {
|
| 78 |
-
flexDirection: "row",
|
| 79 |
-
alignItems: "center",
|
| 80 |
-
gap: 8,
|
| 81 |
-
marginHorizontal: 20,
|
| 82 |
-
marginBottom: 16,
|
| 83 |
-
paddingHorizontal: 14,
|
| 84 |
-
paddingVertical: 10,
|
| 85 |
-
borderRadius: 10,
|
| 86 |
-
borderWidth: 1,
|
| 87 |
-
},
|
| 88 |
-
searchInput: { flex: 1, fontSize: 14, padding: 0 },
|
| 89 |
-
resultCard: {
|
| 90 |
-
padding: 12,
|
| 91 |
-
borderRadius: 10,
|
| 92 |
-
marginBottom: 6,
|
| 93 |
-
},
|
| 94 |
-
resultHeader: {
|
| 95 |
-
flexDirection: "row",
|
| 96 |
-
justifyContent: "space-between",
|
| 97 |
-
alignItems: "center",
|
| 98 |
-
marginBottom: 4,
|
| 99 |
-
},
|
| 100 |
-
resultFile: { fontSize: 13, fontWeight: "500", flex: 1, marginRight: 8 },
|
| 101 |
-
resultLine: { fontSize: 12 },
|
| 102 |
-
resultMatch: { fontSize: 13 },
|
| 103 |
-
empty: { alignItems: "center", marginTop: 80, gap: 12 },
|
| 104 |
-
emptyText: { fontSize: 14 },
|
| 105 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/app/(tabs)/sessions.tsx
DELETED
|
@@ -1,128 +0,0 @@
|
|
| 1 |
-
import { View, Text, TextInput, TouchableOpacity, FlatList, Alert, StyleSheet } from "react-native";
|
| 2 |
-
import { useRouter } from "expo-router";
|
| 3 |
-
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
| 4 |
-
import { Plus, Search, Trash2, MessageSquare } from "lucide-react-native";
|
| 5 |
-
import { useState } from "react";
|
| 6 |
-
import { useSessions, useCreateSession, useDeleteSession } from "../../hooks/useApi";
|
| 7 |
-
import { getTheme } from "../../lib/storage";
|
| 8 |
-
import { themes } from "../../constants/themes";
|
| 9 |
-
|
| 10 |
-
export default function SessionsScreen() {
|
| 11 |
-
const router = useRouter();
|
| 12 |
-
const insets = useSafeAreaInsets();
|
| 13 |
-
const theme = themes[getTheme()];
|
| 14 |
-
const { data: sessions, isLoading } = useSessions();
|
| 15 |
-
const createSession = useCreateSession();
|
| 16 |
-
const deleteSession = useDeleteSession();
|
| 17 |
-
const [search, setSearch] = useState("");
|
| 18 |
-
|
| 19 |
-
const filtered = (sessions || []).filter(
|
| 20 |
-
(s) => !search || (s.title || "").toLowerCase().includes(search.toLowerCase())
|
| 21 |
-
);
|
| 22 |
-
|
| 23 |
-
const handleCreate = async () => {
|
| 24 |
-
try {
|
| 25 |
-
const session = await createSession.mutateAsync({ title: "New Chat", directory: "default" });
|
| 26 |
-
router.push(`/session/${session.id}`);
|
| 27 |
-
} catch {}
|
| 28 |
-
};
|
| 29 |
-
|
| 30 |
-
const handleDelete = (id: string, title: string) => {
|
| 31 |
-
Alert.alert("Delete Session", `Delete "${title || "Untitled"}"?`, [
|
| 32 |
-
{ text: "Cancel", style: "cancel" },
|
| 33 |
-
{ text: "Delete", style: "destructive", onPress: () => deleteSession.mutateAsync(id) },
|
| 34 |
-
]);
|
| 35 |
-
};
|
| 36 |
-
|
| 37 |
-
return (
|
| 38 |
-
<View style={[styles.container, { backgroundColor: theme.colors.bg }]}>
|
| 39 |
-
<View style={[styles.header, { paddingTop: insets.top + 12 }]}>
|
| 40 |
-
<Text style={[styles.title, { color: theme.colors.text }]}>Sessions</Text>
|
| 41 |
-
<TouchableOpacity onPress={handleCreate}>
|
| 42 |
-
<Plus size={22} color={theme.colors.primary} />
|
| 43 |
-
</TouchableOpacity>
|
| 44 |
-
</View>
|
| 45 |
-
|
| 46 |
-
<View style={[styles.searchBar, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
|
| 47 |
-
<Search size={16} color={theme.colors.textMuted} />
|
| 48 |
-
<TextInput
|
| 49 |
-
style={[styles.searchInput, { color: theme.colors.text }]}
|
| 50 |
-
placeholder="Search sessions..."
|
| 51 |
-
placeholderTextColor={theme.colors.textMuted}
|
| 52 |
-
value={search}
|
| 53 |
-
onChangeText={setSearch}
|
| 54 |
-
/>
|
| 55 |
-
</View>
|
| 56 |
-
|
| 57 |
-
<FlatList
|
| 58 |
-
data={filtered}
|
| 59 |
-
keyExtractor={(item) => item.id}
|
| 60 |
-
contentContainerStyle={{ paddingBottom: insets.bottom + 100, paddingHorizontal: 20 }}
|
| 61 |
-
showsVerticalScrollIndicator={false}
|
| 62 |
-
ListEmptyComponent={
|
| 63 |
-
<View style={styles.empty}>
|
| 64 |
-
<MessageSquare size={40} color={theme.colors.textMuted} />
|
| 65 |
-
<Text style={[styles.emptyText, { color: theme.colors.textMuted }]}>
|
| 66 |
-
{isLoading ? "Loading..." : "No sessions yet"}
|
| 67 |
-
</Text>
|
| 68 |
-
</View>
|
| 69 |
-
}
|
| 70 |
-
renderItem={({ item }) => (
|
| 71 |
-
<TouchableOpacity
|
| 72 |
-
style={[styles.sessionCard, { backgroundColor: theme.colors.surface }]}
|
| 73 |
-
onPress={() => router.push(`/session/${item.id}`)}
|
| 74 |
-
>
|
| 75 |
-
<View style={styles.sessionInfo}>
|
| 76 |
-
<Text style={[styles.sessionTitle, { color: theme.colors.text }]} numberOfLines={1}>
|
| 77 |
-
{item.title || "Untitled"}
|
| 78 |
-
</Text>
|
| 79 |
-
<Text style={[styles.sessionDate, { color: theme.colors.textMuted }]}>
|
| 80 |
-
{new Date(item.updatedAt).toLocaleString()}
|
| 81 |
-
</Text>
|
| 82 |
-
</View>
|
| 83 |
-
<TouchableOpacity onPress={() => handleDelete(item.id, item.title)}>
|
| 84 |
-
<Trash2 size={16} color={theme.colors.textMuted} />
|
| 85 |
-
</TouchableOpacity>
|
| 86 |
-
</TouchableOpacity>
|
| 87 |
-
)}
|
| 88 |
-
/>
|
| 89 |
-
</View>
|
| 90 |
-
);
|
| 91 |
-
}
|
| 92 |
-
|
| 93 |
-
const styles = StyleSheet.create({
|
| 94 |
-
container: { flex: 1 },
|
| 95 |
-
header: {
|
| 96 |
-
flexDirection: "row",
|
| 97 |
-
justifyContent: "space-between",
|
| 98 |
-
alignItems: "center",
|
| 99 |
-
paddingHorizontal: 20,
|
| 100 |
-
paddingBottom: 12,
|
| 101 |
-
},
|
| 102 |
-
title: { fontSize: 28, fontWeight: "700", letterSpacing: -0.5 },
|
| 103 |
-
searchBar: {
|
| 104 |
-
flexDirection: "row",
|
| 105 |
-
alignItems: "center",
|
| 106 |
-
gap: 8,
|
| 107 |
-
marginHorizontal: 20,
|
| 108 |
-
marginBottom: 16,
|
| 109 |
-
paddingHorizontal: 14,
|
| 110 |
-
paddingVertical: 10,
|
| 111 |
-
borderRadius: 10,
|
| 112 |
-
borderWidth: 1,
|
| 113 |
-
},
|
| 114 |
-
searchInput: { flex: 1, fontSize: 14, padding: 0 },
|
| 115 |
-
sessionCard: {
|
| 116 |
-
flexDirection: "row",
|
| 117 |
-
alignItems: "center",
|
| 118 |
-
justifyContent: "space-between",
|
| 119 |
-
padding: 14,
|
| 120 |
-
borderRadius: 10,
|
| 121 |
-
marginBottom: 6,
|
| 122 |
-
},
|
| 123 |
-
sessionInfo: { flex: 1, marginRight: 12 },
|
| 124 |
-
sessionTitle: { fontSize: 15, fontWeight: "500" },
|
| 125 |
-
sessionDate: { fontSize: 12, marginTop: 2 },
|
| 126 |
-
empty: { alignItems: "center", marginTop: 80, gap: 12 },
|
| 127 |
-
emptyText: { fontSize: 14 },
|
| 128 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/app/(tabs)/terminal.tsx
DELETED
|
@@ -1,37 +0,0 @@
|
|
| 1 |
-
import { View, Text, StyleSheet } from "react-native";
|
| 2 |
-
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
| 3 |
-
import { Terminal } from "lucide-react-native";
|
| 4 |
-
import { getTheme } from "../../lib/storage";
|
| 5 |
-
import { themes } from "../../constants/themes";
|
| 6 |
-
|
| 7 |
-
export default function TerminalScreen() {
|
| 8 |
-
const insets = useSafeAreaInsets();
|
| 9 |
-
const theme = themes[getTheme()];
|
| 10 |
-
|
| 11 |
-
return (
|
| 12 |
-
<View style={[styles.container, { backgroundColor: theme.colors.bg }]}>
|
| 13 |
-
<View style={[styles.header, { paddingTop: insets.top + 12 }]}>
|
| 14 |
-
<Text style={[styles.title, { color: theme.colors.text }]}>Terminal</Text>
|
| 15 |
-
</View>
|
| 16 |
-
<View style={styles.empty}>
|
| 17 |
-
<Terminal size={48} color={theme.colors.textMuted} />
|
| 18 |
-
<Text style={[styles.emptyTitle, { color: theme.colors.text }]}>Terminal</Text>
|
| 19 |
-
<Text style={[styles.emptyText, { color: theme.colors.textMuted }]}>
|
| 20 |
-
Terminal sessions will appear here.
|
| 21 |
-
</Text>
|
| 22 |
-
</View>
|
| 23 |
-
</View>
|
| 24 |
-
);
|
| 25 |
-
}
|
| 26 |
-
|
| 27 |
-
const styles = StyleSheet.create({
|
| 28 |
-
container: { flex: 1 },
|
| 29 |
-
header: {
|
| 30 |
-
paddingHorizontal: 20,
|
| 31 |
-
paddingBottom: 12,
|
| 32 |
-
},
|
| 33 |
-
title: { fontSize: 28, fontWeight: "700", letterSpacing: -0.5 },
|
| 34 |
-
empty: { flex: 1, alignItems: "center", justifyContent: "center", gap: 12 },
|
| 35 |
-
emptyTitle: { fontSize: 18, fontWeight: "600" },
|
| 36 |
-
emptyText: { fontSize: 14 },
|
| 37 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/app/_layout.tsx
DELETED
|
@@ -1,27 +0,0 @@
|
|
| 1 |
-
import { useEffect } from "react";
|
| 2 |
-
import { Stack } from "expo-router";
|
| 3 |
-
import { StatusBar } from "expo-status-bar";
|
| 4 |
-
import { QueryProvider } from "../lib/query-provider";
|
| 5 |
-
import { getTheme } from "../lib/storage";
|
| 6 |
-
import { themes } from "../constants/themes";
|
| 7 |
-
|
| 8 |
-
export default function RootLayout() {
|
| 9 |
-
const theme = themes[getTheme()];
|
| 10 |
-
|
| 11 |
-
useEffect(() => {
|
| 12 |
-
const { SystemUI } = require("expo-system-ui");
|
| 13 |
-
SystemUI.setBackgroundColorAsync(theme.colors.bg);
|
| 14 |
-
}, []);
|
| 15 |
-
|
| 16 |
-
return (
|
| 17 |
-
<QueryProvider>
|
| 18 |
-
<StatusBar style="light" />
|
| 19 |
-
<Stack
|
| 20 |
-
screenOptions={{
|
| 21 |
-
headerShown: false,
|
| 22 |
-
contentStyle: { backgroundColor: theme.colors.bg },
|
| 23 |
-
}}
|
| 24 |
-
/>
|
| 25 |
-
</QueryProvider>
|
| 26 |
-
);
|
| 27 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/app/session/[id].tsx
DELETED
|
@@ -1,271 +0,0 @@
|
|
| 1 |
-
import { View, Text, TextInput, TouchableOpacity, FlatList, KeyboardAvoidingView, Platform, StyleSheet } from "react-native";
|
| 2 |
-
import { useLocalSearchParams, useRouter } from "expo-router";
|
| 3 |
-
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
| 4 |
-
import { useState, useEffect, useRef } from "react";
|
| 5 |
-
import { ArrowLeft, Send, Loader, AlertTriangle } from "lucide-react-native";
|
| 6 |
-
import { useMessages, useSession, useSendMessage, useReplyPermission, useReplyQuestion } from "../../hooks/useApi";
|
| 7 |
-
import { useSSE, getMessagesFromEvents } from "../../hooks/useSSE";
|
| 8 |
-
import { MarkdownRenderer } from "../../components/MarkdownRenderer";
|
| 9 |
-
import { ToolTimeline } from "../../components/ToolTimeline";
|
| 10 |
-
import { PermissionCard } from "../../components/PermissionCard";
|
| 11 |
-
import { QuestionCard } from "../../components/QuestionCard";
|
| 12 |
-
import { Message, MessagePart, ToolPart, PermissionRequest, QuestionRequest } from "../../constants/types";
|
| 13 |
-
import { getTheme } from "../../lib/storage";
|
| 14 |
-
import { themes } from "../../constants/themes";
|
| 15 |
-
|
| 16 |
-
export default function ChatScreen() {
|
| 17 |
-
const { id } = useLocalSearchParams<{ id: string }>();
|
| 18 |
-
const router = useRouter();
|
| 19 |
-
const insets = useSafeAreaInsets();
|
| 20 |
-
const theme = themes[getTheme()];
|
| 21 |
-
const [input, setInput] = useState("");
|
| 22 |
-
const flatListRef = useRef<FlatList>(null);
|
| 23 |
-
|
| 24 |
-
const { data: session } = useSession(id || "");
|
| 25 |
-
const { data: serverMessages } = useMessages(id || "");
|
| 26 |
-
const { connect, disconnect, streaming, events, error } = useSSE(id);
|
| 27 |
-
const sendMessage = useSendMessage();
|
| 28 |
-
const replyPermission = useReplyPermission();
|
| 29 |
-
const replyQuestion = useReplyQuestion();
|
| 30 |
-
|
| 31 |
-
// Build messages from server + SSE events
|
| 32 |
-
const sseMessages = getMessagesFromEvents(events);
|
| 33 |
-
const allMessages: Message[] = [...(serverMessages || []), ...sseMessages];
|
| 34 |
-
|
| 35 |
-
// Deduplicate by id
|
| 36 |
-
const seen = new Set<string>();
|
| 37 |
-
const uniqueMessages = allMessages.filter((m) => {
|
| 38 |
-
if (seen.has(m.id)) return false;
|
| 39 |
-
seen.add(m.id);
|
| 40 |
-
return true;
|
| 41 |
-
});
|
| 42 |
-
|
| 43 |
-
// Collect pending permissions and questions
|
| 44 |
-
const pendingPermissions: PermissionRequest[] = [];
|
| 45 |
-
const pendingQuestions: QuestionRequest[] = [];
|
| 46 |
-
for (const ev of events) {
|
| 47 |
-
if (ev.type === "permission.requested") {
|
| 48 |
-
const perm = ev.properties as PermissionRequest;
|
| 49 |
-
if (perm.state === "pending") pendingPermissions.push(perm);
|
| 50 |
-
}
|
| 51 |
-
if (ev.type === "question.asked") {
|
| 52 |
-
const q = ev.properties as QuestionRequest;
|
| 53 |
-
if (q.state === "pending") pendingQuestions.push(q);
|
| 54 |
-
}
|
| 55 |
-
}
|
| 56 |
-
|
| 57 |
-
useEffect(() => {
|
| 58 |
-
connect();
|
| 59 |
-
return () => disconnect();
|
| 60 |
-
}, [id]);
|
| 61 |
-
|
| 62 |
-
const handleSend = () => {
|
| 63 |
-
if (!input.trim() || !id) return;
|
| 64 |
-
sendMessage.mutate({ sessionId: id, content: input.trim() });
|
| 65 |
-
setInput("");
|
| 66 |
-
};
|
| 67 |
-
|
| 68 |
-
useEffect(() => {
|
| 69 |
-
if (uniqueMessages.length > 0 || events.length > 0) {
|
| 70 |
-
setTimeout(() => flatListRef.current?.scrollToEnd({ animated: true }), 100);
|
| 71 |
-
}
|
| 72 |
-
}, [uniqueMessages.length, events.length]);
|
| 73 |
-
|
| 74 |
-
const renderPart = (part: MessagePart, index: number) => {
|
| 75 |
-
if (part.type === "text" && part.text) {
|
| 76 |
-
return <MarkdownRenderer key={index} content={part.text} />;
|
| 77 |
-
}
|
| 78 |
-
if (part.type === "tool") {
|
| 79 |
-
const toolPart = part as ToolPart;
|
| 80 |
-
return (
|
| 81 |
-
<ToolTimeline
|
| 82 |
-
key={toolPart.callID || index}
|
| 83 |
-
parts={[toolPart]}
|
| 84 |
-
/>
|
| 85 |
-
);
|
| 86 |
-
}
|
| 87 |
-
if (part.type === "reasoning") {
|
| 88 |
-
return (
|
| 89 |
-
<View key={index} style={[styles.reasoning, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border }]}>
|
| 90 |
-
<Text style={[styles.reasoningLabel, { color: theme.colors.textMuted }]}>Thinking...</Text>
|
| 91 |
-
<Text style={[styles.reasoningText, { color: theme.colors.textMuted }]} numberOfLines={3}>
|
| 92 |
-
{part.text}
|
| 93 |
-
</Text>
|
| 94 |
-
</View>
|
| 95 |
-
);
|
| 96 |
-
}
|
| 97 |
-
return null;
|
| 98 |
-
};
|
| 99 |
-
|
| 100 |
-
const renderMessage = (item: Message) => {
|
| 101 |
-
const isUser = item.role === "user";
|
| 102 |
-
|
| 103 |
-
// Group consecutive tool parts
|
| 104 |
-
const toolParts: ToolPart[] = [];
|
| 105 |
-
const otherParts: MessagePart[] = [];
|
| 106 |
-
for (const part of item.parts) {
|
| 107 |
-
if (part.type === "tool") toolParts.push(part as ToolPart);
|
| 108 |
-
else otherParts.push(part);
|
| 109 |
-
}
|
| 110 |
-
|
| 111 |
-
return (
|
| 112 |
-
<View
|
| 113 |
-
style={[
|
| 114 |
-
styles.bubble,
|
| 115 |
-
isUser
|
| 116 |
-
? { backgroundColor: theme.colors.primary, alignSelf: "flex-end", maxWidth: "85%" }
|
| 117 |
-
: { backgroundColor: theme.colors.surface, alignSelf: "flex-start", maxWidth: "90%" },
|
| 118 |
-
]}
|
| 119 |
-
>
|
| 120 |
-
{otherParts.map((part, i) => renderPart(part, i))}
|
| 121 |
-
{toolParts.length > 0 && <ToolTimeline parts={toolParts} />}
|
| 122 |
-
</View>
|
| 123 |
-
);
|
| 124 |
-
};
|
| 125 |
-
|
| 126 |
-
return (
|
| 127 |
-
<KeyboardAvoidingView
|
| 128 |
-
style={[styles.container, { backgroundColor: theme.colors.bg }]}
|
| 129 |
-
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
| 130 |
-
keyboardVerticalOffset={Platform.OS === "ios" ? 0 : 0}
|
| 131 |
-
>
|
| 132 |
-
<View style={[styles.header, { paddingTop: insets.top + 8, backgroundColor: theme.colors.bgSecondary, borderBottomColor: theme.colors.border }]}>
|
| 133 |
-
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
|
| 134 |
-
<ArrowLeft size={20} color={theme.colors.text} />
|
| 135 |
-
</TouchableOpacity>
|
| 136 |
-
<Text style={[styles.headerTitle, { color: theme.colors.text }]} numberOfLines={1}>
|
| 137 |
-
{session?.title || "Chat"}
|
| 138 |
-
</Text>
|
| 139 |
-
</View>
|
| 140 |
-
|
| 141 |
-
<FlatList
|
| 142 |
-
ref={flatListRef}
|
| 143 |
-
data={uniqueMessages}
|
| 144 |
-
keyExtractor={(item) => item.id}
|
| 145 |
-
contentContainerStyle={{ padding: 20, paddingBottom: 20, gap: 12 }}
|
| 146 |
-
showsVerticalScrollIndicator={false}
|
| 147 |
-
onContentSizeChange={() => flatListRef.current?.scrollToEnd({ animated: true })}
|
| 148 |
-
renderItem={({ item }) => renderMessage(item)}
|
| 149 |
-
ListFooterComponent={
|
| 150 |
-
<>
|
| 151 |
-
{/* Pending permissions */}
|
| 152 |
-
{pendingPermissions.map((perm) => (
|
| 153 |
-
<PermissionCard
|
| 154 |
-
key={perm.id}
|
| 155 |
-
request={perm}
|
| 156 |
-
onAllow={() => replyPermission.mutate({ sessionId: id!, requestId: perm.id, allow: true })}
|
| 157 |
-
onDeny={() => replyPermission.mutate({ sessionId: id!, requestId: perm.id, allow: false })}
|
| 158 |
-
/>
|
| 159 |
-
))}
|
| 160 |
-
|
| 161 |
-
{/* Pending questions */}
|
| 162 |
-
{pendingQuestions.map((q) => (
|
| 163 |
-
<QuestionCard
|
| 164 |
-
key={q.id}
|
| 165 |
-
request={q}
|
| 166 |
-
onReply={(answer) => replyQuestion.mutate({ sessionId: id!, requestId: q.id, answer })}
|
| 167 |
-
/>
|
| 168 |
-
))}
|
| 169 |
-
|
| 170 |
-
{/* Streaming indicator */}
|
| 171 |
-
{streaming && (
|
| 172 |
-
<View style={[styles.bubble, { backgroundColor: theme.colors.surface, alignSelf: "flex-start" }]}>
|
| 173 |
-
<View style={styles.typing}>
|
| 174 |
-
<Loader size={14} color={theme.colors.textMuted} />
|
| 175 |
-
<Text style={[styles.typingText, { color: theme.colors.textMuted }]}>Thinking...</Text>
|
| 176 |
-
</View>
|
| 177 |
-
</View>
|
| 178 |
-
)}
|
| 179 |
-
</>
|
| 180 |
-
}
|
| 181 |
-
/>
|
| 182 |
-
|
| 183 |
-
{error && (
|
| 184 |
-
<View style={[styles.errorBar, { backgroundColor: theme.colors.danger }]}>
|
| 185 |
-
<AlertTriangle size={14} color="#fff" />
|
| 186 |
-
<Text style={[styles.errorText, { color: "#fff" }]}>{error}</Text>
|
| 187 |
-
</View>
|
| 188 |
-
)}
|
| 189 |
-
|
| 190 |
-
<View style={[styles.inputBar, { borderTopColor: theme.colors.border, backgroundColor: theme.colors.bgSecondary, paddingBottom: insets.bottom + 8 }]}>
|
| 191 |
-
<TextInput
|
| 192 |
-
style={[styles.textInput, { color: theme.colors.text, backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}
|
| 193 |
-
placeholder="Type a message..."
|
| 194 |
-
placeholderTextColor={theme.colors.textMuted}
|
| 195 |
-
value={input}
|
| 196 |
-
onChangeText={setInput}
|
| 197 |
-
onSubmitEditing={handleSend}
|
| 198 |
-
multiline
|
| 199 |
-
maxLength={4000}
|
| 200 |
-
/>
|
| 201 |
-
<TouchableOpacity
|
| 202 |
-
style={[styles.sendBtn, { backgroundColor: input.trim() ? theme.colors.primary : theme.colors.surface }]}
|
| 203 |
-
onPress={handleSend}
|
| 204 |
-
disabled={!input.trim() || streaming}
|
| 205 |
-
>
|
| 206 |
-
<Send size={18} color={input.trim() ? theme.colors.primaryText : theme.colors.textMuted} />
|
| 207 |
-
</TouchableOpacity>
|
| 208 |
-
</View>
|
| 209 |
-
</KeyboardAvoidingView>
|
| 210 |
-
);
|
| 211 |
-
}
|
| 212 |
-
|
| 213 |
-
const styles = StyleSheet.create({
|
| 214 |
-
container: { flex: 1 },
|
| 215 |
-
header: {
|
| 216 |
-
flexDirection: "row",
|
| 217 |
-
alignItems: "center",
|
| 218 |
-
gap: 10,
|
| 219 |
-
paddingHorizontal: 16,
|
| 220 |
-
paddingBottom: 10,
|
| 221 |
-
borderBottomWidth: 1,
|
| 222 |
-
},
|
| 223 |
-
backBtn: { padding: 4 },
|
| 224 |
-
headerTitle: { fontSize: 17, fontWeight: "600", flex: 1 },
|
| 225 |
-
bubble: {
|
| 226 |
-
padding: 14,
|
| 227 |
-
borderRadius: 14,
|
| 228 |
-
},
|
| 229 |
-
reasoning: {
|
| 230 |
-
padding: 10,
|
| 231 |
-
borderRadius: 8,
|
| 232 |
-
borderWidth: 1,
|
| 233 |
-
marginTop: 4,
|
| 234 |
-
},
|
| 235 |
-
reasoningLabel: { fontSize: 11, fontWeight: "600", textTransform: "uppercase", marginBottom: 4 },
|
| 236 |
-
reasoningText: { fontSize: 12, lineHeight: 18, fontStyle: "italic" },
|
| 237 |
-
typing: { flexDirection: "row", alignItems: "center", gap: 6 },
|
| 238 |
-
typingText: { fontSize: 14 },
|
| 239 |
-
errorBar: {
|
| 240 |
-
flexDirection: "row",
|
| 241 |
-
alignItems: "center",
|
| 242 |
-
gap: 8,
|
| 243 |
-
paddingHorizontal: 16,
|
| 244 |
-
paddingVertical: 8,
|
| 245 |
-
},
|
| 246 |
-
errorText: { fontSize: 13, flex: 1 },
|
| 247 |
-
inputBar: {
|
| 248 |
-
flexDirection: "row",
|
| 249 |
-
alignItems: "flex-end",
|
| 250 |
-
gap: 8,
|
| 251 |
-
paddingHorizontal: 16,
|
| 252 |
-
paddingTop: 10,
|
| 253 |
-
borderTopWidth: 1,
|
| 254 |
-
},
|
| 255 |
-
textInput: {
|
| 256 |
-
flex: 1,
|
| 257 |
-
fontSize: 15,
|
| 258 |
-
paddingHorizontal: 14,
|
| 259 |
-
paddingVertical: 10,
|
| 260 |
-
borderRadius: 12,
|
| 261 |
-
borderWidth: 1,
|
| 262 |
-
maxHeight: 120,
|
| 263 |
-
},
|
| 264 |
-
sendBtn: {
|
| 265 |
-
width: 40,
|
| 266 |
-
height: 40,
|
| 267 |
-
borderRadius: 12,
|
| 268 |
-
alignItems: "center",
|
| 269 |
-
justifyContent: "center",
|
| 270 |
-
},
|
| 271 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/app/settings.tsx
DELETED
|
@@ -1,281 +0,0 @@
|
|
| 1 |
-
import { View, Text, TextInput, TouchableOpacity, ScrollView, StyleSheet } from "react-native";
|
| 2 |
-
import { useRouter } from "expo-router";
|
| 3 |
-
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
| 4 |
-
import { useState, useEffect } from "react";
|
| 5 |
-
import { ArrowLeft, Check, Shield, Globe, Key, Lock } from "lucide-react-native";
|
| 6 |
-
import { getHealth } from "../lib/api";
|
| 7 |
-
import {
|
| 8 |
-
getServerUrl,
|
| 9 |
-
setServerUrl as persistUrl,
|
| 10 |
-
getAuthType,
|
| 11 |
-
setAuthType,
|
| 12 |
-
getAuthValue,
|
| 13 |
-
setAuthValue,
|
| 14 |
-
clearAuth,
|
| 15 |
-
getTheme,
|
| 16 |
-
setTheme as persistTheme,
|
| 17 |
-
} from "../lib/storage";
|
| 18 |
-
import type { AuthType } from "../lib/storage";
|
| 19 |
-
import { themes } from "../constants/themes";
|
| 20 |
-
import { ThemeName } from "../constants/types";
|
| 21 |
-
|
| 22 |
-
const themeNames = Object.keys(themes) as ThemeName[];
|
| 23 |
-
|
| 24 |
-
const authTypes: { type: AuthType; label: string; description: string; icon: typeof Shield }[] = [
|
| 25 |
-
{ type: "none", label: "None", description: "No authentication", icon: Globe },
|
| 26 |
-
{ type: "basic", label: "Basic Auth", description: "Username & password", icon: Lock },
|
| 27 |
-
{ type: "bearer", label: "Bearer Token", description: "Authorization header", icon: Key },
|
| 28 |
-
{ type: "apikey", label: "API Key", description: "X-API-Key header", icon: Shield },
|
| 29 |
-
];
|
| 30 |
-
|
| 31 |
-
export default function SettingsScreen() {
|
| 32 |
-
const router = useRouter();
|
| 33 |
-
const insets = useSafeAreaInsets();
|
| 34 |
-
const currentTheme = getTheme();
|
| 35 |
-
const theme = themes[currentTheme];
|
| 36 |
-
|
| 37 |
-
const [serverInput, setServerInput] = useState(getServerUrl());
|
| 38 |
-
const [authType, setAuthTypeState] = useState<AuthType>(getAuthType());
|
| 39 |
-
const [authValue, setAuthValueState] = useState(getAuthValue());
|
| 40 |
-
const [connected, setConnected] = useState<boolean | null>(null);
|
| 41 |
-
const [serverVersion, setServerVersion] = useState<string | null>(null);
|
| 42 |
-
|
| 43 |
-
// Basic auth split
|
| 44 |
-
const [basicUser, setBasicUser] = useState(() => {
|
| 45 |
-
const val = getAuthValue();
|
| 46 |
-
return getAuthType() === "basic" && val.includes(":") ? val.split(":")[0] : "";
|
| 47 |
-
});
|
| 48 |
-
const [basicPass, setBasicPass] = useState(() => {
|
| 49 |
-
const val = getAuthValue();
|
| 50 |
-
return getAuthType() === "basic" && val.includes(":") ? val.split(":").slice(1).join(":") : "";
|
| 51 |
-
});
|
| 52 |
-
|
| 53 |
-
const testConnection = async () => {
|
| 54 |
-
persistUrl(serverInput);
|
| 55 |
-
|
| 56 |
-
// Save auth
|
| 57 |
-
if (authType === "basic") {
|
| 58 |
-
setAuthValue(`${basicUser}:${basicPass}`);
|
| 59 |
-
} else {
|
| 60 |
-
setAuthValue(authValue);
|
| 61 |
-
}
|
| 62 |
-
setAuthType(authType);
|
| 63 |
-
|
| 64 |
-
try {
|
| 65 |
-
const health = await getHealth();
|
| 66 |
-
setConnected(health.healthy);
|
| 67 |
-
setServerVersion(health.version || null);
|
| 68 |
-
} catch {
|
| 69 |
-
setConnected(false);
|
| 70 |
-
setServerVersion(null);
|
| 71 |
-
}
|
| 72 |
-
};
|
| 73 |
-
|
| 74 |
-
const handleThemeSelect = (name: ThemeName) => {
|
| 75 |
-
persistTheme(name);
|
| 76 |
-
};
|
| 77 |
-
|
| 78 |
-
return (
|
| 79 |
-
<View style={[styles.container, { backgroundColor: theme.colors.bg }]}>
|
| 80 |
-
<View style={[styles.header, { paddingTop: insets.top + 12, borderBottomColor: theme.colors.border }]}>
|
| 81 |
-
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
|
| 82 |
-
<ArrowLeft size={20} color={theme.colors.text} />
|
| 83 |
-
</TouchableOpacity>
|
| 84 |
-
<Text style={[styles.title, { color: theme.colors.text }]}>Settings</Text>
|
| 85 |
-
</View>
|
| 86 |
-
|
| 87 |
-
<ScrollView
|
| 88 |
-
contentContainerStyle={{ padding: 20, paddingBottom: insets.bottom + 40 }}
|
| 89 |
-
showsVerticalScrollIndicator={false}
|
| 90 |
-
>
|
| 91 |
-
{/* Server */}
|
| 92 |
-
<Text style={[styles.sectionLabel, { color: theme.colors.textSecondary }]}>Server</Text>
|
| 93 |
-
<View style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
|
| 94 |
-
<Text style={[styles.label, { color: theme.colors.textMuted }]}>Server URL</Text>
|
| 95 |
-
<TextInput
|
| 96 |
-
style={[styles.input, { color: theme.colors.text, backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border }]}
|
| 97 |
-
value={serverInput}
|
| 98 |
-
onChangeText={setServerInput}
|
| 99 |
-
placeholder="http://127.0.0.1:4096"
|
| 100 |
-
placeholderTextColor={theme.colors.textMuted}
|
| 101 |
-
autoCapitalize="none"
|
| 102 |
-
autoCorrect={false}
|
| 103 |
-
/>
|
| 104 |
-
|
| 105 |
-
{/* Auth Type */}
|
| 106 |
-
<Text style={[styles.label, { color: theme.colors.textMuted, marginTop: 8 }]}>Authentication</Text>
|
| 107 |
-
<View style={styles.authGrid}>
|
| 108 |
-
{authTypes.map((a) => {
|
| 109 |
-
const Icon = a.icon;
|
| 110 |
-
const selected = authType === a.type;
|
| 111 |
-
return (
|
| 112 |
-
<TouchableOpacity
|
| 113 |
-
key={a.type}
|
| 114 |
-
style={[
|
| 115 |
-
styles.authCard,
|
| 116 |
-
{
|
| 117 |
-
backgroundColor: selected ? theme.colors.bgTertiary : theme.colors.bg,
|
| 118 |
-
borderColor: selected ? theme.colors.primary : theme.colors.border,
|
| 119 |
-
},
|
| 120 |
-
]}
|
| 121 |
-
onPress={() => setAuthTypeState(a.type)}
|
| 122 |
-
>
|
| 123 |
-
<Icon size={16} color={selected ? theme.colors.primary : theme.colors.textMuted} />
|
| 124 |
-
<Text style={[styles.authLabel, { color: selected ? theme.colors.text : theme.colors.textMuted }]}>
|
| 125 |
-
{a.label}
|
| 126 |
-
</Text>
|
| 127 |
-
</TouchableOpacity>
|
| 128 |
-
);
|
| 129 |
-
})}
|
| 130 |
-
</View>
|
| 131 |
-
|
| 132 |
-
{/* Auth Value Input */}
|
| 133 |
-
{authType === "basic" && (
|
| 134 |
-
<View style={{ gap: 8, marginTop: 8 }}>
|
| 135 |
-
<TextInput
|
| 136 |
-
style={[styles.input, { color: theme.colors.text, backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border }]}
|
| 137 |
-
value={basicUser}
|
| 138 |
-
onChangeText={setBasicUser}
|
| 139 |
-
placeholder="Username"
|
| 140 |
-
placeholderTextColor={theme.colors.textMuted}
|
| 141 |
-
autoCapitalize="none"
|
| 142 |
-
/>
|
| 143 |
-
<TextInput
|
| 144 |
-
style={[styles.input, { color: theme.colors.text, backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border }]}
|
| 145 |
-
value={basicPass}
|
| 146 |
-
onChangeText={setBasicPass}
|
| 147 |
-
placeholder="Password"
|
| 148 |
-
placeholderTextColor={theme.colors.textMuted}
|
| 149 |
-
secureTextEntry
|
| 150 |
-
/>
|
| 151 |
-
</View>
|
| 152 |
-
)}
|
| 153 |
-
{authType === "bearer" && (
|
| 154 |
-
<TextInput
|
| 155 |
-
style={[styles.input, { color: theme.colors.text, backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border, marginTop: 8 }]}
|
| 156 |
-
value={authValue}
|
| 157 |
-
onChangeText={setAuthValueState}
|
| 158 |
-
placeholder="Bearer token"
|
| 159 |
-
placeholderTextColor={theme.colors.textMuted}
|
| 160 |
-
autoCapitalize="none"
|
| 161 |
-
secureTextEntry
|
| 162 |
-
/>
|
| 163 |
-
)}
|
| 164 |
-
{authType === "apikey" && (
|
| 165 |
-
<TextInput
|
| 166 |
-
style={[styles.input, { color: theme.colors.text, backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border, marginTop: 8 }]}
|
| 167 |
-
value={authValue}
|
| 168 |
-
onChangeText={setAuthValueState}
|
| 169 |
-
placeholder="API key"
|
| 170 |
-
placeholderTextColor={theme.colors.textMuted}
|
| 171 |
-
autoCapitalize="none"
|
| 172 |
-
secureTextEntry
|
| 173 |
-
/>
|
| 174 |
-
)}
|
| 175 |
-
|
| 176 |
-
<TouchableOpacity style={[styles.btn, { backgroundColor: theme.colors.primary }]} onPress={testConnection}>
|
| 177 |
-
<Text style={[styles.btnText, { color: theme.colors.primaryText }]}>Test Connection</Text>
|
| 178 |
-
</TouchableOpacity>
|
| 179 |
-
{connected !== null && (
|
| 180 |
-
<Text style={[styles.status, { color: connected ? theme.colors.success : theme.colors.danger }]}>
|
| 181 |
-
{connected ? `Connected${serverVersion ? ` (v${serverVersion})` : ""}` : "Failed to connect"}
|
| 182 |
-
</Text>
|
| 183 |
-
)}
|
| 184 |
-
</View>
|
| 185 |
-
|
| 186 |
-
{/* Theme */}
|
| 187 |
-
<Text style={[styles.sectionLabel, { color: theme.colors.textSecondary, marginTop: 28 }]}>Theme</Text>
|
| 188 |
-
<View style={styles.themeGrid}>
|
| 189 |
-
{themeNames.map((name) => {
|
| 190 |
-
const t = themes[name];
|
| 191 |
-
const selected = currentTheme === name;
|
| 192 |
-
return (
|
| 193 |
-
<TouchableOpacity
|
| 194 |
-
key={name}
|
| 195 |
-
style={[
|
| 196 |
-
styles.themeCard,
|
| 197 |
-
{
|
| 198 |
-
backgroundColor: t.colors.bg,
|
| 199 |
-
borderColor: selected ? theme.colors.primary : t.colors.border,
|
| 200 |
-
borderWidth: selected ? 2 : 1,
|
| 201 |
-
},
|
| 202 |
-
]}
|
| 203 |
-
onPress={() => handleThemeSelect(name)}
|
| 204 |
-
>
|
| 205 |
-
<View style={styles.themePreview}>
|
| 206 |
-
<View style={[styles.dot, { backgroundColor: t.colors.primary }]} />
|
| 207 |
-
<View style={[styles.dot, { backgroundColor: t.colors.secondary }]} />
|
| 208 |
-
<View style={[styles.dot, { backgroundColor: t.colors.danger }]} />
|
| 209 |
-
</View>
|
| 210 |
-
<Text style={[styles.themeName, { color: t.colors.text }]}>{t.label}</Text>
|
| 211 |
-
{selected && <Check size={14} color={theme.colors.primary} />}
|
| 212 |
-
</TouchableOpacity>
|
| 213 |
-
);
|
| 214 |
-
})}
|
| 215 |
-
</View>
|
| 216 |
-
|
| 217 |
-
{/* About */}
|
| 218 |
-
<Text style={[styles.sectionLabel, { color: theme.colors.textSecondary, marginTop: 28 }]}>About</Text>
|
| 219 |
-
<View style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
|
| 220 |
-
<Text style={[styles.aboutText, { color: theme.colors.text }]}>OpenCode Mobile</Text>
|
| 221 |
-
<Text style={[styles.aboutVersion, { color: theme.colors.textMuted }]}>v1.0.0</Text>
|
| 222 |
-
</View>
|
| 223 |
-
</ScrollView>
|
| 224 |
-
</View>
|
| 225 |
-
);
|
| 226 |
-
}
|
| 227 |
-
|
| 228 |
-
const styles = StyleSheet.create({
|
| 229 |
-
container: { flex: 1 },
|
| 230 |
-
header: {
|
| 231 |
-
flexDirection: "row",
|
| 232 |
-
alignItems: "center",
|
| 233 |
-
gap: 10,
|
| 234 |
-
paddingHorizontal: 20,
|
| 235 |
-
paddingBottom: 12,
|
| 236 |
-
borderBottomWidth: 1,
|
| 237 |
-
},
|
| 238 |
-
backBtn: { padding: 4 },
|
| 239 |
-
title: { fontSize: 28, fontWeight: "700", letterSpacing: -0.5 },
|
| 240 |
-
sectionLabel: { fontSize: 13, fontWeight: "600", textTransform: "uppercase", letterSpacing: 0.5, marginBottom: 10 },
|
| 241 |
-
card: { padding: 16, borderRadius: 12, borderWidth: 1, gap: 10 },
|
| 242 |
-
label: { fontSize: 13, fontWeight: "500" },
|
| 243 |
-
input: {
|
| 244 |
-
fontSize: 14,
|
| 245 |
-
paddingHorizontal: 12,
|
| 246 |
-
paddingVertical: 10,
|
| 247 |
-
borderRadius: 8,
|
| 248 |
-
borderWidth: 1,
|
| 249 |
-
},
|
| 250 |
-
btn: { paddingVertical: 12, borderRadius: 10, alignItems: "center", marginTop: 4 },
|
| 251 |
-
btnText: { fontSize: 14, fontWeight: "600" },
|
| 252 |
-
status: { fontSize: 13, textAlign: "center" },
|
| 253 |
-
authGrid: {
|
| 254 |
-
flexDirection: "row",
|
| 255 |
-
flexWrap: "wrap",
|
| 256 |
-
gap: 8,
|
| 257 |
-
},
|
| 258 |
-
authCard: {
|
| 259 |
-
flexDirection: "row",
|
| 260 |
-
alignItems: "center",
|
| 261 |
-
gap: 6,
|
| 262 |
-
paddingHorizontal: 12,
|
| 263 |
-
paddingVertical: 8,
|
| 264 |
-
borderRadius: 8,
|
| 265 |
-
borderWidth: 1,
|
| 266 |
-
},
|
| 267 |
-
authLabel: { fontSize: 13, fontWeight: "500" },
|
| 268 |
-
themeGrid: { gap: 8 },
|
| 269 |
-
themeCard: {
|
| 270 |
-
flexDirection: "row",
|
| 271 |
-
alignItems: "center",
|
| 272 |
-
gap: 12,
|
| 273 |
-
padding: 14,
|
| 274 |
-
borderRadius: 10,
|
| 275 |
-
},
|
| 276 |
-
themePreview: { flexDirection: "row", gap: 4 },
|
| 277 |
-
dot: { width: 10, height: 10, borderRadius: 5 },
|
| 278 |
-
themeName: { flex: 1, fontSize: 14, fontWeight: "500" },
|
| 279 |
-
aboutText: { fontSize: 15, fontWeight: "600" },
|
| 280 |
-
aboutVersion: { fontSize: 13 },
|
| 281 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/components/MarkdownRenderer.tsx
DELETED
|
@@ -1,134 +0,0 @@
|
|
| 1 |
-
import React from "react";
|
| 2 |
-
import { View, Text, StyleSheet, ScrollView } from "react-native";
|
| 3 |
-
import Markdown from "react-native-markdown-display";
|
| 4 |
-
import { themes } from "../constants/themes";
|
| 5 |
-
import { getTheme } from "../lib/storage";
|
| 6 |
-
|
| 7 |
-
interface MarkdownRendererProps {
|
| 8 |
-
content: string;
|
| 9 |
-
}
|
| 10 |
-
|
| 11 |
-
const codeStyle = (theme: { colors: Record<string, string> }) =>
|
| 12 |
-
StyleSheet.create({
|
| 13 |
-
body: {},
|
| 14 |
-
heading1: { fontSize: 22, fontWeight: "700", color: theme.colors.text, marginTop: 16, marginBottom: 8 },
|
| 15 |
-
heading2: { fontSize: 19, fontWeight: "700", color: theme.colors.text, marginTop: 14, marginBottom: 6 },
|
| 16 |
-
heading3: { fontSize: 16, fontWeight: "600", color: theme.colors.text, marginTop: 12, marginBottom: 4 },
|
| 17 |
-
paragraph: { fontSize: 15, lineHeight: 22, color: theme.colors.text, marginBottom: 8 },
|
| 18 |
-
link: { color: theme.colors.primary, textDecorationLine: "none" },
|
| 19 |
-
blockquote: {
|
| 20 |
-
borderLeftWidth: 3,
|
| 21 |
-
borderLeftColor: theme.colors.primary,
|
| 22 |
-
paddingLeft: 12,
|
| 23 |
-
marginLeft: 0,
|
| 24 |
-
marginVertical: 8,
|
| 25 |
-
opacity: 0.8,
|
| 26 |
-
},
|
| 27 |
-
code_inline: {
|
| 28 |
-
fontFamily: "monospace",
|
| 29 |
-
fontSize: 13,
|
| 30 |
-
color: theme.colors.accent,
|
| 31 |
-
backgroundColor: theme.colors.bgTertiary,
|
| 32 |
-
paddingHorizontal: 5,
|
| 33 |
-
paddingVertical: 2,
|
| 34 |
-
borderRadius: 4,
|
| 35 |
-
},
|
| 36 |
-
code_block: {
|
| 37 |
-
fontFamily: "monospace",
|
| 38 |
-
fontSize: 13,
|
| 39 |
-
color: theme.colors.text,
|
| 40 |
-
backgroundColor: theme.colors.bgTertiary,
|
| 41 |
-
padding: 12,
|
| 42 |
-
borderRadius: 8,
|
| 43 |
-
marginVertical: 8,
|
| 44 |
-
borderWidth: 1,
|
| 45 |
-
borderColor: theme.colors.border,
|
| 46 |
-
},
|
| 47 |
-
fence: {
|
| 48 |
-
fontFamily: "monospace",
|
| 49 |
-
fontSize: 13,
|
| 50 |
-
color: theme.colors.text,
|
| 51 |
-
backgroundColor: theme.colors.bgTertiary,
|
| 52 |
-
padding: 12,
|
| 53 |
-
borderRadius: 8,
|
| 54 |
-
marginVertical: 8,
|
| 55 |
-
borderWidth: 1,
|
| 56 |
-
borderColor: theme.colors.border,
|
| 57 |
-
},
|
| 58 |
-
list_item: { fontSize: 15, lineHeight: 22, color: theme.colors.text, marginBottom: 4 },
|
| 59 |
-
bullet_list: { marginVertical: 8 },
|
| 60 |
-
ordered_list: { marginVertical: 8 },
|
| 61 |
-
table: { borderWidth: 1, borderColor: theme.colors.border, marginVertical: 8 },
|
| 62 |
-
table_header_cell: {
|
| 63 |
-
backgroundColor: theme.colors.bgTertiary,
|
| 64 |
-
padding: 8,
|
| 65 |
-
borderBottomWidth: 1,
|
| 66 |
-
borderBottomColor: theme.colors.border,
|
| 67 |
-
},
|
| 68 |
-
table_cell: { padding: 8, borderBottomWidth: 1, borderBottomColor: theme.colors.border },
|
| 69 |
-
strong: { fontWeight: "700" },
|
| 70 |
-
em: { fontStyle: "italic" },
|
| 71 |
-
hr: { height: 1, backgroundColor: theme.colors.border, marginVertical: 16 },
|
| 72 |
-
image: { marginVertical: 8 },
|
| 73 |
-
});
|
| 74 |
-
|
| 75 |
-
export function MarkdownRenderer({ content }: MarkdownRendererProps) {
|
| 76 |
-
const theme = themes[getTheme()];
|
| 77 |
-
const styles = codeStyle(theme);
|
| 78 |
-
|
| 79 |
-
return (
|
| 80 |
-
<Markdown style={styles}>{content}</Markdown>
|
| 81 |
-
);
|
| 82 |
-
}
|
| 83 |
-
|
| 84 |
-
// ── Code block with language label ──
|
| 85 |
-
|
| 86 |
-
interface CodeBlockProps {
|
| 87 |
-
language?: string;
|
| 88 |
-
children: string;
|
| 89 |
-
}
|
| 90 |
-
|
| 91 |
-
export function CodeBlock({ language, children }: CodeBlockProps) {
|
| 92 |
-
const theme = themes[getTheme()];
|
| 93 |
-
|
| 94 |
-
return (
|
| 95 |
-
<View style={[codeStyles.container, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border }]}>
|
| 96 |
-
{language ? (
|
| 97 |
-
<View style={[codeStyles.header, { borderBottomColor: theme.colors.border }]}>
|
| 98 |
-
<Text style={[codeStyles.language, { color: theme.colors.textMuted }]}>{language}</Text>
|
| 99 |
-
</View>
|
| 100 |
-
) : null}
|
| 101 |
-
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
| 102 |
-
<Text style={[codeStyles.code, { color: theme.colors.text }]} selectable>
|
| 103 |
-
{children}
|
| 104 |
-
</Text>
|
| 105 |
-
</ScrollView>
|
| 106 |
-
</View>
|
| 107 |
-
);
|
| 108 |
-
}
|
| 109 |
-
|
| 110 |
-
const codeStyles = StyleSheet.create({
|
| 111 |
-
container: {
|
| 112 |
-
borderRadius: 8,
|
| 113 |
-
borderWidth: 1,
|
| 114 |
-
marginVertical: 8,
|
| 115 |
-
overflow: "hidden",
|
| 116 |
-
},
|
| 117 |
-
header: {
|
| 118 |
-
paddingHorizontal: 12,
|
| 119 |
-
paddingVertical: 6,
|
| 120 |
-
borderBottomWidth: 1,
|
| 121 |
-
},
|
| 122 |
-
language: {
|
| 123 |
-
fontSize: 11,
|
| 124 |
-
fontWeight: "600",
|
| 125 |
-
textTransform: "uppercase",
|
| 126 |
-
letterSpacing: 0.5,
|
| 127 |
-
},
|
| 128 |
-
code: {
|
| 129 |
-
fontFamily: "monospace",
|
| 130 |
-
fontSize: 13,
|
| 131 |
-
lineHeight: 20,
|
| 132 |
-
padding: 12,
|
| 133 |
-
},
|
| 134 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/components/PermissionCard.tsx
DELETED
|
@@ -1,113 +0,0 @@
|
|
| 1 |
-
import React from "react";
|
| 2 |
-
import { View, Text, TouchableOpacity, StyleSheet } from "react-native";
|
| 3 |
-
import { Shield, Check, X } from "lucide-react-native";
|
| 4 |
-
import { themes } from "../constants/themes";
|
| 5 |
-
import { getTheme } from "../lib/storage";
|
| 6 |
-
import type { PermissionRequest } from "../constants/types";
|
| 7 |
-
|
| 8 |
-
interface PermissionCardProps {
|
| 9 |
-
request: PermissionRequest;
|
| 10 |
-
onAllow: () => void;
|
| 11 |
-
onDeny: () => void;
|
| 12 |
-
}
|
| 13 |
-
|
| 14 |
-
export function PermissionCard({ request, onAllow, onDeny }: PermissionCardProps) {
|
| 15 |
-
const theme = themes[getTheme()];
|
| 16 |
-
|
| 17 |
-
return (
|
| 18 |
-
<View style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
|
| 19 |
-
<View style={styles.header}>
|
| 20 |
-
<Shield size={16} color={theme.colors.warning} />
|
| 21 |
-
<Text style={[styles.title, { color: theme.colors.text }]}>Permission Required</Text>
|
| 22 |
-
</View>
|
| 23 |
-
|
| 24 |
-
<Text style={[styles.permission, { color: theme.colors.textSecondary }]}>
|
| 25 |
-
{request.permission}
|
| 26 |
-
</Text>
|
| 27 |
-
|
| 28 |
-
{request.description ? (
|
| 29 |
-
<Text style={[styles.description, { color: theme.colors.textMuted }]}>
|
| 30 |
-
{request.description}
|
| 31 |
-
</Text>
|
| 32 |
-
) : null}
|
| 33 |
-
|
| 34 |
-
<View style={styles.actions}>
|
| 35 |
-
<TouchableOpacity
|
| 36 |
-
style={[styles.denyBtn, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border }]}
|
| 37 |
-
onPress={onDeny}
|
| 38 |
-
activeOpacity={0.7}
|
| 39 |
-
>
|
| 40 |
-
<X size={16} color={theme.colors.danger} />
|
| 41 |
-
<Text style={[styles.denyText, { color: theme.colors.danger }]}>Deny</Text>
|
| 42 |
-
</TouchableOpacity>
|
| 43 |
-
|
| 44 |
-
<TouchableOpacity
|
| 45 |
-
style={[styles.allowBtn, { backgroundColor: theme.colors.success }]}
|
| 46 |
-
onPress={onAllow}
|
| 47 |
-
activeOpacity={0.7}
|
| 48 |
-
>
|
| 49 |
-
<Check size={16} color="#fff" />
|
| 50 |
-
<Text style={[styles.allowText, { color: "#fff" }]}>Allow</Text>
|
| 51 |
-
</TouchableOpacity>
|
| 52 |
-
</View>
|
| 53 |
-
</View>
|
| 54 |
-
);
|
| 55 |
-
}
|
| 56 |
-
|
| 57 |
-
const styles = StyleSheet.create({
|
| 58 |
-
card: {
|
| 59 |
-
padding: 16,
|
| 60 |
-
borderRadius: 12,
|
| 61 |
-
borderWidth: 1,
|
| 62 |
-
gap: 10,
|
| 63 |
-
},
|
| 64 |
-
header: {
|
| 65 |
-
flexDirection: "row",
|
| 66 |
-
alignItems: "center",
|
| 67 |
-
gap: 8,
|
| 68 |
-
},
|
| 69 |
-
title: {
|
| 70 |
-
fontSize: 15,
|
| 71 |
-
fontWeight: "600",
|
| 72 |
-
},
|
| 73 |
-
permission: {
|
| 74 |
-
fontSize: 14,
|
| 75 |
-
fontWeight: "500",
|
| 76 |
-
},
|
| 77 |
-
description: {
|
| 78 |
-
fontSize: 13,
|
| 79 |
-
lineHeight: 18,
|
| 80 |
-
},
|
| 81 |
-
actions: {
|
| 82 |
-
flexDirection: "row",
|
| 83 |
-
gap: 10,
|
| 84 |
-
marginTop: 4,
|
| 85 |
-
},
|
| 86 |
-
denyBtn: {
|
| 87 |
-
flex: 1,
|
| 88 |
-
flexDirection: "row",
|
| 89 |
-
alignItems: "center",
|
| 90 |
-
justifyContent: "center",
|
| 91 |
-
gap: 6,
|
| 92 |
-
paddingVertical: 10,
|
| 93 |
-
borderRadius: 8,
|
| 94 |
-
borderWidth: 1,
|
| 95 |
-
},
|
| 96 |
-
denyText: {
|
| 97 |
-
fontSize: 14,
|
| 98 |
-
fontWeight: "600",
|
| 99 |
-
},
|
| 100 |
-
allowBtn: {
|
| 101 |
-
flex: 1,
|
| 102 |
-
flexDirection: "row",
|
| 103 |
-
alignItems: "center",
|
| 104 |
-
justifyContent: "center",
|
| 105 |
-
gap: 6,
|
| 106 |
-
paddingVertical: 10,
|
| 107 |
-
borderRadius: 8,
|
| 108 |
-
},
|
| 109 |
-
allowText: {
|
| 110 |
-
fontSize: 14,
|
| 111 |
-
fontWeight: "600",
|
| 112 |
-
},
|
| 113 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/components/QuestionCard.tsx
DELETED
|
@@ -1,176 +0,0 @@
|
|
| 1 |
-
import React, { useState } from "react";
|
| 2 |
-
import { View, Text, TouchableOpacity, TextInput, StyleSheet } from "react-native";
|
| 3 |
-
import { HelpCircle, Send } from "lucide-react-native";
|
| 4 |
-
import { themes } from "../constants/themes";
|
| 5 |
-
import { getTheme } from "../lib/storage";
|
| 6 |
-
import type { QuestionRequest } from "../constants/types";
|
| 7 |
-
|
| 8 |
-
interface QuestionCardProps {
|
| 9 |
-
request: QuestionRequest;
|
| 10 |
-
onReply: (answer: string) => void;
|
| 11 |
-
onReject?: () => void;
|
| 12 |
-
}
|
| 13 |
-
|
| 14 |
-
export function QuestionCard({ request, onReply, onReject }: QuestionCardProps) {
|
| 15 |
-
const theme = themes[getTheme()];
|
| 16 |
-
const [selected, setSelected] = useState<string | null>(null);
|
| 17 |
-
const [customAnswer, setCustomAnswer] = useState("");
|
| 18 |
-
|
| 19 |
-
const handleSubmit = () => {
|
| 20 |
-
if (selected) {
|
| 21 |
-
onReply(selected);
|
| 22 |
-
} else if (customAnswer.trim()) {
|
| 23 |
-
onReply(customAnswer.trim());
|
| 24 |
-
}
|
| 25 |
-
};
|
| 26 |
-
|
| 27 |
-
const hasOptions = request.options && request.options.length > 0;
|
| 28 |
-
|
| 29 |
-
return (
|
| 30 |
-
<View style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
|
| 31 |
-
<View style={styles.header}>
|
| 32 |
-
<HelpCircle size={16} color={theme.colors.primary} />
|
| 33 |
-
<Text style={[styles.title, { color: theme.colors.text }]}>Question</Text>
|
| 34 |
-
</View>
|
| 35 |
-
|
| 36 |
-
<Text style={[styles.question, { color: theme.colors.textSecondary }]}>
|
| 37 |
-
{request.question}
|
| 38 |
-
</Text>
|
| 39 |
-
|
| 40 |
-
{hasOptions ? (
|
| 41 |
-
<View style={styles.options}>
|
| 42 |
-
{request.options!.map((option) => {
|
| 43 |
-
const isSelected = selected === option;
|
| 44 |
-
return (
|
| 45 |
-
<TouchableOpacity
|
| 46 |
-
key={option}
|
| 47 |
-
style={[
|
| 48 |
-
styles.option,
|
| 49 |
-
{
|
| 50 |
-
backgroundColor: isSelected ? theme.colors.bgTertiary : theme.colors.bg,
|
| 51 |
-
borderColor: isSelected ? theme.colors.primary : theme.colors.border,
|
| 52 |
-
},
|
| 53 |
-
]}
|
| 54 |
-
onPress={() => setSelected(option)}
|
| 55 |
-
activeOpacity={0.7}
|
| 56 |
-
>
|
| 57 |
-
<View
|
| 58 |
-
style={[
|
| 59 |
-
styles.radio,
|
| 60 |
-
{
|
| 61 |
-
borderColor: isSelected ? theme.colors.primary : theme.colors.textMuted,
|
| 62 |
-
backgroundColor: isSelected ? theme.colors.primary : "transparent",
|
| 63 |
-
},
|
| 64 |
-
]}
|
| 65 |
-
/>
|
| 66 |
-
<Text
|
| 67 |
-
style={[
|
| 68 |
-
styles.optionText,
|
| 69 |
-
{ color: isSelected ? theme.colors.text : theme.colors.textSecondary },
|
| 70 |
-
]}
|
| 71 |
-
>
|
| 72 |
-
{option}
|
| 73 |
-
</Text>
|
| 74 |
-
</TouchableOpacity>
|
| 75 |
-
);
|
| 76 |
-
})}
|
| 77 |
-
</View>
|
| 78 |
-
) : (
|
| 79 |
-
<TextInput
|
| 80 |
-
style={[styles.input, { color: theme.colors.text, backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border }]}
|
| 81 |
-
value={customAnswer}
|
| 82 |
-
onChangeText={setCustomAnswer}
|
| 83 |
-
placeholder="Type your answer..."
|
| 84 |
-
placeholderTextColor={theme.colors.textMuted}
|
| 85 |
-
multiline
|
| 86 |
-
/>
|
| 87 |
-
)}
|
| 88 |
-
|
| 89 |
-
<TouchableOpacity
|
| 90 |
-
style={[
|
| 91 |
-
styles.submitBtn,
|
| 92 |
-
{
|
| 93 |
-
backgroundColor: selected || customAnswer.trim() ? theme.colors.primary : theme.colors.bgTertiary,
|
| 94 |
-
},
|
| 95 |
-
]}
|
| 96 |
-
onPress={handleSubmit}
|
| 97 |
-
disabled={!selected && !customAnswer.trim()}
|
| 98 |
-
activeOpacity={0.7}
|
| 99 |
-
>
|
| 100 |
-
<Send size={14} color={selected || customAnswer.trim() ? theme.colors.primaryText : theme.colors.textMuted} />
|
| 101 |
-
<Text
|
| 102 |
-
style={[
|
| 103 |
-
styles.submitText,
|
| 104 |
-
{ color: selected || customAnswer.trim() ? theme.colors.primaryText : theme.colors.textMuted },
|
| 105 |
-
]}
|
| 106 |
-
>
|
| 107 |
-
Submit
|
| 108 |
-
</Text>
|
| 109 |
-
</TouchableOpacity>
|
| 110 |
-
</View>
|
| 111 |
-
);
|
| 112 |
-
}
|
| 113 |
-
|
| 114 |
-
const styles = StyleSheet.create({
|
| 115 |
-
card: {
|
| 116 |
-
padding: 16,
|
| 117 |
-
borderRadius: 12,
|
| 118 |
-
borderWidth: 1,
|
| 119 |
-
gap: 10,
|
| 120 |
-
},
|
| 121 |
-
header: {
|
| 122 |
-
flexDirection: "row",
|
| 123 |
-
alignItems: "center",
|
| 124 |
-
gap: 8,
|
| 125 |
-
},
|
| 126 |
-
title: {
|
| 127 |
-
fontSize: 15,
|
| 128 |
-
fontWeight: "600",
|
| 129 |
-
},
|
| 130 |
-
question: {
|
| 131 |
-
fontSize: 14,
|
| 132 |
-
lineHeight: 20,
|
| 133 |
-
},
|
| 134 |
-
options: {
|
| 135 |
-
gap: 6,
|
| 136 |
-
},
|
| 137 |
-
option: {
|
| 138 |
-
flexDirection: "row",
|
| 139 |
-
alignItems: "center",
|
| 140 |
-
gap: 10,
|
| 141 |
-
padding: 12,
|
| 142 |
-
borderRadius: 8,
|
| 143 |
-
borderWidth: 1,
|
| 144 |
-
},
|
| 145 |
-
radio: {
|
| 146 |
-
width: 16,
|
| 147 |
-
height: 16,
|
| 148 |
-
borderRadius: 8,
|
| 149 |
-
borderWidth: 2,
|
| 150 |
-
},
|
| 151 |
-
optionText: {
|
| 152 |
-
fontSize: 14,
|
| 153 |
-
flex: 1,
|
| 154 |
-
},
|
| 155 |
-
input: {
|
| 156 |
-
fontSize: 14,
|
| 157 |
-
paddingHorizontal: 12,
|
| 158 |
-
paddingVertical: 10,
|
| 159 |
-
borderRadius: 8,
|
| 160 |
-
borderWidth: 1,
|
| 161 |
-
minHeight: 44,
|
| 162 |
-
},
|
| 163 |
-
submitBtn: {
|
| 164 |
-
flexDirection: "row",
|
| 165 |
-
alignItems: "center",
|
| 166 |
-
justifyContent: "center",
|
| 167 |
-
gap: 6,
|
| 168 |
-
paddingVertical: 10,
|
| 169 |
-
borderRadius: 8,
|
| 170 |
-
marginTop: 4,
|
| 171 |
-
},
|
| 172 |
-
submitText: {
|
| 173 |
-
fontSize: 14,
|
| 174 |
-
fontWeight: "600",
|
| 175 |
-
},
|
| 176 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/components/ToolTimeline.tsx
DELETED
|
@@ -1,165 +0,0 @@
|
|
| 1 |
-
import React from "react";
|
| 2 |
-
import { View, Text, StyleSheet } from "react-native";
|
| 3 |
-
import { Loader, Check, X, FileText, Search, Terminal, Globe, Wrench } from "lucide-react-native";
|
| 4 |
-
import { themes } from "../constants/themes";
|
| 5 |
-
import { getTheme } from "../lib/storage";
|
| 6 |
-
import type { ToolPart } from "../constants/types";
|
| 7 |
-
|
| 8 |
-
interface ToolTimelineProps {
|
| 9 |
-
parts: ToolPart[];
|
| 10 |
-
}
|
| 11 |
-
|
| 12 |
-
const toolIcons: Record<string, typeof Wrench> = {
|
| 13 |
-
read: FileText,
|
| 14 |
-
write: FileText,
|
| 15 |
-
edit: FileText,
|
| 16 |
-
glob: Search,
|
| 17 |
-
grep: Search,
|
| 18 |
-
bash: Terminal,
|
| 19 |
-
webfetch: Globe,
|
| 20 |
-
websearch: Globe,
|
| 21 |
-
};
|
| 22 |
-
|
| 23 |
-
const toolLabels: Record<string, string> = {
|
| 24 |
-
read: "Reading file",
|
| 25 |
-
write: "Writing file",
|
| 26 |
-
edit: "Editing file",
|
| 27 |
-
glob: "Searching files",
|
| 28 |
-
grep: "Searching content",
|
| 29 |
-
bash: "Running command",
|
| 30 |
-
webfetch: "Fetching URL",
|
| 31 |
-
websearch: "Searching web",
|
| 32 |
-
};
|
| 33 |
-
|
| 34 |
-
export function ToolTimeline({ parts }: ToolTimelineProps) {
|
| 35 |
-
const theme = themes[getTheme()];
|
| 36 |
-
|
| 37 |
-
if (parts.length === 0) return null;
|
| 38 |
-
|
| 39 |
-
return (
|
| 40 |
-
<View style={styles.container}>
|
| 41 |
-
{parts.map((part, index) => {
|
| 42 |
-
const Icon = toolIcons[part.tool] || Wrench;
|
| 43 |
-
const label = toolLabels[part.tool] || part.tool;
|
| 44 |
-
const isRunning = part.state === "call";
|
| 45 |
-
const isError = part.state === "error";
|
| 46 |
-
const isDone = part.state === "result";
|
| 47 |
-
|
| 48 |
-
return (
|
| 49 |
-
<View key={part.callID || index} style={styles.row}>
|
| 50 |
-
{/* Status indicator */}
|
| 51 |
-
<View
|
| 52 |
-
style={[
|
| 53 |
-
styles.indicator,
|
| 54 |
-
{
|
| 55 |
-
backgroundColor: isRunning
|
| 56 |
-
? theme.colors.warning
|
| 57 |
-
: isError
|
| 58 |
-
? theme.colors.danger
|
| 59 |
-
: theme.colors.success,
|
| 60 |
-
},
|
| 61 |
-
]}
|
| 62 |
-
/>
|
| 63 |
-
|
| 64 |
-
{/* Content */}
|
| 65 |
-
<View style={styles.content}>
|
| 66 |
-
<View style={styles.header}>
|
| 67 |
-
<Icon
|
| 68 |
-
size={14}
|
| 69 |
-
color={
|
| 70 |
-
isRunning
|
| 71 |
-
? theme.colors.warning
|
| 72 |
-
: isError
|
| 73 |
-
? theme.colors.danger
|
| 74 |
-
: theme.colors.success
|
| 75 |
-
}
|
| 76 |
-
/>
|
| 77 |
-
<Text
|
| 78 |
-
style={[
|
| 79 |
-
styles.label,
|
| 80 |
-
{
|
| 81 |
-
color: isRunning
|
| 82 |
-
? theme.colors.text
|
| 83 |
-
: theme.colors.textMuted,
|
| 84 |
-
},
|
| 85 |
-
]}
|
| 86 |
-
numberOfLines={1}
|
| 87 |
-
>
|
| 88 |
-
{label}
|
| 89 |
-
</Text>
|
| 90 |
-
|
| 91 |
-
{isRunning && <Loader size={12} color={theme.colors.warning} />}
|
| 92 |
-
{isDone && <Check size={12} color={theme.colors.success} />}
|
| 93 |
-
{isError && <X size={12} color={theme.colors.danger} />}
|
| 94 |
-
</View>
|
| 95 |
-
|
| 96 |
-
{/* Tool title (if provided) */}
|
| 97 |
-
{part.title ? (
|
| 98 |
-
<Text
|
| 99 |
-
style={[styles.title, { color: theme.colors.textSecondary }]}
|
| 100 |
-
numberOfLines={1}
|
| 101 |
-
>
|
| 102 |
-
{part.title}
|
| 103 |
-
</Text>
|
| 104 |
-
) : null}
|
| 105 |
-
|
| 106 |
-
{/* Show truncated input for certain tools */}
|
| 107 |
-
{part.input && (part.tool === "bash" || part.tool === "webfetch") && (
|
| 108 |
-
<Text
|
| 109 |
-
style={[styles.detail, { color: theme.colors.textMuted }]}
|
| 110 |
-
numberOfLines={1}
|
| 111 |
-
>
|
| 112 |
-
{part.tool === "bash"
|
| 113 |
-
? `$ ${part.input.command || JSON.stringify(part.input).slice(0, 60)}`
|
| 114 |
-
: String(part.input.url || "").slice(0, 80)}
|
| 115 |
-
</Text>
|
| 116 |
-
)}
|
| 117 |
-
</View>
|
| 118 |
-
</View>
|
| 119 |
-
);
|
| 120 |
-
})}
|
| 121 |
-
</View>
|
| 122 |
-
);
|
| 123 |
-
}
|
| 124 |
-
|
| 125 |
-
const styles = StyleSheet.create({
|
| 126 |
-
container: {
|
| 127 |
-
gap: 2,
|
| 128 |
-
marginVertical: 4,
|
| 129 |
-
},
|
| 130 |
-
row: {
|
| 131 |
-
flexDirection: "row",
|
| 132 |
-
alignItems: "flex-start",
|
| 133 |
-
gap: 10,
|
| 134 |
-
},
|
| 135 |
-
indicator: {
|
| 136 |
-
width: 6,
|
| 137 |
-
height: 6,
|
| 138 |
-
borderRadius: 3,
|
| 139 |
-
marginTop: 6,
|
| 140 |
-
},
|
| 141 |
-
content: {
|
| 142 |
-
flex: 1,
|
| 143 |
-
gap: 2,
|
| 144 |
-
},
|
| 145 |
-
header: {
|
| 146 |
-
flexDirection: "row",
|
| 147 |
-
alignItems: "center",
|
| 148 |
-
gap: 6,
|
| 149 |
-
},
|
| 150 |
-
label: {
|
| 151 |
-
fontSize: 13,
|
| 152 |
-
fontWeight: "500",
|
| 153 |
-
flex: 1,
|
| 154 |
-
},
|
| 155 |
-
title: {
|
| 156 |
-
fontSize: 12,
|
| 157 |
-
marginLeft: 20,
|
| 158 |
-
},
|
| 159 |
-
detail: {
|
| 160 |
-
fontSize: 12,
|
| 161 |
-
fontFamily: "monospace",
|
| 162 |
-
marginLeft: 20,
|
| 163 |
-
opacity: 0.7,
|
| 164 |
-
},
|
| 165 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/constants/themes.ts
DELETED
|
@@ -1,132 +0,0 @@
|
|
| 1 |
-
import { Theme, ThemeName } from "./types";
|
| 2 |
-
|
| 3 |
-
export const themes: Record<ThemeName, Theme> = {
|
| 4 |
-
tokyo: {
|
| 5 |
-
name: "tokyo",
|
| 6 |
-
label: "Tokyo Night",
|
| 7 |
-
colors: {
|
| 8 |
-
bg: "#1a1b26",
|
| 9 |
-
bgSecondary: "#16161e",
|
| 10 |
-
bgTertiary: "#24283b",
|
| 11 |
-
surface: "#292e42",
|
| 12 |
-
border: "#3b4261",
|
| 13 |
-
text: "#c0caf5",
|
| 14 |
-
textSecondary: "#a9b1d6",
|
| 15 |
-
textMuted: "#565f89",
|
| 16 |
-
primary: "#7aa2f7",
|
| 17 |
-
primaryText: "#1a1b26",
|
| 18 |
-
secondary: "#bb9af7",
|
| 19 |
-
danger: "#f7768e",
|
| 20 |
-
success: "#9ece6a",
|
| 21 |
-
warning: "#e0af68",
|
| 22 |
-
accent: "#7dcfff",
|
| 23 |
-
},
|
| 24 |
-
},
|
| 25 |
-
dracula: {
|
| 26 |
-
name: "dracula",
|
| 27 |
-
label: "Dracula",
|
| 28 |
-
colors: {
|
| 29 |
-
bg: "#282a36",
|
| 30 |
-
bgSecondary: "#21222c",
|
| 31 |
-
bgTertiary: "#343746",
|
| 32 |
-
surface: "#44475a",
|
| 33 |
-
border: "#6272a4",
|
| 34 |
-
text: "#f8f8f2",
|
| 35 |
-
textSecondary: "#bd93f9",
|
| 36 |
-
textMuted: "#6272a4",
|
| 37 |
-
primary: "#bd93f9",
|
| 38 |
-
primaryText: "#282a36",
|
| 39 |
-
secondary: "#ff79c6",
|
| 40 |
-
danger: "#ff5555",
|
| 41 |
-
success: "#50fa7b",
|
| 42 |
-
warning: "#f1fa8c",
|
| 43 |
-
accent: "#8be9fd",
|
| 44 |
-
},
|
| 45 |
-
},
|
| 46 |
-
nord: {
|
| 47 |
-
name: "nord",
|
| 48 |
-
label: "Nord",
|
| 49 |
-
colors: {
|
| 50 |
-
bg: "#2e3440",
|
| 51 |
-
bgSecondary: "#3b4252",
|
| 52 |
-
bgTertiary: "#434c5e",
|
| 53 |
-
surface: "#4c566a",
|
| 54 |
-
border: "#4c566a",
|
| 55 |
-
text: "#eceff4",
|
| 56 |
-
textSecondary: "#d8dee9",
|
| 57 |
-
textMuted: "#7b88a1",
|
| 58 |
-
primary: "#88c0d0",
|
| 59 |
-
primaryText: "#2e3440",
|
| 60 |
-
secondary: "#b48ead",
|
| 61 |
-
danger: "#bf616a",
|
| 62 |
-
success: "#a3be8c",
|
| 63 |
-
warning: "#ebcb8b",
|
| 64 |
-
accent: "#8fbcbb",
|
| 65 |
-
},
|
| 66 |
-
},
|
| 67 |
-
gruvbox: {
|
| 68 |
-
name: "gruvbox",
|
| 69 |
-
label: "Gruvbox",
|
| 70 |
-
colors: {
|
| 71 |
-
bg: "#282828",
|
| 72 |
-
bgSecondary: "#1d2021",
|
| 73 |
-
bgTertiary: "#3c3836",
|
| 74 |
-
surface: "#504945",
|
| 75 |
-
border: "#665c54",
|
| 76 |
-
text: "#ebdbb2",
|
| 77 |
-
textSecondary: "#d5c4a1",
|
| 78 |
-
textMuted: "#928374",
|
| 79 |
-
primary: "#d79921",
|
| 80 |
-
primaryText: "#282828",
|
| 81 |
-
secondary: "#b8bb26",
|
| 82 |
-
danger: "#cc241d",
|
| 83 |
-
success: "#98971a",
|
| 84 |
-
warning: "#d65d0e",
|
| 85 |
-
accent: "#83a598",
|
| 86 |
-
},
|
| 87 |
-
},
|
| 88 |
-
onedark: {
|
| 89 |
-
name: "onedark",
|
| 90 |
-
label: "One Dark",
|
| 91 |
-
colors: {
|
| 92 |
-
bg: "#282c34",
|
| 93 |
-
bgSecondary: "#21252b",
|
| 94 |
-
bgTertiary: "#333842",
|
| 95 |
-
surface: "#3e4451",
|
| 96 |
-
border: "#4b5263",
|
| 97 |
-
text: "#abb2bf",
|
| 98 |
-
textSecondary: "#c8ccd4",
|
| 99 |
-
textMuted: "#636d83",
|
| 100 |
-
primary: "#61afef",
|
| 101 |
-
primaryText: "#282c34",
|
| 102 |
-
secondary: "#c678dd",
|
| 103 |
-
danger: "#e06c75",
|
| 104 |
-
success: "#98c379",
|
| 105 |
-
warning: "#e5c07b",
|
| 106 |
-
accent: "#56b6c2",
|
| 107 |
-
},
|
| 108 |
-
},
|
| 109 |
-
catppuccin: {
|
| 110 |
-
name: "catppuccin",
|
| 111 |
-
label: "Catppuccin Mocha",
|
| 112 |
-
colors: {
|
| 113 |
-
bg: "#1e1e2e",
|
| 114 |
-
bgSecondary: "#181825",
|
| 115 |
-
bgTertiary: "#313244",
|
| 116 |
-
surface: "#45475a",
|
| 117 |
-
border: "#585b70",
|
| 118 |
-
text: "#cdd6f4",
|
| 119 |
-
textSecondary: "#bac2de",
|
| 120 |
-
textMuted: "#6c7086",
|
| 121 |
-
primary: "#89b4fa",
|
| 122 |
-
primaryText: "#1e1e2e",
|
| 123 |
-
secondary: "#cba6f7",
|
| 124 |
-
danger: "#f38ba8",
|
| 125 |
-
success: "#a6e3a1",
|
| 126 |
-
warning: "#f9e2af",
|
| 127 |
-
accent: "#94e2d5",
|
| 128 |
-
},
|
| 129 |
-
},
|
| 130 |
-
};
|
| 131 |
-
|
| 132 |
-
export const defaultTheme: ThemeName = "tokyo";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/constants/types.ts
DELETED
|
@@ -1,404 +0,0 @@
|
|
| 1 |
-
// Types derived from real OpenCode serve OpenAPI spec (v1.18.3)
|
| 2 |
-
// Endpoint prefix: /api/
|
| 3 |
-
|
| 4 |
-
// ── Health ──
|
| 5 |
-
export interface HealthResponse {
|
| 6 |
-
healthy: boolean;
|
| 7 |
-
version: string;
|
| 8 |
-
}
|
| 9 |
-
|
| 10 |
-
// ── Config ──
|
| 11 |
-
export interface OpenCodeConfig {
|
| 12 |
-
$schema?: string;
|
| 13 |
-
theme?: string;
|
| 14 |
-
provider?: Record<string, ProviderConfig>;
|
| 15 |
-
model?: Record<string, string>;
|
| 16 |
-
command?: Record<string, string>;
|
| 17 |
-
plugin?: Record<string, unknown>;
|
| 18 |
-
mcp?: Record<string, unknown>;
|
| 19 |
-
share?: unknown;
|
| 20 |
-
experimental?: unknown;
|
| 21 |
-
}
|
| 22 |
-
|
| 23 |
-
export interface ProviderConfig {
|
| 24 |
-
api?: string;
|
| 25 |
-
apiKey?: string;
|
| 26 |
-
models?: Record<string, unknown>;
|
| 27 |
-
options?: Record<string, unknown>;
|
| 28 |
-
}
|
| 29 |
-
|
| 30 |
-
// ── Provider ──
|
| 31 |
-
export interface Provider {
|
| 32 |
-
id: string;
|
| 33 |
-
name: string;
|
| 34 |
-
api?: string;
|
| 35 |
-
auth?: ProviderAuth;
|
| 36 |
-
models?: Model[];
|
| 37 |
-
env?: Record<string, string>;
|
| 38 |
-
}
|
| 39 |
-
|
| 40 |
-
export interface ProviderAuth {
|
| 41 |
-
type: "api_key" | "oauth";
|
| 42 |
-
api_key?: { env?: string; header?: string };
|
| 43 |
-
oauth?: { authorize_url: string; token_url: string; scopes: string[] };
|
| 44 |
-
}
|
| 45 |
-
|
| 46 |
-
// ── Model ──
|
| 47 |
-
export interface Model {
|
| 48 |
-
id: string;
|
| 49 |
-
name: string;
|
| 50 |
-
providerID: string;
|
| 51 |
-
context?: number;
|
| 52 |
-
maxTokens?: number;
|
| 53 |
-
reasoning?: boolean;
|
| 54 |
-
config?: Record<string, unknown>;
|
| 55 |
-
}
|
| 56 |
-
|
| 57 |
-
// ── Session ──
|
| 58 |
-
export interface Session {
|
| 59 |
-
id: string;
|
| 60 |
-
parentID?: string;
|
| 61 |
-
title: string;
|
| 62 |
-
agent?: string;
|
| 63 |
-
model?: string;
|
| 64 |
-
status: "idle" | "busy" | "error";
|
| 65 |
-
directory: string;
|
| 66 |
-
createdAt: string;
|
| 67 |
-
updatedAt: string;
|
| 68 |
-
}
|
| 69 |
-
|
| 70 |
-
export interface SessionStatus {
|
| 71 |
-
busy: boolean;
|
| 72 |
-
}
|
| 73 |
-
|
| 74 |
-
/** Paginated list response from /api/session */
|
| 75 |
-
export interface SessionListResponse {
|
| 76 |
-
data: Session[];
|
| 77 |
-
cursor?: {
|
| 78 |
-
previous?: string;
|
| 79 |
-
next?: string;
|
| 80 |
-
};
|
| 81 |
-
}
|
| 82 |
-
|
| 83 |
-
/** Model list response from /api/model */
|
| 84 |
-
export interface ModelListResponse {
|
| 85 |
-
location?: {
|
| 86 |
-
directory?: string;
|
| 87 |
-
project?: { id: string; directory: string };
|
| 88 |
-
};
|
| 89 |
-
data: Model[];
|
| 90 |
-
}
|
| 91 |
-
|
| 92 |
-
// ── Message ──
|
| 93 |
-
export interface Message {
|
| 94 |
-
id: string;
|
| 95 |
-
sessionID: string;
|
| 96 |
-
role: "user" | "assistant" | "tool" | "system";
|
| 97 |
-
parts: MessagePart[];
|
| 98 |
-
createdAt: string;
|
| 99 |
-
}
|
| 100 |
-
|
| 101 |
-
export type MessagePart =
|
| 102 |
-
| TextPart
|
| 103 |
-
| ToolPart
|
| 104 |
-
| StepPart
|
| 105 |
-
| ReasoningPart
|
| 106 |
-
| SourcePart;
|
| 107 |
-
|
| 108 |
-
export interface TextPart {
|
| 109 |
-
type: "text";
|
| 110 |
-
text: string;
|
| 111 |
-
}
|
| 112 |
-
|
| 113 |
-
export interface ToolPart {
|
| 114 |
-
type: "tool";
|
| 115 |
-
tool: string;
|
| 116 |
-
state: "call" | "result" | "error";
|
| 117 |
-
callID: string;
|
| 118 |
-
input?: Record<string, unknown>;
|
| 119 |
-
output?: string;
|
| 120 |
-
title?: string;
|
| 121 |
-
metadata?: Record<string, unknown>;
|
| 122 |
-
}
|
| 123 |
-
|
| 124 |
-
export interface StepPart {
|
| 125 |
-
type: "step";
|
| 126 |
-
name: string;
|
| 127 |
-
state: "pending" | "running" | "completed" | "error";
|
| 128 |
-
}
|
| 129 |
-
|
| 130 |
-
export interface ReasoningPart {
|
| 131 |
-
type: "reasoning";
|
| 132 |
-
text: string;
|
| 133 |
-
}
|
| 134 |
-
|
| 135 |
-
export interface SourcePart {
|
| 136 |
-
type: "source";
|
| 137 |
-
source: string;
|
| 138 |
-
title?: string;
|
| 139 |
-
}
|
| 140 |
-
|
| 141 |
-
// ── Events (SSE) ──
|
| 142 |
-
export type ServerEvent =
|
| 143 |
-
| SessionCreatedEvent
|
| 144 |
-
| SessionUpdatedEvent
|
| 145 |
-
| SessionDeletedEvent
|
| 146 |
-
| MessageCreatedEvent
|
| 147 |
-
| MessageUpdatedEvent
|
| 148 |
-
| MessagePartUpdatedEvent
|
| 149 |
-
| MessagePartDeletedEvent
|
| 150 |
-
| PermissionRequestedEvent
|
| 151 |
-
| PermissionUpdatedEvent
|
| 152 |
-
| QuestionAskedEvent
|
| 153 |
-
| QuestionAnsweredEvent
|
| 154 |
-
| FileChangedEvent
|
| 155 |
-
| TodoUpdatedEvent
|
| 156 |
-
| SyncEvent
|
| 157 |
-
| ErrorEvent;
|
| 158 |
-
|
| 159 |
-
export interface SessionCreatedEvent {
|
| 160 |
-
type: "session.created";
|
| 161 |
-
properties: Session;
|
| 162 |
-
}
|
| 163 |
-
|
| 164 |
-
export interface SessionUpdatedEvent {
|
| 165 |
-
type: "session.updated";
|
| 166 |
-
properties: Partial<Session> & { id: string };
|
| 167 |
-
}
|
| 168 |
-
|
| 169 |
-
export interface SessionDeletedEvent {
|
| 170 |
-
type: "session.deleted";
|
| 171 |
-
properties: { id: string };
|
| 172 |
-
}
|
| 173 |
-
|
| 174 |
-
export interface MessageCreatedEvent {
|
| 175 |
-
type: "message.created";
|
| 176 |
-
properties: Message;
|
| 177 |
-
}
|
| 178 |
-
|
| 179 |
-
export interface MessageUpdatedEvent {
|
| 180 |
-
type: "message.updated";
|
| 181 |
-
properties: Partial<Message> & { id: string };
|
| 182 |
-
}
|
| 183 |
-
|
| 184 |
-
export interface MessagePartUpdatedEvent {
|
| 185 |
-
type: "message.part.updated";
|
| 186 |
-
properties: {
|
| 187 |
-
messageID: string;
|
| 188 |
-
part: MessagePart;
|
| 189 |
-
sessionID: string;
|
| 190 |
-
};
|
| 191 |
-
}
|
| 192 |
-
|
| 193 |
-
export interface MessagePartDeletedEvent {
|
| 194 |
-
type: "message.part.deleted";
|
| 195 |
-
properties: {
|
| 196 |
-
messageID: string;
|
| 197 |
-
partID: string;
|
| 198 |
-
sessionID: string;
|
| 199 |
-
};
|
| 200 |
-
}
|
| 201 |
-
|
| 202 |
-
export interface PermissionRequestedEvent {
|
| 203 |
-
type: "permission.requested";
|
| 204 |
-
properties: PermissionRequest;
|
| 205 |
-
}
|
| 206 |
-
|
| 207 |
-
export interface PermissionUpdatedEvent {
|
| 208 |
-
type: "permission.updated";
|
| 209 |
-
properties: PermissionRequest;
|
| 210 |
-
}
|
| 211 |
-
|
| 212 |
-
export interface QuestionAskedEvent {
|
| 213 |
-
type: "question.asked";
|
| 214 |
-
properties: QuestionRequest;
|
| 215 |
-
}
|
| 216 |
-
|
| 217 |
-
export interface QuestionAnsweredEvent {
|
| 218 |
-
type: "question.answered";
|
| 219 |
-
properties: QuestionRequest;
|
| 220 |
-
}
|
| 221 |
-
|
| 222 |
-
export interface FileChangedEvent {
|
| 223 |
-
type: "file.changed";
|
| 224 |
-
properties: { path: string; status: string };
|
| 225 |
-
}
|
| 226 |
-
|
| 227 |
-
export interface TodoUpdatedEvent {
|
| 228 |
-
type: "todo.updated";
|
| 229 |
-
properties: { sessionID: string; todos: Todo[] };
|
| 230 |
-
}
|
| 231 |
-
|
| 232 |
-
export interface SyncEvent {
|
| 233 |
-
type: "sync";
|
| 234 |
-
properties: Record<string, unknown>;
|
| 235 |
-
}
|
| 236 |
-
|
| 237 |
-
export interface ErrorEvent {
|
| 238 |
-
type: "error";
|
| 239 |
-
properties: { message: string; code?: string };
|
| 240 |
-
}
|
| 241 |
-
|
| 242 |
-
// ── Permission ──
|
| 243 |
-
export interface PermissionRequest {
|
| 244 |
-
id: string;
|
| 245 |
-
sessionID: string;
|
| 246 |
-
messageID?: string;
|
| 247 |
-
partID?: string;
|
| 248 |
-
permission: string;
|
| 249 |
-
description?: string;
|
| 250 |
-
state: "pending" | "approved" | "denied";
|
| 251 |
-
createdAt: string;
|
| 252 |
-
}
|
| 253 |
-
|
| 254 |
-
export interface SavedPermission {
|
| 255 |
-
id: string;
|
| 256 |
-
permission: string;
|
| 257 |
-
pattern?: string;
|
| 258 |
-
allow: boolean;
|
| 259 |
-
}
|
| 260 |
-
|
| 261 |
-
// ── Question ──
|
| 262 |
-
export interface QuestionRequest {
|
| 263 |
-
id: string;
|
| 264 |
-
sessionID: string;
|
| 265 |
-
question: string;
|
| 266 |
-
options?: string[];
|
| 267 |
-
state: "pending" | "answered" | "rejected";
|
| 268 |
-
answer?: string;
|
| 269 |
-
createdAt: string;
|
| 270 |
-
}
|
| 271 |
-
|
| 272 |
-
// ── Todo ──
|
| 273 |
-
export interface Todo {
|
| 274 |
-
id: string;
|
| 275 |
-
content: string;
|
| 276 |
-
status: "pending" | "in_progress" | "completed";
|
| 277 |
-
metadata?: Record<string, unknown>;
|
| 278 |
-
}
|
| 279 |
-
|
| 280 |
-
// ── File ──
|
| 281 |
-
export interface FileEntry {
|
| 282 |
-
name: string;
|
| 283 |
-
path: string;
|
| 284 |
-
type: "file" | "directory";
|
| 285 |
-
size?: number;
|
| 286 |
-
modified?: string;
|
| 287 |
-
}
|
| 288 |
-
|
| 289 |
-
export interface FileContent {
|
| 290 |
-
content: string;
|
| 291 |
-
encoding?: string;
|
| 292 |
-
}
|
| 293 |
-
|
| 294 |
-
export interface FileStatus {
|
| 295 |
-
path: string;
|
| 296 |
-
status: string;
|
| 297 |
-
}
|
| 298 |
-
|
| 299 |
-
// ── Find/Search ──
|
| 300 |
-
export interface FindResult {
|
| 301 |
-
file: string;
|
| 302 |
-
line: number;
|
| 303 |
-
column: number;
|
| 304 |
-
match: string;
|
| 305 |
-
context: string;
|
| 306 |
-
}
|
| 307 |
-
|
| 308 |
-
export interface SymbolResult {
|
| 309 |
-
name: string;
|
| 310 |
-
kind: string;
|
| 311 |
-
file: string;
|
| 312 |
-
line: number;
|
| 313 |
-
column: number;
|
| 314 |
-
}
|
| 315 |
-
|
| 316 |
-
// ── PTY ──
|
| 317 |
-
export interface PTY {
|
| 318 |
-
id: string;
|
| 319 |
-
shell: string;
|
| 320 |
-
cwd: string;
|
| 321 |
-
pid?: number;
|
| 322 |
-
createdAt: string;
|
| 323 |
-
}
|
| 324 |
-
|
| 325 |
-
// ── Command ──
|
| 326 |
-
export interface Command {
|
| 327 |
-
name: string;
|
| 328 |
-
description?: string;
|
| 329 |
-
category?: string;
|
| 330 |
-
}
|
| 331 |
-
|
| 332 |
-
// ── Skill ──
|
| 333 |
-
export interface Skill {
|
| 334 |
-
name: string;
|
| 335 |
-
description?: string;
|
| 336 |
-
location?: string;
|
| 337 |
-
}
|
| 338 |
-
|
| 339 |
-
// ── Agent ──
|
| 340 |
-
export interface Agent {
|
| 341 |
-
name: string;
|
| 342 |
-
description?: string;
|
| 343 |
-
tools?: string[];
|
| 344 |
-
}
|
| 345 |
-
|
| 346 |
-
// ── Diff ──
|
| 347 |
-
export interface DiffEntry {
|
| 348 |
-
path: string;
|
| 349 |
-
status: string;
|
| 350 |
-
additions: number;
|
| 351 |
-
deletions: number;
|
| 352 |
-
hunks?: DiffHunk[];
|
| 353 |
-
}
|
| 354 |
-
|
| 355 |
-
export interface DiffHunk {
|
| 356 |
-
oldStart: number;
|
| 357 |
-
newStart: number;
|
| 358 |
-
oldLines: number;
|
| 359 |
-
newLines: number;
|
| 360 |
-
content: string;
|
| 361 |
-
}
|
| 362 |
-
|
| 363 |
-
// ── VCS ──
|
| 364 |
-
export interface VCSStatus {
|
| 365 |
-
branch: string;
|
| 366 |
-
dirty: boolean;
|
| 367 |
-
ahead: number;
|
| 368 |
-
behind: number;
|
| 369 |
-
}
|
| 370 |
-
|
| 371 |
-
// ── Context helpers ──
|
| 372 |
-
export interface SessionContext {
|
| 373 |
-
files: string[];
|
| 374 |
-
capabilities: string[];
|
| 375 |
-
}
|
| 376 |
-
|
| 377 |
-
export interface SessionHistory {
|
| 378 |
-
messages: Message[];
|
| 379 |
-
}
|
| 380 |
-
|
| 381 |
-
// ── Theme ──
|
| 382 |
-
export type ThemeName = "tokyo" | "dracula" | "nord" | "gruvbox" | "onedark" | "catppuccin";
|
| 383 |
-
|
| 384 |
-
export interface Theme {
|
| 385 |
-
name: ThemeName;
|
| 386 |
-
label: string;
|
| 387 |
-
colors: {
|
| 388 |
-
bg: string;
|
| 389 |
-
bgSecondary: string;
|
| 390 |
-
bgTertiary: string;
|
| 391 |
-
surface: string;
|
| 392 |
-
border: string;
|
| 393 |
-
text: string;
|
| 394 |
-
textSecondary: string;
|
| 395 |
-
textMuted: string;
|
| 396 |
-
primary: string;
|
| 397 |
-
primaryText: string;
|
| 398 |
-
secondary: string;
|
| 399 |
-
danger: string;
|
| 400 |
-
success: string;
|
| 401 |
-
warning: string;
|
| 402 |
-
accent: string;
|
| 403 |
-
};
|
| 404 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/hooks/useApi.ts
DELETED
|
@@ -1,322 +0,0 @@
|
|
| 1 |
-
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
| 2 |
-
import * as api from "../lib/api";
|
| 3 |
-
|
| 4 |
-
// ── Health ──
|
| 5 |
-
|
| 6 |
-
export function useHealth() {
|
| 7 |
-
return useQuery({
|
| 8 |
-
queryKey: ["health"],
|
| 9 |
-
queryFn: api.getHealth,
|
| 10 |
-
refetchInterval: 10000,
|
| 11 |
-
retry: 3,
|
| 12 |
-
});
|
| 13 |
-
}
|
| 14 |
-
|
| 15 |
-
// ── Config ──
|
| 16 |
-
|
| 17 |
-
export function useConfig() {
|
| 18 |
-
return useQuery({
|
| 19 |
-
queryKey: ["config"],
|
| 20 |
-
queryFn: api.getConfig,
|
| 21 |
-
});
|
| 22 |
-
}
|
| 23 |
-
|
| 24 |
-
export function usePatchConfig() {
|
| 25 |
-
const qc = useQueryClient();
|
| 26 |
-
return useMutation({
|
| 27 |
-
mutationFn: api.patchConfig,
|
| 28 |
-
onSuccess: () => qc.invalidateQueries({ queryKey: ["config"] }),
|
| 29 |
-
});
|
| 30 |
-
}
|
| 31 |
-
|
| 32 |
-
// ── Sessions ──
|
| 33 |
-
|
| 34 |
-
export function useSessions() {
|
| 35 |
-
return useQuery({
|
| 36 |
-
queryKey: ["sessions"],
|
| 37 |
-
queryFn: api.getSessions,
|
| 38 |
-
});
|
| 39 |
-
}
|
| 40 |
-
|
| 41 |
-
export function useActiveSessions() {
|
| 42 |
-
return useQuery({
|
| 43 |
-
queryKey: ["sessions", "active"],
|
| 44 |
-
queryFn: api.getActiveSessions,
|
| 45 |
-
});
|
| 46 |
-
}
|
| 47 |
-
|
| 48 |
-
export function useSession(id: string) {
|
| 49 |
-
return useQuery({
|
| 50 |
-
queryKey: ["session", id],
|
| 51 |
-
queryFn: () => api.getSession(id),
|
| 52 |
-
enabled: !!id,
|
| 53 |
-
});
|
| 54 |
-
}
|
| 55 |
-
|
| 56 |
-
export function useCreateSession() {
|
| 57 |
-
const qc = useQueryClient();
|
| 58 |
-
return useMutation({
|
| 59 |
-
mutationFn: (options?: { title?: string; agent?: string; model?: string; directory?: string }) =>
|
| 60 |
-
api.createSession(options),
|
| 61 |
-
onSuccess: () => qc.invalidateQueries({ queryKey: ["sessions"] }),
|
| 62 |
-
});
|
| 63 |
-
}
|
| 64 |
-
|
| 65 |
-
export function useDeleteSession() {
|
| 66 |
-
const qc = useQueryClient();
|
| 67 |
-
return useMutation({
|
| 68 |
-
mutationFn: (id: string) => api.deleteSession(id),
|
| 69 |
-
onSuccess: () => qc.invalidateQueries({ queryKey: ["sessions"] }),
|
| 70 |
-
});
|
| 71 |
-
}
|
| 72 |
-
|
| 73 |
-
export function usePatchSession() {
|
| 74 |
-
const qc = useQueryClient();
|
| 75 |
-
return useMutation({
|
| 76 |
-
mutationFn: ({ id, data }: { id: string; data: { title?: string; agent?: string; model?: string } }) =>
|
| 77 |
-
api.patchSession(id, data),
|
| 78 |
-
onSuccess: () => qc.invalidateQueries({ queryKey: ["sessions"] }),
|
| 79 |
-
});
|
| 80 |
-
}
|
| 81 |
-
|
| 82 |
-
export function useSessionTodos(sessionId: string) {
|
| 83 |
-
return useQuery({
|
| 84 |
-
queryKey: ["todos", sessionId],
|
| 85 |
-
queryFn: () => api.getSessionTodos(sessionId),
|
| 86 |
-
enabled: !!sessionId,
|
| 87 |
-
});
|
| 88 |
-
}
|
| 89 |
-
|
| 90 |
-
// ── Messages ──
|
| 91 |
-
|
| 92 |
-
export function useMessages(sessionId: string) {
|
| 93 |
-
return useQuery({
|
| 94 |
-
queryKey: ["messages", sessionId],
|
| 95 |
-
queryFn: () => api.getMessages(sessionId),
|
| 96 |
-
enabled: !!sessionId,
|
| 97 |
-
});
|
| 98 |
-
}
|
| 99 |
-
|
| 100 |
-
export function useSendMessage() {
|
| 101 |
-
const qc = useQueryClient();
|
| 102 |
-
return useMutation({
|
| 103 |
-
mutationFn: ({ sessionId, content }: { sessionId: string; content: string }) =>
|
| 104 |
-
api.sendMessage(sessionId, content),
|
| 105 |
-
onSuccess: (_, vars) => {
|
| 106 |
-
qc.invalidateQueries({ queryKey: ["messages", vars.sessionId] });
|
| 107 |
-
qc.invalidateQueries({ queryKey: ["sessions"] });
|
| 108 |
-
},
|
| 109 |
-
});
|
| 110 |
-
}
|
| 111 |
-
|
| 112 |
-
export function useAbortSession() {
|
| 113 |
-
return useMutation({
|
| 114 |
-
mutationFn: (sessionId: string) => api.abortSession(sessionId),
|
| 115 |
-
});
|
| 116 |
-
}
|
| 117 |
-
|
| 118 |
-
export function useCompactSession() {
|
| 119 |
-
const qc = useQueryClient();
|
| 120 |
-
return useMutation({
|
| 121 |
-
mutationFn: (sessionId: string) => api.compactSession(sessionId),
|
| 122 |
-
onSuccess: (_, sessionId) => {
|
| 123 |
-
qc.invalidateQueries({ queryKey: ["messages", sessionId] });
|
| 124 |
-
},
|
| 125 |
-
});
|
| 126 |
-
}
|
| 127 |
-
|
| 128 |
-
export function useShareSession() {
|
| 129 |
-
const qc = useQueryClient();
|
| 130 |
-
return useMutation({
|
| 131 |
-
mutationFn: (sessionId: string) => api.shareSession(sessionId),
|
| 132 |
-
onSuccess: () => qc.invalidateQueries({ queryKey: ["sessions"] }),
|
| 133 |
-
});
|
| 134 |
-
}
|
| 135 |
-
|
| 136 |
-
export function useForkSession() {
|
| 137 |
-
const qc = useQueryClient();
|
| 138 |
-
return useMutation({
|
| 139 |
-
mutationFn: (sessionId: string) => api.forkSession(sessionId),
|
| 140 |
-
onSuccess: () => qc.invalidateQueries({ queryKey: ["sessions"] }),
|
| 141 |
-
});
|
| 142 |
-
}
|
| 143 |
-
|
| 144 |
-
// ── Session diff / revert ──
|
| 145 |
-
|
| 146 |
-
export function useSessionDiff(sessionId: string) {
|
| 147 |
-
return useQuery({
|
| 148 |
-
queryKey: ["diff", sessionId],
|
| 149 |
-
queryFn: () => api.getSessionDiff(sessionId),
|
| 150 |
-
enabled: !!sessionId,
|
| 151 |
-
});
|
| 152 |
-
}
|
| 153 |
-
|
| 154 |
-
// ── Permissions ──
|
| 155 |
-
|
| 156 |
-
export function usePermissionRequests() {
|
| 157 |
-
return useQuery({
|
| 158 |
-
queryKey: ["permissions", "requests"],
|
| 159 |
-
queryFn: api.getPermissionRequests,
|
| 160 |
-
refetchInterval: 5000,
|
| 161 |
-
});
|
| 162 |
-
}
|
| 163 |
-
|
| 164 |
-
export function useSavedPermissions() {
|
| 165 |
-
return useQuery({
|
| 166 |
-
queryKey: ["permissions", "saved"],
|
| 167 |
-
queryFn: api.getSavedPermissions,
|
| 168 |
-
});
|
| 169 |
-
}
|
| 170 |
-
|
| 171 |
-
export function useReplyPermission() {
|
| 172 |
-
const qc = useQueryClient();
|
| 173 |
-
return useMutation({
|
| 174 |
-
mutationFn: ({
|
| 175 |
-
sessionId,
|
| 176 |
-
requestId,
|
| 177 |
-
allow,
|
| 178 |
-
}: {
|
| 179 |
-
sessionId: string;
|
| 180 |
-
requestId: string;
|
| 181 |
-
allow: boolean;
|
| 182 |
-
}) => api.replyPermission(sessionId, requestId, allow),
|
| 183 |
-
onSuccess: () => {
|
| 184 |
-
qc.invalidateQueries({ queryKey: ["permissions"] });
|
| 185 |
-
},
|
| 186 |
-
});
|
| 187 |
-
}
|
| 188 |
-
|
| 189 |
-
// ── Questions ──
|
| 190 |
-
|
| 191 |
-
export function useQuestionRequests() {
|
| 192 |
-
return useQuery({
|
| 193 |
-
queryKey: ["questions", "requests"],
|
| 194 |
-
queryFn: api.getQuestionRequests,
|
| 195 |
-
refetchInterval: 5000,
|
| 196 |
-
});
|
| 197 |
-
}
|
| 198 |
-
|
| 199 |
-
export function useReplyQuestion() {
|
| 200 |
-
const qc = useQueryClient();
|
| 201 |
-
return useMutation({
|
| 202 |
-
mutationFn: ({
|
| 203 |
-
sessionId,
|
| 204 |
-
requestId,
|
| 205 |
-
answer,
|
| 206 |
-
}: {
|
| 207 |
-
sessionId: string;
|
| 208 |
-
requestId: string;
|
| 209 |
-
answer: string;
|
| 210 |
-
}) => api.replyQuestion(sessionId, requestId, answer),
|
| 211 |
-
onSuccess: () => {
|
| 212 |
-
qc.invalidateQueries({ queryKey: ["questions"] });
|
| 213 |
-
},
|
| 214 |
-
});
|
| 215 |
-
}
|
| 216 |
-
|
| 217 |
-
// ── Providers / Models ──
|
| 218 |
-
|
| 219 |
-
export function useProviders() {
|
| 220 |
-
return useQuery({
|
| 221 |
-
queryKey: ["providers"],
|
| 222 |
-
queryFn: api.getProviders,
|
| 223 |
-
});
|
| 224 |
-
}
|
| 225 |
-
|
| 226 |
-
export function useModels() {
|
| 227 |
-
return useQuery({
|
| 228 |
-
queryKey: ["models"],
|
| 229 |
-
queryFn: api.getModels,
|
| 230 |
-
});
|
| 231 |
-
}
|
| 232 |
-
|
| 233 |
-
// ── Agent / Command / Skill ──
|
| 234 |
-
|
| 235 |
-
export function useAgents() {
|
| 236 |
-
return useQuery({
|
| 237 |
-
queryKey: ["agents"],
|
| 238 |
-
queryFn: api.getAgents,
|
| 239 |
-
});
|
| 240 |
-
}
|
| 241 |
-
|
| 242 |
-
export function useCommands() {
|
| 243 |
-
return useQuery({
|
| 244 |
-
queryKey: ["commands"],
|
| 245 |
-
queryFn: api.getCommands,
|
| 246 |
-
});
|
| 247 |
-
}
|
| 248 |
-
|
| 249 |
-
export function useSkills() {
|
| 250 |
-
return useQuery({
|
| 251 |
-
queryKey: ["skills"],
|
| 252 |
-
queryFn: api.getSkills,
|
| 253 |
-
});
|
| 254 |
-
}
|
| 255 |
-
|
| 256 |
-
// ── Files ──
|
| 257 |
-
|
| 258 |
-
export function useListFiles(dir: string = ".") {
|
| 259 |
-
return useQuery({
|
| 260 |
-
queryKey: ["files", dir],
|
| 261 |
-
queryFn: () => api.listFiles(dir),
|
| 262 |
-
});
|
| 263 |
-
}
|
| 264 |
-
|
| 265 |
-
export function useFileContent(path: string) {
|
| 266 |
-
return useQuery({
|
| 267 |
-
queryKey: ["fileContent", path],
|
| 268 |
-
queryFn: () => api.readFile(path),
|
| 269 |
-
enabled: !!path,
|
| 270 |
-
});
|
| 271 |
-
}
|
| 272 |
-
|
| 273 |
-
export function useFindFiles(query: string, dir?: string) {
|
| 274 |
-
return useQuery({
|
| 275 |
-
queryKey: ["findFiles", query, dir],
|
| 276 |
-
queryFn: () => api.findFiles(query, dir),
|
| 277 |
-
enabled: query.length > 0,
|
| 278 |
-
});
|
| 279 |
-
}
|
| 280 |
-
|
| 281 |
-
export function useFindSymbols(query: string) {
|
| 282 |
-
return useQuery({
|
| 283 |
-
queryKey: ["findSymbols", query],
|
| 284 |
-
queryFn: () => api.findSymbols(query),
|
| 285 |
-
enabled: query.length > 0,
|
| 286 |
-
});
|
| 287 |
-
}
|
| 288 |
-
|
| 289 |
-
// ── VCS ──
|
| 290 |
-
|
| 291 |
-
export function useVCSStatus() {
|
| 292 |
-
return useQuery({
|
| 293 |
-
queryKey: ["vcs"],
|
| 294 |
-
queryFn: api.getVCSStatus,
|
| 295 |
-
refetchInterval: 15000,
|
| 296 |
-
});
|
| 297 |
-
}
|
| 298 |
-
|
| 299 |
-
// ── PTY ──
|
| 300 |
-
|
| 301 |
-
export function usePTYs() {
|
| 302 |
-
return useQuery({
|
| 303 |
-
queryKey: ["pty"],
|
| 304 |
-
queryFn: api.getPTYs,
|
| 305 |
-
});
|
| 306 |
-
}
|
| 307 |
-
|
| 308 |
-
export function useCreatePTY() {
|
| 309 |
-
const qc = useQueryClient();
|
| 310 |
-
return useMutation({
|
| 311 |
-
mutationFn: (shell?: string) => api.createPTY(shell),
|
| 312 |
-
onSuccess: () => qc.invalidateQueries({ queryKey: ["pty"] }),
|
| 313 |
-
});
|
| 314 |
-
}
|
| 315 |
-
|
| 316 |
-
export function useDeletePTY() {
|
| 317 |
-
const qc = useQueryClient();
|
| 318 |
-
return useMutation({
|
| 319 |
-
mutationFn: (id: string) => api.deletePTY(id),
|
| 320 |
-
onSuccess: () => qc.invalidateQueries({ queryKey: ["pty"] }),
|
| 321 |
-
});
|
| 322 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/hooks/useConnection.ts
DELETED
|
@@ -1,65 +0,0 @@
|
|
| 1 |
-
import { useState, useEffect, useCallback, useRef } from "react";
|
| 2 |
-
import {
|
| 3 |
-
getConnectionManager,
|
| 4 |
-
type ConnectionState,
|
| 5 |
-
} from "../lib/connection-manager";
|
| 6 |
-
import type { ServerEvent } from "../constants/types";
|
| 7 |
-
|
| 8 |
-
export interface UseConnectionReturn {
|
| 9 |
-
state: ConnectionState;
|
| 10 |
-
connect: (sessionId?: string) => void;
|
| 11 |
-
disconnect: () => void;
|
| 12 |
-
switchSession: (sessionId: string | null) => void;
|
| 13 |
-
isConnected: boolean;
|
| 14 |
-
}
|
| 15 |
-
|
| 16 |
-
export function useConnection(sessionId?: string): UseConnectionReturn {
|
| 17 |
-
const [state, setState] = useState<ConnectionState>("disconnected");
|
| 18 |
-
const manager = useRef(getConnectionManager());
|
| 19 |
-
|
| 20 |
-
useEffect(() => {
|
| 21 |
-
const unsub = manager.current.onStateChange(setState);
|
| 22 |
-
return unsub;
|
| 23 |
-
}, []);
|
| 24 |
-
|
| 25 |
-
// Auto-connect on mount
|
| 26 |
-
useEffect(() => {
|
| 27 |
-
manager.current.connect(sessionId);
|
| 28 |
-
return () => {
|
| 29 |
-
// Don't disconnect on unmount — keep connection alive
|
| 30 |
-
};
|
| 31 |
-
}, [sessionId]);
|
| 32 |
-
|
| 33 |
-
const connect = useCallback(
|
| 34 |
-
(sid?: string) => manager.current.connect(sid ?? sessionId),
|
| 35 |
-
[sessionId]
|
| 36 |
-
);
|
| 37 |
-
|
| 38 |
-
const disconnect = useCallback(() => manager.current.disconnect(), []);
|
| 39 |
-
|
| 40 |
-
const switchSession = useCallback(
|
| 41 |
-
(sid: string | null) => manager.current.switchSession(sid),
|
| 42 |
-
[]
|
| 43 |
-
);
|
| 44 |
-
|
| 45 |
-
return {
|
| 46 |
-
state,
|
| 47 |
-
connect,
|
| 48 |
-
disconnect,
|
| 49 |
-
switchSession,
|
| 50 |
-
isConnected: state === "connected",
|
| 51 |
-
};
|
| 52 |
-
}
|
| 53 |
-
|
| 54 |
-
export function useConnectionEvents(
|
| 55 |
-
onEvent: (event: ServerEvent) => void
|
| 56 |
-
) {
|
| 57 |
-
const callbackRef = useRef(onEvent);
|
| 58 |
-
callbackRef.current = onEvent;
|
| 59 |
-
|
| 60 |
-
useEffect(() => {
|
| 61 |
-
const manager = getConnectionManager();
|
| 62 |
-
const unsub = manager.onEvent((event) => callbackRef.current(event));
|
| 63 |
-
return unsub;
|
| 64 |
-
}, []);
|
| 65 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/hooks/useSSE.ts
DELETED
|
@@ -1,156 +0,0 @@
|
|
| 1 |
-
import { useState, useCallback, useRef, useEffect } from "react";
|
| 2 |
-
import { subscribeEvents, parseSSELines } from "../lib/api";
|
| 3 |
-
import type { ServerEvent, Message, MessagePart } from "../constants/types";
|
| 4 |
-
|
| 5 |
-
export interface SSEState {
|
| 6 |
-
connected: boolean;
|
| 7 |
-
streaming: boolean;
|
| 8 |
-
error: string | null;
|
| 9 |
-
events: ServerEvent[];
|
| 10 |
-
}
|
| 11 |
-
|
| 12 |
-
export function useSSE(sessionId?: string) {
|
| 13 |
-
const [state, setState] = useState<SSEState>({
|
| 14 |
-
connected: false,
|
| 15 |
-
streaming: false,
|
| 16 |
-
error: null,
|
| 17 |
-
events: [],
|
| 18 |
-
});
|
| 19 |
-
|
| 20 |
-
const abortRef = useRef<AbortController | null>(null);
|
| 21 |
-
const bufferRef = useRef("");
|
| 22 |
-
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
| 23 |
-
|
| 24 |
-
const connect = useCallback(async () => {
|
| 25 |
-
setState((s) => ({ ...s, error: null }));
|
| 26 |
-
|
| 27 |
-
try {
|
| 28 |
-
const response = await subscribeEvents(sessionId);
|
| 29 |
-
abortRef.current = new AbortController();
|
| 30 |
-
setState((s) => ({ ...s, connected: true, streaming: true }));
|
| 31 |
-
|
| 32 |
-
const reader = response.body?.getReader();
|
| 33 |
-
if (!reader) throw new Error("No readable stream");
|
| 34 |
-
|
| 35 |
-
const decoder = new TextDecoder();
|
| 36 |
-
|
| 37 |
-
while (true) {
|
| 38 |
-
const { done, value } = await reader.read();
|
| 39 |
-
if (done) break;
|
| 40 |
-
|
| 41 |
-
bufferRef.current += decoder.decode(value, { stream: true });
|
| 42 |
-
const { events, remaining } = parseSSELines(bufferRef.current);
|
| 43 |
-
bufferRef.current = remaining;
|
| 44 |
-
|
| 45 |
-
if (events.length > 0) {
|
| 46 |
-
setState((s) => ({
|
| 47 |
-
...s,
|
| 48 |
-
events: [...s.events, ...events],
|
| 49 |
-
}));
|
| 50 |
-
}
|
| 51 |
-
}
|
| 52 |
-
|
| 53 |
-
setState((s) => ({ ...s, connected: false, streaming: false }));
|
| 54 |
-
} catch (err: unknown) {
|
| 55 |
-
if (err instanceof Error && err.name !== "AbortError") {
|
| 56 |
-
setState((s) => ({
|
| 57 |
-
...s,
|
| 58 |
-
connected: false,
|
| 59 |
-
streaming: false,
|
| 60 |
-
error: err.message,
|
| 61 |
-
}));
|
| 62 |
-
}
|
| 63 |
-
}
|
| 64 |
-
}, [sessionId]);
|
| 65 |
-
|
| 66 |
-
const disconnect = useCallback(() => {
|
| 67 |
-
abortRef.current?.abort();
|
| 68 |
-
if (reconnectTimer.current) {
|
| 69 |
-
clearTimeout(reconnectTimer.current);
|
| 70 |
-
}
|
| 71 |
-
setState({ connected: false, streaming: false, error: null, events: [] });
|
| 72 |
-
}, []);
|
| 73 |
-
|
| 74 |
-
const clearEvents = useCallback(() => {
|
| 75 |
-
setState((s) => ({ ...s, events: [] }));
|
| 76 |
-
}, []);
|
| 77 |
-
|
| 78 |
-
// Auto-reconnect on disconnect
|
| 79 |
-
useEffect(() => {
|
| 80 |
-
if (!state.connected && !state.streaming && sessionId) {
|
| 81 |
-
reconnectTimer.current = setTimeout(() => {
|
| 82 |
-
connect();
|
| 83 |
-
}, 3000);
|
| 84 |
-
}
|
| 85 |
-
return () => {
|
| 86 |
-
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
| 87 |
-
};
|
| 88 |
-
}, [state.connected, state.streaming, sessionId, connect]);
|
| 89 |
-
|
| 90 |
-
// Cleanup on unmount
|
| 91 |
-
useEffect(() => {
|
| 92 |
-
return () => {
|
| 93 |
-
abortRef.current?.abort();
|
| 94 |
-
};
|
| 95 |
-
}, []);
|
| 96 |
-
|
| 97 |
-
return {
|
| 98 |
-
...state,
|
| 99 |
-
connect,
|
| 100 |
-
disconnect,
|
| 101 |
-
clearEvents,
|
| 102 |
-
};
|
| 103 |
-
}
|
| 104 |
-
|
| 105 |
-
// ── Derived helpers ──
|
| 106 |
-
|
| 107 |
-
/** Extract the latest assistant message from events */
|
| 108 |
-
export function getLatestAssistantMessage(events: ServerEvent[]): Message | null {
|
| 109 |
-
for (let i = events.length - 1; i >= 0; i--) {
|
| 110 |
-
const ev = events[i];
|
| 111 |
-
if (ev.type === "message.created" || ev.type === "message.updated") {
|
| 112 |
-
const msg = ev.properties as Message;
|
| 113 |
-
if (msg.role === "assistant") return msg;
|
| 114 |
-
}
|
| 115 |
-
}
|
| 116 |
-
return null;
|
| 117 |
-
}
|
| 118 |
-
|
| 119 |
-
/** Extract all messages from events in order */
|
| 120 |
-
export function getMessagesFromEvents(events: ServerEvent[]): Message[] {
|
| 121 |
-
const map = new Map<string, Message>();
|
| 122 |
-
|
| 123 |
-
for (const ev of events) {
|
| 124 |
-
if (ev.type === "message.created") {
|
| 125 |
-
const msg = ev.properties as Message;
|
| 126 |
-
map.set(msg.id, msg);
|
| 127 |
-
} else if (ev.type === "message.updated") {
|
| 128 |
-
const partial = ev.properties as Partial<Message> & { id: string };
|
| 129 |
-
const existing = map.get(partial.id);
|
| 130 |
-
if (existing) {
|
| 131 |
-
map.set(partial.id, { ...existing, ...partial });
|
| 132 |
-
}
|
| 133 |
-
} else if (ev.type === "message.part.updated") {
|
| 134 |
-
const { messageID, part } = ev.properties as {
|
| 135 |
-
messageID: string;
|
| 136 |
-
part: MessagePart;
|
| 137 |
-
sessionID: string;
|
| 138 |
-
};
|
| 139 |
-
const existing = map.get(messageID);
|
| 140 |
-
if (existing) {
|
| 141 |
-
const partIdx = existing.parts.findIndex(
|
| 142 |
-
(p) => JSON.stringify(p) === JSON.stringify(part)
|
| 143 |
-
);
|
| 144 |
-
const newParts = [...existing.parts];
|
| 145 |
-
if (partIdx >= 0) {
|
| 146 |
-
newParts[partIdx] = part;
|
| 147 |
-
} else {
|
| 148 |
-
newParts.push(part);
|
| 149 |
-
}
|
| 150 |
-
map.set(messageID, { ...existing, parts: newParts });
|
| 151 |
-
}
|
| 152 |
-
}
|
| 153 |
-
}
|
| 154 |
-
|
| 155 |
-
return Array.from(map.values());
|
| 156 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/lib/api.ts
DELETED
|
@@ -1,434 +0,0 @@
|
|
| 1 |
-
import { getServerUrl, getAuthHeader, setServerUrl as persistServerUrl, setCredentials, clearAuth, setAuthType, setAuthValue } from "./storage";
|
| 2 |
-
import type { AuthType } from "./storage";
|
| 3 |
-
import type {
|
| 4 |
-
HealthResponse,
|
| 5 |
-
Session,
|
| 6 |
-
SessionStatus,
|
| 7 |
-
SessionListResponse,
|
| 8 |
-
Message,
|
| 9 |
-
MessagePart,
|
| 10 |
-
OpenCodeConfig,
|
| 11 |
-
Provider,
|
| 12 |
-
Model,
|
| 13 |
-
ModelListResponse,
|
| 14 |
-
FileEntry,
|
| 15 |
-
FileContent,
|
| 16 |
-
FindResult,
|
| 17 |
-
SymbolResult,
|
| 18 |
-
PTY,
|
| 19 |
-
Command,
|
| 20 |
-
Skill,
|
| 21 |
-
Agent,
|
| 22 |
-
DiffEntry,
|
| 23 |
-
VCSStatus,
|
| 24 |
-
PermissionRequest,
|
| 25 |
-
SavedPermission,
|
| 26 |
-
QuestionRequest,
|
| 27 |
-
Todo,
|
| 28 |
-
ServerEvent,
|
| 29 |
-
} from "../constants/types";
|
| 30 |
-
|
| 31 |
-
// ── Base request helper ──
|
| 32 |
-
|
| 33 |
-
async function request<T>(
|
| 34 |
-
path: string,
|
| 35 |
-
options?: RequestInit
|
| 36 |
-
): Promise<T> {
|
| 37 |
-
const base = getServerUrl();
|
| 38 |
-
const authHeaders = getAuthHeader();
|
| 39 |
-
const headers: Record<string, string> = {
|
| 40 |
-
"Content-Type": "application/json",
|
| 41 |
-
...authHeaders,
|
| 42 |
-
...((options?.headers as Record<string, string>) || {}),
|
| 43 |
-
};
|
| 44 |
-
|
| 45 |
-
const res = await fetch(`${base}${path}`, {
|
| 46 |
-
...options,
|
| 47 |
-
headers,
|
| 48 |
-
});
|
| 49 |
-
|
| 50 |
-
if (!res.ok) {
|
| 51 |
-
const text = await res.text();
|
| 52 |
-
throw new Error(text || `HTTP ${res.status} ${res.statusText}`);
|
| 53 |
-
}
|
| 54 |
-
|
| 55 |
-
const contentType = res.headers.get("content-type") || "";
|
| 56 |
-
if (contentType.includes("application/json")) {
|
| 57 |
-
return res.json();
|
| 58 |
-
}
|
| 59 |
-
return res.text() as unknown as T;
|
| 60 |
-
}
|
| 61 |
-
|
| 62 |
-
/** Extract data array from { location, data } or { data } response */
|
| 63 |
-
function extractData<T>(response: { data?: T[] } | T[]): T[] {
|
| 64 |
-
if (Array.isArray(response)) return response;
|
| 65 |
-
return response.data || [];
|
| 66 |
-
}
|
| 67 |
-
|
| 68 |
-
// ── Server URL / Credentials ──
|
| 69 |
-
|
| 70 |
-
export function setServerUrl(url: string) {
|
| 71 |
-
persistServerUrl(url);
|
| 72 |
-
}
|
| 73 |
-
|
| 74 |
-
export function setServerCredentials(username: string, password: string) {
|
| 75 |
-
setCredentials(username, password);
|
| 76 |
-
}
|
| 77 |
-
|
| 78 |
-
// ── Health ──
|
| 79 |
-
|
| 80 |
-
export function getHealth(): Promise<HealthResponse> {
|
| 81 |
-
return request("/api/health");
|
| 82 |
-
}
|
| 83 |
-
|
| 84 |
-
// ── Config ── (root endpoint, not /api/)
|
| 85 |
-
|
| 86 |
-
export function getConfig(): Promise<OpenCodeConfig> {
|
| 87 |
-
return request("/config");
|
| 88 |
-
}
|
| 89 |
-
|
| 90 |
-
export function patchConfig(config: Partial<OpenCodeConfig>): Promise<void> {
|
| 91 |
-
return request("/config", { method: "PATCH", body: JSON.stringify(config) });
|
| 92 |
-
}
|
| 93 |
-
|
| 94 |
-
// ── Sessions ──
|
| 95 |
-
|
| 96 |
-
export function getSessions(): Promise<Session[]> {
|
| 97 |
-
return request("/session");
|
| 98 |
-
}
|
| 99 |
-
|
| 100 |
-
export function getActiveSessions(): Promise<Session[]> {
|
| 101 |
-
return request("/session/active");
|
| 102 |
-
}
|
| 103 |
-
|
| 104 |
-
export function getSession(id: string): Promise<Session> {
|
| 105 |
-
return request(`/session/${id}`);
|
| 106 |
-
}
|
| 107 |
-
|
| 108 |
-
/** Get session via /api/ — returns {data: Session} */
|
| 109 |
-
export function getSessionApi(id: string): Promise<Session> {
|
| 110 |
-
return request<{ data: Session }>(`/api/session/${id}`).then((r) => r.data);
|
| 111 |
-
}
|
| 112 |
-
|
| 113 |
-
export function createSession(options?: {
|
| 114 |
-
title?: string;
|
| 115 |
-
agent?: string;
|
| 116 |
-
model?: string;
|
| 117 |
-
directory?: string;
|
| 118 |
-
}): Promise<Session> {
|
| 119 |
-
return request("/session", {
|
| 120 |
-
method: "POST",
|
| 121 |
-
body: JSON.stringify(options || {}),
|
| 122 |
-
});
|
| 123 |
-
}
|
| 124 |
-
|
| 125 |
-
export function deleteSession(id: string): Promise<void> {
|
| 126 |
-
return request(`/session/${id}`, { method: "DELETE" });
|
| 127 |
-
}
|
| 128 |
-
|
| 129 |
-
export function patchSession(
|
| 130 |
-
id: string,
|
| 131 |
-
data: { title?: string; agent?: string; model?: string }
|
| 132 |
-
): Promise<Session> {
|
| 133 |
-
return request(`/session/${id}`, {
|
| 134 |
-
method: "PATCH",
|
| 135 |
-
body: JSON.stringify(data),
|
| 136 |
-
});
|
| 137 |
-
}
|
| 138 |
-
|
| 139 |
-
export function getSessionStatus(id: string): Promise<SessionStatus> {
|
| 140 |
-
return request(`/session/${id}`);
|
| 141 |
-
}
|
| 142 |
-
|
| 143 |
-
export function getSessionChildren(id: string): Promise<Session[]> {
|
| 144 |
-
return request<Session[]>(`/session/${id}/children`).catch(() => []);
|
| 145 |
-
}
|
| 146 |
-
|
| 147 |
-
export function getSessionTodos(id: string): Promise<Todo[]> {
|
| 148 |
-
return request(`/session/${id}/todo`);
|
| 149 |
-
}
|
| 150 |
-
|
| 151 |
-
// ── Messages ──
|
| 152 |
-
|
| 153 |
-
export function getMessages(sessionId: string): Promise<Message[]> {
|
| 154 |
-
return request(`/session/${sessionId}/message`);
|
| 155 |
-
}
|
| 156 |
-
|
| 157 |
-
export function getMessage(sessionId: string, messageId: string): Promise<Message> {
|
| 158 |
-
return request(`/session/${sessionId}/message/${messageId}`);
|
| 159 |
-
}
|
| 160 |
-
|
| 161 |
-
export function deleteMessage(sessionId: string, messageId: string): Promise<void> {
|
| 162 |
-
return request(`/session/${sessionId}/message/${messageId}`, {
|
| 163 |
-
method: "DELETE",
|
| 164 |
-
});
|
| 165 |
-
}
|
| 166 |
-
|
| 167 |
-
// ── Send message (triggers SSE response) ──
|
| 168 |
-
|
| 169 |
-
export interface MessageResponse {
|
| 170 |
-
info: {
|
| 171 |
-
id: string;
|
| 172 |
-
parentID?: string;
|
| 173 |
-
role: string;
|
| 174 |
-
sessionID: string;
|
| 175 |
-
modelID?: string;
|
| 176 |
-
providerID?: string;
|
| 177 |
-
time?: { created: number; completed?: number };
|
| 178 |
-
finish?: string;
|
| 179 |
-
};
|
| 180 |
-
parts: MessagePart[];
|
| 181 |
-
}
|
| 182 |
-
|
| 183 |
-
export function sendMessage(
|
| 184 |
-
sessionId: string,
|
| 185 |
-
content: string
|
| 186 |
-
): Promise<MessageResponse> {
|
| 187 |
-
return request(`/session/${sessionId}/message`, {
|
| 188 |
-
method: "POST",
|
| 189 |
-
body: JSON.stringify({
|
| 190 |
-
parts: [{ type: "text", text: content }],
|
| 191 |
-
}),
|
| 192 |
-
});
|
| 193 |
-
}
|
| 194 |
-
|
| 195 |
-
// ── Session actions ──
|
| 196 |
-
|
| 197 |
-
export function abortSession(sessionId: string): Promise<void> {
|
| 198 |
-
return request(`/session/${sessionId}/abort`, { method: "POST" });
|
| 199 |
-
}
|
| 200 |
-
|
| 201 |
-
export function initSession(sessionId: string): Promise<void> {
|
| 202 |
-
return request(`/session/${sessionId}/init`, { method: "POST" });
|
| 203 |
-
}
|
| 204 |
-
|
| 205 |
-
export function compactSession(sessionId: string): Promise<void> {
|
| 206 |
-
return request(`/session/${sessionId}/compact`, { method: "POST" });
|
| 207 |
-
}
|
| 208 |
-
|
| 209 |
-
export function summarizeSession(sessionId: string): Promise<void> {
|
| 210 |
-
return request(`/session/${sessionId}/summarize`, { method: "POST" });
|
| 211 |
-
}
|
| 212 |
-
|
| 213 |
-
export function shareSession(sessionId: string): Promise<{ url?: string }> {
|
| 214 |
-
return request(`/session/${sessionId}/share`, { method: "POST" });
|
| 215 |
-
}
|
| 216 |
-
|
| 217 |
-
export function forkSession(sessionId: string): Promise<Session> {
|
| 218 |
-
return request(`/session/${sessionId}/fork`, { method: "POST" });
|
| 219 |
-
}
|
| 220 |
-
|
| 221 |
-
// ── Session diff / revert ──
|
| 222 |
-
|
| 223 |
-
export function getSessionDiff(sessionId: string): Promise<DiffEntry[]> {
|
| 224 |
-
return request(`/session/${sessionId}/diff`);
|
| 225 |
-
}
|
| 226 |
-
|
| 227 |
-
export function revertSessionStage(sessionId: string): Promise<void> {
|
| 228 |
-
return request(`/session/${sessionId}/revert`, { method: "POST" });
|
| 229 |
-
}
|
| 230 |
-
|
| 231 |
-
export function revertSessionClear(sessionId: string): Promise<void> {
|
| 232 |
-
return request(`/session/${sessionId}/unrevert`, { method: "POST" });
|
| 233 |
-
}
|
| 234 |
-
|
| 235 |
-
// ── Session history / context ──
|
| 236 |
-
|
| 237 |
-
export function getSessionHistory(sessionId: string): Promise<{ messages: Message[] }> {
|
| 238 |
-
return request(`/session/${sessionId}/history`);
|
| 239 |
-
}
|
| 240 |
-
|
| 241 |
-
export function getSessionContext(sessionId: string): Promise<{ files: string[]; capabilities: string[] }> {
|
| 242 |
-
return request(`/session/${sessionId}/context`);
|
| 243 |
-
}
|
| 244 |
-
|
| 245 |
-
// ── Permissions ──
|
| 246 |
-
|
| 247 |
-
export function getPermissionRequests(): Promise<PermissionRequest[]> {
|
| 248 |
-
return request("/permission");
|
| 249 |
-
}
|
| 250 |
-
|
| 251 |
-
export function getSavedPermissions(): Promise<SavedPermission[]> {
|
| 252 |
-
return request("/permission/saved");
|
| 253 |
-
}
|
| 254 |
-
|
| 255 |
-
export function deleteSavedPermission(id: string): Promise<void> {
|
| 256 |
-
return request(`/permission/saved/${id}`, { method: "DELETE" });
|
| 257 |
-
}
|
| 258 |
-
|
| 259 |
-
export function replyPermission(
|
| 260 |
-
sessionId: string,
|
| 261 |
-
requestId: string,
|
| 262 |
-
allow: boolean
|
| 263 |
-
): Promise<void> {
|
| 264 |
-
return request(`/session/${sessionId}/permissions/${requestId}`, {
|
| 265 |
-
method: "POST",
|
| 266 |
-
body: JSON.stringify({ allow }),
|
| 267 |
-
});
|
| 268 |
-
}
|
| 269 |
-
|
| 270 |
-
// ── Questions ──
|
| 271 |
-
|
| 272 |
-
export function getQuestionRequests(): Promise<QuestionRequest[]> {
|
| 273 |
-
return request("/question");
|
| 274 |
-
}
|
| 275 |
-
|
| 276 |
-
export function getSessionQuestions(sessionId: string): Promise<QuestionRequest[]> {
|
| 277 |
-
return request(`/session/${sessionId}/question`);
|
| 278 |
-
}
|
| 279 |
-
|
| 280 |
-
export function replyQuestion(
|
| 281 |
-
sessionId: string,
|
| 282 |
-
requestId: string,
|
| 283 |
-
answer: string
|
| 284 |
-
): Promise<void> {
|
| 285 |
-
return request(`/question/${requestId}/reply`, {
|
| 286 |
-
method: "POST",
|
| 287 |
-
body: JSON.stringify({ answer }),
|
| 288 |
-
});
|
| 289 |
-
}
|
| 290 |
-
|
| 291 |
-
export function rejectQuestion(
|
| 292 |
-
sessionId: string,
|
| 293 |
-
requestId: string
|
| 294 |
-
): Promise<void> {
|
| 295 |
-
return request(`/question/${requestId}/reject`, {
|
| 296 |
-
method: "POST",
|
| 297 |
-
});
|
| 298 |
-
}
|
| 299 |
-
|
| 300 |
-
// ── Providers ──
|
| 301 |
-
|
| 302 |
-
export function getProviders(): Promise<Provider[]> {
|
| 303 |
-
return request<{ data: Provider[] }>("/api/provider").then((r) => r.data);
|
| 304 |
-
}
|
| 305 |
-
|
| 306 |
-
export function getProvider(id: string): Promise<Provider> {
|
| 307 |
-
return request(`/api/provider/${id}`);
|
| 308 |
-
}
|
| 309 |
-
|
| 310 |
-
// ── Models ──
|
| 311 |
-
|
| 312 |
-
export function getModels(): Promise<Model[]> {
|
| 313 |
-
return request<{ data: Model[] }>("/api/model").then((r) => r.data);
|
| 314 |
-
}
|
| 315 |
-
|
| 316 |
-
// ── Agent / Command / Skill ──
|
| 317 |
-
|
| 318 |
-
export function getAgents(): Promise<Agent[]> {
|
| 319 |
-
return request<{ data: Agent[] }>("/api/agent").then((r) => r.data);
|
| 320 |
-
}
|
| 321 |
-
|
| 322 |
-
export function getCommands(): Promise<Command[]> {
|
| 323 |
-
return request<{ data: Command[] }>("/api/command").then((r) => r.data);
|
| 324 |
-
}
|
| 325 |
-
|
| 326 |
-
export function getSkills(): Promise<Skill[]> {
|
| 327 |
-
return request<{ data: Skill[] }>("/api/skill").then((r) => r.data);
|
| 328 |
-
}
|
| 329 |
-
|
| 330 |
-
// ── Files ──
|
| 331 |
-
|
| 332 |
-
export function listFiles(dir: string = "."): Promise<FileEntry[]> {
|
| 333 |
-
return request<{ data: FileEntry[] }>(`/api/fs/list?path=${encodeURIComponent(dir)}`).then((r) => r.data);
|
| 334 |
-
}
|
| 335 |
-
|
| 336 |
-
export function readFile(filePath: string): Promise<FileContent> {
|
| 337 |
-
return request(`/file/content?path=${encodeURIComponent(filePath)}`);
|
| 338 |
-
}
|
| 339 |
-
|
| 340 |
-
export function findFiles(query: string, dir?: string): Promise<FindResult[]> {
|
| 341 |
-
const params = new URLSearchParams({ query });
|
| 342 |
-
if (dir) params.set("path", dir);
|
| 343 |
-
return request(`/find/file?${params.toString()}`);
|
| 344 |
-
}
|
| 345 |
-
|
| 346 |
-
export function findSymbols(query: string): Promise<SymbolResult[]> {
|
| 347 |
-
return request(`/find/symbol?query=${encodeURIComponent(query)}`);
|
| 348 |
-
}
|
| 349 |
-
|
| 350 |
-
// ── VCS ──
|
| 351 |
-
|
| 352 |
-
export function getVCSStatus(): Promise<VCSStatus> {
|
| 353 |
-
return request("/api/vcs/status");
|
| 354 |
-
}
|
| 355 |
-
|
| 356 |
-
// ── PTY ──
|
| 357 |
-
|
| 358 |
-
export function getPTYs(): Promise<PTY[]> {
|
| 359 |
-
return request<{ data: PTY[] }>("/api/pty").then((r) => r.data);
|
| 360 |
-
}
|
| 361 |
-
|
| 362 |
-
export function createPTY(shell?: string): Promise<PTY> {
|
| 363 |
-
return request("/api/pty", {
|
| 364 |
-
method: "POST",
|
| 365 |
-
body: JSON.stringify({ shell }),
|
| 366 |
-
});
|
| 367 |
-
}
|
| 368 |
-
|
| 369 |
-
export function getPTY(id: string): Promise<PTY> {
|
| 370 |
-
return request(`/api/pty/${id}`);
|
| 371 |
-
}
|
| 372 |
-
|
| 373 |
-
export function deletePTY(id: string): Promise<void> {
|
| 374 |
-
return request(`/api/pty/${id}`, { method: "DELETE" });
|
| 375 |
-
}
|
| 376 |
-
|
| 377 |
-
export function getPTYConnectToken(id: string): Promise<{ token: string }> {
|
| 378 |
-
return request(`/api/pty/${id}/connect-token`, { method: "POST" });
|
| 379 |
-
}
|
| 380 |
-
|
| 381 |
-
// ── SSE Event Stream (returns raw Response for manual parsing) ──
|
| 382 |
-
|
| 383 |
-
export async function subscribeEvents(
|
| 384 |
-
sessionId?: string
|
| 385 |
-
): Promise<Response> {
|
| 386 |
-
const base = getServerUrl();
|
| 387 |
-
const authHeaders = getAuthHeader();
|
| 388 |
-
const url = sessionId
|
| 389 |
-
? `${base}/session/${sessionId}/event`
|
| 390 |
-
: `${base}/event`;
|
| 391 |
-
|
| 392 |
-
const res = await fetch(url, {
|
| 393 |
-
headers: { Accept: "text/event-stream", ...authHeaders },
|
| 394 |
-
});
|
| 395 |
-
|
| 396 |
-
if (!res.ok) {
|
| 397 |
-
throw new Error(`SSE connection failed: ${res.status}`);
|
| 398 |
-
}
|
| 399 |
-
|
| 400 |
-
return res;
|
| 401 |
-
}
|
| 402 |
-
|
| 403 |
-
// ── SSE Parser helper ──
|
| 404 |
-
|
| 405 |
-
export function parseSSELines(
|
| 406 |
-
buffer: string
|
| 407 |
-
): { events: ServerEvent[]; remaining: string } {
|
| 408 |
-
const events: ServerEvent[] = [];
|
| 409 |
-
const lines = buffer.split("\n");
|
| 410 |
-
const remaining = lines.pop() || "";
|
| 411 |
-
|
| 412 |
-
let eventType = "";
|
| 413 |
-
let eventData = "";
|
| 414 |
-
|
| 415 |
-
for (const line of lines) {
|
| 416 |
-
if (line.startsWith("event: ")) {
|
| 417 |
-
eventType = line.slice(7).trim();
|
| 418 |
-
} else if (line.startsWith("data: ")) {
|
| 419 |
-
eventData = line.slice(6);
|
| 420 |
-
} else if (line === "") {
|
| 421 |
-
if (eventType && eventData) {
|
| 422 |
-
try {
|
| 423 |
-
events.push(JSON.parse(eventData) as ServerEvent);
|
| 424 |
-
} catch {
|
| 425 |
-
// skip malformed
|
| 426 |
-
}
|
| 427 |
-
}
|
| 428 |
-
eventType = "";
|
| 429 |
-
eventData = "";
|
| 430 |
-
}
|
| 431 |
-
}
|
| 432 |
-
|
| 433 |
-
return { events, remaining };
|
| 434 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/lib/connection-manager.ts
DELETED
|
@@ -1,285 +0,0 @@
|
|
| 1 |
-
import { getHealth, subscribeEvents, parseSSELines } from "./api";
|
| 2 |
-
import { getServerUrl, getCredentials } from "./storage";
|
| 3 |
-
import type { ServerEvent } from "../constants/types";
|
| 4 |
-
|
| 5 |
-
export type ConnectionState =
|
| 6 |
-
| "disconnected"
|
| 7 |
-
| "connecting"
|
| 8 |
-
| "connected"
|
| 9 |
-
| "reconnecting"
|
| 10 |
-
| "error";
|
| 11 |
-
|
| 12 |
-
export type ConnectionListener = (state: ConnectionState) => void;
|
| 13 |
-
export type EventListener = (event: ServerEvent) => void;
|
| 14 |
-
|
| 15 |
-
interface ConnectionManagerOptions {
|
| 16 |
-
/** Max reconnect attempts before giving up (default: Infinity) */
|
| 17 |
-
maxRetries?: number;
|
| 18 |
-
/** Base delay in ms for exponential backoff (default: 1000) */
|
| 19 |
-
baseDelay?: number;
|
| 20 |
-
/** Max delay in ms (default: 30000) */
|
| 21 |
-
maxDelay?: number;
|
| 22 |
-
/** Health check interval in ms (default: 15000) */
|
| 23 |
-
heartbeatInterval?: number;
|
| 24 |
-
/** Timeout for health check in ms (default: 5000) */
|
| 25 |
-
healthTimeout?: number;
|
| 26 |
-
}
|
| 27 |
-
|
| 28 |
-
export class ConnectionManager {
|
| 29 |
-
private state: ConnectionState = "disconnected";
|
| 30 |
-
private retryCount = 0;
|
| 31 |
-
private maxRetries: number;
|
| 32 |
-
private baseDelay: number;
|
| 33 |
-
private maxDelay: number;
|
| 34 |
-
private heartbeatMs: number;
|
| 35 |
-
private healthTimeout: number;
|
| 36 |
-
|
| 37 |
-
private stateListeners: Set<ConnectionListener> = new Set();
|
| 38 |
-
private eventListeners: Set<EventListener> = new Set();
|
| 39 |
-
|
| 40 |
-
private heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
|
| 41 |
-
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
| 42 |
-
private abortController: AbortController | null = null;
|
| 43 |
-
private sseBuffer = "";
|
| 44 |
-
|
| 45 |
-
/** Currently active SSE session subscription (null = global events) */
|
| 46 |
-
private activeSessionId: string | null = null;
|
| 47 |
-
|
| 48 |
-
constructor(options?: ConnectionManagerOptions) {
|
| 49 |
-
this.maxRetries = options?.maxRetries ?? Infinity;
|
| 50 |
-
this.baseDelay = options?.baseDelay ?? 1000;
|
| 51 |
-
this.maxDelay = options?.maxDelay ?? 30000;
|
| 52 |
-
this.heartbeatMs = options?.heartbeatInterval ?? 15000;
|
| 53 |
-
this.healthTimeout = options?.healthTimeout ?? 5000;
|
| 54 |
-
}
|
| 55 |
-
|
| 56 |
-
// ── State ──
|
| 57 |
-
|
| 58 |
-
getState(): ConnectionState {
|
| 59 |
-
return this.state;
|
| 60 |
-
}
|
| 61 |
-
|
| 62 |
-
isConnected(): boolean {
|
| 63 |
-
return this.state === "connected";
|
| 64 |
-
}
|
| 65 |
-
|
| 66 |
-
// ── Listeners ──
|
| 67 |
-
|
| 68 |
-
onStateChange(listener: ConnectionListener): () => void {
|
| 69 |
-
this.stateListeners.add(listener);
|
| 70 |
-
return () => this.stateListeners.delete(listener);
|
| 71 |
-
}
|
| 72 |
-
|
| 73 |
-
onEvent(listener: EventListener): () => void {
|
| 74 |
-
this.eventListeners.add(listener);
|
| 75 |
-
return () => this.eventListeners.delete(listener);
|
| 76 |
-
}
|
| 77 |
-
|
| 78 |
-
private setState(newState: ConnectionState) {
|
| 79 |
-
if (this.state === newState) return;
|
| 80 |
-
this.state = newState;
|
| 81 |
-
this.stateListeners.forEach((fn) => fn(newState));
|
| 82 |
-
}
|
| 83 |
-
|
| 84 |
-
private emitEvent(event: ServerEvent) {
|
| 85 |
-
this.eventListeners.forEach((fn) => fn(event));
|
| 86 |
-
}
|
| 87 |
-
|
| 88 |
-
// ── Connect ──
|
| 89 |
-
|
| 90 |
-
async connect(sessionId?: string) {
|
| 91 |
-
if (this.state === "connected" || this.state === "connecting") return;
|
| 92 |
-
|
| 93 |
-
this.activeSessionId = sessionId ?? null;
|
| 94 |
-
this.setState("connecting");
|
| 95 |
-
|
| 96 |
-
try {
|
| 97 |
-
await this.checkHealth();
|
| 98 |
-
this.retryCount = 0;
|
| 99 |
-
this.setState("connected");
|
| 100 |
-
this.startHeartbeat();
|
| 101 |
-
this.connectSSE();
|
| 102 |
-
} catch {
|
| 103 |
-
this.setState("error");
|
| 104 |
-
this.scheduleReconnect();
|
| 105 |
-
}
|
| 106 |
-
}
|
| 107 |
-
|
| 108 |
-
disconnect() {
|
| 109 |
-
this.stopHeartbeat();
|
| 110 |
-
this.stopReconnect();
|
| 111 |
-
this.abortController?.abort();
|
| 112 |
-
this.abortController = null;
|
| 113 |
-
this.sseBuffer = "";
|
| 114 |
-
this.retryCount = 0;
|
| 115 |
-
this.setState("disconnected");
|
| 116 |
-
}
|
| 117 |
-
|
| 118 |
-
// ── Health check ──
|
| 119 |
-
|
| 120 |
-
private async checkHealth(): Promise<void> {
|
| 121 |
-
const controller = new AbortController();
|
| 122 |
-
const timeout = setTimeout(() => controller.abort(), this.healthTimeout);
|
| 123 |
-
|
| 124 |
-
try {
|
| 125 |
-
const result = await Promise.race([
|
| 126 |
-
getHealth(),
|
| 127 |
-
new Promise<never>((_, reject) => {
|
| 128 |
-
controller.signal.addEventListener("abort", () =>
|
| 129 |
-
reject(new Error("Health check timeout"))
|
| 130 |
-
);
|
| 131 |
-
}),
|
| 132 |
-
]);
|
| 133 |
-
|
| 134 |
-
clearTimeout(timeout);
|
| 135 |
-
|
| 136 |
-
if (!result.healthy) {
|
| 137 |
-
throw new Error("Server reports unhealthy");
|
| 138 |
-
}
|
| 139 |
-
} catch (err) {
|
| 140 |
-
clearTimeout(timeout);
|
| 141 |
-
throw err;
|
| 142 |
-
}
|
| 143 |
-
}
|
| 144 |
-
|
| 145 |
-
// ── Heartbeat ──
|
| 146 |
-
|
| 147 |
-
private startHeartbeat() {
|
| 148 |
-
this.stopHeartbeat();
|
| 149 |
-
this.heartbeatTimer = setInterval(async () => {
|
| 150 |
-
try {
|
| 151 |
-
await this.checkHealth();
|
| 152 |
-
// If we were in error state, recover
|
| 153 |
-
if (this.state === "error") {
|
| 154 |
-
this.retryCount = 0;
|
| 155 |
-
this.setState("connected");
|
| 156 |
-
this.connectSSE();
|
| 157 |
-
}
|
| 158 |
-
} catch {
|
| 159 |
-
if (this.state === "connected") {
|
| 160 |
-
this.setState("error");
|
| 161 |
-
this.scheduleReconnect();
|
| 162 |
-
}
|
| 163 |
-
}
|
| 164 |
-
}, this.heartbeatMs);
|
| 165 |
-
}
|
| 166 |
-
|
| 167 |
-
private stopHeartbeat() {
|
| 168 |
-
if (this.heartbeatTimer) {
|
| 169 |
-
clearInterval(this.heartbeatTimer);
|
| 170 |
-
this.heartbeatTimer = null;
|
| 171 |
-
}
|
| 172 |
-
}
|
| 173 |
-
|
| 174 |
-
// ── SSE ──
|
| 175 |
-
|
| 176 |
-
private connectSSE() {
|
| 177 |
-
this.abortController?.abort();
|
| 178 |
-
this.abortController = new AbortController();
|
| 179 |
-
this.sseBuffer = "";
|
| 180 |
-
|
| 181 |
-
const doConnect = async () => {
|
| 182 |
-
try {
|
| 183 |
-
const response = await subscribeEvents(this.activeSessionId ?? undefined);
|
| 184 |
-
const reader = response.body?.getReader();
|
| 185 |
-
if (!reader) throw new Error("No readable stream");
|
| 186 |
-
|
| 187 |
-
const decoder = new TextDecoder();
|
| 188 |
-
|
| 189 |
-
while (true) {
|
| 190 |
-
if (this.abortController?.signal.aborted) break;
|
| 191 |
-
|
| 192 |
-
const { done, value } = await reader.read();
|
| 193 |
-
if (done) break;
|
| 194 |
-
|
| 195 |
-
this.sseBuffer += decoder.decode(value, { stream: true });
|
| 196 |
-
const { events, remaining } = parseSSELines(this.sseBuffer);
|
| 197 |
-
this.sseBuffer = remaining;
|
| 198 |
-
|
| 199 |
-
for (const event of events) {
|
| 200 |
-
this.emitEvent(event);
|
| 201 |
-
}
|
| 202 |
-
}
|
| 203 |
-
|
| 204 |
-
// SSE ended normally — try reconnect
|
| 205 |
-
if (this.state === "connected") {
|
| 206 |
-
this.setState("error");
|
| 207 |
-
this.scheduleReconnect();
|
| 208 |
-
}
|
| 209 |
-
} catch (err) {
|
| 210 |
-
if (
|
| 211 |
-
this.abortController?.signal.aborted ||
|
| 212 |
-
this.state === "disconnected"
|
| 213 |
-
) {
|
| 214 |
-
return;
|
| 215 |
-
}
|
| 216 |
-
|
| 217 |
-
console.warn("[ConnectionManager] SSE error:", err);
|
| 218 |
-
if (this.state === "connected") {
|
| 219 |
-
this.setState("error");
|
| 220 |
-
this.scheduleReconnect();
|
| 221 |
-
}
|
| 222 |
-
}
|
| 223 |
-
};
|
| 224 |
-
|
| 225 |
-
doConnect();
|
| 226 |
-
}
|
| 227 |
-
|
| 228 |
-
// ── Reconnect with exponential backoff ──
|
| 229 |
-
|
| 230 |
-
private scheduleReconnect() {
|
| 231 |
-
if (this.retryCount >= this.maxRetries) {
|
| 232 |
-
this.setState("disconnected");
|
| 233 |
-
return;
|
| 234 |
-
}
|
| 235 |
-
|
| 236 |
-
this.setState("reconnecting");
|
| 237 |
-
|
| 238 |
-
const delay = Math.min(
|
| 239 |
-
this.baseDelay * Math.pow(2, this.retryCount),
|
| 240 |
-
this.maxDelay
|
| 241 |
-
);
|
| 242 |
-
this.retryCount++;
|
| 243 |
-
|
| 244 |
-
this.reconnectTimer = setTimeout(() => {
|
| 245 |
-
this.connectSSE();
|
| 246 |
-
// Also check health
|
| 247 |
-
this.checkHealth()
|
| 248 |
-
.then(() => {
|
| 249 |
-
this.retryCount = 0;
|
| 250 |
-
this.setState("connected");
|
| 251 |
-
})
|
| 252 |
-
.catch(() => {
|
| 253 |
-
// Will retry again
|
| 254 |
-
});
|
| 255 |
-
}, delay);
|
| 256 |
-
}
|
| 257 |
-
|
| 258 |
-
private stopReconnect() {
|
| 259 |
-
if (this.reconnectTimer) {
|
| 260 |
-
clearTimeout(this.reconnectTimer);
|
| 261 |
-
this.reconnectTimer = null;
|
| 262 |
-
}
|
| 263 |
-
}
|
| 264 |
-
|
| 265 |
-
// ── Switch session subscription ──
|
| 266 |
-
|
| 267 |
-
switchSession(sessionId: string | null) {
|
| 268 |
-
if (this.activeSessionId === sessionId) return;
|
| 269 |
-
this.activeSessionId = sessionId;
|
| 270 |
-
if (this.state === "connected") {
|
| 271 |
-
this.connectSSE();
|
| 272 |
-
}
|
| 273 |
-
}
|
| 274 |
-
}
|
| 275 |
-
|
| 276 |
-
// ── Singleton ──
|
| 277 |
-
|
| 278 |
-
let instance: ConnectionManager | null = null;
|
| 279 |
-
|
| 280 |
-
export function getConnectionManager(options?: ConnectionManagerOptions): ConnectionManager {
|
| 281 |
-
if (!instance) {
|
| 282 |
-
instance = new ConnectionManager(options);
|
| 283 |
-
}
|
| 284 |
-
return instance;
|
| 285 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/lib/query-provider.tsx
DELETED
|
@@ -1,17 +0,0 @@
|
|
| 1 |
-
import React from "react";
|
| 2 |
-
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
| 3 |
-
|
| 4 |
-
const queryClient = new QueryClient({
|
| 5 |
-
defaultOptions: {
|
| 6 |
-
queries: {
|
| 7 |
-
staleTime: 5000,
|
| 8 |
-
refetchOnWindowFocus: false,
|
| 9 |
-
},
|
| 10 |
-
},
|
| 11 |
-
});
|
| 12 |
-
|
| 13 |
-
export function QueryProvider({ children }: { children: React.ReactNode }) {
|
| 14 |
-
return (
|
| 15 |
-
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
| 16 |
-
);
|
| 17 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/lib/storage.ts
DELETED
|
@@ -1,134 +0,0 @@
|
|
| 1 |
-
import AsyncStorage from "@react-native-async-storage/async-storage";
|
| 2 |
-
import { ThemeName } from "../constants/types";
|
| 3 |
-
|
| 4 |
-
export const storageKeys = {
|
| 5 |
-
serverUrl: "opencode_server_url",
|
| 6 |
-
authType: "opencode_auth_type",
|
| 7 |
-
authValue: "opencode_auth_value",
|
| 8 |
-
theme: "opencode_theme",
|
| 9 |
-
lastSessionId: "opencode_last_session_id",
|
| 10 |
-
} as const;
|
| 11 |
-
|
| 12 |
-
export type AuthType = "none" | "basic" | "bearer" | "apikey";
|
| 13 |
-
|
| 14 |
-
const cache: Record<string, string | null> = {};
|
| 15 |
-
|
| 16 |
-
async function get(key: string): Promise<string | null> {
|
| 17 |
-
if (cache[key] !== undefined) return cache[key];
|
| 18 |
-
const val = await AsyncStorage.getItem(key);
|
| 19 |
-
cache[key] = val;
|
| 20 |
-
return val;
|
| 21 |
-
}
|
| 22 |
-
|
| 23 |
-
async function set(key: string, value: string): Promise<void> {
|
| 24 |
-
cache[key] = value;
|
| 25 |
-
await AsyncStorage.setItem(key, value);
|
| 26 |
-
}
|
| 27 |
-
|
| 28 |
-
async function del(key: string): Promise<void> {
|
| 29 |
-
cache[key] = null;
|
| 30 |
-
await AsyncStorage.removeItem(key);
|
| 31 |
-
}
|
| 32 |
-
|
| 33 |
-
function getSync(key: string): string | null {
|
| 34 |
-
if (cache[key] !== undefined) return cache[key];
|
| 35 |
-
return null;
|
| 36 |
-
}
|
| 37 |
-
|
| 38 |
-
// Preload all keys on module load
|
| 39 |
-
const _loaded = (async () => {
|
| 40 |
-
const entries = await AsyncStorage.multiGet(Object.values(storageKeys));
|
| 41 |
-
for (const [k, v] of entries) {
|
| 42 |
-
cache[k] = v;
|
| 43 |
-
}
|
| 44 |
-
})();
|
| 45 |
-
|
| 46 |
-
// ── Server URL ──
|
| 47 |
-
|
| 48 |
-
export function getServerUrl(): string {
|
| 49 |
-
return getSync(storageKeys.serverUrl) || "http://127.0.0.1:4096";
|
| 50 |
-
}
|
| 51 |
-
|
| 52 |
-
export async function setServerUrl(url: string): Promise<void> {
|
| 53 |
-
await set(storageKeys.serverUrl, url);
|
| 54 |
-
}
|
| 55 |
-
|
| 56 |
-
// ── Auth ──
|
| 57 |
-
|
| 58 |
-
export function getAuthType(): AuthType {
|
| 59 |
-
return (getSync(storageKeys.authType) as AuthType) || "none";
|
| 60 |
-
}
|
| 61 |
-
|
| 62 |
-
export async function setAuthType(type: AuthType): Promise<void> {
|
| 63 |
-
await set(storageKeys.authType, type);
|
| 64 |
-
}
|
| 65 |
-
|
| 66 |
-
export function getAuthValue(): string {
|
| 67 |
-
return getSync(storageKeys.authValue) || "";
|
| 68 |
-
}
|
| 69 |
-
|
| 70 |
-
export async function setAuthValue(value: string): Promise<void> {
|
| 71 |
-
await set(storageKeys.authValue, value);
|
| 72 |
-
}
|
| 73 |
-
|
| 74 |
-
export function getCredentials(): { username: string; password: string } {
|
| 75 |
-
const val = getAuthValue();
|
| 76 |
-
if (getAuthType() === "basic" && val.includes(":")) {
|
| 77 |
-
const [username, ...rest] = val.split(":");
|
| 78 |
-
return { username, password: rest.join(":") };
|
| 79 |
-
}
|
| 80 |
-
return { username: "", password: "" };
|
| 81 |
-
}
|
| 82 |
-
|
| 83 |
-
export async function setCredentials(username: string, password: string): Promise<void> {
|
| 84 |
-
await setAuthType("basic");
|
| 85 |
-
await setAuthValue(`${username}:${password}`);
|
| 86 |
-
}
|
| 87 |
-
|
| 88 |
-
export async function clearAuth(): Promise<void> {
|
| 89 |
-
await del(storageKeys.authType);
|
| 90 |
-
await del(storageKeys.authValue);
|
| 91 |
-
}
|
| 92 |
-
|
| 93 |
-
export function getAuthHeader(): Record<string, string> {
|
| 94 |
-
const type = getAuthType();
|
| 95 |
-
const value = getAuthValue();
|
| 96 |
-
|
| 97 |
-
if (!value) return {};
|
| 98 |
-
|
| 99 |
-
switch (type) {
|
| 100 |
-
case "basic": {
|
| 101 |
-
const creds = getCredentials();
|
| 102 |
-
if (creds.username) {
|
| 103 |
-
return { Authorization: `Basic ${btoa(`${creds.username}:${creds.password}`)}` };
|
| 104 |
-
}
|
| 105 |
-
return {};
|
| 106 |
-
}
|
| 107 |
-
case "bearer":
|
| 108 |
-
return { Authorization: `Bearer ${value}` };
|
| 109 |
-
case "apikey":
|
| 110 |
-
return { "X-API-Key": value };
|
| 111 |
-
default:
|
| 112 |
-
return {};
|
| 113 |
-
}
|
| 114 |
-
}
|
| 115 |
-
|
| 116 |
-
// ── Theme ──
|
| 117 |
-
|
| 118 |
-
export function getTheme(): ThemeName {
|
| 119 |
-
return (getSync(storageKeys.theme) as ThemeName) || "tokyo";
|
| 120 |
-
}
|
| 121 |
-
|
| 122 |
-
export async function setTheme(theme: ThemeName): Promise<void> {
|
| 123 |
-
await set(storageKeys.theme, theme);
|
| 124 |
-
}
|
| 125 |
-
|
| 126 |
-
// ── Last session ──
|
| 127 |
-
|
| 128 |
-
export function getLastSessionId(): string | undefined {
|
| 129 |
-
return getSync(storageKeys.lastSessionId) || undefined;
|
| 130 |
-
}
|
| 131 |
-
|
| 132 |
-
export async function setLastSessionId(id: string): Promise<void> {
|
| 133 |
-
await set(storageKeys.lastSessionId, id);
|
| 134 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/package-lock.json
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
mobile/package.json
DELETED
|
@@ -1,38 +0,0 @@
|
|
| 1 |
-
{
|
| 2 |
-
"name": "opencode-mobile",
|
| 3 |
-
"version": "1.0.0",
|
| 4 |
-
"main": "expo-router/entry",
|
| 5 |
-
"scripts": {
|
| 6 |
-
"start": "expo start",
|
| 7 |
-
"android": "expo start --android",
|
| 8 |
-
"ios": "expo start --ios",
|
| 9 |
-
"web": "expo start --web"
|
| 10 |
-
},
|
| 11 |
-
"dependencies": {
|
| 12 |
-
"@react-native-async-storage/async-storage": "2.1.2",
|
| 13 |
-
"@tanstack/react-query": "^5.60.6",
|
| 14 |
-
"expo": "~53.0.27",
|
| 15 |
-
"expo-asset": "~11.1.7",
|
| 16 |
-
"expo-constants": "~17.1.8",
|
| 17 |
-
"expo-font": "~13.3.2",
|
| 18 |
-
"expo-linking": "~7.1.7",
|
| 19 |
-
"expo-router": "~5.1.11",
|
| 20 |
-
"expo-secure-store": "~14.2.4",
|
| 21 |
-
"expo-splash-screen": "~0.30.10",
|
| 22 |
-
"expo-status-bar": "~2.2.3",
|
| 23 |
-
"expo-system-ui": "~5.0.11",
|
| 24 |
-
"lucide-react-native": "^0.460.0",
|
| 25 |
-
"react": "19.0.0",
|
| 26 |
-
"react-native": "0.79.6",
|
| 27 |
-
"react-native-gesture-handler": "~2.24.0",
|
| 28 |
-
"react-native-markdown-display": "^7.0.2",
|
| 29 |
-
"react-native-reanimated": "~3.17.4",
|
| 30 |
-
"react-native-safe-area-context": "5.4.0",
|
| 31 |
-
"react-native-screens": "~4.11.1",
|
| 32 |
-
"react-native-svg": "15.11.2"
|
| 33 |
-
},
|
| 34 |
-
"devDependencies": {
|
| 35 |
-
"@types/react": "~19.0.10",
|
| 36 |
-
"typescript": "~5.8.3"
|
| 37 |
-
}
|
| 38 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/tests/integration.test.ts
DELETED
|
@@ -1,251 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* End-to-end integration test for OpenCode Mobile client
|
| 3 |
-
* Run against a live opencode serve instance
|
| 4 |
-
*
|
| 5 |
-
* Usage: npx tsx mobile/tests/integration.test.ts
|
| 6 |
-
*/
|
| 7 |
-
|
| 8 |
-
const BASE = "http://127.0.0.1:4096";
|
| 9 |
-
|
| 10 |
-
interface TestResult {
|
| 11 |
-
name: string;
|
| 12 |
-
passed: boolean;
|
| 13 |
-
error?: string;
|
| 14 |
-
duration: number;
|
| 15 |
-
}
|
| 16 |
-
|
| 17 |
-
const results: TestResult[] = [];
|
| 18 |
-
|
| 19 |
-
async function test(name: string, fn: () => Promise<void>) {
|
| 20 |
-
const start = Date.now();
|
| 21 |
-
try {
|
| 22 |
-
await fn();
|
| 23 |
-
results.push({ name, passed: true, duration: Date.now() - start });
|
| 24 |
-
console.log(` ✓ ${name} (${Date.now() - start}ms)`);
|
| 25 |
-
} catch (err: any) {
|
| 26 |
-
results.push({
|
| 27 |
-
name,
|
| 28 |
-
passed: false,
|
| 29 |
-
error: err.message,
|
| 30 |
-
duration: Date.now() - start,
|
| 31 |
-
});
|
| 32 |
-
console.log(` ✗ ${name}: ${err.message}`);
|
| 33 |
-
}
|
| 34 |
-
}
|
| 35 |
-
|
| 36 |
-
function assert(condition: boolean, msg: string) {
|
| 37 |
-
if (!condition) throw new Error(msg);
|
| 38 |
-
}
|
| 39 |
-
|
| 40 |
-
async function api<T = any>(path: string, opts?: RequestInit): Promise<T> {
|
| 41 |
-
const res = await fetch(`${BASE}${path}`, {
|
| 42 |
-
headers: { "Content-Type": "application/json", ...opts?.headers },
|
| 43 |
-
...opts,
|
| 44 |
-
});
|
| 45 |
-
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
| 46 |
-
return res.json();
|
| 47 |
-
}
|
| 48 |
-
|
| 49 |
-
// ── Tests ──
|
| 50 |
-
|
| 51 |
-
async function run() {
|
| 52 |
-
console.log("\n=== OpenCode Integration Tests ===\n");
|
| 53 |
-
|
| 54 |
-
let sessionId: string;
|
| 55 |
-
|
| 56 |
-
// 1. Health
|
| 57 |
-
await test("GET /api/health", async () => {
|
| 58 |
-
const data = await api("/api/health");
|
| 59 |
-
assert(data.healthy === true, "not healthy");
|
| 60 |
-
});
|
| 61 |
-
|
| 62 |
-
// 2. Config (root endpoint — /api/config returns HTML)
|
| 63 |
-
await test("GET /config", async () => {
|
| 64 |
-
const data = await api("/config");
|
| 65 |
-
assert(typeof data === "object", "config is not object");
|
| 66 |
-
});
|
| 67 |
-
|
| 68 |
-
// 3. List sessions (root returns array)
|
| 69 |
-
await test("GET /session (list)", async () => {
|
| 70 |
-
const data = await api("/session");
|
| 71 |
-
assert(Array.isArray(data), "not array");
|
| 72 |
-
});
|
| 73 |
-
|
| 74 |
-
// 4. List sessions via /api/ (returns {data, cursor})
|
| 75 |
-
await test("GET /api/session (list)", async () => {
|
| 76 |
-
const data = await api("/api/session");
|
| 77 |
-
assert(Array.isArray(data.data), "data is not array");
|
| 78 |
-
});
|
| 79 |
-
|
| 80 |
-
// 5. Create session
|
| 81 |
-
await test("POST /session (create)", async () => {
|
| 82 |
-
const data = await api("/session", {
|
| 83 |
-
method: "POST",
|
| 84 |
-
body: JSON.stringify({ title: "Integration Test" }),
|
| 85 |
-
});
|
| 86 |
-
assert(data.id, "no id returned");
|
| 87 |
-
sessionId = data.id;
|
| 88 |
-
});
|
| 89 |
-
|
| 90 |
-
// 6. Get session (root)
|
| 91 |
-
await test("GET /session/{id}", async () => {
|
| 92 |
-
const data = await api(`/session/${sessionId}`);
|
| 93 |
-
assert(data.id === sessionId, "id mismatch");
|
| 94 |
-
});
|
| 95 |
-
|
| 96 |
-
// 7. Get session via /api/ (returns {data: Session})
|
| 97 |
-
await test("GET /api/session/{id}", async () => {
|
| 98 |
-
const data = await api(`/api/session/${sessionId}`);
|
| 99 |
-
assert(data.data?.id === sessionId, "id mismatch in data");
|
| 100 |
-
});
|
| 101 |
-
|
| 102 |
-
// 8. List messages (empty)
|
| 103 |
-
await test("GET /session/{id}/message (empty)", async () => {
|
| 104 |
-
const data = await api(`/session/${sessionId}/message`);
|
| 105 |
-
assert(Array.isArray(data), "not array");
|
| 106 |
-
});
|
| 107 |
-
|
| 108 |
-
// 9. Send message (requires {parts: [{type:'text', text:'...'}]})
|
| 109 |
-
await test("POST /session/{id}/message (send)", async () => {
|
| 110 |
-
const data = await api(`/session/${sessionId}/message`, {
|
| 111 |
-
method: "POST",
|
| 112 |
-
body: JSON.stringify({
|
| 113 |
-
parts: [{ type: "text", text: "Say exactly: integration test ok" }],
|
| 114 |
-
}),
|
| 115 |
-
});
|
| 116 |
-
assert(data.info || data.parts, "unexpected response shape");
|
| 117 |
-
});
|
| 118 |
-
|
| 119 |
-
// 10. SSE event stream (global endpoint)
|
| 120 |
-
await test("GET /event (SSE)", async () => {
|
| 121 |
-
const controller = new AbortController();
|
| 122 |
-
const timeout = setTimeout(() => controller.abort(), 4000);
|
| 123 |
-
|
| 124 |
-
const res = await fetch(`${BASE}/event`, {
|
| 125 |
-
headers: { Accept: "text/event-stream" },
|
| 126 |
-
signal: controller.signal,
|
| 127 |
-
});
|
| 128 |
-
|
| 129 |
-
clearTimeout(timeout);
|
| 130 |
-
assert(res.ok, `HTTP ${res.status}`);
|
| 131 |
-
|
| 132 |
-
const reader = res.body!.getReader();
|
| 133 |
-
const decoder = new TextDecoder();
|
| 134 |
-
let buffer = "";
|
| 135 |
-
let eventCount = 0;
|
| 136 |
-
|
| 137 |
-
const readTimeout = setTimeout(() => reader.cancel(), 3000);
|
| 138 |
-
|
| 139 |
-
while (eventCount < 1) {
|
| 140 |
-
const { done, value } = await reader.read();
|
| 141 |
-
if (done) break;
|
| 142 |
-
buffer += decoder.decode(value, { stream: true });
|
| 143 |
-
const lines = buffer.split("\n");
|
| 144 |
-
buffer = lines.pop() || "";
|
| 145 |
-
for (const line of lines) {
|
| 146 |
-
if (line.startsWith("data: ") || line.startsWith("event: ")) eventCount++;
|
| 147 |
-
}
|
| 148 |
-
}
|
| 149 |
-
|
| 150 |
-
clearTimeout(readTimeout);
|
| 151 |
-
reader.cancel();
|
| 152 |
-
assert(eventCount > 0, `no events (got ${eventCount})`);
|
| 153 |
-
});
|
| 154 |
-
|
| 155 |
-
// 11. Providers
|
| 156 |
-
await test("GET /api/provider", async () => {
|
| 157 |
-
const data = await api("/api/provider");
|
| 158 |
-
assert(Array.isArray(data.data), "data is not array");
|
| 159 |
-
});
|
| 160 |
-
|
| 161 |
-
// 12. Models
|
| 162 |
-
await test("GET /api/model", async () => {
|
| 163 |
-
const data = await api("/api/model");
|
| 164 |
-
assert(Array.isArray(data.data), "data is not array");
|
| 165 |
-
});
|
| 166 |
-
|
| 167 |
-
// 13. Agents
|
| 168 |
-
await test("GET /api/agent", async () => {
|
| 169 |
-
const data = await api("/api/agent");
|
| 170 |
-
assert(Array.isArray(data.data), "data is not array");
|
| 171 |
-
});
|
| 172 |
-
|
| 173 |
-
// 14. Commands
|
| 174 |
-
await test("GET /api/command", async () => {
|
| 175 |
-
const data = await api("/api/command");
|
| 176 |
-
assert(Array.isArray(data.data), "data is not array");
|
| 177 |
-
});
|
| 178 |
-
|
| 179 |
-
// 15. Skills
|
| 180 |
-
await test("GET /api/skill", async () => {
|
| 181 |
-
const data = await api("/api/skill");
|
| 182 |
-
assert(Array.isArray(data.data), "data is not array");
|
| 183 |
-
});
|
| 184 |
-
|
| 185 |
-
// 16. File list
|
| 186 |
-
await test("GET /api/fs/list", async () => {
|
| 187 |
-
const data = await api("/api/fs/list?path=.");
|
| 188 |
-
assert(Array.isArray(data.data), "data is not array");
|
| 189 |
-
assert(data.data.length > 0, "empty file list");
|
| 190 |
-
});
|
| 191 |
-
|
| 192 |
-
// 17. File content (root endpoint)
|
| 193 |
-
await test("GET /file/content", async () => {
|
| 194 |
-
const data = await api("/file/content?path=package.json");
|
| 195 |
-
assert(data.content || typeof data === "string", "no content");
|
| 196 |
-
});
|
| 197 |
-
|
| 198 |
-
// 18. Find files (root endpoint)
|
| 199 |
-
await test("GET /find/file", async () => {
|
| 200 |
-
const data = await api("/find/file?query=package");
|
| 201 |
-
assert(Array.isArray(data), "not array");
|
| 202 |
-
});
|
| 203 |
-
|
| 204 |
-
// 19. PTY list
|
| 205 |
-
await test("GET /api/pty", async () => {
|
| 206 |
-
const data = await api("/api/pty");
|
| 207 |
-
assert(Array.isArray(data.data), "data is not array");
|
| 208 |
-
});
|
| 209 |
-
|
| 210 |
-
// 20. VCS status
|
| 211 |
-
await test("GET /vcs/status", async () => {
|
| 212 |
-
const data = await api("/vcs/status");
|
| 213 |
-
assert(typeof data === "object", "not object");
|
| 214 |
-
});
|
| 215 |
-
|
| 216 |
-
// 21. Delete session
|
| 217 |
-
await test("DELETE /session/{id}", async () => {
|
| 218 |
-
await api(`/session/${sessionId}`, { method: "DELETE" });
|
| 219 |
-
});
|
| 220 |
-
|
| 221 |
-
// 22. Verify deleted
|
| 222 |
-
await test("GET /session/{id} after delete", async () => {
|
| 223 |
-
try {
|
| 224 |
-
await api(`/session/${sessionId}`);
|
| 225 |
-
assert(false, "should have thrown");
|
| 226 |
-
} catch {
|
| 227 |
-
// Expected
|
| 228 |
-
}
|
| 229 |
-
});
|
| 230 |
-
|
| 231 |
-
// Summary
|
| 232 |
-
console.log("\n=== Results ===\n");
|
| 233 |
-
const passed = results.filter((r) => r.passed).length;
|
| 234 |
-
const failed = results.filter((r) => !r.passed).length;
|
| 235 |
-
console.log(` ${passed} passed, ${failed} failed out of ${results.length} tests`);
|
| 236 |
-
|
| 237 |
-
if (failed > 0) {
|
| 238 |
-
console.log("\n Failed tests:");
|
| 239 |
-
results
|
| 240 |
-
.filter((r) => !r.passed)
|
| 241 |
-
.forEach((r) => console.log(` - ${r.name}: ${r.error}`));
|
| 242 |
-
}
|
| 243 |
-
|
| 244 |
-
console.log("");
|
| 245 |
-
process.exit(failed > 0 ? 1 : 0);
|
| 246 |
-
}
|
| 247 |
-
|
| 248 |
-
run().catch((err) => {
|
| 249 |
-
console.error("Fatal:", err);
|
| 250 |
-
process.exit(1);
|
| 251 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/tsconfig.json
DELETED
|
@@ -1,15 +0,0 @@
|
|
| 1 |
-
{
|
| 2 |
-
"extends": "expo/tsconfig.base",
|
| 3 |
-
"compilerOptions": {
|
| 4 |
-
"strict": true,
|
| 5 |
-
"paths": {
|
| 6 |
-
"@/*": [
|
| 7 |
-
"./*"
|
| 8 |
-
]
|
| 9 |
-
}
|
| 10 |
-
},
|
| 11 |
-
"include": [
|
| 12 |
-
"**/*.ts",
|
| 13 |
-
"**/*.tsx"
|
| 14 |
-
]
|
| 15 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
sync_engine.py
DELETED
|
@@ -1,560 +0,0 @@
|
|
| 1 |
-
#!/usr/bin/env python3
|
| 2 |
-
"""
|
| 3 |
-
OpenCode Sync Engine
|
| 4 |
-
====================
|
| 5 |
-
Persists /projects/default (workspace), /data/share/opencode (OpenCode DB),
|
| 6 |
-
and /data/config/opencode (OpenCode config) to a private HF Dataset repo.
|
| 7 |
-
|
| 8 |
-
Usage:
|
| 9 |
-
python3 /sync_engine.py restore — pull dataset → local dirs at startup
|
| 10 |
-
python3 /sync_engine.py watch — background sync daemon
|
| 11 |
-
python3 /sync_engine.py sync — one-shot sync then exit
|
| 12 |
-
|
| 13 |
-
Environment variables (set as Space Secrets):
|
| 14 |
-
HF_TOKEN — HuggingFace token with read+write access to the dataset
|
| 15 |
-
HF_DATASET — dataset repo id (default: Jishnupg/OpenCode-Storage)
|
| 16 |
-
"""
|
| 17 |
-
|
| 18 |
-
import sys
|
| 19 |
-
import os
|
| 20 |
-
import time
|
| 21 |
-
import hashlib
|
| 22 |
-
import logging
|
| 23 |
-
import traceback
|
| 24 |
-
import shutil
|
| 25 |
-
import tempfile
|
| 26 |
-
from pathlib import Path
|
| 27 |
-
|
| 28 |
-
# ---------------------------------------------------------------------------
|
| 29 |
-
# Logging
|
| 30 |
-
# ---------------------------------------------------------------------------
|
| 31 |
-
logging.basicConfig(
|
| 32 |
-
level=logging.INFO,
|
| 33 |
-
format="[%(asctime)s] [SYNC] %(levelname)s %(message)s",
|
| 34 |
-
datefmt="%H:%M:%S",
|
| 35 |
-
stream=sys.stdout,
|
| 36 |
-
force=True,
|
| 37 |
-
)
|
| 38 |
-
log = logging.getLogger("sync")
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
# ---------------------------------------------------------------------------
|
| 42 |
-
# Configuration
|
| 43 |
-
# ---------------------------------------------------------------------------
|
| 44 |
-
HF_TOKEN = os.environ.get("HF_TOKEN", "")
|
| 45 |
-
HF_DATASET = os.environ.get("HF_DATASET", "Jishnupg/OpenCode-Storage")
|
| 46 |
-
|
| 47 |
-
# ─── Watched directories ─────────────────────────────────────────────────────
|
| 48 |
-
# Scoped to the SPECIFIC OpenCode subdirectories only.
|
| 49 |
-
# The XDG roots /data/share and /data/config contain Android SDK caches,
|
| 50 |
-
# nvm installations, sdkbin-*/sdkinf-* files and other system noise —
|
| 51 |
-
# none of that should ever be synced.
|
| 52 |
-
#
|
| 53 |
-
# "workspace" → /projects/default (user's project files)
|
| 54 |
-
# "share" → /data/share/opencode (OpenCode SQLite DB)
|
| 55 |
-
# "config" → /data/config/opencode (OpenCode config JSON)
|
| 56 |
-
# ─────────────────────────────────────────────────────────────────────────────
|
| 57 |
-
WATCH_DIRS: dict[str, Path] = {
|
| 58 |
-
"workspace": Path("/projects/default"),
|
| 59 |
-
"share": Path("/data/share/opencode"),
|
| 60 |
-
"config": Path("/data/config/opencode"),
|
| 61 |
-
}
|
| 62 |
-
|
| 63 |
-
# Exact directory-name segments to skip anywhere in the path
|
| 64 |
-
IGNORE_NAMES: set[str] = {
|
| 65 |
-
".git",
|
| 66 |
-
"node_modules",
|
| 67 |
-
"__pycache__",
|
| 68 |
-
".cache",
|
| 69 |
-
".npm",
|
| 70 |
-
".yarn",
|
| 71 |
-
".pnpm",
|
| 72 |
-
".pip",
|
| 73 |
-
"dist",
|
| 74 |
-
"build",
|
| 75 |
-
# Android / SDK
|
| 76 |
-
".android",
|
| 77 |
-
".gradle",
|
| 78 |
-
".kotlin",
|
| 79 |
-
"sdktools",
|
| 80 |
-
# Temp / logs / misc
|
| 81 |
-
"tmp",
|
| 82 |
-
"temp",
|
| 83 |
-
"logs",
|
| 84 |
-
".local",
|
| 85 |
-
}
|
| 86 |
-
|
| 87 |
-
# Name *prefixes* to skip (catches sdkbin-*, sdkinf-*, etc.)
|
| 88 |
-
IGNORE_PREFIXES: tuple[str, ...] = (
|
| 89 |
-
"sdkbin-",
|
| 90 |
-
"sdkinf-",
|
| 91 |
-
"tmp-",
|
| 92 |
-
"temp-",
|
| 93 |
-
".~",
|
| 94 |
-
)
|
| 95 |
-
|
| 96 |
-
# File suffixes to skip
|
| 97 |
-
IGNORE_SUFFIXES: set[str] = {
|
| 98 |
-
".tmp", ".sock", ".pid", ".lock", ".pyc",
|
| 99 |
-
".log", ".bak", ".swp", ".swo",
|
| 100 |
-
}
|
| 101 |
-
|
| 102 |
-
MAX_FILE_BYTES = 50 * 1_024 * 1_024 # 50 MB hard limit per file
|
| 103 |
-
POLL_INTERVAL_SECS = 15 # how often to check for changes
|
| 104 |
-
CHECKPOINT_INTERVAL_SECS = 300 # full-disk re-scan interval
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
# ---------------------------------------------------------------------------
|
| 108 |
-
# Helpers
|
| 109 |
-
# ---------------------------------------------------------------------------
|
| 110 |
-
|
| 111 |
-
def _should_skip(path: Path) -> bool:
|
| 112 |
-
"""Return True if this file should not be synced."""
|
| 113 |
-
for part in path.parts:
|
| 114 |
-
if part in IGNORE_NAMES:
|
| 115 |
-
return True
|
| 116 |
-
for pfx in IGNORE_PREFIXES:
|
| 117 |
-
if part.startswith(pfx):
|
| 118 |
-
return True
|
| 119 |
-
if path.suffix in IGNORE_SUFFIXES:
|
| 120 |
-
return True
|
| 121 |
-
try:
|
| 122 |
-
if path.stat().st_size > MAX_FILE_BYTES:
|
| 123 |
-
log.warning(f"Skip (too large): {path}")
|
| 124 |
-
return True
|
| 125 |
-
except OSError:
|
| 126 |
-
return True
|
| 127 |
-
return False
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
def _md5(path: Path) -> str:
|
| 131 |
-
h = hashlib.md5()
|
| 132 |
-
try:
|
| 133 |
-
with open(path, "rb") as fh:
|
| 134 |
-
for chunk in iter(lambda: fh.read(65_536), b""):
|
| 135 |
-
h.update(chunk)
|
| 136 |
-
except OSError:
|
| 137 |
-
return ""
|
| 138 |
-
return h.hexdigest()
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
def _repo_path(prefix: str, local_path: Path, base: Path) -> str:
|
| 142 |
-
"""Convert a local absolute path to its dataset repo path."""
|
| 143 |
-
return f"{prefix}/{local_path.relative_to(base)}"
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
def _local_path(repo_file: str) -> Path | None:
|
| 147 |
-
"""Convert a dataset repo path back to a local absolute path."""
|
| 148 |
-
parts = repo_file.split("/", 1)
|
| 149 |
-
if len(parts) < 2:
|
| 150 |
-
return None
|
| 151 |
-
prefix, rel = parts
|
| 152 |
-
base = WATCH_DIRS.get(prefix)
|
| 153 |
-
if base is None:
|
| 154 |
-
return None
|
| 155 |
-
return base / rel
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
# ---------------------------------------------------------------------------
|
| 159 |
-
# SyncEngine
|
| 160 |
-
# ---------------------------------------------------------------------------
|
| 161 |
-
|
| 162 |
-
class SyncEngine:
|
| 163 |
-
def __init__(self) -> None:
|
| 164 |
-
self._api = None
|
| 165 |
-
self._known: dict[str, str] = {} # repo_path → md5
|
| 166 |
-
|
| 167 |
-
if not HF_TOKEN:
|
| 168 |
-
log.warning(
|
| 169 |
-
"HF_TOKEN not set — sync is disabled.\n"
|
| 170 |
-
"Add HF_TOKEN to your Space Secrets to enable persistent storage."
|
| 171 |
-
)
|
| 172 |
-
return
|
| 173 |
-
|
| 174 |
-
try:
|
| 175 |
-
from huggingface_hub import HfApi
|
| 176 |
-
self._api = HfApi(token=HF_TOKEN)
|
| 177 |
-
log.info(f"Sync engine ready → {HF_DATASET}")
|
| 178 |
-
log.info("Watching directories:")
|
| 179 |
-
for prefix, path in WATCH_DIRS.items():
|
| 180 |
-
log.info(f" [{prefix}] → {path}")
|
| 181 |
-
except ImportError:
|
| 182 |
-
log.error("huggingface_hub not installed — sync disabled")
|
| 183 |
-
|
| 184 |
-
# ------------------------------------------------------------------
|
| 185 |
-
# Repo helpers
|
| 186 |
-
# ------------------------------------------------------------------
|
| 187 |
-
|
| 188 |
-
def _ensure_repo(self) -> bool:
|
| 189 |
-
try:
|
| 190 |
-
self._api.create_repo(
|
| 191 |
-
repo_id=HF_DATASET,
|
| 192 |
-
repo_type="dataset",
|
| 193 |
-
private=True,
|
| 194 |
-
exist_ok=True,
|
| 195 |
-
)
|
| 196 |
-
return True
|
| 197 |
-
except Exception as exc:
|
| 198 |
-
log.error(f"Could not create/access dataset repo {HF_DATASET}: {exc}")
|
| 199 |
-
log.error(traceback.format_exc())
|
| 200 |
-
return False
|
| 201 |
-
|
| 202 |
-
def _test_write(self) -> bool:
|
| 203 |
-
try:
|
| 204 |
-
from huggingface_hub import CommitOperationAdd
|
| 205 |
-
import io
|
| 206 |
-
self._api.create_commit(
|
| 207 |
-
repo_id=HF_DATASET,
|
| 208 |
-
repo_type="dataset",
|
| 209 |
-
commit_message="sync: write-access test",
|
| 210 |
-
operations=[CommitOperationAdd(
|
| 211 |
-
path_in_repo=".sync-ok",
|
| 212 |
-
path_or_fileobj=io.BytesIO(b"ok"),
|
| 213 |
-
)],
|
| 214 |
-
)
|
| 215 |
-
log.info("✅ Write access confirmed — dataset sync is active")
|
| 216 |
-
return True
|
| 217 |
-
except Exception as exc:
|
| 218 |
-
log.error("❌ SYNC WRITE FAILED — files will NOT be saved to dataset")
|
| 219 |
-
log.error(f" Error: {exc}")
|
| 220 |
-
log.error(traceback.format_exc())
|
| 221 |
-
return False
|
| 222 |
-
|
| 223 |
-
# ------------------------------------------------------------------
|
| 224 |
-
# Restore (startup)
|
| 225 |
-
# ------------------------------------------------------------------
|
| 226 |
-
|
| 227 |
-
def restore(self) -> None:
|
| 228 |
-
if not self._api:
|
| 229 |
-
log.info("Restore skipped (sync disabled)")
|
| 230 |
-
return
|
| 231 |
-
|
| 232 |
-
if not self._ensure_repo():
|
| 233 |
-
log.warning("Skipping restore — dataset repo not accessible")
|
| 234 |
-
return
|
| 235 |
-
|
| 236 |
-
self._test_write()
|
| 237 |
-
|
| 238 |
-
log.info(f"=== RESTORE: pulling {HF_DATASET} ===")
|
| 239 |
-
try:
|
| 240 |
-
from huggingface_hub import snapshot_download
|
| 241 |
-
with tempfile.TemporaryDirectory(prefix="hf_restore_") as tmp:
|
| 242 |
-
local_repo = snapshot_download(
|
| 243 |
-
repo_id=HF_DATASET,
|
| 244 |
-
repo_type="dataset",
|
| 245 |
-
token=HF_TOKEN,
|
| 246 |
-
local_dir=tmp,
|
| 247 |
-
local_dir_use_symlinks=False,
|
| 248 |
-
ignore_patterns=["*.gitattributes", ".gitattributes"],
|
| 249 |
-
)
|
| 250 |
-
count = self._copy_snapshot(Path(local_repo))
|
| 251 |
-
log.info(f"=== RESTORE complete: {count} files ===")
|
| 252 |
-
except Exception as exc:
|
| 253 |
-
log.warning(f"Restore skipped or partial: {exc}")
|
| 254 |
-
log.warning(traceback.format_exc())
|
| 255 |
-
|
| 256 |
-
def _copy_snapshot(self, repo_root: Path) -> int:
|
| 257 |
-
"""
|
| 258 |
-
Copy files from a local snapshot dir to their actual locations.
|
| 259 |
-
Only restores files that would NOT be skipped by _should_skip, so that
|
| 260 |
-
a previously-contaminated dataset does not poison the watched dirs.
|
| 261 |
-
"""
|
| 262 |
-
count = 0
|
| 263 |
-
for prefix, dest_base in WATCH_DIRS.items():
|
| 264 |
-
src_base = repo_root / prefix
|
| 265 |
-
if not src_base.exists():
|
| 266 |
-
log.info(f" No '{prefix}' in dataset yet — skipping")
|
| 267 |
-
continue
|
| 268 |
-
for src_file in src_base.rglob("*"):
|
| 269 |
-
if not src_file.is_file():
|
| 270 |
-
continue
|
| 271 |
-
rel = src_file.relative_to(src_base)
|
| 272 |
-
dest_file = dest_base / rel
|
| 273 |
-
|
| 274 |
-
# Apply the same skip filter used during scanning —
|
| 275 |
-
# prevents old contaminated dataset entries from coming back
|
| 276 |
-
if _should_skip(dest_file):
|
| 277 |
-
log.debug(f" Restore skipped (filtered): {dest_file}")
|
| 278 |
-
continue
|
| 279 |
-
|
| 280 |
-
dest_file.parent.mkdir(parents=True, exist_ok=True)
|
| 281 |
-
try:
|
| 282 |
-
shutil.copy2(src_file, dest_file)
|
| 283 |
-
log.info(f" Restored: dataset:{prefix}/{rel} → {dest_file}")
|
| 284 |
-
count += 1
|
| 285 |
-
except Exception as exc:
|
| 286 |
-
log.warning(f" Could not restore {dest_file}: {exc}")
|
| 287 |
-
return count
|
| 288 |
-
|
| 289 |
-
# ------------------------------------------------------------------
|
| 290 |
-
# Local junk cleanup
|
| 291 |
-
# ------------------------------------------------------------------
|
| 292 |
-
|
| 293 |
-
def _clean_watched_dirs(self) -> int:
|
| 294 |
-
"""
|
| 295 |
-
Remove files from the watched dirs that fail the _should_skip filter.
|
| 296 |
-
This is a safety pass to evict any contamination left over from
|
| 297 |
-
contaminated restores (e.g. Android SDK cache files in /data/share).
|
| 298 |
-
Returns the number of files removed.
|
| 299 |
-
"""
|
| 300 |
-
removed = 0
|
| 301 |
-
for prefix, base in WATCH_DIRS.items():
|
| 302 |
-
if not base.exists():
|
| 303 |
-
continue
|
| 304 |
-
for path in sorted(base.rglob("*"), reverse=True): # deep first
|
| 305 |
-
if not path.is_file():
|
| 306 |
-
continue
|
| 307 |
-
if _should_skip(path):
|
| 308 |
-
try:
|
| 309 |
-
path.unlink()
|
| 310 |
-
log.info(f" Cleaned junk from watched dir: {path}")
|
| 311 |
-
removed += 1
|
| 312 |
-
except Exception as exc:
|
| 313 |
-
log.warning(f" Could not clean {path}: {exc}")
|
| 314 |
-
# Remove now-empty dirs (cosmetic, non-fatal)
|
| 315 |
-
for prefix, base in WATCH_DIRS.items():
|
| 316 |
-
if not base.exists():
|
| 317 |
-
continue
|
| 318 |
-
for d in sorted(base.rglob("*"), reverse=True):
|
| 319 |
-
if d.is_dir():
|
| 320 |
-
try:
|
| 321 |
-
d.rmdir() # only succeeds if empty
|
| 322 |
-
except OSError:
|
| 323 |
-
pass
|
| 324 |
-
return removed
|
| 325 |
-
|
| 326 |
-
# ------------------------------------------------------------------
|
| 327 |
-
# Dataset contamination purge
|
| 328 |
-
# ------------------------------------------------------------------
|
| 329 |
-
|
| 330 |
-
def _purge_dataset_junk(self, valid_repo_paths: set[str]) -> int:
|
| 331 |
-
"""
|
| 332 |
-
Delete files from the dataset that are NOT in valid_repo_paths.
|
| 333 |
-
Removes any entries that don't correspond to currently tracked local
|
| 334 |
-
files, guarding against dataset contamination from future regressions.
|
| 335 |
-
Returns the number of files deleted from the dataset.
|
| 336 |
-
"""
|
| 337 |
-
if not self._api:
|
| 338 |
-
return 0
|
| 339 |
-
|
| 340 |
-
log.info("=== DATASET PURGE: scanning for stale entries ===")
|
| 341 |
-
try:
|
| 342 |
-
from huggingface_hub.hf_api import RepoFile
|
| 343 |
-
dataset_files = self._api.list_repo_tree(
|
| 344 |
-
repo_id=HF_DATASET,
|
| 345 |
-
repo_type="dataset",
|
| 346 |
-
recursive=True,
|
| 347 |
-
)
|
| 348 |
-
to_delete = []
|
| 349 |
-
for f in dataset_files:
|
| 350 |
-
if not isinstance(f, RepoFile):
|
| 351 |
-
continue # skip directory entries
|
| 352 |
-
rp = f.path
|
| 353 |
-
if rp.startswith("."): # .gitattributes, .sync-ok etc.
|
| 354 |
-
continue
|
| 355 |
-
# If it's not in our current valid set, delete it
|
| 356 |
-
if rp not in valid_repo_paths:
|
| 357 |
-
to_delete.append(rp)
|
| 358 |
-
|
| 359 |
-
if not to_delete:
|
| 360 |
-
log.info(" Dataset is clean — no stale entries found")
|
| 361 |
-
return 0
|
| 362 |
-
|
| 363 |
-
log.info(f" Found {len(to_delete)} stale entries to remove from dataset")
|
| 364 |
-
for rp in to_delete[:10]:
|
| 365 |
-
log.info(f" - {rp}")
|
| 366 |
-
if len(to_delete) > 10:
|
| 367 |
-
log.info(f" ... and {len(to_delete) - 10} more")
|
| 368 |
-
|
| 369 |
-
from huggingface_hub import CommitOperationDelete
|
| 370 |
-
ops = [CommitOperationDelete(path_in_repo=rp) for rp in to_delete]
|
| 371 |
-
|
| 372 |
-
# Delete in batches of 200 to stay within API limits
|
| 373 |
-
batch_size = 200
|
| 374 |
-
total_deleted = 0
|
| 375 |
-
for i in range(0, len(ops), batch_size):
|
| 376 |
-
batch = ops[i:i + batch_size]
|
| 377 |
-
bnum = i // batch_size + 1
|
| 378 |
-
self._api.create_commit(
|
| 379 |
-
repo_id=HF_DATASET,
|
| 380 |
-
repo_type="dataset",
|
| 381 |
-
commit_message=f"sync: purge {len(batch)} stale entries (batch {bnum})",
|
| 382 |
-
operations=batch,
|
| 383 |
-
)
|
| 384 |
-
total_deleted += len(batch)
|
| 385 |
-
log.info(f" Purge batch {bnum}: deleted {len(batch)} entries")
|
| 386 |
-
|
| 387 |
-
log.info(f"=== DATASET PURGE complete: {total_deleted} entries removed ===")
|
| 388 |
-
return total_deleted
|
| 389 |
-
|
| 390 |
-
except Exception as exc:
|
| 391 |
-
log.error(f"Dataset purge failed (non-fatal): {exc}")
|
| 392 |
-
log.error(traceback.format_exc())
|
| 393 |
-
return 0
|
| 394 |
-
|
| 395 |
-
# ------------------------------------------------------------------
|
| 396 |
-
# Scanning
|
| 397 |
-
# ------------------------------------------------------------------
|
| 398 |
-
|
| 399 |
-
def _scan(self) -> dict[str, str]:
|
| 400 |
-
"""Scan all watched dirs; return {repo_path: md5}."""
|
| 401 |
-
result: dict[str, str] = {}
|
| 402 |
-
for prefix, base in WATCH_DIRS.items():
|
| 403 |
-
if not base.exists():
|
| 404 |
-
continue
|
| 405 |
-
try:
|
| 406 |
-
n_tracked = n_skipped = 0
|
| 407 |
-
for path in base.rglob("*"):
|
| 408 |
-
if not path.is_file():
|
| 409 |
-
continue
|
| 410 |
-
if _should_skip(path):
|
| 411 |
-
n_skipped += 1
|
| 412 |
-
continue
|
| 413 |
-
rp = _repo_path(prefix, path, base)
|
| 414 |
-
result[rp] = _md5(path)
|
| 415 |
-
n_tracked += 1
|
| 416 |
-
log.debug(f"Scan [{prefix}] {base}: {n_tracked} tracked, {n_skipped} skipped")
|
| 417 |
-
except Exception as exc:
|
| 418 |
-
log.warning(f"Scan error in {base}: {exc}")
|
| 419 |
-
log.warning(traceback.format_exc())
|
| 420 |
-
return result
|
| 421 |
-
|
| 422 |
-
# ------------------------------------------------------------------
|
| 423 |
-
# Sync (one shot)
|
| 424 |
-
# ------------------------------------------------------------------
|
| 425 |
-
|
| 426 |
-
def sync_once(self) -> tuple[int, int]:
|
| 427 |
-
"""Detect changes vs _known and push them to the dataset."""
|
| 428 |
-
if not self._api:
|
| 429 |
-
return 0, 0
|
| 430 |
-
|
| 431 |
-
current = self._scan()
|
| 432 |
-
|
| 433 |
-
to_upload = [rp for rp, md5 in current.items() if self._known.get(rp) != md5]
|
| 434 |
-
to_delete = [rp for rp in self._known if rp not in current]
|
| 435 |
-
|
| 436 |
-
if not to_upload and not to_delete:
|
| 437 |
-
self._known = current
|
| 438 |
-
return 0, 0
|
| 439 |
-
|
| 440 |
-
try:
|
| 441 |
-
from huggingface_hub import CommitOperationAdd, CommitOperationDelete
|
| 442 |
-
|
| 443 |
-
ops: list = []
|
| 444 |
-
for rp in to_upload:
|
| 445 |
-
local = _local_path(rp)
|
| 446 |
-
if local and local.exists():
|
| 447 |
-
log.info(f" UPLOAD local:{local} → dataset:{rp}")
|
| 448 |
-
ops.append(CommitOperationAdd(path_in_repo=rp, path_or_fileobj=str(local)))
|
| 449 |
-
else:
|
| 450 |
-
log.warning(f" UPLOAD skipped (file vanished): {rp}")
|
| 451 |
-
for rp in to_delete:
|
| 452 |
-
log.info(f" DELETE dataset:{rp}")
|
| 453 |
-
ops.append(CommitOperationDelete(path_in_repo=rp))
|
| 454 |
-
|
| 455 |
-
if not ops:
|
| 456 |
-
self._known = current
|
| 457 |
-
return 0, 0
|
| 458 |
-
|
| 459 |
-
n_up = len(to_upload)
|
| 460 |
-
n_del = len(to_delete)
|
| 461 |
-
msg = f"sync: +{n_up} ~{n_del}"
|
| 462 |
-
|
| 463 |
-
log.info(f"Committing '{msg}' → {HF_DATASET} ({len(ops)} ops) ...")
|
| 464 |
-
commit_info = self._api.create_commit(
|
| 465 |
-
repo_id=HF_DATASET,
|
| 466 |
-
repo_type="dataset",
|
| 467 |
-
commit_message=msg,
|
| 468 |
-
operations=ops,
|
| 469 |
-
)
|
| 470 |
-
url = getattr(commit_info, "commit_url", "ok")
|
| 471 |
-
log.info(f"✅ Commit created: {url}")
|
| 472 |
-
log.info(f"↑ Pushed: +{n_up} uploads, -{n_del} deletes → {HF_DATASET}")
|
| 473 |
-
|
| 474 |
-
self._known = current
|
| 475 |
-
return n_up, n_del
|
| 476 |
-
|
| 477 |
-
except Exception as exc:
|
| 478 |
-
log.error(f"❌ Commit FAILED (will retry next cycle): {exc}")
|
| 479 |
-
log.error(traceback.format_exc())
|
| 480 |
-
return 0, 0
|
| 481 |
-
|
| 482 |
-
# ------------------------------------------------------------------
|
| 483 |
-
# Watch loop (daemon)
|
| 484 |
-
# ------------------------------------------------------------------
|
| 485 |
-
|
| 486 |
-
def watch(self) -> None:
|
| 487 |
-
if not self._api:
|
| 488 |
-
log.info("Watch loop running in no-op mode (HF_TOKEN not set)")
|
| 489 |
-
while True:
|
| 490 |
-
time.sleep(3_600)
|
| 491 |
-
|
| 492 |
-
# Step 1: Establish baseline
|
| 493 |
-
log.info("=== WATCH: establishing baseline scan ===")
|
| 494 |
-
self._known = self._scan()
|
| 495 |
-
log.info(f"Baseline: {len(self._known)} files tracked")
|
| 496 |
-
log.info("Polling every %ds. Watched dirs:", POLL_INTERVAL_SECS)
|
| 497 |
-
for prefix, path in WATCH_DIRS.items():
|
| 498 |
-
log.info(f" [{prefix}] {path}")
|
| 499 |
-
|
| 500 |
-
# Step 3: Purge any stale dataset entries not present in the baseline
|
| 501 |
-
self._purge_dataset_junk(set(self._known.keys()))
|
| 502 |
-
|
| 503 |
-
last_checkpoint = time.monotonic()
|
| 504 |
-
|
| 505 |
-
# Step 4: Poll loop
|
| 506 |
-
while True:
|
| 507 |
-
time.sleep(POLL_INTERVAL_SECS)
|
| 508 |
-
try:
|
| 509 |
-
now = time.monotonic()
|
| 510 |
-
|
| 511 |
-
if (now - last_checkpoint) >= CHECKPOINT_INTERVAL_SECS:
|
| 512 |
-
# Refresh the baseline FROM DISK; only upload genuinely changed files.
|
| 513 |
-
# We do NOT reset self._known to {} — that would cause mass re-uploads.
|
| 514 |
-
log.info("[CHECKPOINT] Refreshing baseline from disk ...")
|
| 515 |
-
self._known = self._scan()
|
| 516 |
-
last_checkpoint = now
|
| 517 |
-
|
| 518 |
-
n_up, n_del = self.sync_once()
|
| 519 |
-
|
| 520 |
-
if n_up or n_del:
|
| 521 |
-
log.info(f"Sync cycle: +{n_up} ~{n_del}")
|
| 522 |
-
|
| 523 |
-
except Exception as exc:
|
| 524 |
-
log.error(f"Watch cycle error: {exc}")
|
| 525 |
-
log.error(traceback.format_exc())
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
# ---------------------------------------------------------------------------
|
| 529 |
-
# Entry point
|
| 530 |
-
# ---------------------------------------------------------------------------
|
| 531 |
-
|
| 532 |
-
def main() -> None:
|
| 533 |
-
mode = sys.argv[1] if len(sys.argv) > 1 else "watch"
|
| 534 |
-
|
| 535 |
-
for d in [
|
| 536 |
-
"/data/share/opencode",
|
| 537 |
-
"/data/config/opencode",
|
| 538 |
-
"/data/cache/opencode",
|
| 539 |
-
"/data/state/opencode",
|
| 540 |
-
"/data/logs",
|
| 541 |
-
"/data/workspaces",
|
| 542 |
-
"/projects/default",
|
| 543 |
-
]:
|
| 544 |
-
Path(d).mkdir(parents=True, exist_ok=True)
|
| 545 |
-
|
| 546 |
-
engine = SyncEngine()
|
| 547 |
-
|
| 548 |
-
if mode == "restore":
|
| 549 |
-
engine.restore()
|
| 550 |
-
elif mode == "watch":
|
| 551 |
-
engine.watch()
|
| 552 |
-
elif mode == "sync":
|
| 553 |
-
engine.sync_once()
|
| 554 |
-
else:
|
| 555 |
-
print(f"Usage: sync_engine.py [restore|watch|sync]", file=sys.stderr)
|
| 556 |
-
sys.exit(1)
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
if __name__ == "__main__":
|
| 560 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|