diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 5626704..dbbc2da 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -84,6 +84,78 @@ curl "http://localhost:3000/api/control/missions//events?types=tool_ **Code entry points**: `src/api/mission_store/` handles persistence; `src/api/control.rs` exposes the events endpoint. +## Dashboard Data Fetching (SWR) + +The dashboard uses [SWR](https://swr.vercel.app/) for data fetching with stale-while-revalidate caching. This provides instant UI updates from cache while revalidating in the background. + +### Common SWR Keys + +Use consistent keys to enable cache sharing across components: + +| Key | Fetcher | Used In | +|-----|---------|---------| +| `'stats'` | `getStats` | Overview page (3s polling) | +| `'workspaces'` | `listWorkspaces` | Overview, Workspaces page | +| `'missions'` | `listMissions` | Recent tasks sidebar, History page | +| `'workspace-templates'` | `listWorkspaceTemplates` | Workspaces page | +| `'library-skills'` | `listLibrarySkills` | Workspaces page | +| `'ai-providers'` | `listAIProviders` | Settings page | +| `'health'` | `getHealth` | Settings page | +| `'system-components'` | `getSystemComponents` | Server connection card | +| `'opencode-agents'` | `getVisibleAgents` | New mission dialog | +| `'openagent-config'` | `getOpenAgentConfig` | New mission dialog | +| `'tools'` | `listTools` | Tools page | + +### Usage Patterns + +**Basic fetch (no polling):** +```tsx +const { data, isLoading, error } = useSWR('workspaces', listWorkspaces, { + revalidateOnFocus: false, +}); +``` + +**With polling:** +```tsx +const { data } = useSWR('stats', getStats, { + refreshInterval: 3000, + revalidateOnFocus: false, +}); +``` + +**After mutations (revalidate cache):** +```tsx +const { mutate } = useSWR('missions', listMissions); +// After deleting a mission: +await deleteMission(id); +mutate(); // Revalidates from server +``` + +**Optimistic updates:** +```tsx +mutate(missions.filter(m => m.id !== deletedId), false); // Update cache without revalidation +``` + +### Guidelines + +- Always use `revalidateOnFocus: false` unless you need tab-focus refresh +- Use the same SWR key when multiple components need the same data +- Prefer `mutate()` after mutations instead of manual state updates +- SWR returns `undefined` (not `null`) when data hasn't loaded - use `?? null` or `?? []` as needed + +## Production Deployment + +For deploying Open Agent on a VPS or dedicated server, see **[INSTALL.md](../INSTALL.md)**. + +This covers: +- systemd services for OpenCode and Open Agent +- nginx/Caddy reverse proxy with SSL (Let's Encrypt) +- DNS setup and domain configuration +- Authentication configuration +- Library git repo setup + +**If asked to deploy Open Agent**: Read INSTALL.md first. It contains an "AI Agents" section at the top listing prerequisites to collect from the user (server IP, domain, SSH access, Library repo URL). + ## Notes - OpenCode config files are generated per workspace; do not keep static `opencode.json` in the repo. diff --git a/.env.example b/.env.example index 8871f3c..7b4b609 100644 --- a/.env.example +++ b/.env.example @@ -48,12 +48,6 @@ JWT_TTL_DAYS=30 # ============================================================================= # No SSH configuration required. -# ============================================================================= -# Optional: File Sharing / Screenshots (Supabase) -# ============================================================================= -# SUPABASE_URL=https://your-project.supabase.co -# SUPABASE_SERVICE_ROLE_KEY=eyJ... - # ============================================================================= # Optional: Web Search (Tavily) # ============================================================================= diff --git a/INSTALL.md b/INSTALL.md index 5e61944..a0f4217 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -4,6 +4,15 @@ This is the installation approach currently used on a **dedicated Ubuntu 24.04 s Open Agent is the orchestrator/UI backend. **It does not run model inference**; it delegates execution to an **OpenCode server** running locally (default `http://127.0.0.1:4096`). +> **For AI Agents**: Before starting this installation, ask the user to provide: +> 1. **Server IP address** (e.g., `95.216.112.253`) +> 2. **Domain name** pointing to that IP (e.g., `agent.example.com`) +> 3. **SSH access** credentials or key path for the server +> 4. **Library git repo URL** (or confirm using the template) +> 5. **Dashboard password** to set for authentication (or offer to generate one) +> +> Verify you have SSH access before proceeding: `ssh root@ "hostname"` + --- ## 0) Assumptions @@ -15,6 +24,74 @@ Open Agent is the orchestrator/UI backend. **It does not run model inference**; - Open Agent bound to: `0.0.0.0:3000` - You have a Git repo for your **Library** (skills/tools/agents/rules/MCP configs) +> **Recommendation**: Unless you know exactly what you need, install **all components** in this guide: +> - **Bun** (required for OpenCode plugins and Playwright MCP) +> - **systemd-container + debootstrap** (for isolated container workspaces) +> - **Desktop automation tools** (Xvfb, i3, Chromium, xdotool, etc.) +> - **Reverse proxy with SSL** (Caddy or Nginx + Certbot) +> +> Skipping components may limit functionality. The full installation uses ~2-3 GB of disk space. + +--- + +## 0.5) DNS & Domain Setup (before you begin) + +Before starting the installation, ensure your domain is configured: + +### 0.5.1 Point your domain to the server + +Add an A record in your DNS provider: + +``` +agent.yourdomain.com → A → YOUR_SERVER_IP +``` + +Example with common providers: +- **Cloudflare**: DNS → Add Record → Type: A, Name: `agent`, IPv4: `YOUR_SERVER_IP` +- **Namecheap**: Advanced DNS → Add New Record → A Record +- **Route53**: Create Record → Simple routing → A record + +### 0.5.2 Verify DNS propagation + +Wait for DNS to propagate (usually 1-15 minutes), then verify: + +```bash +# From your local machine +dig +short agent.yourdomain.com +# Should return your server IP + +# Or use an online checker +curl -s "https://dns.google/resolve?name=agent.yourdomain.com&type=A" | jq . +``` + +### 0.5.3 SSH key for Library repo (if private) + +If your Library repo is private, set up an SSH deploy key on the server: + +```bash +# On the server +ssh-keygen -t ed25519 -C "openagent-server" -f /root/.ssh/openagent -N "" +cat /root/.ssh/openagent.pub +# Copy this public key +``` + +Add the public key as a **deploy key** in your git provider: +- **GitHub**: Repository → Settings → Deploy keys → Add deploy key +- **GitLab**: Repository → Settings → Repository → Deploy keys + +Configure SSH to use the key: + +```bash +cat >> /root/.ssh/config <<'EOF' +Host github.com + IdentityFile /root/.ssh/openagent + IdentitiesOnly yes +EOF + +# Test the connection +ssh -T git@github.com +``` + --- ## 1) Install base OS dependencies @@ -26,19 +103,19 @@ apt install -y \ build-essential pkg-config libssl-dev ``` -If you plan to use container workspaces (systemd-nspawn), also install: +**Container workspaces** (systemd-nspawn) — recommended for isolated environments: ```bash apt install -y systemd-container debootstrap ``` -If you plan to use **desktop automation** tools (Xvfb/i3/Chromium screenshots/OCR), install: +**Desktop automation** (Xvfb/i3/Chromium screenshots/OCR) — recommended for browser control: ```bash apt install -y xvfb i3 x11-utils xdotool scrot imagemagick chromium chromium-sandbox tesseract-ocr ``` -See `docs/DESKTOP_SETUP.md` for a full checklist and i3 config recommendations. +See `docs/DESKTOP_SETUP.md` for i3 config and additional setup after installation. --- @@ -510,9 +587,179 @@ systemctl restart opencode.service curl -fsSL http://127.0.0.1:4096/global/health | jq . ``` -## Suggested improvements +## 10) Production Security (TLS + Reverse Proxy) -- Put Open Agent behind a reverse proxy (Caddy/Nginx) with TLS and restrict who can reach `:3000`. -- Set `DEV_MODE=false` in production and use strong JWT secrets / multi-user auth. -- Run OpenCode on localhost only (already recommended) and keep it firewalled. -- Pin OpenCode/plugin versions for reproducible deployments. +For production deployments, **always** put Open Agent behind a reverse proxy with TLS. The backend serves HTTP only and should never be exposed directly to the internet. + +### 10.1 Caddy (recommended - automatic HTTPS) + +Caddy automatically obtains and renews Let's Encrypt certificates. + +Install Caddy: + +```bash +apt install -y debian-keyring debian-archive-keyring apt-transport-https +curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg +curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list +apt update && apt install caddy +``` + +Create `/etc/caddy/Caddyfile`: + +``` +agent.yourdomain.com { + reverse_proxy localhost:3000 +} +``` + +Enable and start: + +```bash +systemctl enable --now caddy +``` + +Caddy will automatically obtain TLS certificates for your domain. + +### 10.2 Nginx (manual certificate setup) + +Install Nginx and Certbot: + +```bash +apt install -y nginx certbot python3-certbot-nginx +``` + +Create `/etc/nginx/sites-available/openagent`: + +```nginx +server { + listen 80; + server_name agent.yourdomain.com; + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # SSE support (for mission streaming) + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 86400s; + } +} +``` + +Enable the site and obtain certificates: + +```bash +ln -s /etc/nginx/sites-available/openagent /etc/nginx/sites-enabled/ +nginx -t && systemctl reload nginx +certbot --nginx -d agent.yourdomain.com +``` + +### 10.3 Firewall + +Block direct access to port 3000 from the internet: + +```bash +# Allow only localhost to reach Open Agent directly +iptables -A INPUT -p tcp --dport 3000 -s 127.0.0.1 -j ACCEPT +iptables -A INPUT -p tcp --dport 3000 -j DROP +``` + +--- + +## 11) Authentication Modes + +Open Agent supports three authentication modes: + +| Mode | Environment Variables | Use Case | +|------|----------------------|----------| +| **Disabled** | `DEV_MODE=true` | Local development only | +| **Single Tenant** | `DASHBOARD_PASSWORD`, `JWT_SECRET` | Personal server, one user | +| **Multi-User** | `OPEN_AGENT_USERS`, `JWT_SECRET` | Shared server, multiple users | + +### 11.1 Single Tenant (default for production) + +Set a strong password and JWT secret: + +```bash +# Generate a random JWT secret +JWT_SECRET=$(openssl rand -base64 32) + +# In /etc/open_agent/open_agent.env: +DEV_MODE=false +DASHBOARD_PASSWORD=your-strong-password-here +JWT_SECRET=$JWT_SECRET +JWT_TTL_DAYS=30 +``` + +### 11.2 Multi-User Mode + +For multiple users with separate credentials: + +```bash +# In /etc/open_agent/open_agent.env: +DEV_MODE=false +OPEN_AGENT_USERS='[ + {"username": "alice", "password": "alice-strong-password"}, + {"username": "bob", "password": "bob-strong-password"} +]' +JWT_SECRET=$(openssl rand -base64 32) +``` + +Note: Multi-user mode provides separate login credentials but does **not** provide workspace or data isolation between users. All users see the same missions and workspaces. + +--- + +## 12) Dashboard Configuration + +### 12.1 Web Dashboard + +The web dashboard auto-detects the backend URL. For production, set the environment variable: + +```bash +# When building/running the Next.js dashboard +NEXT_PUBLIC_API_URL=https://agent.yourdomain.com +``` + +Or configure it at runtime via the Settings page. + +### 12.2 iOS App + +On first launch, the iOS app prompts for the server URL. Enter your production URL (e.g., `https://agent.yourdomain.com`). + +To change later: **Menu (⋮) → Settings** + +--- + +## 13) OAuth Provider Setup + +Open Agent uses OAuth for AI provider authentication. The following providers are pre-configured: + +| Provider | OAuth Client | Setup Required | +|----------|-------------|----------------| +| **Anthropic** | OpenCode's client | None (works out of the box) | +| **OpenAI** | Codex CLI client | None (works out of the box) | +| **Google/Gemini** | Gemini CLI client | Install `opencode-gemini-auth` plugin | + +OAuth flows use copy-paste for the authorization code. The user: +1. Clicks "Authorize" in the dashboard +2. Completes OAuth in their browser +3. Copies the redirect URL back to the dashboard + +--- + +## Checklist for Production Deployment + +- [ ] Set `DEV_MODE=false` +- [ ] Set strong `DASHBOARD_PASSWORD` and `JWT_SECRET` +- [ ] Configure reverse proxy (Caddy or Nginx) with TLS +- [ ] Firewall port 3000 (only allow localhost) +- [ ] Pin OpenCode version for stability +- [ ] Set up your Library git repo +- [ ] Test OAuth flows for AI providers diff --git a/README.md b/README.md index abcaaa2..18282e6 100644 --- a/README.md +++ b/README.md @@ -80,19 +80,27 @@ Works great with [**oh-my-opencode**](https://github.com/code-yeongyu/oh-my-open ## Getting Started -### Prerequisites -- Rust 1.75+ -- Bun 1.0+ -- [OpenCode](https://github.com/anomalyco/opencode) server -- Linux host (Ubuntu/Debian for container workspaces) +### Production Setup (the easy way) -### Backend +1. Get a dedicated server (e.g., [Hetzner](https://www.hetzner.com/), Ubuntu 24.04) +2. Point a domain to your server IP +3. Clone this repo locally +4. Ask Claude to set it up: + > "Please deploy Open Agent on my server at `` with domain `agent.example.com`" + +That's it. Claude will handle nginx, SSL, systemd services, and everything else. + +If you feel smarter than the AI, check out **[INSTALL.md](./INSTALL.md)**. + +### Local Development + +**Backend**: ```bash export OPENCODE_BASE_URL="http://127.0.0.1:4096" -cargo run --release +cargo run ``` -### Dashboard +**Dashboard**: ```bash cd dashboard bun install diff --git a/SHARED_TASK_NOTES.md b/SHARED_TASK_NOTES.md deleted file mode 100644 index 7b3cfb1..0000000 --- a/SHARED_TASK_NOTES.md +++ /dev/null @@ -1,85 +0,0 @@ -# Encrypted Env Vars - Implementation Notes - -## Goal -Encryption-at-rest for workspace template env vars using `PRIVATE_KEY` in `.env`. - -## Current Status -- [x] Design complete -- [x] `src/library/env_crypto.rs` implemented with full test coverage (13 tests) -- [x] Integration into `get_workspace_template()` (decrypt on load) -- [x] Integration into `save_workspace_template()` (encrypt on save) -- [x] `.env.example` updated with PRIVATE_KEY documentation -- [x] All 43 tests passing - -## What Was Implemented - -### Encryption Format -``` -BASE64(nonce||ciphertext) -``` -- Version in wrapper allows future format changes -- 12-byte random nonce prepended to ciphertext -- AES-256-GCM AEAD encryption - -### Key Functions (`src/library/env_crypto.rs`) -- `is_encrypted(value)` - Check for wrapper format -- `encrypt_value(key, plaintext)` - Returns wrapped encrypted string -- `decrypt_value(key, value)` - Passthrough if plaintext, decrypt if wrapped -- `encrypt_env_vars()` / `decrypt_env_vars()` - Batch operations for HashMap -- `load_private_key_from_env()` - Load from PRIVATE_KEY (hex or base64) -- `load_or_create_private_key(path)` - Auto-generate if missing (async) -- `generate_private_key()` - Generate 32 random bytes - -### Integration Points -- `LibraryStore::get_workspace_template()` - Decrypts after JSON parse -- `LibraryStore::save_workspace_template()` - Encrypts before JSON serialize - -### Backward Compatibility -- Plaintext values pass through unchanged on decrypt -- Warning logged if encrypted values found but no key configured -- Warning logged if saving plaintext when no key configured - -## Remaining Work - -### 1. Auto-generate key on startup (Priority) -Currently `load_or_create_private_key()` exists but isn't called at startup. -Need to integrate into application initialization to auto-generate the key. - -Look at `src/main.rs` or startup code to call: -```rust -let env_path = std::env::current_dir()?.join(".env"); -env_crypto::load_or_create_private_key(&env_path).await?; -``` - -### 2. Key rotation command -Implement a CLI command or API endpoint to: -1. Load old key from env -2. Generate new key -3. Re-encrypt all template env vars with new key -4. Update .env with new key - -### 3. Integration tests -Add tests that actually save/load templates through `LibraryStore` with encryption. -Current tests only cover the crypto primitives. - -### 4. Dashboard UI verification -Verify the dashboard displays plaintext env vars correctly (no UX regression). -API endpoints should return decrypted values transparently. - -## Files Changed -- `src/library/env_crypto.rs` (NEW) - Crypto utilities -- `src/library/mod.rs` - Module declaration + template load/save integration -- `Cargo.toml` - Added `hex = "0.4"` dependency -- `.env.example` - Documented PRIVATE_KEY - -## Testing -```bash -cargo test --lib env_crypto # 13 crypto tests -cargo test --lib # All 43 tests -``` - -## Notes -- Key format: 64 hex chars OR base64-encoded 32 bytes -- No double-encryption (already-encrypted values pass through) -- Different encryptions produce different ciphertext (random nonce) -- Existing `src/secrets/crypto.rs` uses passphrase-based PBKDF2 (different use case) diff --git a/dashboard/bun.lock b/dashboard/bun.lock index cda5498..9ffdd9a 100644 --- a/dashboard/bun.lock +++ b/dashboard/bun.lock @@ -22,6 +22,7 @@ "recharts": "^3.6.0", "remark-gfm": "^4.0.1", "sonner": "^2.0.7", + "swr": "^2.3.8", "tailwind-merge": "^3.4.0", "xterm": "^5.3.0", "xterm-addon-fit": "^0.8.0", @@ -1092,6 +1093,8 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "swr": ["swr@2.3.8", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gaCPRVoMq8WGDcWj9p4YWzCMPHzE0WNl6W8ADIx9c3JBEIdMkJGMzW+uzXvxHMltwcYACr9jP+32H8/hgwMR7w=="], + "tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], diff --git a/dashboard/package.json b/dashboard/package.json index 8aeadd5..f65b6bc 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -29,6 +29,7 @@ "recharts": "^3.6.0", "remark-gfm": "^4.0.1", "sonner": "^2.0.7", + "swr": "^2.3.8", "tailwind-merge": "^3.4.0", "xterm": "^5.3.0", "xterm-addon-fit": "^0.8.0", diff --git a/dashboard/src/app/config/commands/page.tsx b/dashboard/src/app/config/commands/page.tsx index 6794af7..79753cc 100644 --- a/dashboard/src/app/config/commands/page.tsx +++ b/dashboard/src/app/config/commands/page.tsx @@ -168,7 +168,7 @@ Describe what this command does. } return ( -
+
{libraryUnavailable ? ( ) : ( diff --git a/dashboard/src/app/config/rules/page.tsx b/dashboard/src/app/config/rules/page.tsx index 405eb33..661d4d4 100644 --- a/dashboard/src/app/config/rules/page.tsx +++ b/dashboard/src/app/config/rules/page.tsx @@ -187,7 +187,7 @@ Describe what this rule does. } return ( -
+
{libraryUnavailable ? ( ) : ( diff --git a/dashboard/src/app/config/settings/page.tsx b/dashboard/src/app/config/settings/page.tsx index f1ef20f..3508c8d 100644 --- a/dashboard/src/app/config/settings/page.tsx +++ b/dashboard/src/app/config/settings/page.tsx @@ -4,15 +4,17 @@ import { useState, useEffect, useCallback } from 'react'; import { getLibraryOpenCodeSettings, saveLibraryOpenCodeSettings, + getOpenCodeSettings, restartOpenCodeService, getOpenAgentConfig, saveOpenAgentConfig, listOpenCodeAgents, OpenAgentConfig, } from '@/lib/api'; -import { Save, Loader, AlertCircle, Check, RefreshCw, RotateCcw, Eye, EyeOff } from 'lucide-react'; +import { Save, Loader, AlertCircle, Check, RefreshCw, RotateCcw, Eye, EyeOff, AlertTriangle, X, GitBranch, Upload } from 'lucide-react'; import { cn } from '@/lib/utils'; import { ConfigCodeEditor } from '@/components/config-code-editor'; +import { useLibrary } from '@/contexts/library-context'; // Parse agents from OpenCode response (handles both object and array formats) function parseAgentNames(agents: unknown): string[] { @@ -26,9 +28,21 @@ function parseAgentNames(agents: unknown): string[] { } export default function SettingsPage() { + const { + status, + sync, + commit, + push, + syncing, + committing, + pushing, + refreshStatus, + } = useLibrary(); + // OpenCode settings state const [settings, setSettings] = useState(''); const [originalSettings, setOriginalSettings] = useState(''); + const [systemSettings, setSystemSettings] = useState(''); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [restarting, setRestarting] = useState(false); @@ -37,6 +51,7 @@ export default function SettingsPage() { const [saveSuccess, setSaveSuccess] = useState(false); const [restartSuccess, setRestartSuccess] = useState(false); const [needsRestart, setNeedsRestart] = useState(false); + const [showRestartModal, setShowRestartModal] = useState(false); // OpenAgent config state const [openAgentConfig, setOpenAgentConfig] = useState({ @@ -51,10 +66,20 @@ export default function SettingsPage() { const [savingOpenAgent, setSavingOpenAgent] = useState(false); const [openAgentSaveSuccess, setOpenAgentSaveSuccess] = useState(false); + const [showCommitDialog, setShowCommitDialog] = useState(false); + const [commitMessage, setCommitMessage] = useState(''); + const isDirty = settings !== originalSettings; const isOpenAgentDirty = JSON.stringify(openAgentConfig) !== JSON.stringify(originalOpenAgentConfig); + // Check if Library and System settings are in sync (ignoring whitespace differences) + const normalizeJson = (s: string) => { + try { return JSON.stringify(JSON.parse(s)); } catch { return s; } + }; + const isOutOfSync = systemSettings && originalSettings && + normalizeJson(systemSettings) !== normalizeJson(originalSettings); + const loadSettings = useCallback(async () => { try { setLoading(true); @@ -66,6 +91,15 @@ export default function SettingsPage() { setSettings(formatted); setOriginalSettings(formatted); + // Load system settings (for sync status comparison) + try { + const sysData = await getOpenCodeSettings(); + setSystemSettings(JSON.stringify(sysData, null, 2)); + } catch { + // System settings might not exist yet + setSystemSettings(''); + } + // Load OpenAgent config const openAgentData = await getOpenAgentConfig(); setOpenAgentConfig(openAgentData); @@ -108,10 +142,13 @@ export default function SettingsPage() { handleSave(); } } + if (e.key === 'Escape') { + if (showCommitDialog) setShowCommitDialog(false); + } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); - }, [isDirty, parseError, settings]); + }, [isDirty, parseError, settings, showCommitDialog]); const handleSave = async () => { if (parseError) return; @@ -122,9 +159,11 @@ export default function SettingsPage() { const parsed = JSON.parse(settings); await saveLibraryOpenCodeSettings(parsed); setOriginalSettings(settings); + setSystemSettings(settings); // Sync happened, update local system state setSaveSuccess(true); - setNeedsRestart(true); + setShowRestartModal(true); // Show modal asking to restart setTimeout(() => setSaveSuccess(false), 2000); + await refreshStatus(); // Update git status bar } catch (err) { setError(err instanceof Error ? err.message : 'Failed to save settings'); } finally { @@ -132,6 +171,16 @@ export default function SettingsPage() { } }; + const handleRestartFromModal = async () => { + setShowRestartModal(false); + await handleRestart(); + }; + + const handleSkipRestart = () => { + setShowRestartModal(false); + setNeedsRestart(true); + }; + const handleSaveOpenAgent = async () => { try { setSavingOpenAgent(true); @@ -140,6 +189,7 @@ export default function SettingsPage() { setOriginalOpenAgentConfig({ ...openAgentConfig }); setOpenAgentSaveSuccess(true); setTimeout(() => setOpenAgentSaveSuccess(false), 2000); + await refreshStatus(); // Update git status bar } catch (err) { setError(err instanceof Error ? err.message : 'Failed to save OpenAgent config'); } finally { @@ -167,6 +217,34 @@ export default function SettingsPage() { setParseError(null); }; + const handleSync = async () => { + try { + await sync(); + await loadSettings(); + } catch { + // Error handled by context + } + }; + + const handleCommit = async () => { + if (!commitMessage.trim()) return; + try { + await commit(commitMessage); + setCommitMessage(''); + setShowCommitDialog(false); + } catch { + // Error handled by context + } + }; + + const handlePush = async () => { + try { + await push(); + } catch { + // Error handled by context + } + }; + const toggleHiddenAgent = (agentName: string) => { setOpenAgentConfig((prev) => { const hidden = prev.hidden_agents.includes(agentName) @@ -188,6 +266,68 @@ export default function SettingsPage() { return (
+ {/* Git Status Bar */} + {status && ( +
+
+
+
+ + {status.branch} +
+
+ {status.clean ? ( + + + Clean + + ) : ( + + + {status.modified_files.length} modified + + )} +
+ {(status.ahead > 0 || status.behind > 0) && ( +
+ {status.ahead > 0 && +{status.ahead}} + {status.ahead > 0 && status.behind > 0 && ' / '} + {status.behind > 0 && -{status.behind}} +
+ )} +
+
+ + {!status.clean && ( + + )} + +
+
+
+ )} + {/* Header */}
@@ -240,6 +380,72 @@ export default function SettingsPage() {
)} + {/* Out of Sync Warning */} + {isOutOfSync && ( +
+ +
+

Settings out of sync

+

+ The Library settings differ from what OpenCode is currently using. + This can happen if settings were changed outside the Library. + Save your current settings to sync them to OpenCode. +

+
+
+ )} + + {/* Restart Modal */} + {showRestartModal && ( +
+
+
+
+
+ +
+

Settings Saved

+
+ +
+

+ Your settings have been saved to the Library and synced to the system. + OpenCode needs to be restarted for the changes to take effect. +

+
+ + +
+
+
+ )} + {/* OpenCode Settings Section */}
@@ -396,6 +602,50 @@ export default function SettingsPage() {
+ {/* Commit Dialog */} + {showCommitDialog && ( +
+
+
+
+

Commit Changes

+

Describe your configuration changes.

+
+ +
+
+ + setCommitMessage(e.target.value)} + placeholder="Update configuration settings" + className="w-full px-3 py-2 rounded-lg bg-black/20 border border-white/[0.06] text-xs text-white placeholder:text-white/25 focus:outline-none focus:border-indigo-500/50" + /> +
+
+ + +
+
+
+ )}
); } diff --git a/dashboard/src/app/config/skills/page.tsx b/dashboard/src/app/config/skills/page.tsx index 51ffa75..8cb4e04 100644 --- a/dashboard/src/app/config/skills/page.tsx +++ b/dashboard/src/app/config/skills/page.tsx @@ -934,7 +934,7 @@ Describe what this skill does. } return ( -
+
{/* Git Status Bar */} {status && (
@@ -1093,7 +1093,7 @@ Describe what this skill does. )} {/* Editor */} -
+
{selectedSkill && selectedFile ? ( <>
@@ -1128,13 +1128,13 @@ Describe what this skill does.
-
+
{loadingFile ? (
) : ( - <> +
{selectedFile === 'SKILL.md' && ( )} -
+
@@ -1150,6 +1150,7 @@ Describe what this skill does. value={bodyContent} onChange={handleBodyChange} disabled={saving} + scrollable={false} language={ selectedFile === 'SKILL.md' || selectedFile?.toLowerCase().endsWith('.md') || @@ -1158,10 +1159,15 @@ Describe what this skill does. ? 'markdown' : 'text' } - className="flex-1 min-h-0" + highlightEncrypted={ + selectedFile === 'SKILL.md' || + selectedFile?.toLowerCase().endsWith('.md') || + selectedFile?.toLowerCase().endsWith('.mdx') || + selectedFile?.toLowerCase().endsWith('.markdown') + } />
- +
)}
diff --git a/dashboard/src/app/config/workspace-templates/page.tsx b/dashboard/src/app/config/workspace-templates/page.tsx index dd04b59..8d9b266 100644 --- a/dashboard/src/app/config/workspace-templates/page.tsx +++ b/dashboard/src/app/config/workspace-templates/page.tsx @@ -25,7 +25,6 @@ import { X, LayoutTemplate, Sparkles, - FileText, Terminal, Upload, Pencil, @@ -33,6 +32,7 @@ import { import { cn } from '@/lib/utils'; import { LibraryUnavailable } from '@/components/library-unavailable'; import { useLibrary } from '@/contexts/library-context'; +import { EnvVarsEditor, type EnvRow, toEnvRows, envRowsToMap, getEncryptedKeys } from '@/components/env-vars-editor'; import Editor from 'react-simple-code-editor'; import { highlight, languages } from 'prismjs'; import 'prismjs/components/prism-bash'; @@ -46,25 +46,6 @@ const templateTabs: { id: TemplateTab; label: string }[] = [ { id: 'init', label: 'Init Script' }, ]; -type EnvRow = { id: string; key: string; value: string }; - -const toEnvRows = (env: Record): EnvRow[] => - Object.entries(env).map(([key, value]) => ({ - id: `${key}-${Math.random().toString(36).slice(2, 8)}`, - key, - value, - })); - -const envRowsToMap = (rows: EnvRow[]) => { - const env: Record = {}; - rows.forEach((row) => { - const key = row.key.trim(); - if (!key) return; - env[key] = row.value; - }); - return env; -}; - const buildSnapshot = (data: { description: string; distro: string; @@ -76,7 +57,7 @@ const buildSnapshot = (data: { description: data.description, distro: data.distro, skills: data.skills, - env: data.envRows.map((row) => ({ key: row.key, value: row.value })), + env: data.envRows.map((row) => ({ key: row.key, value: row.value, encrypted: row.encrypted })), initScript: data.initScript, }); @@ -202,13 +183,14 @@ export default function WorkspaceTemplatesPage() { setDescription(template.description || ''); setDistro(template.distro || ''); setSelectedSkills(template.skills || []); - setEnvRows(toEnvRows(template.env_vars || {})); + const rows = toEnvRows(template.env_vars || {}, template.encrypted_keys); + setEnvRows(rows); setInitScript(template.init_script || ''); baselineRef.current = buildSnapshot({ description: template.description || '', distro: template.distro || '', skills: template.skills || [], - envRows: toEnvRows(template.env_vars || {}), + envRows: rows, initScript: template.init_script || '', }); setDirty(false); @@ -226,6 +208,7 @@ export default function WorkspaceTemplatesPage() { distro: distro || undefined, skills: selectedSkills, env_vars: envRowsToMap(envRows), + encrypted_keys: getEncryptedKeys(envRows), init_script: initScript, }); baselineRef.current = snapshot; @@ -364,7 +347,7 @@ export default function WorkspaceTemplatesPage() { } return ( -
+
{/* Git Status Bar */} {status && (
@@ -431,9 +414,9 @@ export default function WorkspaceTemplatesPage() {
)} -
+
{/* Template List */} -
+
@@ -504,7 +487,7 @@ export default function WorkspaceTemplatesPage() {
{/* Editor */} -
+

Workspace

@@ -569,8 +552,10 @@ export default function WorkspaceTemplatesPage() {
{activeTab === 'overview' && (
@@ -680,78 +665,13 @@ export default function WorkspaceTemplatesPage() { )} {activeTab === 'environment' && ( -
-
-
- -

Environment Variables

-
- -
-
- {envRows.length === 0 ? ( -
-

No environment variables

- -
- ) : ( -
- {envRows.map((row) => ( -
- - setEnvRows((rows) => - rows.map((r) => (r.id === row.id ? { ...r, key: e.target.value } : r)) - ) - } - placeholder="KEY" - className="flex-1 px-3 py-2 rounded-lg bg-black/20 border border-white/[0.06] text-xs text-white placeholder:text-white/30 focus:outline-none focus:border-indigo-500/50" - /> - - setEnvRows((rows) => - rows.map((r) => (r.id === row.id ? { ...r, value: e.target.value } : r)) - ) - } - placeholder="value" - className="flex-1 px-3 py-2 rounded-lg bg-black/20 border border-white/[0.06] text-xs text-white placeholder:text-white/30 focus:outline-none focus:border-indigo-500/50" - /> - -
- ))} -
- )} - {envRows.length > 0 && ( -

- Injected into workspace shells and MCP tool runs. -

- )} -
-
+ )} {activeTab === 'init' && ( diff --git a/dashboard/src/app/control/control-client.tsx b/dashboard/src/app/control/control-client.tsx index 20e3275..168e3e0 100644 --- a/dashboard/src/app/control/control-client.tsx +++ b/dashboard/src/app/control/control-client.tsx @@ -38,6 +38,8 @@ import { closeDesktopSession, keepAliveDesktopSession, cleanupOrphanedDesktopSessions, + removeFromQueue, + clearQueue, type StreamDiagnosticUpdate, type ControlRunState, type Mission, @@ -50,6 +52,7 @@ import { type DesktopSessionStatus, type StoredEvent, } from "@/lib/api"; +import { QueueStrip, type QueueItem } from "@/components/queue-strip"; import { Send, Square, @@ -75,6 +78,7 @@ import { RotateCcw, PlayCircle, Link2, + ListPlus, X, Wrench, Terminal, @@ -2432,6 +2436,14 @@ export default function ControlClient() { const missionHistoryToItems = useCallback((mission: Mission): ChatItem[] => { // Estimate timestamps based on mission creation time const baseTime = new Date(mission.created_at).getTime(); + // Find index of last assistant message to apply mission status + const lastAssistantIdx = mission.history.reduce( + (lastIdx, entry, i) => (entry.role === "assistant" ? i : lastIdx), + -1 + ); + // Mission is considered failed if status is "failed" + const missionFailed = mission.status === "failed"; + return mission.history.map((entry, i) => { // Spread timestamps across history (rough estimate) const timestamp = baseTime + i * 60000; // 1 minute apart @@ -2443,14 +2455,19 @@ export default function ControlClient() { timestamp, }; } else { + // Last assistant message inherits mission status + // Earlier assistant messages are assumed successful + const isLastAssistant = i === lastAssistantIdx; + const success = isLastAssistant ? !missionFailed : true; return { kind: "assistant" as const, id: `history-${mission.id}-${i}`, content: entry.content, - success: true, + success, costCents: 0, model: null, timestamp, + resumable: isLastAssistant && missionFailed ? mission.resumable : undefined, }; } }); @@ -3982,6 +3999,45 @@ export default function ControlClient() { } }; + // Compute queued items for the queue strip + const queuedItems: QueueItem[] = useMemo(() => { + return items + .filter((item): item is Extract => + item.kind === "user" && item.queued === true + ) + .map((item) => ({ + id: item.id, + content: item.content, + agent: null, // Agent info not stored in current item structure + })); + }, [items]); + + // Handle removing a message from the queue + const handleRemoveFromQueue = async (messageId: string) => { + try { + await removeFromQueue(messageId); + // Optimistically remove from local state + setItems((prev) => prev.filter((item) => item.id !== messageId)); + toast.success("Removed from queue"); + } catch (err) { + console.error(err); + toast.error("Failed to remove from queue"); + } + }; + + // Handle clearing all queued messages + const handleClearQueue = async () => { + try { + const { cleared } = await clearQueue(); + // Optimistically remove all queued items from local state + setItems((prev) => prev.filter((item) => !(item.kind === "user" && item.queued === true))); + toast.success(`Cleared ${cleared} message${cleared !== 1 ? "s" : ""} from queue`); + } catch (err) { + console.error(err); + toast.error("Failed to clear queue"); + } + }; + const activeMission = viewingMission ?? currentMission; const missionStatus = activeMission ? missionStatusLabel(activeMission.status) @@ -5149,56 +5205,75 @@ export default function ControlClient() {
) : ( -
e.preventDefault()} - className="mx-auto flex max-w-3xl gap-3 items-end" - > -
- - -
- - + {/* Queue Strip - shows queued messages when present */} + - {isBusy ? ( - - ) : ( - + e.preventDefault()} + className="flex gap-3 items-end" + > +
+ + +
+ + + + {isBusy ? ( + <> + + + + ) : ( + )} - + +
)}
diff --git a/dashboard/src/app/extensions/mcps/page.tsx b/dashboard/src/app/extensions/mcps/page.tsx index 88021db..7c02946 100644 --- a/dashboard/src/app/extensions/mcps/page.tsx +++ b/dashboard/src/app/extensions/mcps/page.tsx @@ -1038,7 +1038,7 @@ function McpFormModal({ type="text" value={form.name} onChange={(e) => updateForm({ name: e.target.value })} - placeholder="e.g., Supabase MCP" + placeholder="e.g., My Custom MCP" className="w-full rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2.5 text-sm text-white placeholder-white/30 focus:border-indigo-500/50 focus:outline-none transition-colors" required /> diff --git a/dashboard/src/app/extensions/tools/page.tsx b/dashboard/src/app/extensions/tools/page.tsx index 72f38a7..834b341 100644 --- a/dashboard/src/app/extensions/tools/page.tsx +++ b/dashboard/src/app/extensions/tools/page.tsx @@ -1,10 +1,10 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useMemo } from 'react'; +import useSWR from 'swr'; import { Loader, Wrench } from 'lucide-react'; import { listTools, type ToolInfo } from '@/lib/api'; import { cn } from '@/lib/utils'; -import { useToast } from '@/components/toast'; function formatToolSource(source: ToolInfo['source']): string { if (source === 'builtin') return 'Built-in'; @@ -20,24 +20,12 @@ function formatToolSource(source: ToolInfo['source']): string { } export default function ToolsPage() { - const [tools, setTools] = useState([]); - const [loading, setLoading] = useState(true); - const { showError } = useToast(); - - useEffect(() => { - const loadTools = async () => { - try { - setLoading(true); - const data = await listTools(); - setTools(data); - } catch (err) { - showError(err instanceof Error ? err.message : 'Failed to load tools'); - } finally { - setLoading(false); - } - }; - loadTools(); - }, [showError]); + // SWR: fetch tools list + const { data: tools = [], isLoading: loading } = useSWR( + 'tools', + listTools, + { revalidateOnFocus: false } + ); const sortedTools = useMemo(() => { return [...tools].sort((a, b) => a.name.localeCompare(b.name)); diff --git a/dashboard/src/app/history/page.tsx b/dashboard/src/app/history/page.tsx index 5d7ea63..fa25ea9 100644 --- a/dashboard/src/app/history/page.tsx +++ b/dashboard/src/app/history/page.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, useRef, useMemo, useCallback } from "react"; import Link from "next/link"; +import useSWR from "swr"; import { toast } from "@/components/toast"; import { cn } from "@/lib/utils"; import { listMissions, getMissionTree, deleteMission, cleanupEmptyMissions, Mission } from "@/lib/api"; @@ -107,13 +108,17 @@ function convertTreeNode(node: Record): AgentNode { } export default function HistoryPage() { - const [missions, setMissions] = useState([]); - const [loading, setLoading] = useState(true); const [filter, setFilter] = useState("all"); const [search, setSearch] = useState(""); const [sortField, setSortField] = useState("date"); const [sortDirection, setSortDirection] = useState("desc"); - const fetchedRef = useRef(false); + + // SWR: fetch missions (shared key with recent-tasks sidebar) + const { data: missions = [], isLoading: loading, mutate: mutateMissions } = useSWR( + 'missions', + listMissions, + { revalidateOnFocus: false } + ); // Tree preview state const [previewMissionId, setPreviewMissionId] = useState(null); @@ -127,25 +132,6 @@ export default function HistoryPage() { const [deletingMissionId, setDeletingMissionId] = useState(null); const [cleaningUp, setCleaningUp] = useState(false); - useEffect(() => { - if (fetchedRef.current) return; - fetchedRef.current = true; - - const fetchData = async () => { - try { - const missionsData = await listMissions().catch(() => []); - setMissions(missionsData); - } catch (error) { - console.error("Failed to fetch data:", error); - toast.error("Failed to load history"); - } finally { - setLoading(false); - } - }; - - fetchData(); - }, []); - // Handle Escape key for modal useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -227,7 +213,8 @@ export default function HistoryPage() { setDeletingMissionId(missionId); try { await deleteMission(missionId); - setMissions(prev => prev.filter(m => m.id !== missionId)); + // Optimistic update: filter out deleted mission from cache + mutateMissions(missions.filter(m => m.id !== missionId), false); toast.success("Mission deleted"); } catch (error) { console.error("Failed to delete mission:", error); @@ -235,16 +222,15 @@ export default function HistoryPage() { } finally { setDeletingMissionId(null); } - }, [missions]); + }, [missions, mutateMissions]); const handleCleanupEmpty = useCallback(async () => { setCleaningUp(true); try { const result = await cleanupEmptyMissions(); if (result.deleted_count > 0) { - // Refresh the missions list - const missionsData = await listMissions().catch(() => []); - setMissions(missionsData); + // Refresh the missions list from server + await mutateMissions(); toast.success(`Cleaned up ${result.deleted_count} empty mission${result.deleted_count === 1 ? '' : 's'}`); } else { toast.info("No empty missions to clean up"); @@ -255,7 +241,7 @@ export default function HistoryPage() { } finally { setCleaningUp(false); } - }, []); + }, [mutateMissions]); const filteredMissions = useMemo(() => { const filtered = missions.filter((mission) => { diff --git a/dashboard/src/app/modules/page.tsx b/dashboard/src/app/modules/page.tsx index b623ddf..d677137 100644 --- a/dashboard/src/app/modules/page.tsx +++ b/dashboard/src/app/modules/page.tsx @@ -508,7 +508,7 @@ function AddMcpModal({ type="text" value={name} onChange={(e) => setName(e.target.value)} - placeholder="e.g., Supabase MCP" + placeholder="e.g., My Custom MCP" className="w-full rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2.5 text-sm text-white placeholder-white/30 focus:border-indigo-500/50 focus:outline-none transition-colors" required /> @@ -646,7 +646,7 @@ function ConfigureMcpModal({ type="text" value={name} onChange={(e) => setName(e.target.value)} - placeholder="e.g., Supabase MCP" + placeholder="e.g., My Custom MCP" className="w-full rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2.5 text-sm text-white placeholder-white/30 focus:border-indigo-500/50 focus:outline-none transition-colors" required /> @@ -660,7 +660,7 @@ function ConfigureMcpModal({ type="url" value={endpoint} onChange={(e) => setEndpoint(e.target.value)} - placeholder="https://mcp.supabase.com/mcp" + placeholder="https://example.com/mcp" className="w-full rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2.5 text-sm text-white placeholder-white/30 focus:border-indigo-500/50 focus:outline-none transition-colors" required /> diff --git a/dashboard/src/app/page.tsx b/dashboard/src/app/page.tsx index ddf2ad3..78e3b66 100644 --- a/dashboard/src/app/page.tsx +++ b/dashboard/src/app/page.tsx @@ -1,13 +1,14 @@ 'use client'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; +import useSWR from 'swr'; import { toast } from '@/components/toast'; import { StatsCard } from '@/components/stats-card'; import { ConnectionStatus } from '@/components/connection-status'; import { RecentTasks } from '@/components/recent-tasks'; import { ShimmerStat } from '@/components/ui/shimmer'; -import { createMission, getStats, isNetworkError, listWorkspaces, type StatsResponse, type Workspace } from '@/lib/api'; +import { createMission, getStats, listWorkspaces } from '@/lib/api'; import { Activity, CheckCircle, DollarSign, Zap } from 'lucide-react'; import { formatCents } from '@/lib/utils'; import { SystemMonitor } from '@/components/system-monitor'; @@ -15,56 +16,34 @@ import { NewMissionDialog } from '@/components/new-mission-dialog'; export default function OverviewPage() { const router = useRouter(); - const [stats, setStats] = useState(null); - const [isActive, setIsActive] = useState(false); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [workspaces, setWorkspaces] = useState([]); const [creatingMission, setCreatingMission] = useState(false); + const hasShownErrorRef = useRef(false); - useEffect(() => { - let mounted = true; - let hasShownError = false; - - const fetchStats = async () => { - try { - const data = await getStats(); - if (!mounted) return; - setStats(data); - setIsActive(data.active_tasks > 0); - setError(null); - setLoading(false); - hasShownError = false; - } catch (err) { - if (!mounted) return; - const message = err instanceof Error ? err.message : 'Failed to fetch stats'; - setError(message); - setLoading(false); - if (!hasShownError) { + // SWR: poll stats every 3 seconds + const { data: stats, isLoading: statsLoading, error: statsError } = useSWR( + 'stats', + getStats, + { + refreshInterval: 3000, + revalidateOnFocus: false, + onSuccess: () => { + hasShownErrorRef.current = false; + }, + onError: () => { + if (!hasShownErrorRef.current) { toast.error('Failed to connect to agent server'); - hasShownError = true; + hasShownErrorRef.current = true; } - } - }; + }, + } + ); - fetchStats(); - const interval = setInterval(fetchStats, 3000); - return () => { - mounted = false; - clearInterval(interval); - }; - }, []); + // SWR: fetch workspaces (shared key with workspaces page) + const { data: workspaces = [] } = useSWR('workspaces', listWorkspaces, { + revalidateOnFocus: false, + }); - useEffect(() => { - listWorkspaces() - .then((data) => { - setWorkspaces(data); - }) - .catch((err) => { - if (isNetworkError(err)) return; - console.error('Failed to fetch workspaces:', err); - }); - }, []); + const isActive = (stats?.active_tasks ?? 0) > 0; const handleNewMission = useCallback( async (options?: { workspaceId?: string; agent?: string }) => { @@ -124,7 +103,7 @@ export default function OverviewPage() { {/* Stats grid - at bottom */}
- {loading ? ( + {statsLoading ? ( <> diff --git a/dashboard/src/app/settings/page.tsx b/dashboard/src/app/settings/page.tsx index 3ce556e..deccfa3 100644 --- a/dashboard/src/app/settings/page.tsx +++ b/dashboard/src/app/settings/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState, useEffect, useCallback } from 'react'; +import useSWR from 'swr'; import { toast } from '@/components/toast'; import { getHealth, @@ -31,6 +32,7 @@ import { import { readSavedSettings, writeSavedSettings } from '@/lib/settings'; import { cn } from '@/lib/utils'; import { AddProviderModal } from '@/components/ui/add-provider-modal'; +import { ServerConnectionCard } from '@/components/server-connection-card'; // Provider icons/colors mapping const providerConfig: Record = { @@ -51,9 +53,19 @@ function getProviderConfig(type: string) { return providerConfig[type] || providerConfig.custom; } +// Default provider types fallback +const defaultProviderTypes: AIProviderTypeInfo[] = [ + { id: 'anthropic', name: 'Anthropic', uses_oauth: true, env_var: 'ANTHROPIC_API_KEY' }, + { id: 'openai', name: 'OpenAI', uses_oauth: true, env_var: 'OPENAI_API_KEY' }, + { id: 'google', name: 'Google AI', uses_oauth: true, env_var: 'GOOGLE_API_KEY' }, + { id: 'open-router', name: 'OpenRouter', uses_oauth: false, env_var: 'OPENROUTER_API_KEY' }, + { id: 'groq', name: 'Groq', uses_oauth: false, env_var: 'GROQ_API_KEY' }, + { id: 'mistral', name: 'Mistral AI', uses_oauth: false, env_var: 'MISTRAL_API_KEY' }, + { id: 'xai', name: 'xAI', uses_oauth: false, env_var: 'XAI_API_KEY' }, + { id: 'github-copilot', name: 'GitHub Copilot', uses_oauth: true, env_var: null }, +]; + export default function SettingsPage() { - const [health, setHealth] = useState(null); - const [healthLoading, setHealthLoading] = useState(true); const [testingConnection, setTestingConnection] = useState(false); // Form state @@ -74,10 +86,7 @@ export default function SettingsPage() { const [urlError, setUrlError] = useState(null); const [repoError, setRepoError] = useState(null); - // AI Providers state - const [providers, setProviders] = useState([]); - const [providerTypes, setProviderTypes] = useState([]); - const [providersLoading, setProvidersLoading] = useState(true); + // Modal/edit state const [showAddModal, setShowAddModal] = useState(false); const [authenticatingProviderId, setAuthenticatingProviderId] = useState(null); const [editingProvider, setEditingProvider] = useState(null); @@ -88,6 +97,27 @@ export default function SettingsPage() { enabled?: boolean; }>({}); + // SWR: fetch health status + const { data: health, isLoading: healthLoading, mutate: mutateHealth } = useSWR( + 'health', + getHealth, + { revalidateOnFocus: false } + ); + + // SWR: fetch AI providers + const { data: providers = [], isLoading: providersLoading, mutate: mutateProviders } = useSWR( + 'ai-providers', + listAIProviders, + { revalidateOnFocus: false } + ); + + // SWR: fetch provider types (with fallback) + const { data: providerTypes = defaultProviderTypes } = useSWR( + 'ai-provider-types', + listAIProviderTypes, + { revalidateOnFocus: false, fallbackData: defaultProviderTypes } + ); + // Check if there are unsaved changes const hasUnsavedChanges = apiUrl !== originalValues.apiUrl || @@ -123,54 +153,6 @@ export default function SettingsPage() { return true; }, []); - // Load health and providers on mount - useEffect(() => { - const checkHealth = async () => { - setHealthLoading(true); - try { - const data = await getHealth(); - setHealth(data); - } catch { - setHealth(null); - } finally { - setHealthLoading(false); - } - }; - checkHealth(); - loadProviders(); - loadProviderTypes(); - }, []); - - const loadProviders = async () => { - try { - setProvidersLoading(true); - const data = await listAIProviders(); - setProviders(data); - } catch { - // Silent fail - providers might not be available yet - } finally { - setProvidersLoading(false); - } - }; - - const loadProviderTypes = async () => { - try { - const data = await listAIProviderTypes(); - setProviderTypes(data); - } catch { - // Use defaults if API fails - setProviderTypes([ - { id: 'anthropic', name: 'Anthropic', uses_oauth: true, env_var: 'ANTHROPIC_API_KEY' }, - { id: 'openai', name: 'OpenAI', uses_oauth: true, env_var: 'OPENAI_API_KEY' }, - { id: 'google', name: 'Google AI', uses_oauth: true, env_var: 'GOOGLE_API_KEY' }, - { id: 'open-router', name: 'OpenRouter', uses_oauth: false, env_var: 'OPENROUTER_API_KEY' }, - { id: 'groq', name: 'Groq', uses_oauth: false, env_var: 'GROQ_API_KEY' }, - { id: 'mistral', name: 'Mistral AI', uses_oauth: false, env_var: 'MISTRAL_API_KEY' }, - { id: 'xai', name: 'xAI', uses_oauth: false, env_var: 'XAI_API_KEY' }, - { id: 'github-copilot', name: 'GitHub Copilot', uses_oauth: true, env_var: null }, - ]); - } - }; // Unsaved changes warning useEffect(() => { @@ -225,10 +207,10 @@ export default function SettingsPage() { throw new Error(`HTTP ${response.status}`); } const data = await response.json(); - setHealth(data); + mutateHealth(data, false); // Update cache without revalidation toast.success(`Connected to OpenAgent v${data.version}`); } catch (err) { - setHealth(null); + mutateHealth(undefined, false); // Clear cache on error toast.error( `Connection failed: ${err instanceof Error ? err.message : 'Unknown error'}` ); @@ -243,7 +225,7 @@ export default function SettingsPage() { const result = await authenticateAIProvider(provider.id); if (result.success) { toast.success(result.message); - loadProviders(); + mutateProviders(); } else { if (result.auth_url) { window.open(result.auth_url, '_blank'); @@ -265,7 +247,7 @@ export default function SettingsPage() { try { await setDefaultAIProvider(id); toast.success('Default provider updated'); - loadProviders(); + mutateProviders(); } catch (err) { toast.error( `Failed to set default: ${err instanceof Error ? err.message : 'Unknown error'}` @@ -277,7 +259,7 @@ export default function SettingsPage() { try { await deleteAIProvider(id); toast.success('Provider removed'); - loadProviders(); + mutateProviders(); } catch (err) { toast.error( `Failed to delete: ${err instanceof Error ? err.message : 'Unknown error'}` @@ -307,7 +289,7 @@ export default function SettingsPage() { }); toast.success('Provider updated'); setEditingProvider(null); - loadProviders(); + mutateProviders(); } catch (err) { toast.error( `Failed to update: ${err instanceof Error ? err.message : 'Unknown error'}` @@ -326,7 +308,7 @@ export default function SettingsPage() { setShowAddModal(false)} - onSuccess={loadProviders} + onSuccess={() => mutateProviders()} providerTypes={providerTypes} /> @@ -349,74 +331,17 @@ export default function SettingsPage() {
- {/* API Connection */} -
-
-
- -
-
-

API Connection

-

Configure server endpoint

-
-
- -
-
- - { - setApiUrl(e.target.value); - validateUrl(e.target.value); - }} - className={cn( - 'w-full rounded-lg border bg-white/[0.02] px-3 py-2.5 text-sm text-white placeholder-white/30 focus:outline-none transition-colors', - urlError - ? 'border-red-500/50 focus:border-red-500/50' - : 'border-white/[0.06] focus:border-indigo-500/50' - )} - /> - {urlError &&

{urlError}

} -
- -
-
- Status: - {healthLoading ? ( - - - Checking... - - ) : health ? ( - - - Connected (v{health.version}) - - ) : ( - - - Disconnected - - )} -
- - -
-
-
+ {/* Server Connection & System Components */} + {/* AI Providers */}
diff --git a/dashboard/src/app/settings/secrets/page.tsx b/dashboard/src/app/settings/secrets/page.tsx index 721f10a..79a43ce 100644 --- a/dashboard/src/app/settings/secrets/page.tsx +++ b/dashboard/src/app/settings/secrets/page.tsx @@ -552,7 +552,7 @@ export default function SecretsPage() { setNewSecretKey(e.target.value)} className="w-full px-4 py-2 rounded-lg bg-white/[0.04] border border-white/[0.08] text-white placeholder:text-white/30 focus:outline-none focus:border-indigo-500/50" diff --git a/dashboard/src/app/workspaces/page.tsx b/dashboard/src/app/workspaces/page.tsx index 43a4a54..f736239 100644 --- a/dashboard/src/app/workspaces/page.tsx +++ b/dashboard/src/app/workspaces/page.tsx @@ -1,7 +1,8 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; +import useSWR from 'swr'; import { listWorkspaces, getWorkspace, @@ -36,132 +37,95 @@ import { RefreshCw, Save, Bookmark, - FileText, Sparkles, - Eye, - EyeOff, - Lock, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useToast } from '@/components/toast'; import { ConfigCodeEditor } from '@/components/config-code-editor'; +import { EnvVarsEditor, type EnvRow, toEnvRows, envRowsToMap, getEncryptedKeys } from '@/components/env-vars-editor'; // The nil UUID represents the default "host" workspace which cannot be deleted const DEFAULT_WORKSPACE_ID = '00000000-0000-0000-0000-000000000000'; +// Format bytes into human-readable size +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + const kb = bytes / 1024; + if (kb < 1024) return `${kb.toFixed(1)} KB`; + const mb = kb / 1024; + if (mb < 1024) return `${mb.toFixed(1)} MB`; + const gb = mb / 1024; + if (gb < 1024) return `${gb.toFixed(2)} GB`; + const tb = gb / 1024; + return `${tb.toFixed(2)} TB`; +} + export default function WorkspacesPage() { const router = useRouter(); - const [workspaces, setWorkspaces] = useState([]); const [selectedWorkspace, setSelectedWorkspace] = useState(null); - const [loading, setLoading] = useState(true); const [creating, setCreating] = useState(false); - const { showError } = useToast(); + const { showError, showInfo } = useToast(); const [showNewWorkspaceDialog, setShowNewWorkspaceDialog] = useState(false); const [newWorkspaceName, setNewWorkspaceName] = useState(''); const [newWorkspaceType, setNewWorkspaceType] = useState<'host' | 'chroot'>('chroot'); const [newWorkspaceTemplate, setNewWorkspaceTemplate] = useState(''); - const [templates, setTemplates] = useState([]); - const [templatesError, setTemplatesError] = useState(null); - const [availableSkills, setAvailableSkills] = useState([]); - const [skillsError, setSkillsError] = useState(null); const [skillsFilter, setSkillsFilter] = useState(''); const [selectedSkills, setSelectedSkills] = useState([]); - const [workspaceTab, setWorkspaceTab] = useState<'overview' | 'skills' | 'environment' | 'template'>('overview'); + const [workspaceTab, setWorkspaceTab] = useState<'overview' | 'skills' | 'environment' | 'template' | 'build'>('overview'); // Build state const [building, setBuilding] = useState(false); const [selectedDistro, setSelectedDistro] = useState('ubuntu-noble'); const [buildDebug, setBuildDebug] = useState(null); const [buildLog, setBuildLog] = useState(null); - const [showBuildLogs, setShowBuildLogs] = useState(false); + const buildLogRef = useRef(null); // Workspace settings state - const [envRows, setEnvRows] = useState<{ id: string; key: string; value: string; secret: boolean; visible: boolean }[]>([]); + const [envRows, setEnvRows] = useState([]); const [initScript, setInitScript] = useState(''); const [savingWorkspace, setSavingWorkspace] = useState(false); const [savingTemplate, setSavingTemplate] = useState(false); const [templateName, setTemplateName] = useState(''); const [templateDescription, setTemplateDescription] = useState(''); - const loadData = async () => { - try { - setLoading(true); - const workspacesData = await listWorkspaces(); - setWorkspaces(workspacesData); - } catch (err) { - showError(err instanceof Error ? err.message : 'Failed to load workspaces'); - } finally { - setLoading(false); - } - }; + // SWR: fetch workspaces (shared key with overview page) + const { data: workspaces = [], isLoading: loading, mutate: mutateWorkspaces } = useSWR( + 'workspaces', + listWorkspaces, + { revalidateOnFocus: false } + ); - const loadTemplates = async () => { - try { - setTemplatesError(null); - const templateData = await listWorkspaceTemplates(); - setTemplates(templateData); - } catch (err) { - setTemplates([]); - setTemplatesError(err instanceof Error ? err.message : 'Failed to load templates'); - } - }; + // SWR: fetch templates + const { data: templates = [], error: templatesError, mutate: mutateTemplates } = useSWR( + 'workspace-templates', + listWorkspaceTemplates, + { revalidateOnFocus: false } + ); - const loadSkills = async () => { - try { - setSkillsError(null); - const skills = await listLibrarySkills(); - setAvailableSkills(skills); - } catch (err) { - setAvailableSkills([]); - setSkillsError(err instanceof Error ? err.message : 'Failed to load skills'); - } - }; + // SWR: fetch skills (shared key with library) + const { data: availableSkills = [], error: skillsError } = useSWR( + 'library-skills', + listLibrarySkills, + { revalidateOnFocus: false } + ); - // Patterns that indicate a sensitive value - const isSensitiveKey = (key: string) => { - const upperKey = key.toUpperCase(); - const sensitivePatterns = [ - 'KEY', 'TOKEN', 'SECRET', 'PASSWORD', 'PASS', 'CREDENTIAL', 'AUTH', - 'PRIVATE', 'API_KEY', 'ACCESS_TOKEN', 'B64', 'BASE64', 'ENCRYPTED', + // Dynamic tabs based on workspace state - Build tab only shows for chroot workspaces + const getWorkspaceTabs = (workspace: Workspace | null) => { + const tabs: { id: 'overview' | 'skills' | 'environment' | 'template' | 'build'; label: string }[] = [ + { id: 'overview', label: 'Overview' }, + { id: 'skills', label: 'Skills' }, + { id: 'environment', label: 'Env' }, ]; - return sensitivePatterns.some(pattern => upperKey.includes(pattern)); + // Add Build tab for chroot workspaces + if (workspace?.workspace_type === 'chroot') { + tabs.push({ id: 'build', label: 'Build' }); + } + tabs.push({ id: 'template', label: 'Template' }); + return tabs; }; + const workspaceTabs = getWorkspaceTabs(selectedWorkspace); - const toEnvRows = (env: Record) => - Object.entries(env).map(([key, value]) => { - const secret = isSensitiveKey(key); - return { - id: `${key}-${Math.random().toString(36).slice(2, 8)}`, - key, - value, - secret, - visible: !secret, // Hidden by default if secret - }; - }); - - const envRowsToMap = (rows: { key: string; value: string }[]) => { - const env: Record = {}; - rows.forEach((row) => { - const key = row.key.trim(); - if (!key) return; - env[key] = row.value; - }); - return env; - }; - - const workspaceTabs = [ - { id: 'overview', label: 'Overview' }, - { id: 'skills', label: 'Skills' }, - { id: 'environment', label: 'Env & Init' }, - { id: 'template', label: 'Template' }, - ] as const; - - useEffect(() => { - loadData(); - loadTemplates(); - loadSkills(); - }, []); // Handle Escape key for modals useEffect(() => { @@ -211,20 +175,32 @@ export default function WorkspacesPage() { } }, [newWorkspaceTemplate]); - // Poll build progress when workspace is building + // Poll build progress when workspace is building, or fetch logs on error useEffect(() => { - if (!selectedWorkspace || selectedWorkspace.status !== 'building') { + if (!selectedWorkspace) { setBuildDebug(null); setBuildLog(null); return; } - // Auto-expand logs when building starts - setShowBuildLogs(true); + const isBuilding = selectedWorkspace.status === 'building'; + const hasError = selectedWorkspace.status === 'error'; + + // Clear state when transitioning to ready or other non-error states + if (!isBuilding && !hasError) { + setBuildDebug(null); + setBuildLog(null); + return; + } + + // Auto-switch to Build tab when building starts or on error + if (isBuilding || hasError) { + setWorkspaceTab('build'); + } let cancelled = false; - const pollBuildProgress = async () => { + const fetchBuildInfo = async () => { try { const [debug, log] = await Promise.all([ getWorkspaceDebug(selectedWorkspace.id).catch(() => null), @@ -234,28 +210,44 @@ export default function WorkspacesPage() { if (debug) setBuildDebug(debug); if (log) setBuildLog(log); - // Refresh workspace status - const updated = await getWorkspace(selectedWorkspace.id); - if (cancelled) return; - if (updated.status !== selectedWorkspace.status) { - setSelectedWorkspace(updated); - await loadData(); + // Only poll for status updates when building (not when already in error state) + if (isBuilding) { + const updated = await getWorkspace(selectedWorkspace.id); + if (cancelled) return; + if (updated.status !== selectedWorkspace.status) { + setSelectedWorkspace(updated); + await mutateWorkspaces(); + } } } catch { // Ignore errors during polling } }; - // Poll immediately and then every 3 seconds - pollBuildProgress(); - const interval = setInterval(pollBuildProgress, 3000); + // Fetch immediately + fetchBuildInfo(); + + // Only poll repeatedly when building, not when in error state + if (isBuilding) { + const interval = setInterval(fetchBuildInfo, 3000); + return () => { + cancelled = true; + clearInterval(interval); + }; + } return () => { cancelled = true; - clearInterval(interval); }; }, [selectedWorkspace?.id, selectedWorkspace?.status]); + // Auto-scroll build log to bottom when new content arrives + useEffect(() => { + if (buildLogRef.current) { + buildLogRef.current.scrollTop = buildLogRef.current.scrollHeight; + } + }, [buildLog?.content]); + const loadWorkspace = async (id: string) => { try { const workspace = await getWorkspace(id); @@ -275,30 +267,33 @@ export default function WorkspacesPage() { workspace_type: workspaceType, template: newWorkspaceTemplate || undefined, }); - await loadData(); - setShowNewWorkspaceDialog(false); - setNewWorkspaceName(''); - setNewWorkspaceTemplate(''); - // Auto-select the new workspace - setSelectedWorkspace(created); + // Refresh workspace list immediately after creation so it appears in the UI + // even if the build step fails later + await mutateWorkspaces(); - // Auto-trigger build for isolated (chroot) workspaces + // For chroot workspaces, trigger build BEFORE showing the modal + // This prevents the flicker where status briefly shows as non-building + let workspaceToShow = created; if (workspaceType === 'chroot') { - setBuilding(true); try { - const updated = await buildWorkspace(created.id, created.distro as ChrootDistro || 'ubuntu-noble', false); - setSelectedWorkspace(updated); - await loadData(); + workspaceToShow = await buildWorkspace(created.id, created.distro as ChrootDistro || 'ubuntu-noble', false); } catch (buildErr) { showError(buildErr instanceof Error ? buildErr.message : 'Failed to start build'); // Refresh workspace to get error status - const refreshed = await getWorkspace(created.id); - setSelectedWorkspace(refreshed); - } finally { - setBuilding(false); + try { + workspaceToShow = await getWorkspace(created.id); + } catch { + // If getWorkspace also fails, use created workspace + } + // Refresh list again to show error status + await mutateWorkspaces(); } } + setShowNewWorkspaceDialog(false); + setNewWorkspaceName(''); + setNewWorkspaceTemplate(''); + setSelectedWorkspace(workspaceToShow); } catch (err) { showError(err instanceof Error ? err.message : 'Failed to create workspace'); } finally { @@ -311,7 +306,7 @@ export default function WorkspacesPage() { try { await deleteWorkspace(id); setSelectedWorkspace(null); - await loadData(); + await mutateWorkspaces(); } catch (err) { showError(err instanceof Error ? err.message : 'Failed to delete workspace'); } @@ -323,11 +318,11 @@ export default function WorkspacesPage() { setBuilding(true); const updated = await buildWorkspace(selectedWorkspace.id, selectedDistro, rebuild); setSelectedWorkspace(updated); - await loadData(); + await mutateWorkspaces(); } catch (err) { showError(err instanceof Error ? err.message : 'Failed to build workspace'); // Refresh to get latest status - await loadData(); + await mutateWorkspaces(); if (selectedWorkspace) { const refreshed = await getWorkspace(selectedWorkspace.id); setSelectedWorkspace(refreshed); @@ -348,7 +343,8 @@ export default function WorkspacesPage() { skills: selectedSkills, }); setSelectedWorkspace(updated); - await loadData(); + await mutateWorkspaces(); + showInfo('Changes will apply to new missions', 'Saved'); } catch (err) { showError(err instanceof Error ? err.message : 'Failed to save workspace settings'); } finally { @@ -366,14 +362,16 @@ export default function WorkspacesPage() { try { setSavingTemplate(true); const env_vars = envRowsToMap(envRows); + const encrypted_keys = getEncryptedKeys(envRows); await saveWorkspaceTemplate(trimmedName, { description: templateDescription.trim() || undefined, distro: selectedDistro, skills: selectedSkills, env_vars, + encrypted_keys, init_script: initScript, }); - await loadTemplates(); + await mutateTemplates(); } catch (err) { showError(err instanceof Error ? err.message : 'Failed to save workspace template'); } finally { @@ -515,51 +513,48 @@ export default function WorkspacesPage() { className="w-full max-w-2xl max-h-[85vh] rounded-2xl bg-[#161618] border border-white/[0.06] shadow-[0_25px_100px_rgba(0,0,0,0.7)] flex flex-col overflow-hidden animate-scale-in-simple" onClick={(e) => e.stopPropagation()} > - {/* Header */} -
-
-
-
- -
-
-

{selectedWorkspace.name}

-
- - {formatWorkspaceType(selectedWorkspace.workspace_type)} - - · - - {selectedWorkspace.status} - -
-
+ {/* Header - Compact */} +
+
+
+

{selectedWorkspace.name}

+ · + + {formatWorkspaceType(selectedWorkspace.workspace_type)} + + · + + {selectedWorkspace.status === 'building' && ( + + )} + {selectedWorkspace.status} +
{/* Tabs */} -
+
{workspaceTabs.map((tab) => ( tab to create the container environment. +

+
+ )} +
+ )} + + {workspaceTab === 'build' && selectedWorkspace.workspace_type === 'chroot' && ( +
+ {/* Build controls - shown when not building */} + {selectedWorkspace.status !== 'building' && ( +
+
+ + + + {selectedWorkspace.status === 'ready' + ? 'Destroys container and reruns init script' + : 'Creates isolated Linux filesystem'} +
-
- {/* Show build controls when not building */} - {selectedWorkspace.status !== 'building' && ( - <> -
- - -
-
- -

- {selectedWorkspace.status === 'ready' - ? 'Destroys container and reruns init script' - : 'Creates isolated Linux filesystem'} -

-
- - )} - {/* Build Progress Logs - shown when building */} - {selectedWorkspace.status === 'building' && ( -
- {/* Header with size */} -
-
- - Build Output -
- {buildDebug?.size_bytes != null && buildDebug.size_bytes > 0 && ( - - {buildDebug.size_bytes >= 1024 * 1024 * 1024 - ? `${(buildDebug.size_bytes / 1024 / 1024 / 1024).toFixed(2)} GB` - : `${(buildDebug.size_bytes / 1024 / 1024).toFixed(1)} MB`} - - )} -
- - {/* Container Status Badges */} - {buildDebug && ( -
- {buildDebug.has_bash && ( - - bash ready - - )} - {buildDebug.init_script_exists && ( - - init script running - - )} - {buildDebug.distro && ( - - {buildDebug.distro} - - )} -
- )} - - {/* Init Log Output */} - {buildLog?.exists && buildLog.content ? ( -
-
- {buildLog.log_path} - {buildLog.total_lines && ( - {buildLog.total_lines} lines - )} -
-
-                                  {buildLog.content.split('\n').slice(-50).join('\n')}
-                                
-
- ) : ( -
- - Waiting for build output... -
- )} -
- )} + {/* Init Script */} +
+
+ +

Init Script

+
+
+ +

+ Runs during build. Save changes, then Rebuild to apply. +

+
)} + {/* Build Progress - shown when building or on error */} + {(selectedWorkspace.status === 'building' || selectedWorkspace.status === 'error') && ( +
+ {/* Error message */} + {selectedWorkspace.status === 'error' && selectedWorkspace.error_message && ( +
+
+ +
+

Build Failed

+

{selectedWorkspace.error_message}

+ {selectedWorkspace.error_message.includes('signal KILL') && ( +

+ SIGKILL usually indicates out-of-memory. Try reducing packages installed or increasing server memory. +

+ )} +
+
+
+ )} + + {/* Status header */} +
+
+ {buildDebug?.has_bash && ( + + bash ready + + )} + {selectedWorkspace.status === 'building' && buildDebug?.init_script_exists && ( + + init script running + + )} + {buildDebug?.distro && ( + + {buildDebug.distro} + + )} +
+ {buildDebug?.size_bytes != null && buildDebug.size_bytes > 0 && ( + + {formatBytes(buildDebug.size_bytes)} + + )} +
+ + {/* Log output - constrained height with internal scroll */} + {buildLog?.exists && buildLog.content ? ( +
+
+ {buildLog.log_path} + {buildLog.total_lines && ( + {buildLog.total_lines} lines + )} +
+
+                            {buildLog.content.split('\n').slice(-100).join('\n')}
+                          
+
+ ) : selectedWorkspace.status === 'error' ? ( +
+ No build log available +
+ ) : ( +
+ + Waiting for build output... +
+ )} +
+ )} + + {/* Ready state info */} + {selectedWorkspace.status === 'ready' && ( +
+ Container is ready. Use Rebuild to recreate with updated init script or distro. +
+ )}
)} @@ -786,7 +797,7 @@ export default function WorkspacesPage() { /> {skillsError ? ( -

{skillsError}

+

{skillsError instanceof Error ? skillsError.message : 'Failed to load skills'}

) : availableSkills.length === 0 ? (
@@ -840,138 +851,14 @@ export default function WorkspacesPage() { {workspaceTab === 'environment' && (
- {/* Environment Variables */} -
-
-
- -

Environment Variables

-
- -
- -
- {envRows.length === 0 ? ( -
-

No environment variables

- -
- ) : ( -
- {envRows.map((row) => ( -
- { - const newKey = e.target.value; - const secret = isSensitiveKey(newKey); - setEnvRows((rows) => - rows.map((r) => - r.id === row.id ? { ...r, key: newKey, secret, visible: r.visible || !secret } : r - ) - ); - }} - placeholder="KEY" - className="flex-1 px-3 py-2 rounded-lg bg-black/20 border border-white/[0.06] text-xs text-white placeholder:text-white/25 font-mono focus:outline-none focus:border-indigo-500/50" - /> - = -
- - setEnvRows((rows) => - rows.map((r) => - r.id === row.id ? { ...r, value: e.target.value } : r - ) - ) - } - placeholder="value" - className={cn( - "w-full px-3 py-2 rounded-lg bg-black/20 border border-white/[0.06] text-xs text-white placeholder:text-white/25 font-mono focus:outline-none focus:border-indigo-500/50", - row.secret && "pr-8" - )} - /> - {row.secret && ( - - )} -
- {row.secret && ( - - - - )} - -
- ))} -
- )} - {envRows.length > 0 && ( -

- Injected into workspace shells and MCP tool runs. Sensitive values () are encrypted at rest. -

- )} -
-
- - {/* Init Script */} -
-
- -

Init Script

-
-
- -

- Runs during build. Changes require rebuild to take effect. -

-
-
+ +

+ Applied to new missions automatically. Running missions keep their original values. +

)} @@ -1147,7 +1034,7 @@ export default function WorkspacesPage() { ))} {templatesError && ( -

{templatesError}

+

{templatesError instanceof Error ? templatesError.message : 'Failed to load templates'}

)}
diff --git a/dashboard/src/components/config-code-editor.tsx b/dashboard/src/components/config-code-editor.tsx index f872784..1fb3e2e 100644 --- a/dashboard/src/components/config-code-editor.tsx +++ b/dashboard/src/components/config-code-editor.tsx @@ -20,6 +20,10 @@ interface ConfigCodeEditorProps { minHeight?: number | string; language?: SupportedLanguage; padding?: number; + /** Enable highlighting of ... tags */ + highlightEncrypted?: boolean; + /** Whether the editor should scroll internally. Set to false when parent handles scrolling. */ + scrollable?: boolean; } const languageMap: Record = { @@ -35,6 +39,37 @@ const escapeHtml = (code: string) => .replace(//g, '>'); +/** + * Encrypted tag highlighting using marker-based pre/post processing. + * This approach handles PrismJS wrapping content in span tags. + */ +const ENCRYPTED_TAG_RAW = /(.*?)<\/encrypted>/g; + +// Unique markers that won't appear in normal content +const MARKER_OPEN = '\u200B\u200BENCOPEN\u200B\u200B'; +const MARKER_CLOSE = '\u200B\u200BENCCLOSE\u200B\u200B'; +const MARKER_VALUE_START = '\u200B\u200BENCVAL\u200B\u200B'; +const MARKER_VALUE_END = '\u200B\u200BENCVALEND\u200B\u200B'; + +/** Pre-process code to replace encrypted tags with markers before PrismJS */ +const preprocessEncryptedTags = (code: string): string => { + return code.replace( + ENCRYPTED_TAG_RAW, + `${MARKER_OPEN}${MARKER_VALUE_START}$1${MARKER_VALUE_END}${MARKER_CLOSE}` + ); +}; + +/** Post-process highlighted HTML to replace markers with styled content */ +const postprocessEncryptedTags = (html: string): string => { + // The markers get HTML-escaped by PrismJS, so we need to match the escaped versions + // Zero-width spaces are not escaped, so markers remain intact + return html + .replace(new RegExp(MARKER_OPEN, 'g'), '<encrypted>') + .replace(new RegExp(MARKER_VALUE_START, 'g'), '') + .replace(new RegExp(MARKER_VALUE_END, 'g'), '') + .replace(new RegExp(MARKER_CLOSE, 'g'), '</encrypted>'); +}; + export function ConfigCodeEditor({ value, onChange, @@ -45,17 +80,33 @@ export function ConfigCodeEditor({ minHeight = '100%', language = 'markdown', padding = 12, + highlightEncrypted = false, + scrollable = true, }: ConfigCodeEditorProps) { const grammar = languageMap[language]; const highlightCode = (code: string) => { - if (!grammar) return escapeHtml(code); - return highlight(code, grammar, language); + // Pre-process to replace encrypted tags with markers + let processedCode = highlightEncrypted ? preprocessEncryptedTags(code) : code; + + let html: string; + if (!grammar) { + html = escapeHtml(processedCode); + } else { + html = highlight(processedCode, grammar, language); + } + + // Post-process to replace markers with styled HTML + if (highlightEncrypted) { + html = postprocessEncryptedTags(html); + } + return html; }; return (
diff --git a/dashboard/src/components/enhanced-input.tsx b/dashboard/src/components/enhanced-input.tsx index 8f056ab..8814f68 100644 --- a/dashboard/src/components/enhanced-input.tsx +++ b/dashboard/src/components/enhanced-input.tsx @@ -344,7 +344,7 @@ export function EnhancedInput({
{ + const upperKey = key.toUpperCase(); + return SENSITIVE_PATTERNS.some(pattern => upperKey.includes(pattern)); +}; + +export const toEnvRows = (env: Record, encryptedKeys?: string[]): EnvRow[] => + Object.entries(env).map(([key, value]) => { + const secret = isSensitiveKey(key); + // If encryptedKeys is provided and non-empty, use it as the source of truth. + // Otherwise fall back to auto-detection based on key name patterns (secret). + // This ensures sensitive keys show as "will be encrypted" by default. + const encrypted = (encryptedKeys && encryptedKeys.length > 0) + ? encryptedKeys.includes(key) + : secret; + return { + id: `${key}-${Math.random().toString(36).slice(2, 8)}`, + key, + value, + secret, + visible: !(secret || encrypted), // Hide if secret OR encrypted + encrypted, + }; + }); + +export const envRowsToMap = (rows: EnvRow[]): Record => { + const env: Record = {}; + rows.forEach((row) => { + const key = row.key.trim(); + if (!key) return; + env[key] = row.value; + }); + return env; +}; + +export const getEncryptedKeys = (rows: EnvRow[]): string[] => + rows.filter((row) => row.encrypted && row.key.trim()).map((row) => row.key.trim()); + +export const createEmptyEnvRow = (): EnvRow => ({ + id: Math.random().toString(36).slice(2), + key: '', + value: '', + secret: false, + visible: true, + encrypted: false, +}); + +interface EnvVarsEditorProps { + rows: EnvRow[]; + onChange: (rows: EnvRow[]) => void; + className?: string; + description?: string; + /** Show encryption toggle per row. Only enable for templates which persist encrypted_keys. */ + showEncryptionToggle?: boolean; +} + +export function EnvVarsEditor({ rows, onChange, className, description, showEncryptionToggle = false }: EnvVarsEditorProps) { + const handleAddRow = () => { + onChange([...rows, createEmptyEnvRow()]); + }; + + const handleRemoveRow = (id: string) => { + onChange(rows.filter((r) => r.id !== id)); + }; + + const handleKeyChange = (id: string, newKey: string) => { + const newSecret = isSensitiveKey(newKey); + onChange( + rows.map((r) => { + if (r.id !== id) return r; + // When key changes and becomes sensitive, auto-enable encryption + const newEncrypted = newSecret ? true : r.encrypted; + return { ...r, key: newKey, secret: newSecret, visible: newSecret ? r.visible : true, encrypted: newEncrypted }; + }) + ); + }; + + const handleToggleEncrypted = (id: string) => { + onChange(rows.map((r) => { + if (r.id !== id) return r; + const newEncrypted = !r.encrypted; + // When enabling encryption, hide the value + return { ...r, encrypted: newEncrypted, visible: newEncrypted ? false : r.visible }; + })); + }; + + const handleValueChange = (id: string, newValue: string) => { + onChange(rows.map((r) => (r.id === id ? { ...r, value: newValue } : r))); + }; + + const handleToggleVisibility = (id: string) => { + onChange(rows.map((r) => (r.id === id ? { ...r, visible: !r.visible } : r))); + }; + + return ( +
+
+
+ +

Environment Variables

+
+ +
+
+ {rows.length === 0 ? ( +
+

No environment variables

+ +
+ ) : ( +
+ {rows.map((row) => ( +
+ {showEncryptionToggle && ( + + )} + handleKeyChange(row.id, e.target.value)} + placeholder="KEY" + className="flex-1 px-3 py-2 rounded-lg bg-black/20 border border-white/[0.06] text-xs text-white placeholder:text-white/30 focus:outline-none focus:border-indigo-500/50" + /> + = +
+ handleValueChange(row.id, e.target.value)} + placeholder="value" + className={cn( + "w-full px-3 py-2 rounded-lg bg-black/20 border border-white/[0.06] text-xs text-white placeholder:text-white/30 focus:outline-none focus:border-indigo-500/50", + (row.encrypted || row.secret) && "pr-8" + )} + /> + {(row.encrypted || row.secret) && ( + + )} +
+ +
+ ))} +
+ )} + {rows.length > 0 && description && ( +

+ {description} +

+ )} +
+
+ ); +} diff --git a/dashboard/src/components/new-mission-dialog.tsx b/dashboard/src/components/new-mission-dialog.tsx index 87eb3a4..7f58594 100644 --- a/dashboard/src/components/new-mission-dialog.tsx +++ b/dashboard/src/components/new-mission-dialog.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import { Plus } from 'lucide-react'; +import useSWR from 'swr'; import { getVisibleAgents, getOpenAgentConfig } from '@/lib/api'; import type { Provider, Workspace } from '@/lib/api'; @@ -16,6 +17,30 @@ interface NewMissionDialogProps { }) => Promise | void; } +// Parse agent names from API response +const parseAgentNames = (payload: unknown): string[] => { + const normalizeEntry = (entry: unknown): string | null => { + if (typeof entry === 'string') return entry; + if (entry && typeof entry === 'object') { + const name = (entry as { name?: unknown }).name; + if (typeof name === 'string') return name; + const id = (entry as { id?: unknown }).id; + if (typeof id === 'string') return id; + } + return null; + }; + + const raw = Array.isArray(payload) + ? payload + : (payload as { agents?: unknown })?.agents; + if (!Array.isArray(raw)) return []; + + const names = raw + .map(normalizeEntry) + .filter((name): name is string => Boolean(name)); + return Array.from(new Set(names)); +}; + export function NewMissionDialog({ workspaces, providers = [], @@ -26,36 +51,26 @@ export function NewMissionDialog({ const [newMissionWorkspace, setNewMissionWorkspace] = useState(''); const [newMissionAgent, setNewMissionAgent] = useState(''); const [newMissionModelOverride, setNewMissionModelOverride] = useState(''); - const [opencodeAgents, setOpencodeAgents] = useState([]); const [submitting, setSubmitting] = useState(false); + const [defaultSet, setDefaultSet] = useState(false); const dialogRef = useRef(null); - const parseAgentNames = (payload: unknown): string[] => { - const normalizeEntry = (entry: unknown): string | null => { - if (typeof entry === 'string') return entry; - if (entry && typeof entry === 'object') { - const name = (entry as { name?: unknown }).name; - if (typeof name === 'string') return name; - const id = (entry as { id?: unknown }).id; - if (typeof id === 'string') return id; - } - return null; - }; + // SWR: fetch once, cache globally, revalidate in background + const { data: agentsPayload } = useSWR('opencode-agents', getVisibleAgents, { + revalidateOnFocus: false, + dedupingInterval: 30000, + }); + const { data: config } = useSWR('openagent-config', getOpenAgentConfig, { + revalidateOnFocus: false, + dedupingInterval: 30000, + }); - const raw = Array.isArray(payload) - ? payload - : (payload as { agents?: unknown })?.agents; - if (!Array.isArray(raw)) return []; - - const names = raw - .map(normalizeEntry) - .filter((name): name is string => Boolean(name)); - return Array.from(new Set(names)); - }; + const opencodeAgents = agentsPayload ? parseAgentNames(agentsPayload) : []; const formatWorkspaceType = (type: Workspace['workspace_type']) => type === 'host' ? 'host' : 'isolated'; + // Click outside handler useEffect(() => { if (!open) return; @@ -69,45 +84,26 @@ export function NewMissionDialog({ return () => document.removeEventListener('mousedown', handleClickOutside); }, [open]); + // Set default agent when dialog opens (only once per open) + // Wait for both agents AND config to load before setting defaults useEffect(() => { - if (!open) return; - let cancelled = false; + if (!open || defaultSet || opencodeAgents.length === 0) return; + // Wait for config to finish loading (undefined = still loading, null/object = loaded) + if (config === undefined) return; - const loadAgentsAndConfig = async () => { - try { - // Load visible agents (pre-filtered by OpenAgent config) - const payload = await getVisibleAgents(); - if (cancelled) return; - const agents = parseAgentNames(payload); - setOpencodeAgents(agents); - - // Load OpenAgent config for default agent - const config = await getOpenAgentConfig(); - if (cancelled) return; - - // Set default agent from config, or fallback to Sisyphus if available - if (config.default_agent && agents.includes(config.default_agent)) { - setNewMissionAgent(config.default_agent); - } else if (agents.includes("Sisyphus")) { - setNewMissionAgent("Sisyphus"); - } - } catch { - if (!cancelled) { - setOpencodeAgents([]); - } - } - }; - - void loadAgentsAndConfig(); - return () => { - cancelled = true; - }; - }, [open]); + if (config?.default_agent && opencodeAgents.includes(config.default_agent)) { + setNewMissionAgent(config.default_agent); + } else if (opencodeAgents.includes('Sisyphus')) { + setNewMissionAgent('Sisyphus'); + } + setDefaultSet(true); + }, [open, defaultSet, opencodeAgents, config]); const resetForm = () => { setNewMissionWorkspace(''); setNewMissionAgent(''); setNewMissionModelOverride(''); + setDefaultSet(false); }; const handleCancel = () => { diff --git a/dashboard/src/components/queue-strip.tsx b/dashboard/src/components/queue-strip.tsx new file mode 100644 index 0000000..7b69543 --- /dev/null +++ b/dashboard/src/components/queue-strip.tsx @@ -0,0 +1,136 @@ +'use client'; + +import { useState } from 'react'; +import { X, ChevronDown, ChevronUp, Trash2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +export interface QueueItem { + id: string; + content: string; + agent?: string | null; +} + +interface QueueStripProps { + items: QueueItem[]; + onRemove: (id: string) => void; + onClearAll: () => void; + className?: string; +} + +export function QueueStrip({ items, onRemove, onClearAll, className }: QueueStripProps) { + const [expanded, setExpanded] = useState(false); + + if (items.length === 0) return null; + + const truncate = (text: string, maxLen: number) => { + if (text.length <= maxLen) return text; + return text.slice(0, maxLen - 3) + '...'; + }; + + // Collapsed view: show first item preview + if (!expanded && items.length === 1) { + const item = items[0]; + return ( +
+ Queue (1) + + {item.agent && @{item.agent} } + {truncate(item.content, 60)} + + +
+ ); + } + + // Collapsed view with multiple items + if (!expanded) { + return ( +
+ Queue ({items.length}) + + {truncate(items[0].content, 40)} + {items.length > 1 && +{items.length - 1} more} + + +
+ ); + } + + // Expanded view + return ( +
+ {/* Header */} +
+ Queued Messages ({items.length}) +
+ {items.length > 1 && ( + + )} + +
+
+ + {/* Queue items */} +
+ {items.map((item, index) => ( +
+ {index + 1}. +
+

+ {item.agent && @{item.agent} } + {item.content} +

+
+ +
+ ))} +
+
+ ); +} diff --git a/dashboard/src/components/recent-tasks.tsx b/dashboard/src/components/recent-tasks.tsx index 6a7b38f..b9f358d 100644 --- a/dashboard/src/components/recent-tasks.tsx +++ b/dashboard/src/components/recent-tasks.tsx @@ -1,9 +1,9 @@ "use client"; -import { useEffect, useState } from "react"; import Link from "next/link"; +import useSWR from "swr"; import { cn } from "@/lib/utils"; -import { isNetworkError, listMissions, Mission } from "@/lib/api"; +import { listMissions, Mission } from "@/lib/api"; import { ArrowRight, CheckCircle, @@ -11,7 +11,6 @@ import { Loader, Clock, Ban, - Target, } from "lucide-react"; const statusIcons: Record = { @@ -38,30 +37,18 @@ const statusColors: Record = { not_feasible: "text-rose-400", }; +// Sort missions by updated_at descending +const sortMissions = (data: Mission[]): Mission[] => + [...data].sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()); + export function RecentTasks() { - const [missions, setMissions] = useState([]); - const [loading, setLoading] = useState(true); + // SWR: poll missions every 5 seconds (shared key with history page) + const { data: missions = [], isLoading } = useSWR('missions', listMissions, { + refreshInterval: 5000, + revalidateOnFocus: false, + }); - useEffect(() => { - const fetchMissions = async () => { - try { - const data = await listMissions(); - // Sort by updated_at descending - const sorted = data - .sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()); - setMissions(sorted); - } catch (error) { - if (isNetworkError(error)) return; - console.error("Failed to fetch missions:", error); - } finally { - setLoading(false); - } - }; - - fetchMissions(); - const interval = setInterval(fetchMissions, 5000); - return () => clearInterval(interval); - }, []); + const sortedMissions = sortMissions(missions); return (
@@ -73,13 +60,13 @@ export function RecentTasks() {
- {loading ? ( + {isLoading ? (

Loading...

- ) : missions.length === 0 ? ( + ) : sortedMissions.length === 0 ? (

No missions yet

) : (
- {missions.map((mission) => { + {sortedMissions.map((mission) => { const Icon = statusIcons[mission.status] || Clock; const color = statusColors[mission.status] || "text-white/40"; const title = mission.title || "Untitled Mission"; diff --git a/dashboard/src/components/server-connection-card.tsx b/dashboard/src/components/server-connection-card.tsx new file mode 100644 index 0000000..294d1ac --- /dev/null +++ b/dashboard/src/components/server-connection-card.tsx @@ -0,0 +1,333 @@ +'use client'; + +import { useState } from 'react'; +import useSWR from 'swr'; +import { toast } from '@/components/toast'; +import { + getSystemComponents, + updateSystemComponent, + ComponentInfo, + UpdateProgressEvent, +} from '@/lib/api'; +import { + Server, + RefreshCw, + ArrowUp, + Check, + AlertCircle, + Loader, + ChevronDown, + ChevronUp, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; + +// Component display names +const componentNames: Record = { + open_agent: 'Open Agent', + opencode: 'OpenCode', + oh_my_opencode: 'oh-my-opencode', +}; + +// Component icons +const componentIcons: Record = { + open_agent: '🚀', + opencode: '⚡', + oh_my_opencode: '🎭', +}; + +interface UpdateLog { + message: string; + progress?: number; + type: 'log' | 'complete' | 'error'; +} + +interface ServerConnectionCardProps { + apiUrl: string; + setApiUrl: (url: string) => void; + urlError: string | null; + validateUrl: (url: string) => void; + health: { version: string } | null; + healthLoading: boolean; + testingConnection: boolean; + testApiConnection: () => void; +} + +export function ServerConnectionCard({ + apiUrl, + setApiUrl, + urlError, + validateUrl, + health, + healthLoading, + testingConnection, + testApiConnection, +}: ServerConnectionCardProps) { + const [componentsExpanded, setComponentsExpanded] = useState(true); + const [updatingComponent, setUpdatingComponent] = useState(null); + const [updateLogs, setUpdateLogs] = useState([]); + + // SWR: fetch system components + const { data, isLoading: loading, mutate } = useSWR( + 'system-components', + async () => { + const result = await getSystemComponents(); + return result.components; + }, + { revalidateOnFocus: false } + ); + const components = data ?? []; + + const handleUpdate = async (component: ComponentInfo) => { + if (updatingComponent) return; + + setUpdatingComponent(component.name); + setUpdateLogs([]); + + await updateSystemComponent( + component.name, + (event: UpdateProgressEvent) => { + setUpdateLogs((prev) => [ + ...prev, + { + message: event.message, + progress: event.progress ?? undefined, + type: event.event_type === 'complete' + ? 'complete' + : event.event_type === 'error' + ? 'error' + : 'log', + }, + ]); + }, + () => { + toast.success( + `${componentNames[component.name] || component.name} updated successfully!` + ); + setUpdatingComponent(null); + mutate(); // Revalidate components list + }, + (error: string) => { + toast.error(`Update failed: ${error}`); + setUpdatingComponent(null); + } + ); + }; + + const getStatusIcon = (component: ComponentInfo) => { + if (updatingComponent === component.name) { + return ; + } + if (component.status === 'update_available') { + return ; + } + if (component.status === 'not_installed' || component.status === 'error') { + return ; + } + return ; + }; + + const getStatusDot = (component: ComponentInfo) => { + if (component.status === 'update_available') { + return 'bg-amber-400'; + } + if (component.status === 'not_installed' || component.status === 'error') { + return 'bg-red-400'; + } + return 'bg-emerald-400'; + }; + + return ( +
+ {/* Header */} +
+
+ +
+
+

Server Connection

+

Backend endpoint & system components

+
+
+ + {/* API URL Input */} +
+ {/* Header row: Label + Status + Refresh */} +
+ +
+ {/* Status indicator */} + {healthLoading ? ( + + + Checking... + + ) : health ? ( + + + Connected (v{health.version}) + + ) : ( + + + Disconnected + + )} + {/* Refresh button */} + +
+
+ + {/* URL input */} + { + setApiUrl(e.target.value); + validateUrl(e.target.value); + }} + className={cn( + 'w-full rounded-lg border bg-white/[0.02] px-3 py-2.5 text-sm text-white placeholder-white/30 focus:outline-none transition-colors', + urlError + ? 'border-red-500/50 focus:border-red-500/50' + : 'border-white/[0.06] focus:border-indigo-500/50' + )} + /> + {urlError &&

{urlError}

} +
+ + {/* Divider */} +
+ + {/* System Components Section */} +
+
+
+ System Components + OpenCode stack +
+
+ + +
+
+ + {componentsExpanded && ( +
+ {loading ? ( +
+ +
+ ) : ( + components.map((component) => ( +
+
+ {/* Icon */} + + {componentIcons[component.name] || '📦'} + + + {/* Name & Version */} +
+
+ + {componentNames[component.name] || component.name} + + {component.version && ( + + v{component.version} + + )} +
+ {component.update_available && ( +
+ v{component.update_available} available +
+ )} + {!component.installed && ( +
+ Not installed +
+ )} +
+ + {/* Status */} +
+ {getStatusIcon(component)} + +
+ + {/* Update button */} + {component.status === 'update_available' && component.name !== 'open_agent' && ( + + )} +
+ + {/* Update logs */} + {updatingComponent === component.name && updateLogs.length > 0 && ( +
+
+ {updateLogs.map((log, i) => ( +
+ {log.progress !== undefined && ( + [{log.progress}%] + )} + {log.message} +
+ ))} +
+
+ )} +
+ )) + )} +
+ )} +
+
+ ); +} diff --git a/dashboard/src/components/system-components-card.tsx b/dashboard/src/components/system-components-card.tsx new file mode 100644 index 0000000..e8975c5 --- /dev/null +++ b/dashboard/src/components/system-components-card.tsx @@ -0,0 +1,253 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { toast } from '@/components/toast'; +import { + getSystemComponents, + updateSystemComponent, + ComponentInfo, + UpdateProgressEvent, +} from '@/lib/api'; +import { + Cpu, + RefreshCw, + ArrowUp, + Check, + AlertCircle, + Loader, + ChevronDown, + ChevronUp, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; + +// Component display names +const componentNames: Record = { + open_agent: 'Open Agent', + opencode: 'OpenCode', + oh_my_opencode: 'oh-my-opencode', +}; + +// Component icons +const componentIcons: Record = { + open_agent: '🚀', + opencode: '⚡', + oh_my_opencode: '🎭', +}; + +interface UpdateLog { + message: string; + progress?: number; + type: 'log' | 'complete' | 'error'; +} + +export function SystemComponentsCard() { + const [components, setComponents] = useState([]); + const [loading, setLoading] = useState(true); + const [expanded, setExpanded] = useState(true); + const [updatingComponent, setUpdatingComponent] = useState(null); + const [updateLogs, setUpdateLogs] = useState([]); + + const loadComponents = useCallback(async () => { + try { + setLoading(true); + const data = await getSystemComponents(); + setComponents(data.components); + } catch (err) { + console.error('Failed to load system components:', err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadComponents(); + }, [loadComponents]); + + const handleUpdate = async (component: ComponentInfo) => { + if (updatingComponent) return; + + setUpdatingComponent(component.name); + setUpdateLogs([]); + + await updateSystemComponent( + component.name, + (event: UpdateProgressEvent) => { + setUpdateLogs((prev) => [ + ...prev, + { + message: event.message, + progress: event.progress ?? undefined, + type: event.event_type === 'complete' + ? 'complete' + : event.event_type === 'error' + ? 'error' + : 'log', + }, + ]); + }, + () => { + toast.success( + `${componentNames[component.name] || component.name} updated successfully!` + ); + setUpdatingComponent(null); + loadComponents(); // Refresh to get new version + }, + (error: string) => { + toast.error(`Update failed: ${error}`); + setUpdatingComponent(null); + } + ); + }; + + const getStatusIcon = (component: ComponentInfo) => { + if (updatingComponent === component.name) { + return ; + } + if (component.status === 'update_available') { + return ; + } + if (component.status === 'not_installed') { + return ; + } + if (component.status === 'error') { + return ; + } + return ; + }; + + const getStatusDot = (component: ComponentInfo) => { + if (component.status === 'update_available') { + return 'bg-amber-400'; + } + if (component.status === 'not_installed' || component.status === 'error') { + return 'bg-red-400'; + } + return 'bg-emerald-400'; + }; + + return ( +
+
+
+
+ +
+
+

System Components

+

OpenCode stack versions

+
+
+
+ + +
+
+ + {expanded && ( +
+ {loading ? ( +
+ +
+ ) : ( + components.map((component) => ( +
+
+ {/* Icon */} + + {componentIcons[component.name] || '📦'} + + + {/* Name & Version */} +
+
+ + {componentNames[component.name] || component.name} + + {component.version && ( + + v{component.version} + + )} +
+ {component.update_available && ( +
+ v{component.update_available} available +
+ )} + {!component.installed && ( +
+ Not installed +
+ )} +
+ + {/* Status */} +
+ {getStatusIcon(component)} + +
+ + {/* Update button */} + {component.status === 'update_available' && component.name !== 'open_agent' && ( + + )} +
+ + {/* Update logs */} + {updatingComponent === component.name && updateLogs.length > 0 && ( +
+
+ {updateLogs.map((log, i) => ( +
+ {log.progress !== undefined && ( + [{log.progress}%] + )} + {log.message} +
+ ))} +
+
+ )} +
+ )) + )} +
+ )} +
+ ); +} diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts index f4d0aaa..cf9a726 100644 --- a/dashboard/src/lib/api.ts +++ b/dashboard/src/lib/api.ts @@ -635,6 +635,32 @@ export async function cancelControl(): Promise { if (!res.ok) throw new Error("Failed to cancel control session"); } +// Queue management +export interface QueuedMessage { + id: string; + content: string; + agent: string | null; +} + +export async function getQueue(): Promise { + const res = await apiFetch("/api/control/queue"); + if (!res.ok) throw new Error("Failed to fetch queue"); + return res.json(); +} + +export async function removeFromQueue(messageId: string): Promise { + const res = await apiFetch(`/api/control/queue/${messageId}`, { + method: "DELETE", + }); + if (!res.ok) throw new Error("Failed to remove from queue"); +} + +export async function clearQueue(): Promise<{ cleared: number }> { + const res = await apiFetch("/api/control/queue", { method: "DELETE" }); + if (!res.ok) throw new Error("Failed to clear queue"); + return res.json(); +} + // Agent tree snapshot (for refresh resilience) export interface AgentTreeNode { id: string; @@ -1723,6 +1749,7 @@ export interface WorkspaceTemplate { distro?: string; skills: string[]; env_vars: Record; + encrypted_keys: string[]; init_script: string; } @@ -1745,6 +1772,7 @@ export async function saveWorkspaceTemplate( distro?: string; skills?: string[]; env_vars?: Record; + encrypted_keys?: string[]; init_script?: string; } ): Promise { @@ -1772,6 +1800,7 @@ export async function renameWorkspaceTemplate(oldName: string, newName: string): distro: template.distro, skills: template.skills, env_vars: template.env_vars, + encrypted_keys: template.encrypted_keys, init_script: template.init_script, }); // Delete old template @@ -2526,3 +2555,103 @@ export async function cleanupOrphanedDesktopSessions(): Promise { + const res = await apiFetch('/api/system/components'); + if (!res.ok) throw new Error('Failed to get system components'); + return res.json(); +} + +// Update a system component (streams progress via SSE) +export async function updateSystemComponent( + name: string, + onProgress: (event: UpdateProgressEvent) => void, + onComplete: () => void, + onError: (error: string) => void +): Promise { + try { + const res = await apiFetch(`/api/system/components/${name}/update`, { + method: 'POST', + headers: { + 'Accept': 'text/event-stream', + }, + }); + + if (!res.ok) { + const text = await res.text(); + onError(text || 'Failed to start update'); + return; + } + + if (!res.body) { + onError('No response body'); + return; + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + // Parse SSE events from buffer + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; // Keep incomplete line in buffer + + for (const line of lines) { + if (line.startsWith('data: ')) { + const jsonData = line.slice(6); + try { + const data: UpdateProgressEvent = JSON.parse(jsonData); + onProgress(data); + + if (data.event_type === 'complete') { + onComplete(); + return; + } else if (data.event_type === 'error') { + onError(data.message); + return; + } + } catch (e) { + console.error('Failed to parse SSE event:', e, jsonData); + } + } + } + } + + // Stream ended without explicit completion + onComplete(); + } catch (e) { + onError(e instanceof Error ? e.message : 'Unknown error'); + } +} diff --git a/dashboard/src/lib/skill-encryption.ts b/dashboard/src/lib/skill-encryption.ts new file mode 100644 index 0000000..54d1289 --- /dev/null +++ b/dashboard/src/lib/skill-encryption.ts @@ -0,0 +1,225 @@ +/** + * Skill content encryption utilities. + * + * Handles detection and marking of sensitive values in skill markdown content. + * Values are wrapped in ... tags for highlighting + * and backend encryption. + */ + +/** Pattern to match encrypted tags */ +export const ENCRYPTED_TAG_REGEX = /([^<]*)<\/encrypted>/g; + +/** Pattern for unversioned encrypted tags (for editing display) */ +export const ENCRYPTED_DISPLAY_REGEX = /([^<]*)<\/encrypted>/g; + +/** Check if a value looks like an encrypted tag */ +export const isEncryptedTag = (value: string): boolean => { + const trimmed = value.trim(); + return trimmed.startsWith(''); +}; + +/** Extract the value from an encrypted tag */ +export const extractEncryptedValue = (tag: string): string | null => { + const match = tag.match(/(.*?)<\/encrypted>/); + return match ? match[1] : null; +}; + +/** Wrap a value in an encrypted tag for display/editing */ +export const wrapEncrypted = (value: string): string => { + return `${value}`; +}; + +/** + * Common patterns for sensitive values that should be encrypted. + * These are variable-like patterns found in skill markdown that represent + * actual secrets, not placeholder patterns like ${OPENAI_API_KEY}. + */ +export const SENSITIVE_PATTERNS = [ + // OpenAI + /sk-[a-zA-Z0-9]{48,}/g, // OpenAI API keys + /sk-proj-[a-zA-Z0-9_-]{48,}/g, // OpenAI project API keys + + // Anthropic + /sk-ant-[a-zA-Z0-9_-]{40,}/g, // Anthropic API keys + + // Google + /AIza[a-zA-Z0-9_-]{35}/g, // Google API keys + + // AWS + /AKIA[A-Z0-9]{16}/g, // AWS access key IDs + /[a-zA-Z0-9/+=]{40}/g, // AWS secret keys (40 char base64) + + // GitHub + /ghp_[a-zA-Z0-9]{36}/g, // GitHub personal access tokens + /gho_[a-zA-Z0-9]{36}/g, // GitHub OAuth tokens + /ghs_[a-zA-Z0-9]{36}/g, // GitHub server tokens + /github_pat_[a-zA-Z0-9_]{22,}/g, // GitHub fine-grained PATs + + // Stripe + /sk_live_[a-zA-Z0-9]{24,}/g, // Stripe live secret keys + /sk_test_[a-zA-Z0-9]{24,}/g, // Stripe test secret keys + /rk_live_[a-zA-Z0-9]{24,}/g, // Stripe live restricted keys + /rk_test_[a-zA-Z0-9]{24,}/g, // Stripe test restricted keys + + // Twilio + /SK[a-f0-9]{32}/g, // Twilio API keys + + // Slack + /xoxb-[0-9]+-[0-9]+-[a-zA-Z0-9]+/g, // Slack bot tokens + /xoxp-[0-9]+-[0-9]+-[0-9]+-[a-f0-9]+/g, // Slack user tokens + + // Discord + /[MN][a-zA-Z0-9_-]{23,}\.[a-zA-Z0-9_-]{6}\.[a-zA-Z0-9_-]{27,}/g, // Discord bot tokens + + // Supabase + /sbp_[a-f0-9]{40}/g, // Supabase service role keys + + // Generic patterns (less specific, use with caution) + /Bearer\s+[a-zA-Z0-9_-]{20,}/g, // Bearer tokens +]; + +/** + * Variable name patterns that typically contain sensitive values. + * Used to detect assignments like `OPENAI_API_KEY=sk-...` in markdown. + */ +export const SENSITIVE_VAR_NAMES = [ + 'OPENAI_API_KEY', + 'ANTHROPIC_API_KEY', + 'CLAUDE_API_KEY', + 'GOOGLE_API_KEY', + 'GOOGLE_CLOUD_KEY', + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY', + 'GITHUB_TOKEN', + 'GITHUB_PAT', + 'GH_TOKEN', + 'STRIPE_SECRET_KEY', + 'STRIPE_API_KEY', + 'TWILIO_AUTH_TOKEN', + 'TWILIO_API_KEY', + 'SLACK_TOKEN', + 'SLACK_BOT_TOKEN', + 'DISCORD_TOKEN', + 'DISCORD_BOT_TOKEN', + 'SUPABASE_SERVICE_KEY', + 'SUPABASE_ANON_KEY', + 'DATABASE_URL', + 'DB_PASSWORD', + 'POSTGRES_PASSWORD', + 'MYSQL_PASSWORD', + 'REDIS_PASSWORD', + 'SECRET_KEY', + 'PRIVATE_KEY', + 'API_KEY', + 'API_SECRET', + 'AUTH_TOKEN', + 'ACCESS_TOKEN', + 'REFRESH_TOKEN', + 'JWT_SECRET', + 'ENCRYPTION_KEY', + 'SIGNING_KEY', + 'WEBHOOK_SECRET', +]; + +/** + * Find all sensitive values in content that should be encrypted. + * Returns matches with their positions for highlighting/replacement. + */ +export interface SensitiveMatch { + value: string; + start: number; + end: number; + pattern: string; +} + +export const findSensitiveValues = (content: string): SensitiveMatch[] => { + const matches: SensitiveMatch[] = []; + + // Skip values already wrapped in tags + const alreadyEncrypted = new Set(); + let encMatch; + const encRegex = new RegExp(ENCRYPTED_TAG_REGEX.source, 'g'); + while ((encMatch = encRegex.exec(content)) !== null) { + alreadyEncrypted.add(encMatch[1]); + } + + for (const pattern of SENSITIVE_PATTERNS) { + // Clone regex to reset lastIndex + const regex = new RegExp(pattern.source, pattern.flags); + let match; + while ((match = regex.exec(content)) !== null) { + const value = match[0]; + // Skip if already encrypted or too short (false positive) + if (alreadyEncrypted.has(value) || value.length < 20) continue; + + // Check if this position is inside an tag + const beforeText = content.slice(0, match.index); + const lastOpenTag = beforeText.lastIndexOf(''); + if (lastOpenTag > lastCloseTag) continue; // Inside encrypted tag + + matches.push({ + value, + start: match.index, + end: match.index + value.length, + pattern: pattern.source, + }); + } + } + + // Deduplicate overlapping matches (keep longer ones) + matches.sort((a, b) => a.start - b.start || b.end - a.end); + const deduped: SensitiveMatch[] = []; + for (const match of matches) { + const last = deduped[deduped.length - 1]; + if (!last || match.start >= last.end) { + deduped.push(match); + } + } + + return deduped; +}; + +/** + * Auto-wrap detected sensitive values in tags. + * Does not encrypt already-wrapped values. + */ +export const autoWrapSensitiveValues = (content: string): string => { + const matches = findSensitiveValues(content); + if (matches.length === 0) return content; + + // Replace from end to start to preserve indices + let result = content; + for (let i = matches.length - 1; i >= 0; i--) { + const match = matches[i]; + result = + result.slice(0, match.start) + + wrapEncrypted(match.value) + + result.slice(match.end); + } + + return result; +}; + +/** + * Count encrypted tags in content. + */ +export const countEncryptedTags = (content: string): number => { + const regex = new RegExp(ENCRYPTED_TAG_REGEX.source, 'g'); + let count = 0; + while (regex.exec(content) !== null) count++; + return count; +}; + +/** + * List all encrypted values in content (for display). + */ +export const listEncryptedValues = (content: string): string[] => { + const values: string[] = []; + const regex = new RegExp(ENCRYPTED_TAG_REGEX.source, 'g'); + let match; + while ((match = regex.exec(content)) !== null) { + values.push(match[1]); + } + return values; +}; diff --git a/dashboard/tests/secrets.spec.ts b/dashboard/tests/secrets.spec.ts index 14d6cc7..2b913f5 100644 --- a/dashboard/tests/secrets.spec.ts +++ b/dashboard/tests/secrets.spec.ts @@ -258,7 +258,7 @@ test.describe('Library - Secrets Unlocked State', () => { // Should have form fields await expect(page.getByPlaceholder(/mcp-tokens/i)).toBeVisible(); - await expect(page.getByPlaceholder(/supabase\/access_token/i)).toBeVisible(); + await expect(page.getByPlaceholder(/service\/api_key/i)).toBeVisible(); await expect(page.getByPlaceholder(/Secret value/i)).toBeVisible(); // Should have type selector @@ -289,13 +289,13 @@ test.describe('Library - Secrets Unlocked State', () => { // Clear all fields await page.getByPlaceholder(/mcp-tokens/i).fill(''); - await page.getByPlaceholder(/supabase\/access_token/i).fill(''); + await page.getByPlaceholder(/service\/api_key/i).fill(''); await expect(dialogAddBtn).toBeDisabled(); // Fill all fields await page.getByPlaceholder(/mcp-tokens/i).fill('test-registry'); - await page.getByPlaceholder(/supabase\/access_token/i).fill('test-key'); + await page.getByPlaceholder(/service\/api_key/i).fill('test-key'); await page.getByPlaceholder(/Secret value/i).fill('test-value'); // Button should now be enabled @@ -624,7 +624,7 @@ test.describe('Library - Secrets Integration', () => { // Fill in the form await page.getByPlaceholder(/mcp-tokens/i).fill('test-registry'); - await page.getByPlaceholder(/supabase\/access_token/i).fill(testKey); + await page.getByPlaceholder(/service\/api_key/i).fill(testKey); await page.getByPlaceholder(/Secret value/i).fill('test-secret-value-12345'); // Select API Key type diff --git a/docs/DESKTOP_SETUP.md b/docs/DESKTOP_SETUP.md index 2e9180d..4357919 100644 --- a/docs/DESKTOP_SETUP.md +++ b/docs/DESKTOP_SETUP.md @@ -312,7 +312,7 @@ These are installed on the production server: |------|-------------| | `desktop_start_session` | Start Xvfb + i3 + optional Chromium | | `desktop_stop_session` | Stop the desktop session | -| `desktop_screenshot` | Take screenshot (auto-uploads to Supabase) | +| `desktop_screenshot` | Take screenshot (saves locally) | | `desktop_type` | Send keyboard input (text or keys) | | `desktop_click` | Mouse click at coordinates | | `desktop_mouse_move` | Move mouse cursor | diff --git a/ios_dashboard/OpenAgentDashboard.xcodeproj/project.pbxproj b/ios_dashboard/OpenAgentDashboard.xcodeproj/project.pbxproj index 8b830d8..465698c 100644 --- a/ios_dashboard/OpenAgentDashboard.xcodeproj/project.pbxproj +++ b/ios_dashboard/OpenAgentDashboard.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 03176DF3878C25A0B557462C /* ToolUIOptionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4D419C8490A0C5FC4DCDF20 /* ToolUIOptionListView.swift */; }; 0620B298DEF91DFCAE050DAC /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 66A48A20D2178760301256C9 /* Assets.xcassets */; }; 0B5E1A6153270BFF21A54C23 /* TerminalState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52DDF35DB8CD7D70F3CFC4A6 /* TerminalState.swift */; }; + WORKSPACE1234567890ABCDEF /* WorkspaceState.swift in Sources */ = {isa = PBXBuildFile; fileRef = WORKSPACEREF1234567890AB /* WorkspaceState.swift */; }; 1BBE749F3758FD704D1BFA0B /* ToolUIDataTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45213C3E550D451EDC566CDE /* ToolUIDataTableView.swift */; }; 29372E691F6A5C5D2CCD9331 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A09A33A3A1A99446C8A88DC /* HistoryView.swift */; }; 2E26F9659B38872F562C3B2B /* WorkspacesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91BF18B4AEAEB407887401AC /* WorkspacesView.swift */; }; @@ -37,8 +38,10 @@ DA4634D7424AF3FC985987E7 /* GlassButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5267DE67017A858357F68424 /* GlassButton.swift */; }; E1A2B3C4D5E6F78901234567 /* MarkdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6789012345678 /* MarkdownView.swift */; }; EFABDC95B65F6ED3420186FC /* NewMissionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A8191F935AB50463216395 /* NewMissionSheet.swift */; }; + QUEUESHEETBUILD123456789 /* QueueSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = QUEUESHEETREF1234567890 /* QueueSheet.swift */; }; FA7E68F22D16E1AC0B5F5E22 /* StatusBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD6FB2E54DC07BE7A1EB08F8 /* StatusBadge.swift */; }; FF9C447978711CBA9185B8B0 /* OpenAgentDashboardApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 139C740B7D55C13F3B167EF3 /* OpenAgentDashboardApp.swift */; }; + SETTINGSVIEWBUILD1234567 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = SETTINGSVIEWREF123456789 /* SettingsView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -65,6 +68,7 @@ 504A1222CE8971417834D229 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; 5267DE67017A858357F68424 /* GlassButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassButton.swift; sourceTree = ""; }; 52DDF35DB8CD7D70F3CFC4A6 /* TerminalState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalState.swift; sourceTree = ""; }; + WORKSPACEREF1234567890AB /* WorkspaceState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceState.swift; sourceTree = ""; }; 5908645A518F48B501390AB8 /* FilesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilesView.swift; sourceTree = ""; }; 5A09A33A3A1A99446C8A88DC /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; 66A48A20D2178760301256C9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -82,15 +86,25 @@ CD6FB2E54DC07BE7A1EB08F8 /* StatusBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBadge.swift; sourceTree = ""; }; CD8D224B6758B664864F3987 /* ANSIParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ANSIParser.swift; sourceTree = ""; }; D1A8191F935AB50463216395 /* NewMissionSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewMissionSheet.swift; sourceTree = ""; }; + QUEUESHEETREF1234567890 /* QueueSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueueSheet.swift; sourceTree = ""; }; D4AB47CF121ABA1946A4D879 /* Mission.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mission.swift; sourceTree = ""; }; E7C1198DDF17571DE85F5ABA /* Workspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Workspace.swift; sourceTree = ""; }; E7FC053808661C9A0E21E83C /* RunningMissionsBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunningMissionsBar.swift; sourceTree = ""; }; EB5A4720378F06807FDE73E1 /* GlassCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassCard.swift; sourceTree = ""; }; F51395D8FB559D3C79AAA0A4 /* OpenAgentDashboard.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = OpenAgentDashboard.app; sourceTree = BUILT_PRODUCTS_DIR; }; FCA36F5FA00B575DDD336598 /* DesktopStreamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesktopStreamView.swift; sourceTree = ""; }; + SETTINGSVIEWREF123456789 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ + SETTINGSGROUP123456789A /* Settings */ = { + isa = PBXGroup; + children = ( + SETTINGSVIEWREF123456789 /* SettingsView.swift */, + ); + path = Settings; + sourceTree = ""; + }; 0C1185300420EEF31B892A3A /* Files */ = { isa = PBXGroup; children = ( @@ -171,6 +185,7 @@ A688A831235D3E218A0A6783 /* Desktop */, 0C1185300420EEF31B892A3A /* Files */, 5A40B212F0D2055C1C499FCC /* History */, + SETTINGSGROUP123456789A /* Settings */, 0D9369EE2F3374EAA1EF332E /* Terminal */, 2E7A509F8D87B5FCDE5387AF /* Workspaces */, ); @@ -237,6 +252,7 @@ children = ( A84519FDE8FC75084938B292 /* ControlView.swift */, D1A8191F935AB50463216395 /* NewMissionSheet.swift */, + QUEUESHEETREF1234567890 /* QueueSheet.swift */, ); path = Control; sourceTree = ""; @@ -249,6 +265,7 @@ A07EFDD6964AA3B251967041 /* DesktopStreamService.swift */, 3729F39FBF53046124D05BC1 /* NavigationState.swift */, 52DDF35DB8CD7D70F3CFC4A6 /* TerminalState.swift */, + WORKSPACEREF1234567890AB /* WorkspaceState.swift */, ); path = Services; sourceTree = ""; @@ -371,8 +388,10 @@ 9BC40E40E1B5622B24328AEB /* Mission.swift in Sources */, 83BB0F0AAFE4F2735FF76B87 /* NavigationState.swift in Sources */, EFABDC95B65F6ED3420186FC /* NewMissionSheet.swift in Sources */, + QUEUESHEETBUILD123456789 /* QueueSheet.swift in Sources */, FF9C447978711CBA9185B8B0 /* OpenAgentDashboardApp.swift in Sources */, 51436A7671B1E3C8478F81A2 /* RunningMissionsBar.swift in Sources */, + SETTINGSVIEWBUILD1234567 /* SettingsView.swift in Sources */, FA7E68F22D16E1AC0B5F5E22 /* StatusBadge.swift in Sources */, 0B5E1A6153270BFF21A54C23 /* TerminalState.swift in Sources */, 4D0CF2666262F45370D000DF /* TerminalView.swift in Sources */, @@ -383,6 +402,7 @@ 3361B14E949CB2A6E75B6962 /* ToolUIView.swift in Sources */, 6DCB8CE8092980A29DA5EE9A /* Workspace.swift in Sources */, 2E26F9659B38872F562C3B2B /* WorkspacesView.swift in Sources */, + WORKSPACE1234567890ABCDEF /* WorkspaceState.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios_dashboard/OpenAgentDashboard/ContentView.swift b/ios_dashboard/OpenAgentDashboard/ContentView.swift index 399547e..f670da4 100644 --- a/ios_dashboard/OpenAgentDashboard/ContentView.swift +++ b/ios_dashboard/OpenAgentDashboard/ContentView.swift @@ -11,9 +11,10 @@ struct ContentView: View { @State private var isAuthenticated = false @State private var isCheckingAuth = true @State private var authRequired = false - + @State private var showSetupSheet = false + private let api = APIService.shared - + var body: some View { Group { if isCheckingAuth { @@ -28,11 +29,30 @@ struct ContentView: View { .task { await checkAuth() } + .sheet(isPresented: $showSetupSheet) { + SetupSheet(onComplete: { + showSetupSheet = false + Task { await checkAuth() } + }) + } + .onChange(of: api.isConfigured) { _, isConfigured in + // Re-check auth when server URL is configured + if isConfigured { + Task { await checkAuth() } + } + } } - + private func checkAuth() async { isCheckingAuth = true - + + // If not configured, show setup sheet + guard api.isConfigured else { + isCheckingAuth = false + showSetupSheet = true + return + } + do { let _ = try await api.checkHealth() authRequired = api.authRequired @@ -42,11 +62,155 @@ struct ContentView: View { authRequired = true isAuthenticated = api.isAuthenticated } - + isCheckingAuth = false } } +// MARK: - Setup Sheet (First Launch) + +struct SetupSheet: View { + let onComplete: () -> Void + + @State private var serverURL = "" + @State private var isTestingConnection = false + @State private var connectionSuccess = false + @State private var errorMessage: String? + + private let api = APIService.shared + + var body: some View { + NavigationStack { + ZStack { + Theme.backgroundPrimary.ignoresSafeArea() + + VStack(spacing: 32) { + Spacer() + + // Welcome icon + VStack(spacing: 16) { + Image(systemName: "server.rack") + .font(.system(size: 64, weight: .light)) + .foregroundStyle(Theme.accent) + + VStack(spacing: 8) { + Text("Welcome to Open Agent") + .font(.title2.bold()) + .foregroundStyle(Theme.textPrimary) + + Text("Enter your server URL to get started") + .font(.subheadline) + .foregroundStyle(Theme.textSecondary) + } + } + + // Server URL input + GlassCard(padding: 24, cornerRadius: 24) { + VStack(spacing: 20) { + VStack(alignment: .leading, spacing: 8) { + Text("Server URL") + .font(.caption.weight(.medium)) + .foregroundStyle(Theme.textSecondary) + + TextField("https://your-server.com", text: $serverURL) + .textFieldStyle(.plain) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + .padding(.horizontal, 16) + .padding(.vertical, 14) + .background(Color.white.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Theme.border, lineWidth: 1) + ) + } + + if let error = errorMessage { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(Theme.error) + Text(error) + .font(.caption) + .foregroundStyle(Theme.error) + Spacer() + } + } + + if connectionSuccess { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(Theme.success) + Text("Connection successful!") + .font(.caption) + .foregroundStyle(Theme.success) + Spacer() + } + } + + Button { + Task { await connectToServer() } + } label: { + HStack { + if isTestingConnection { + ProgressView() + .progressViewStyle(.circular) + .tint(.white) + .scaleEffect(0.8) + } + Text(isTestingConnection ? "Connecting..." : "Connect") + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + } + .buttonStyle(GlassProminentButtonStyle()) + .disabled(serverURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isTestingConnection) + } + } + .padding(.horizontal, 20) + + Spacer() + } + } + .navigationBarTitleDisplayMode(.inline) + .interactiveDismissDisabled() + } + .presentationDetents([.large]) + .presentationDragIndicator(.hidden) + } + + private func connectToServer() async { + let trimmedURL = serverURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedURL.isEmpty else { return } + + isTestingConnection = true + errorMessage = nil + connectionSuccess = false + + // Save original URL to restore on failure + let originalURL = api.baseURL + api.baseURL = trimmedURL + + do { + _ = try await api.checkHealth() + connectionSuccess = true + HapticService.success() + + // Brief delay to show success state + try? await Task.sleep(for: .milliseconds(500)) + onComplete() + } catch { + // Restore original URL on failure + api.baseURL = originalURL + errorMessage = "Could not connect. Please check the URL." + HapticService.error() + } + + isTestingConnection = false + } +} + // MARK: - Login View struct LoginView: View { diff --git a/ios_dashboard/OpenAgentDashboard/Models/Mission.swift b/ios_dashboard/OpenAgentDashboard/Models/Mission.swift index d445388..a9c5898 100644 --- a/ios_dashboard/OpenAgentDashboard/Models/Mission.swift +++ b/ios_dashboard/OpenAgentDashboard/Models/Mission.swift @@ -144,6 +144,22 @@ struct TaskState: Codable, Identifiable { } } +// MARK: - Queue + +struct QueuedMessage: Codable, Identifiable { + let id: String + let content: String + let agent: String? + + /// Truncated content for display (max 100 chars) + var displayContent: String { + if content.count > 100 { + return String(content.prefix(100)) + "..." + } + return content + } +} + // MARK: - Parallel Execution struct RunningMissionInfo: Codable, Identifiable { diff --git a/ios_dashboard/OpenAgentDashboard/Services/APIService.swift b/ios_dashboard/OpenAgentDashboard/Services/APIService.swift index bb72a64..d24b4d7 100644 --- a/ios_dashboard/OpenAgentDashboard/Services/APIService.swift +++ b/ios_dashboard/OpenAgentDashboard/Services/APIService.swift @@ -16,9 +16,14 @@ final class APIService { // Configuration var baseURL: String { - get { UserDefaults.standard.string(forKey: "api_base_url") ?? "https://agent-backend.thomas.md" } + get { UserDefaults.standard.string(forKey: "api_base_url") ?? "" } set { UserDefaults.standard.set(newValue, forKey: "api_base_url") } } + + /// Whether the server URL has been configured + var isConfigured: Bool { + !baseURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } private var jwtToken: String? { get { UserDefaults.standard.string(forKey: "jwt_token") } @@ -190,7 +195,25 @@ final class APIService { func cancelControl() async throws { let _: EmptyResponse = try await post("/api/control/cancel", body: EmptyBody()) } - + + // MARK: - Queue Management + + func getQueue() async throws -> [QueuedMessage] { + try await get("/api/control/queue") + } + + func removeFromQueue(messageId: String) async throws { + let _: EmptyResponse = try await delete("/api/control/queue/\(messageId)") + } + + func clearQueue() async throws -> Int { + struct ClearResponse: Decodable { + let cleared: Int + } + let response: ClearResponse = try await delete("/api/control/queue") + return response.cleared + } + // MARK: - Tasks func listTasks() async throws -> [TaskState] { diff --git a/ios_dashboard/OpenAgentDashboard/Services/DesktopStreamService.swift b/ios_dashboard/OpenAgentDashboard/Services/DesktopStreamService.swift index 6a5a6a0..1e94501 100644 --- a/ios_dashboard/OpenAgentDashboard/Services/DesktopStreamService.swift +++ b/ios_dashboard/OpenAgentDashboard/Services/DesktopStreamService.swift @@ -117,7 +117,8 @@ final class DesktopStreamService: NSObject { // MARK: - Private private func buildWebSocketURL(displayId: String) -> URL? { - let baseURL = UserDefaults.standard.string(forKey: "api_base_url") ?? "https://agent-backend.thomas.md" + let baseURL = APIService.shared.baseURL + guard !baseURL.isEmpty else { return nil } // Convert https to wss, http to ws var wsURL = baseURL diff --git a/ios_dashboard/OpenAgentDashboard/Services/WorkspaceState.swift b/ios_dashboard/OpenAgentDashboard/Services/WorkspaceState.swift new file mode 100644 index 0000000..8bb6df6 --- /dev/null +++ b/ios_dashboard/OpenAgentDashboard/Services/WorkspaceState.swift @@ -0,0 +1,92 @@ +// +// WorkspaceState.swift +// OpenAgentDashboard +// +// Global workspace selection state shared across tabs +// + +import Foundation +import Observation + +@MainActor +@Observable +final class WorkspaceState { + static let shared = WorkspaceState() + private init() {} + + /// All available workspaces + var workspaces: [Workspace] = [] + + /// Currently selected workspace (nil means host/default) + var selectedWorkspace: Workspace? + + /// Whether we're currently loading workspaces + var isLoading = false + + /// Error message if loading failed + var errorMessage: String? + + private let api = APIService.shared + + /// Load workspaces from the API + func loadWorkspaces() async { + guard !isLoading else { return } + isLoading = true + errorMessage = nil + + do { + workspaces = try await api.listWorkspaces() + + // If no workspace is selected, default to host + if selectedWorkspace == nil { + selectedWorkspace = workspaces.first { $0.isDefault } + } + + // Validate selected workspace still exists + if let selected = selectedWorkspace, + !workspaces.contains(where: { $0.id == selected.id }) { + selectedWorkspace = workspaces.first { $0.isDefault } + } + } catch { + errorMessage = error.localizedDescription + } + + isLoading = false + } + + /// Select a workspace by ID + func selectWorkspace(id: String) { + selectedWorkspace = workspaces.first { $0.id == id } + } + + /// Get the display name for the current workspace + var currentWorkspaceLabel: String { + selectedWorkspace?.displayLabel ?? "Host" + } + + /// Get the icon for the current workspace type + var currentWorkspaceIcon: String { + selectedWorkspace?.workspaceType.icon ?? "desktopcomputer" + } + + /// Check if the selected workspace is ready + var isWorkspaceReady: Bool { + selectedWorkspace?.status.isReady ?? true + } + + /// Get the base path for file browsing in the current workspace + var filesBasePath: String { + guard let workspace = selectedWorkspace else { + return "/root/context" + } + + // For host workspace, use /root/context + if workspace.isDefault { + return "/root/context" + } + + // For container workspaces, use the workspace root + // The backend maps this appropriately + return "/root" + } +} diff --git a/ios_dashboard/OpenAgentDashboard/Views/Control/ControlView.swift b/ios_dashboard/OpenAgentDashboard/Views/Control/ControlView.swift index 06bdabb..6bc1f56 100644 --- a/ios_dashboard/OpenAgentDashboard/Views/Control/ControlView.swift +++ b/ios_dashboard/OpenAgentDashboard/Views/Control/ControlView.swift @@ -13,6 +13,8 @@ struct ControlView: View { @State private var inputText = "" @State private var runState: ControlRunState = .idle @State private var queueLength = 0 + @State private var queuedItems: [QueuedMessage] = [] + @State private var showQueueSheet = false @State private var currentMission: Mission? @State private var viewingMission: Mission? @State private var isLoading = true @@ -41,10 +43,10 @@ struct ControlView: View { @State private var desktopDisplayId = ":101" private let availableDisplays = [":99", ":100", ":101", ":102"] - // Workspace selection state - @State private var workspaces: [Workspace] = [] + // Workspace selection state (global) + private var workspaceState = WorkspaceState.shared @State private var showNewMissionSheet = false - @State private var selectedWorkspaceId: String? = nil + @State private var showSettings = false @FocusState private var isInputFocused: Bool @@ -101,9 +103,15 @@ struct ControlView: View { .foregroundStyle(Theme.textSecondary) if queueLength > 0 { - Text("• \(queueLength) queued") - .font(.caption2) - .foregroundStyle(Theme.textTertiary) + Button { + Task { await loadQueueItems() } + showQueueSheet = true + HapticService.lightTap() + } label: { + Text("• \(queueLength) queued") + .font(.caption2) + .foregroundStyle(Theme.warning) + } } // Progress indicator @@ -120,25 +128,48 @@ struct ControlView: View { } ToolbarItem(placement: .topBarLeading) { - // Running missions toggle - Button { - withAnimation(.easeInOut(duration: 0.2)) { - showRunningMissions.toggle() - } - HapticService.selectionChanged() - } label: { - HStack(spacing: 4) { - Image(systemName: "square.stack.3d.up") - .font(.system(size: 14)) - if !runningMissions.isEmpty { - Text("\(runningMissions.count)") - .font(.caption2.weight(.semibold)) + // Workspace selector menu + Menu { + // Workspace selection section + Section("Workspace") { + ForEach(workspaceState.workspaces) { workspace in + Button { + workspaceState.selectWorkspace(id: workspace.id) + HapticService.selectionChanged() + } label: { + HStack { + Label(workspace.displayLabel, systemImage: workspace.workspaceType.icon) + if workspaceState.selectedWorkspace?.id == workspace.id { + Spacer() + Image(systemName: "checkmark") + } + } + } } } - .foregroundStyle(showRunningMissions ? Theme.accent : Theme.textSecondary) + + // Running missions section + if !runningMissions.isEmpty { + Section("Running Missions (\(runningMissions.count))") { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + showRunningMissions.toggle() + } + } label: { + Label( + showRunningMissions ? "Hide Running Missions" : "Show Running Missions", + systemImage: "square.stack.3d.up" + ) + } + } + } + } label: { + Image(systemName: "square.stack.3d.up") + .font(.system(size: 16)) + .foregroundStyle(Theme.textSecondary) } } - + ToolbarItem(placement: .topBarTrailing) { // Desktop stream button Button { @@ -155,7 +186,7 @@ struct ControlView: View { Menu { Button { Task { - await loadWorkspaces() + await workspaceState.loadWorkspaces() showNewMissionSheet = true } } label: { @@ -181,6 +212,14 @@ struct ControlView: View { Label("View Desktop (\(desktopDisplayId))", systemImage: "display") } + Divider() + + Button { + showSettings = true + } label: { + Label("Settings", systemImage: "gearshape") + } + if let mission = viewingMission { Divider() @@ -220,6 +259,9 @@ struct ControlView: View { } } .task { + // Load workspaces for the workspace picker + await workspaceState.loadWorkspaces() + // Check if we're being opened with a specific mission from History if let pendingId = nav.consumePendingMission() { await loadMission(id: pendingId) @@ -228,15 +270,15 @@ struct ControlView: View { } else { await loadCurrentMission(updateViewing: true) } - + // Fetch initial running missions await refreshRunningMissions() - + // Auto-show bar if there are multiple running missions if runningMissions.count > 1 { showRunningMissions = true } - + startStreaming() startPollingRunningMissions() } @@ -269,8 +311,11 @@ struct ControlView: View { } .sheet(isPresented: $showNewMissionSheet) { NewMissionSheet( - workspaces: workspaces, - selectedWorkspaceId: $selectedWorkspaceId, + workspaces: workspaceState.workspaces, + selectedWorkspaceId: Binding( + get: { workspaceState.selectedWorkspace?.id }, + set: { if let id = $0 { workspaceState.selectWorkspace(id: id) } } + ), onCreate: { workspaceId in showNewMissionSheet = false Task { await createNewMission(workspaceId: workspaceId) } @@ -282,8 +327,27 @@ struct ControlView: View { .presentationDetents([.medium]) .presentationDragIndicator(.visible) } + .sheet(isPresented: $showSettings) { + SettingsView() + } + .sheet(isPresented: $showQueueSheet) { + QueueSheet( + items: queuedItems, + onRemove: { messageId in + Task { await removeFromQueue(messageId: messageId) } + }, + onClearAll: { + Task { await clearQueue() } + }, + onDismiss: { + showQueueSheet = false + } + ) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } } - + // MARK: - Running Missions Bar private var runningMissionsBar: some View { @@ -496,7 +560,7 @@ struct ControlView: View { private var emptyStateView: some View { VStack(spacing: 32) { Spacer() - + // Animated brain icon Image(systemName: "brain") .font(.system(size: 56, weight: .light)) @@ -508,59 +572,35 @@ struct ControlView: View { ) ) .symbolEffect(.pulse, options: .repeating.speed(0.5)) - + VStack(spacing: 12) { Text("Ready to Help") .font(.title2.bold()) .foregroundStyle(Theme.textPrimary) - - Text("Send a message to start working\nwith the AI agent") + + Text(emptyStateSubtitle) .font(.subheadline) .foregroundStyle(Theme.textSecondary) .multilineTextAlignment(.center) .lineSpacing(4) } - - // Quick action templates - VStack(spacing: 12) { - Text("Quick actions:") - .font(.caption) - .foregroundStyle(Theme.textMuted) - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) { - quickActionButton( - icon: "doc.text.fill", - title: "Analyze files", - prompt: "Read the files in /root/context and summarize what they contain", - color: Theme.accent - ) - quickActionButton( - icon: "globe", - title: "Search web", - prompt: "Search the web for the latest news about ", - color: Theme.success - ) - quickActionButton( - icon: "chevron.left.forwardslash.chevron.right", - title: "Write code", - prompt: "Write a Python script that ", - color: Theme.warning - ) - quickActionButton( - icon: "terminal.fill", - title: "Run command", - prompt: "Run the command: ", - color: Theme.info - ) - } - } - .padding(.top, 8) - Spacer() Spacer() } .padding(.horizontal, 32) } + + private var emptyStateSubtitle: String { + if let workspace = workspaceState.selectedWorkspace { + if workspace.isDefault { + return "Send a message to start working\non the host environment" + } else { + return "Send a message to start working\nin \(workspace.name)" + } + } + return "Send a message to start working\nwith the AI agent" + } private func suggestionChip(_ text: String) -> some View { Button { @@ -581,40 +621,12 @@ struct ControlView: View { } } - private func quickActionButton(icon: String, title: String, prompt: String, color: Color) -> some View { - Button { - inputText = prompt - isInputFocused = true - } label: { - HStack(spacing: 8) { - Image(systemName: icon) - .font(.system(size: 14)) - .foregroundStyle(color) - .frame(width: 20) - - Text(title) - .font(.caption.weight(.medium)) - .foregroundStyle(Theme.textSecondary) - - Spacer() - } - .padding(.horizontal, 12) - .padding(.vertical, 10) - .background(Theme.backgroundSecondary) - .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(Theme.border, lineWidth: 1) - ) - } - } - // MARK: - Input private var inputView: some View { VStack(spacing: 0) { // ChatGPT-style input: clean outline, no fill, integrated send button - HStack(alignment: .bottom, spacing: 0) { + HStack(alignment: .center, spacing: 0) { // Text input - minimal style with just a border TextField("Message the agent...", text: $inputText, axis: .vertical) .textFieldStyle(.plain) @@ -662,10 +674,8 @@ struct ControlView: View { .disabled(runState == .idle && inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) .animation(.easeInOut(duration: 0.15), value: runState) .animation(.easeInOut(duration: 0.15), value: inputText.isEmpty) - .padding(.trailing, 6) - .padding(.bottom, 6) + .padding(.trailing, 8) } - .background(Color.clear) .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 24, style: .continuous) @@ -674,7 +684,6 @@ struct ControlView: View { .padding(.horizontal, 16) .padding(.top, 12) .padding(.bottom, 16) - .background(Theme.backgroundPrimary) } } @@ -781,23 +790,6 @@ struct ControlView: View { } } - private func loadWorkspaces() async { - do { - workspaces = try await api.listWorkspaces() - // Validate selected workspace still exists, reset to default if not - if let selected = selectedWorkspaceId, !workspaces.contains(where: { $0.id == selected }) { - selectedWorkspaceId = nil - } - // Default to host workspace if none selected - if selectedWorkspaceId == nil, let defaultWorkspace = workspaces.first(where: { $0.isDefault }) { - selectedWorkspaceId = defaultWorkspace.id - } - } catch { - print("Failed to load workspaces: \(error)") - workspaces = [] - selectedWorkspaceId = nil - } - } private func setMissionStatus(_ status: MissionStatus) async { guard let mission = viewingMission else { return } @@ -881,6 +873,50 @@ struct ControlView: View { HapticService.error() } } + + // MARK: - Queue Management + + private func loadQueueItems() async { + do { + queuedItems = try await api.getQueue() + } catch { + print("Failed to load queue: \(error)") + } + } + + private func removeFromQueue(messageId: String) async { + // Optimistic update + queuedItems.removeAll { $0.id == messageId } + queueLength = max(0, queueLength - 1) + + do { + try await api.removeFromQueue(messageId: messageId) + } catch { + print("Failed to remove from queue: \(error)") + // Refresh from server on error to get actual state + await loadQueueItems() + queueLength = queuedItems.count + HapticService.error() + } + } + + private func clearQueue() async { + // Optimistic update + queuedItems = [] + queueLength = 0 + showQueueSheet = false + + do { + _ = try await api.clearQueue() + HapticService.success() + } catch { + print("Failed to clear queue: \(error)") + // Refresh from server on error to get actual state + await loadQueueItems() + queueLength = queuedItems.count + HapticService.error() + } + } private func startStreaming() { streamTask = Task { diff --git a/ios_dashboard/OpenAgentDashboard/Views/Control/QueueSheet.swift b/ios_dashboard/OpenAgentDashboard/Views/Control/QueueSheet.swift new file mode 100644 index 0000000..423e77d --- /dev/null +++ b/ios_dashboard/OpenAgentDashboard/Views/Control/QueueSheet.swift @@ -0,0 +1,164 @@ +// +// QueueSheet.swift +// OpenAgentDashboard +// +// Bottom sheet for viewing and managing queued messages +// + +import SwiftUI + +struct QueueSheet: View { + let items: [QueuedMessage] + let onRemove: (String) -> Void + let onClearAll: () -> Void + let onDismiss: () -> Void + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + if items.isEmpty { + emptyState + } else { + queueList + } + } + .background(Theme.backgroundSecondary) + .navigationTitle("Message Queue") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Done") { + onDismiss() + } + .foregroundStyle(Theme.accent) + } + + if items.count > 1 { + ToolbarItem(placement: .topBarTrailing) { + Button(role: .destructive) { + onClearAll() + HapticService.success() + } label: { + Text("Clear All") + .foregroundStyle(Theme.error) + } + } + } + } + } + } + + private var emptyState: some View { + VStack(spacing: 16) { + Spacer() + + Image(systemName: "tray") + .font(.system(size: 48, weight: .light)) + .foregroundStyle(Theme.textMuted) + + VStack(spacing: 8) { + Text("Queue Empty") + .font(.title3.weight(.semibold)) + .foregroundStyle(Theme.textPrimary) + + Text("Messages sent while the agent is busy will appear here") + .font(.subheadline) + .foregroundStyle(Theme.textSecondary) + .multilineTextAlignment(.center) + } + + Spacer() + Spacer() + } + .padding(.horizontal, 32) + } + + private var queueList: some View { + List { + Section { + ForEach(Array(items.enumerated()), id: \.element.id) { index, item in + QueueItemRow(item: item, position: index + 1) + .listRowBackground(Theme.backgroundTertiary) + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + } + .onDelete { indexSet in + for index in indexSet { + let item = items[index] + onRemove(item.id) + HapticService.lightTap() + } + } + } header: { + Text("\(items.count) message\(items.count == 1 ? "" : "s") waiting") + .font(.caption) + .foregroundStyle(Theme.textSecondary) + } footer: { + Text("Swipe left to remove individual messages") + .font(.caption) + .foregroundStyle(Theme.textMuted) + } + } + .listStyle(.insetGrouped) + .scrollContentBackground(.hidden) + } +} + +struct QueueItemRow: View { + let item: QueuedMessage + let position: Int + + var body: some View { + HStack(alignment: .top, spacing: 12) { + // Position badge + Text("\(position)") + .font(.caption.weight(.semibold).monospacedDigit()) + .foregroundStyle(Theme.textMuted) + .frame(width: 24, height: 24) + .background(Theme.backgroundSecondary) + .clipShape(Circle()) + + // Message content + VStack(alignment: .leading, spacing: 4) { + if let agent = item.agent { + HStack(spacing: 4) { + Image(systemName: "at") + .font(.caption2) + Text(agent) + .font(.caption.weight(.medium)) + } + .foregroundStyle(Theme.success) + } + + Text(item.content) + .font(.subheadline) + .foregroundStyle(Theme.textPrimary) + .lineLimit(3) + } + + Spacer(minLength: 0) + } + .padding(.vertical, 4) + } +} + +#Preview("With Items") { + QueueSheet( + items: [ + QueuedMessage(id: "1", content: "Can you also fix the login bug?", agent: nil), + QueuedMessage(id: "2", content: "Run the tests after that", agent: "claude"), + QueuedMessage(id: "3", content: "This is a much longer message that should get truncated at some point to prevent it from taking up too much space in the queue list view", agent: nil) + ], + onRemove: { _ in }, + onClearAll: {}, + onDismiss: {} + ) +} + +#Preview("Empty") { + QueueSheet( + items: [], + onRemove: { _ in }, + onClearAll: {}, + onDismiss: {} + ) +} diff --git a/ios_dashboard/OpenAgentDashboard/Views/Files/FilesView.swift b/ios_dashboard/OpenAgentDashboard/Views/Files/FilesView.swift index 5c70439..0f6af85 100644 --- a/ios_dashboard/OpenAgentDashboard/Views/Files/FilesView.swift +++ b/ios_dashboard/OpenAgentDashboard/Views/Files/FilesView.swift @@ -9,6 +9,7 @@ import SwiftUI import UniformTypeIdentifiers struct FilesView: View { + private var workspaceState = WorkspaceState.shared @State private var currentPath = "/root/context" @State private var entries: [FileEntry] = [] @State private var isLoading = false @@ -21,10 +22,13 @@ struct FilesView: View { @State private var showingNewFolderAlert = false @State private var newFolderName = "" @State private var isImporting = false - + // Track pending path fetch to prevent race conditions @State private var fetchingPath: String? - + + // Track workspace changes + @State private var lastWorkspaceId: String? + private let api = APIService.shared private var sortedEntries: [FileEntry] { @@ -88,44 +92,71 @@ struct FilesView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { - // Quick nav menu + // Workspace selector Menu { - Button { - navigateTo("/root/context") - } label: { - Label("Context", systemImage: "tray.and.arrow.down") + // Workspace selection section + Section("Workspace") { + ForEach(workspaceState.workspaces) { workspace in + Button { + workspaceState.selectWorkspace(id: workspace.id) + // Navigate to the workspace's base path + navigateTo(workspaceState.filesBasePath) + HapticService.selectionChanged() + } label: { + HStack { + Label(workspace.displayLabel, systemImage: workspace.workspaceType.icon) + if workspaceState.selectedWorkspace?.id == workspace.id { + Spacer() + Image(systemName: "checkmark") + } + } + } + } } - - Button { - navigateTo("/root/work") - } label: { - Label("Work", systemImage: "hammer") - } - - Button { - navigateTo("/root/tools") - } label: { - Label("Tools", systemImage: "wrench.and.screwdriver") - } - + Divider() - - Button { - navigateTo("/root") - } label: { - Label("Home", systemImage: "house") - } - - Button { - navigateTo("/") - } label: { - Label("Root", systemImage: "externaldrive") + + // Quick nav section + Section("Quick Nav") { + Button { + navigateTo("/root/context") + } label: { + Label("Context", systemImage: "tray.and.arrow.down") + } + + Button { + navigateTo("/root/work") + } label: { + Label("Work", systemImage: "hammer") + } + + Button { + navigateTo("/root/tools") + } label: { + Label("Tools", systemImage: "wrench.and.screwdriver") + } + + Divider() + + Button { + navigateTo("/root") + } label: { + Label("Home", systemImage: "house") + } + + Button { + navigateTo("/") + } label: { + Label("Root", systemImage: "externaldrive") + } } } label: { - Image(systemName: "folder.badge.gearshape") + Image(systemName: "square.stack.3d.up") + .font(.system(size: 16)) + .foregroundStyle(Theme.textSecondary) } } - + ToolbarItem(placement: .topBarTrailing) { Menu { Button { @@ -175,8 +206,24 @@ struct FilesView: View { Task { await handleFileImport(result) } } .task { + // Load workspaces if not already loaded + if workspaceState.workspaces.isEmpty { + await workspaceState.loadWorkspaces() + } + + // Set initial path based on workspace + currentPath = workspaceState.filesBasePath + lastWorkspaceId = workspaceState.selectedWorkspace?.id + await loadDirectory() } + .onChange(of: workspaceState.selectedWorkspace?.id) { _, newId in + // Handle workspace change from other tabs + if newId != lastWorkspaceId { + lastWorkspaceId = newId + navigateTo(workspaceState.filesBasePath) + } + } } // MARK: - Subviews diff --git a/ios_dashboard/OpenAgentDashboard/Views/History/HistoryView.swift b/ios_dashboard/OpenAgentDashboard/Views/History/HistoryView.swift index 0ceeaed..ca177ef 100644 --- a/ios_dashboard/OpenAgentDashboard/Views/History/HistoryView.swift +++ b/ios_dashboard/OpenAgentDashboard/Views/History/HistoryView.swift @@ -83,52 +83,21 @@ struct HistoryView: View { .stroke(Theme.border, lineWidth: 1) ) - // Filter pills and cleanup button - HStack(spacing: 12) { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - ForEach(StatusFilter.allCases, id: \.rawValue) { filter in - FilterPill( - title: filter.rawValue, - isSelected: selectedFilter == filter - ) { - withAnimation(.easeInOut(duration: 0.2)) { - selectedFilter = filter - } - HapticService.selectionChanged() + // Filter pills + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(StatusFilter.allCases, id: \.rawValue) { filter in + FilterPill( + title: filter.rawValue, + isSelected: selectedFilter == filter + ) { + withAnimation(.easeInOut(duration: 0.2)) { + selectedFilter = filter } + HapticService.selectionChanged() } } } - - // Cleanup button - Button { - Task { await cleanupEmptyMissions() } - } label: { - HStack(spacing: 6) { - if isCleaningUp { - ProgressView() - .scaleEffect(0.7) - .tint(Theme.textSecondary) - } else { - Image(systemName: "sparkles") - .font(.caption) - } - Text("Cleanup") - .font(.caption.weight(.medium)) - } - .foregroundStyle(Theme.textSecondary) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(.ultraThinMaterial) - .clipShape(Capsule()) - .overlay( - Capsule() - .stroke(Theme.border, lineWidth: 0.5) - ) - } - .disabled(isCleaningUp) - .opacity(isCleaningUp ? 0.6 : 1) } } .padding() @@ -157,25 +126,52 @@ struct HistoryView: View { .transition(.move(edge: .top).combined(with: .opacity)) } - // Content - if isLoading { - LoadingView(message: "Loading history...") - } else if let error = errorMessage { - EmptyStateView( - icon: "exclamationmark.triangle", - title: "Failed to Load", - message: error, - action: { Task { await loadData() } }, - actionLabel: "Retry" - ) - } else if filteredMissions.isEmpty && tasks.isEmpty { - EmptyStateView( - icon: "clock.arrow.circlepath", - title: "No History", - message: "Your missions will appear here" - ) - } else { - missionsList + // Content with floating cleanup button + ZStack(alignment: .bottomTrailing) { + if isLoading { + LoadingView(message: "Loading history...") + } else if let error = errorMessage { + EmptyStateView( + icon: "exclamationmark.triangle", + title: "Failed to Load", + message: error, + action: { Task { await loadData() } }, + actionLabel: "Retry" + ) + } else if filteredMissions.isEmpty && tasks.isEmpty { + EmptyStateView( + icon: "clock.arrow.circlepath", + title: "No History", + message: "Your missions will appear here" + ) + } else { + missionsList + } + + // Floating cleanup button + Button { + Task { await cleanupEmptyMissions() } + } label: { + Group { + if isCleaningUp { + ProgressView() + .scaleEffect(0.8) + .tint(.white) + } else { + Image(systemName: "sparkles") + .font(.body.weight(.medium)) + } + } + .foregroundStyle(.white) + .frame(width: 48, height: 48) + .background(Theme.accent) + .clipShape(Circle()) + .shadow(color: Theme.accent.opacity(0.4), radius: 8, x: 0, y: 4) + } + .disabled(isCleaningUp) + .opacity(isCleaningUp ? 0.7 : 1) + .padding(.trailing, 20) + .padding(.bottom, 20) } } } @@ -388,16 +384,11 @@ private struct MissionRow: View { HStack(spacing: 8) { StatusBadge(status: mission.status.statusType, compact: true) - - if mission.canResume { - Text("Resumable") - .font(.caption2.weight(.medium)) - .foregroundStyle(Theme.warning) - } - - Text("\(mission.history.count) messages") + + Text("\(mission.history.count) msg") .font(.caption) .foregroundStyle(Theme.textTertiary) + .fixedSize() } } diff --git a/ios_dashboard/OpenAgentDashboard/Views/Settings/SettingsView.swift b/ios_dashboard/OpenAgentDashboard/Views/Settings/SettingsView.swift new file mode 100644 index 0000000..9f7a7ba --- /dev/null +++ b/ios_dashboard/OpenAgentDashboard/Views/Settings/SettingsView.swift @@ -0,0 +1,237 @@ +// +// SettingsView.swift +// OpenAgentDashboard +// +// Settings page for configuring server connection and app preferences +// + +import SwiftUI + +struct SettingsView: View { + @Environment(\.dismiss) private var dismiss + + @State private var serverURL: String + @State private var isTestingConnection = false + @State private var connectionStatus: ConnectionStatus = .unknown + @State private var showingSaveConfirmation = false + + private let api = APIService.shared + private let originalURL: String + + enum ConnectionStatus: Equatable { + case unknown + case testing + case success(authMode: String) + case failure(message: String) + + var icon: String { + switch self { + case .unknown: return "questionmark.circle" + case .testing: return "arrow.trianglehead.2.clockwise.rotate.90" + case .success: return "checkmark.circle.fill" + case .failure: return "xmark.circle.fill" + } + } + + var color: Color { + switch self { + case .unknown: return Theme.textSecondary + case .testing: return Theme.accent + case .success: return Theme.success + case .failure: return Theme.error + } + } + + var message: String { + switch self { + case .unknown: return "Not tested" + case .testing: return "Testing connection..." + case .success(let authMode): return "Connected (\(authMode))" + case .failure(let message): return message + } + } + + /// Header message for display above the URL field + var headerMessage: String { + switch self { + case .unknown: return "Not tested" + case .testing: return "Testing..." + case .success(let authMode): return "Connected (\(authMode))" + case .failure: return "Failed" + } + } + } + + init() { + let currentURL = APIService.shared.baseURL + _serverURL = State(initialValue: currentURL) + originalURL = currentURL + } + + var body: some View { + NavigationStack { + ZStack { + Theme.backgroundPrimary.ignoresSafeArea() + + ScrollView { + VStack(spacing: 24) { + // Server Configuration Section + VStack(alignment: .leading, spacing: 16) { + Label("Server Configuration", systemImage: "server.rack") + .font(.headline) + .foregroundStyle(Theme.textPrimary) + + GlassCard(padding: 20, cornerRadius: 20) { + VStack(alignment: .leading, spacing: 10) { + // Header: "API URL" + status + refresh button + HStack(spacing: 8) { + Text("API URL") + .font(.caption.weight(.medium)) + .foregroundStyle(Theme.textSecondary) + + Spacer() + + // Status indicator + HStack(spacing: 5) { + Circle() + .fill(connectionStatus.color) + .frame(width: 6, height: 6) + + Text(connectionStatus.headerMessage) + .font(.caption2) + .foregroundStyle(connectionStatus.color) + } + + // Refresh button + Button { + Task { await testConnection() } + } label: { + Image(systemName: "arrow.clockwise") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(connectionStatus == .testing ? Theme.accent : Theme.textMuted) + } + .disabled(serverURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || connectionStatus == .testing) + .symbolEffect(.rotate, isActive: connectionStatus == .testing) + } + + // URL input field + TextField("https://your-server.com", text: $serverURL) + .textFieldStyle(.plain) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + .padding(.horizontal, 16) + .padding(.vertical, 14) + .background(Color.white.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Theme.border, lineWidth: 1) + ) + .onChange(of: serverURL) { _, _ in + connectionStatus = .unknown + } + } + } + } + + // About Section + VStack(alignment: .leading, spacing: 16) { + Label("About", systemImage: "info.circle") + .font(.headline) + .foregroundStyle(Theme.textPrimary) + + GlassCard(padding: 20, cornerRadius: 20) { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Open Agent Dashboard") + .font(.subheadline.weight(.medium)) + .foregroundStyle(Theme.textPrimary) + Spacer() + Text("v1.0") + .font(.caption) + .foregroundStyle(Theme.textSecondary) + } + + Divider() + .background(Theme.border) + + Text("A native iOS dashboard for managing Open Agent workspaces and missions.") + .font(.caption) + .foregroundStyle(Theme.textSecondary) + } + } + } + + Spacer(minLength: 40) + } + .padding(.horizontal, 20) + .padding(.top, 20) + } + } + .navigationTitle("Settings") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cancel") { + // Restore original URL on cancel + api.baseURL = originalURL + dismiss() + } + .foregroundStyle(Theme.textSecondary) + } + + ToolbarItem(placement: .topBarTrailing) { + Button("Save") { + saveSettings() + } + .fontWeight(.semibold) + .foregroundStyle(Theme.accent) + .disabled(serverURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } + + private func testConnection() async { + let trimmedURL = serverURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedURL.isEmpty else { return } + + connectionStatus = .testing + + // Temporarily set the URL to test + let originalURL = api.baseURL + api.baseURL = trimmedURL + + do { + _ = try await api.checkHealth() + let modeString: String + switch api.authMode { + case .disabled: + modeString = "no auth" + case .singleTenant: + modeString = "single tenant" + case .multiUser: + modeString = "multi-user" + } + connectionStatus = .success(authMode: modeString) + } catch { + connectionStatus = .failure(message: error.localizedDescription) + // Restore original URL on failure + api.baseURL = originalURL + } + } + + private func saveSettings() { + let trimmedURL = serverURL.trimmingCharacters(in: .whitespacesAndNewlines) + api.baseURL = trimmedURL + HapticService.success() + dismiss() + } +} + +#Preview { + SettingsView() +} diff --git a/ios_dashboard/OpenAgentDashboard/Views/Terminal/TerminalView.swift b/ios_dashboard/OpenAgentDashboard/Views/Terminal/TerminalView.swift index 300a94c..ece8c71 100644 --- a/ios_dashboard/OpenAgentDashboard/Views/Terminal/TerminalView.swift +++ b/ios_dashboard/OpenAgentDashboard/Views/Terminal/TerminalView.swift @@ -9,10 +9,11 @@ import SwiftUI struct TerminalView: View { private var state = TerminalState.shared + private var workspaceState = WorkspaceState.shared @State private var inputText = "" - + @FocusState private var isInputFocused: Bool - + private let api = APIService.shared // Convenience accessors @@ -40,6 +41,35 @@ struct TerminalView: View { .navigationTitle("Terminal") .navigationBarTitleDisplayMode(.inline) .toolbar { + ToolbarItem(placement: .topBarLeading) { + // Workspace selector + Menu { + ForEach(workspaceState.workspaces) { workspace in + Button { + workspaceState.selectWorkspace(id: workspace.id) + // Reconnect to the new workspace + disconnect() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + connect() + } + HapticService.selectionChanged() + } label: { + HStack { + Label(workspace.displayLabel, systemImage: workspace.workspaceType.icon) + if workspaceState.selectedWorkspace?.id == workspace.id { + Spacer() + Image(systemName: "checkmark") + } + } + } + } + } label: { + Image(systemName: "square.stack.3d.up") + .font(.system(size: 16)) + .foregroundStyle(Theme.textSecondary) + } + } + ToolbarItem(placement: .topBarTrailing) { // Unified status pill HStack(spacing: 0) { @@ -55,12 +85,12 @@ struct TerminalView: View { .padding(.horizontal, 10) .padding(.vertical, 6) .background(connectionStatus == .connected ? Theme.success.opacity(0.15) : Color.clear) - + // Divider Rectangle() .fill(Theme.border) .frame(width: 1) - + // Action side Button { if connectionStatus == .connected { @@ -76,15 +106,18 @@ struct TerminalView: View { .padding(.vertical, 6) } } - .background(Theme.backgroundSecondary) .clipShape(Capsule()) .overlay( Capsule() - .stroke(Theme.border, lineWidth: 1) + .stroke(Theme.border, lineWidth: 0.5) ) } } - .onAppear { + .task { + // Load workspaces if not already loaded + if workspaceState.workspaces.isEmpty { + await workspaceState.loadWorkspaces() + } connect() } .onDisappear { @@ -222,10 +255,12 @@ struct TerminalView: View { private func connect() { guard state.connectionStatus != .connected && !state.isConnecting else { return } - + state.isConnecting = true state.connectionStatus = .connecting - state.appendLine(TerminalLine(text: "Connecting to \(api.baseURL)...", type: .system)) + + let workspaceName = workspaceState.currentWorkspaceLabel + state.appendLine(TerminalLine(text: "Connecting to \(workspaceName)...", type: .system)) guard let wsURL = buildWebSocketURL() else { state.appendLine(TerminalLine(text: "Invalid WebSocket URL", type: .error)) @@ -268,7 +303,14 @@ struct TerminalView: View { private func buildWebSocketURL() -> URL? { guard var components = URLComponents(string: api.baseURL) else { return nil } components.scheme = components.scheme == "https" ? "wss" : "ws" - components.path = "/api/console/ws" + + // Use workspace-specific shell if a non-default workspace is selected + if let workspace = workspaceState.selectedWorkspace, !workspace.isDefault { + components.path = "/api/workspaces/\(workspace.id)/shell" + } else { + components.path = "/api/console/ws" + } + return components.url } diff --git a/src/api/ai_providers.rs b/src/api/ai_providers.rs index 5f9cd07..44c7f9b 100644 --- a/src/api/ai_providers.rs +++ b/src/api/ai_providers.rs @@ -10,7 +10,6 @@ //! - Set default provider use std::collections::{BTreeSet, HashMap}; -use std::env; use std::path::{Path, PathBuf}; use axum::{ @@ -38,19 +37,22 @@ const OPENAI_TOKEN_URL: &str = "https://auth.openai.com/oauth/token"; const OPENAI_REDIRECT_URI: &str = "http://localhost:1455/auth/callback"; const OPENAI_SCOPE: &str = "openid profile email offline_access"; -/// Google/Gemini OAuth constants (from opencode-gemini-auth plugin) +/// Google/Gemini OAuth constants (from opencode-gemini-auth plugin / Gemini CLI) +const GOOGLE_CLIENT_ID: &str = + "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"; +const GOOGLE_CLIENT_SECRET: &str = "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"; const GOOGLE_AUTHORIZE_URL: &str = "https://accounts.google.com/o/oauth2/v2/auth"; const GOOGLE_TOKEN_URL: &str = "https://oauth2.googleapis.com/token"; const GOOGLE_REDIRECT_URI: &str = "http://localhost:8085/oauth2callback"; const GOOGLE_SCOPES: &str = "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile"; -fn google_client_id() -> String { - env::var("GOOGLE_CLIENT_ID").unwrap_or_else(|_| "missing-google-client-id".to_string()) +fn google_client_id() -> &'static str { + GOOGLE_CLIENT_ID } -fn google_client_secret() -> String { - env::var("GOOGLE_CLIENT_SECRET").unwrap_or_else(|_| "missing-google-client-secret".to_string()) +fn google_client_secret() -> &'static str { + GOOGLE_CLIENT_SECRET } fn anthropic_client_id() -> String { diff --git a/src/api/control.rs b/src/api/control.rs index 0e1daff..b1a25d7 100644 --- a/src/api/control.rs +++ b/src/api/control.rs @@ -133,6 +133,14 @@ pub struct ControlMessageResponse { pub queued: bool, } +/// A message waiting in the queue +#[derive(Debug, Clone, Serialize)] +pub struct QueuedMessage { + pub id: Uuid, + pub content: String, + pub agent: Option, +} + /// Tool result posted by the frontend for an interactive tool call. #[derive(Debug, Clone, Deserialize)] pub struct ControlToolResultRequest { @@ -507,6 +515,19 @@ pub enum ControlCommand { GracefulShutdown { respond: oneshot::Sender>, }, + /// Get the current message queue + GetQueue { + respond: oneshot::Sender>, + }, + /// Remove a message from the queue + RemoveFromQueue { + message_id: Uuid, + respond: oneshot::Sender, // true if removed, false if not found + }, + /// Clear all messages from the queue + ClearQueue { + respond: oneshot::Sender, // number of messages cleared + }, } // ==================== Mission Types ==================== @@ -622,7 +643,7 @@ pub struct ControlState { pub running_missions: Arc>>, /// Max parallel missions allowed pub max_parallel: usize, - /// Mission persistence (in-memory or Supabase-backed) + /// Mission persistence (SQLite-backed) pub mission_store: Arc, } @@ -878,6 +899,94 @@ pub async fn post_cancel( Ok(Json(serde_json::json!({ "ok": true }))) } +// ==================== Queue Management Endpoints ==================== + +/// Get the current message queue. +pub async fn get_queue( + State(state): State>, + Extension(user): Extension, +) -> Result>, (StatusCode, String)> { + let control = control_for_user(&state, &user).await; + let (tx, rx) = oneshot::channel(); + control + .cmd_tx + .send(ControlCommand::GetQueue { respond: tx }) + .await + .map_err(|_| { + ( + StatusCode::SERVICE_UNAVAILABLE, + "control session unavailable".to_string(), + ) + })?; + let queue = rx.await.map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to get queue".to_string(), + ) + })?; + Ok(Json(queue)) +} + +/// Remove a message from the queue. +pub async fn remove_from_queue( + State(state): State>, + Extension(user): Extension, + Path(message_id): Path, +) -> Result, (StatusCode, String)> { + let control = control_for_user(&state, &user).await; + let (tx, rx) = oneshot::channel(); + control + .cmd_tx + .send(ControlCommand::RemoveFromQueue { + message_id, + respond: tx, + }) + .await + .map_err(|_| { + ( + StatusCode::SERVICE_UNAVAILABLE, + "control session unavailable".to_string(), + ) + })?; + let removed = rx.await.map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to remove from queue".to_string(), + ) + })?; + if removed { + Ok(Json(serde_json::json!({ "ok": true }))) + } else { + Err((StatusCode::NOT_FOUND, "message not in queue".to_string())) + } +} + +/// Clear all messages from the queue. +pub async fn clear_queue( + State(state): State>, + Extension(user): Extension, +) -> Result, (StatusCode, String)> { + let control = control_for_user(&state, &user).await; + let (tx, rx) = oneshot::channel(); + control + .cmd_tx + .send(ControlCommand::ClearQueue { respond: tx }) + .await + .map_err(|_| { + ( + StatusCode::SERVICE_UNAVAILABLE, + "control session unavailable".to_string(), + ) + })?; + let cleared = rx.await.map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to clear queue".to_string(), + ) + })?; + Ok(Json(serde_json::json!({ "ok": true, "cleared": cleared }))) +} + // ==================== Mission Endpoints ==================== /// List all missions. @@ -2853,6 +2962,50 @@ async fn control_actor_loop( let _ = respond.send(interrupted_ids); } + ControlCommand::GetQueue { respond } => { + let queued: Vec = queue + .iter() + .map(|(id, content, agent)| QueuedMessage { + id: *id, + content: content.clone(), + agent: agent.clone(), + }) + .collect(); + let _ = respond.send(queued); + } + ControlCommand::RemoveFromQueue { message_id, respond } => { + let before_len = queue.len(); + queue.retain(|(id, _, _)| *id != message_id); + let removed = queue.len() < before_len; + if removed { + // Emit event to notify frontend + let _ = events_tx.send(AgentEvent::Status { + state: if running.is_some() { + ControlRunState::Running + } else { + ControlRunState::Idle + }, + queue_len: queue.len(), + mission_id: current_mission.read().await.clone(), + }); + } + let _ = respond.send(removed); + } + ControlCommand::ClearQueue { respond } => { + let cleared = queue.len(); + queue.clear(); + // Emit event to notify frontend + let _ = events_tx.send(AgentEvent::Status { + state: if running.is_some() { + ControlRunState::Running + } else { + ControlRunState::Idle + }, + queue_len: 0, + mission_id: current_mission.read().await.clone(), + }); + let _ = respond.send(cleared); + } } } // Handle agent-initiated mission status changes (from complete_mission tool) diff --git a/src/api/desktop.rs b/src/api/desktop.rs index ee9664d..c28d4cf 100644 --- a/src/api/desktop.rs +++ b/src/api/desktop.rs @@ -3,6 +3,7 @@ //! Provides endpoints for listing, closing, and managing desktop sessions. //! Also includes background cleanup of orphaned sessions. +use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; @@ -252,7 +253,7 @@ async fn cleanup_orphaned_sessions(State(state): State>) -> Json) -> Vec { - let mut sessions = Vec::new(); + let mut sessions_by_display: HashMap = HashMap::new(); // Get desktop config for grace period let grace_period_secs = get_desktop_config(&state.library) @@ -265,7 +266,7 @@ async fn collect_desktop_sessions(state: &Arc) -> Vec m, Err(e) => { tracing::warn!("Failed to list missions for desktop sessions: {}", e); - return sessions; + return Vec::new(); } }; @@ -315,7 +316,7 @@ async fn collect_desktop_sessions(state: &Arc) -> Vec) -> Vec { + if session_rank(&detail) > session_rank(existing) { + sessions_by_display.insert(detail.display.clone(), detail); + } + } + None => { + sessions_by_display.insert(detail.display.clone(), detail); + } + } } } @@ -334,25 +346,39 @@ async fn collect_desktop_sessions(state: &Arc) -> Vec = sessions_by_display.into_values().collect(); + sessions.sort_by(|a, b| a.display.cmp(&b.display)); sessions } +fn session_rank(detail: &DesktopSessionDetail) -> (u8, u8, i64) { + let running_rank = if detail.process_running { 1 } else { 0 }; + let status_rank = match detail.status { + DesktopSessionStatus::Active => 3, + DesktopSessionStatus::Orphaned => 2, + DesktopSessionStatus::Stopped => 1, + DesktopSessionStatus::Unknown => 0, + }; + let started_rank = DateTime::parse_from_rfc3339(&detail.started_at) + .map(|dt| dt.timestamp()) + .unwrap_or(0); + (running_rank, status_rank, started_rank) +} + /// Calculate seconds until auto-close based on mission completion time. fn calculate_auto_close_secs( mission: &super::mission_store::Mission, diff --git a/src/api/library.rs b/src/api/library.rs index dfad77c..a1b9367 100644 --- a/src/api/library.rs +++ b/src/api/library.rs @@ -289,6 +289,7 @@ pub struct SaveWorkspaceTemplateRequest { pub distro: Option, pub skills: Option>, pub env_vars: Option>, + pub encrypted_keys: Option>, pub init_script: Option, } @@ -943,6 +944,7 @@ async fn save_workspace_template( distro: req.distro.clone(), skills: sanitize_skill_list(req.skills.unwrap_or_default()), env_vars: req.env_vars.unwrap_or_default(), + encrypted_keys: req.encrypted_keys.unwrap_or_default(), init_script: req.init_script.unwrap_or_default(), }; diff --git a/src/api/mod.rs b/src/api/mod.rs index eb3c83c..62d5d1c 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -31,6 +31,7 @@ pub mod opencode; mod providers; mod routes; pub mod secrets; +pub mod system; pub mod types; pub mod workspaces; diff --git a/src/api/routes.rs b/src/api/routes.rs index 3f85c95..3fe87a6 100644 --- a/src/api/routes.rs +++ b/src/api/routes.rs @@ -38,6 +38,7 @@ use super::mcp as mcp_api; use super::monitoring; use super::opencode as opencode_api; use super::secrets as secrets_api; +use super::system as system_api; use super::types::*; use super::workspaces as workspaces_api; @@ -265,6 +266,16 @@ pub async fn serve(config: Config) -> anyhow::Result<()> { .route("/api/control/tool_result", post(control::post_tool_result)) .route("/api/control/stream", get(control::stream)) .route("/api/control/cancel", post(control::post_cancel)) + // Queue management endpoints + .route("/api/control/queue", get(control::get_queue)) + .route( + "/api/control/queue/:id", + axum::routing::delete(control::remove_from_queue), + ) + .route( + "/api/control/queue", + axum::routing::delete(control::clear_queue), + ) // State snapshots (for refresh resilience) .route("/api/control/tree", get(control::get_tree)) .route("/api/control/progress", get(control::get_progress)) @@ -379,6 +390,8 @@ pub async fn serve(config: Config) -> anyhow::Result<()> { .nest("/api/secrets", secrets_api::routes()) // Desktop session management endpoints .nest("/api/desktop", desktop::routes()) + // System component management endpoints + .nest("/api/system", system_api::routes()) .layer(middleware::from_fn_with_state( Arc::clone(&state), auth::require_auth, diff --git a/src/api/system.rs b/src/api/system.rs new file mode 100644 index 0000000..6bac544 --- /dev/null +++ b/src/api/system.rs @@ -0,0 +1,537 @@ +//! System component management API. +//! +//! Provides endpoints to query and update system components like OpenCode +//! and oh-my-opencode. + +use std::pin::Pin; +use std::sync::Arc; + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::{ + sse::{Event, Sse}, + Json, + }, + routing::{get, post}, + Router, +}; +use futures::stream::Stream; +use serde::Serialize; +use tokio::process::Command; + +use super::routes::AppState; + +/// Information about a system component. +#[derive(Debug, Clone, Serialize)] +pub struct ComponentInfo { + pub name: String, + pub version: Option, + pub installed: bool, + pub update_available: Option, + pub path: Option, + pub status: ComponentStatus, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ComponentStatus { + Ok, + UpdateAvailable, + NotInstalled, + Error, +} + +/// Response for the system components endpoint. +#[derive(Debug, Clone, Serialize)] +pub struct SystemComponentsResponse { + pub components: Vec, +} + +/// Response for update progress events. +#[derive(Debug, Clone, Serialize)] +pub struct UpdateProgressEvent { + pub event_type: String, // "log", "progress", "complete", "error" + pub message: String, + pub progress: Option, // 0-100 +} + +// Type alias for the boxed stream to avoid opaque type mismatch +type UpdateStream = Pin> + Send>>; + +/// Create routes for system management. +pub fn routes() -> Router> { + Router::new() + .route("/components", get(get_components)) + .route("/components/:name/update", post(update_component)) +} + +/// Get information about all system components. +async fn get_components(State(state): State>) -> Json { + let mut components = Vec::new(); + + // Open Agent (self) + components.push(ComponentInfo { + name: "open_agent".to_string(), + version: Some(env!("CARGO_PKG_VERSION").to_string()), + installed: true, + update_available: None, // Would need to check GitHub releases + path: Some("/usr/local/bin/open_agent".to_string()), + status: ComponentStatus::Ok, + }); + + // OpenCode + let opencode_info = get_opencode_info(&state.config).await; + components.push(opencode_info); + + // oh-my-opencode + let omo_info = get_oh_my_opencode_info().await; + components.push(omo_info); + + Json(SystemComponentsResponse { components }) +} + +/// Get OpenCode version and status. +async fn get_opencode_info(config: &crate::config::Config) -> ComponentInfo { + // Try to get version from the health endpoint + let client = reqwest::Client::new(); + let health_url = format!("{}/global/health", config.opencode_base_url); + + match client.get(&health_url).send().await { + Ok(resp) if resp.status().is_success() => { + if let Ok(json) = resp.json::().await { + let version = json + .get("version") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + // Check for updates by querying the latest release + let update_available = check_opencode_update(version.as_deref()).await; + let status = if update_available.is_some() { + ComponentStatus::UpdateAvailable + } else { + ComponentStatus::Ok + }; + + return ComponentInfo { + name: "opencode".to_string(), + version, + installed: true, + update_available, + path: Some("/usr/local/bin/opencode".to_string()), + status, + }; + } + } + _ => {} + } + + // Fallback: try to run opencode --version + match Command::new("opencode").arg("--version").output().await { + Ok(output) if output.status.success() => { + let version_str = String::from_utf8_lossy(&output.stdout); + let version = version_str + .lines() + .next() + .map(|l| l.trim().replace("opencode version ", "").replace("opencode ", "")); + + let update_available = check_opencode_update(version.as_deref()).await; + let status = if update_available.is_some() { + ComponentStatus::UpdateAvailable + } else { + ComponentStatus::Ok + }; + + ComponentInfo { + name: "opencode".to_string(), + version, + installed: true, + update_available, + path: Some("/usr/local/bin/opencode".to_string()), + status, + } + } + _ => ComponentInfo { + name: "opencode".to_string(), + version: None, + installed: false, + update_available: None, + path: None, + status: ComponentStatus::NotInstalled, + }, + } +} + +/// Check if there's a newer version of OpenCode available. +async fn check_opencode_update(current_version: Option<&str>) -> Option { + let current = current_version?; + + // Fetch latest release from opencode.ai or GitHub + let client = reqwest::Client::new(); + + // Check the anomalyco/opencode GitHub releases (the actual OpenCode source) + // Note: anthropics/claude-code is a different project + let resp = client + .get("https://api.github.com/repos/anomalyco/opencode/releases/latest") + .header("User-Agent", "open-agent") + .send() + .await + .ok()?; + + if !resp.status().is_success() { + return None; + } + + let json: serde_json::Value = resp.json().await.ok()?; + let latest = json.get("tag_name")?.as_str()?; + let latest_version = latest.trim_start_matches('v'); + + // Simple version comparison (assumes semver-like format) + if latest_version != current && version_is_newer(latest_version, current) { + Some(latest_version.to_string()) + } else { + None + } +} + +/// Simple semver comparison (newer returns true if a > b). +fn version_is_newer(a: &str, b: &str) -> bool { + let parse = |v: &str| -> Vec { + v.split('.') + .filter_map(|s| s.parse().ok()) + .collect() + }; + + let va = parse(a); + let vb = parse(b); + + for i in 0..va.len().max(vb.len()) { + let a_part = va.get(i).copied().unwrap_or(0); + let b_part = vb.get(i).copied().unwrap_or(0); + if a_part > b_part { + return true; + } + if a_part < b_part { + return false; + } + } + false +} + +/// Get oh-my-opencode version and status. +async fn get_oh_my_opencode_info() -> ComponentInfo { + // Check if oh-my-opencode is installed by looking for the config file + let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()); + let config_path = format!("{}/.config/opencode/oh-my-opencode.json", home); + + let installed = tokio::fs::metadata(&config_path).await.is_ok(); + + if !installed { + return ComponentInfo { + name: "oh_my_opencode".to_string(), + version: None, + installed: false, + update_available: None, + path: None, + status: ComponentStatus::NotInstalled, + }; + } + + // Try to get version from the package + // oh-my-opencode doesn't have a --version flag, so we check npm/bun + let version = get_oh_my_opencode_version().await; + let update_available = check_oh_my_opencode_update(version.as_deref()).await; + let status = if update_available.is_some() { + ComponentStatus::UpdateAvailable + } else { + ComponentStatus::Ok + }; + + ComponentInfo { + name: "oh_my_opencode".to_string(), + version, + installed: true, + update_available, + path: Some(config_path), + status, + } +} + +/// Get the installed version of oh-my-opencode. +async fn get_oh_my_opencode_version() -> Option { + let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()); + + // First, try to find the version from bun's cache (most reliable for the actual installed version) + // Run: find ~/.bun -name 'package.json' -path '*oh-my-opencode*' and parse version + let output = Command::new("bash") + .args([ + "-c", + &format!( + "find {}/.bun -name 'package.json' -path '*oh-my-opencode*' 2>/dev/null | head -1 | xargs cat 2>/dev/null", + home + ), + ]) + .output() + .await + .ok()?; + + if output.status.success() { + let content = String::from_utf8_lossy(&output.stdout); + if let Ok(json) = serde_json::from_str::(&content) { + if let Some(version) = json.get("version").and_then(|v| v.as_str()) { + return Some(version.to_string()); + } + } + } + + // Fallback: try running bunx to check the version (may be buggy in some versions) + let output = Command::new("bunx") + .args(["oh-my-opencode", "--version"]) + .output() + .await + .ok()?; + + if output.status.success() { + let version_str = String::from_utf8_lossy(&output.stdout); + return version_str + .lines() + .next() + .map(|l| l.trim().to_string()); + } + + None +} + +/// Check if there's a newer version of oh-my-opencode available. +async fn check_oh_my_opencode_update(current_version: Option<&str>) -> Option { + // Query npm registry for latest version + let client = reqwest::Client::new(); + let resp = client + .get("https://registry.npmjs.org/oh-my-opencode/latest") + .send() + .await + .ok()?; + + if !resp.status().is_success() { + return None; + } + + let json: serde_json::Value = resp.json().await.ok()?; + let latest = json.get("version")?.as_str()?; + + match current_version { + Some(current) if latest != current && version_is_newer(latest, current) => { + Some(latest.to_string()) + } + None => Some(latest.to_string()), // If no current version, suggest the latest + _ => None, + } +} + +/// Update a system component. +async fn update_component( + State(_state): State>, + Path(name): Path, +) -> Result, (StatusCode, String)> { + match name.as_str() { + "opencode" => Ok(Sse::new(Box::pin(stream_opencode_update()))), + "oh_my_opencode" => Ok(Sse::new(Box::pin(stream_oh_my_opencode_update()))), + _ => Err(( + StatusCode::BAD_REQUEST, + format!("Unknown component: {}", name), + )), + } +} + +/// Stream the OpenCode update process. +fn stream_opencode_update() -> impl Stream> { + async_stream::stream! { + // Send initial progress + yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent { + event_type: "log".to_string(), + message: "Starting OpenCode update...".to_string(), + progress: Some(0), + }).unwrap())); + + // Download and install OpenCode + yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent { + event_type: "log".to_string(), + message: "Downloading latest OpenCode release...".to_string(), + progress: Some(10), + }).unwrap())); + + // Run the install script + let install_result = Command::new("bash") + .args(["-c", "curl -fsSL https://opencode.ai/install | bash -s -- --no-modify-path"]) + .output() + .await; + + match install_result { + Ok(output) if output.status.success() => { + yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent { + event_type: "log".to_string(), + message: "Download complete, installing...".to_string(), + progress: Some(50), + }).unwrap())); + + // Copy to /usr/local/bin + let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()); + let install_result = Command::new("install") + .args(["-m", "0755", &format!("{}/.opencode/bin/opencode", home), "/usr/local/bin/opencode"]) + .output() + .await; + + match install_result { + Ok(output) if output.status.success() => { + yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent { + event_type: "log".to_string(), + message: "Binary installed, restarting service...".to_string(), + progress: Some(80), + }).unwrap())); + + // Restart the opencode service + let restart_result = Command::new("systemctl") + .args(["restart", "opencode.service"]) + .output() + .await; + + match restart_result { + Ok(output) if output.status.success() => { + // Wait a moment for the service to start + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + + yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent { + event_type: "complete".to_string(), + message: "OpenCode updated successfully!".to_string(), + progress: Some(100), + }).unwrap())); + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent { + event_type: "error".to_string(), + message: format!("Failed to restart service: {}", stderr), + progress: None, + }).unwrap())); + } + Err(e) => { + yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent { + event_type: "error".to_string(), + message: format!("Failed to restart service: {}", e), + progress: None, + }).unwrap())); + } + } + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent { + event_type: "error".to_string(), + message: format!("Failed to install binary: {}", stderr), + progress: None, + }).unwrap())); + } + Err(e) => { + yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent { + event_type: "error".to_string(), + message: format!("Failed to install binary: {}", e), + progress: None, + }).unwrap())); + } + } + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent { + event_type: "error".to_string(), + message: format!("Failed to download OpenCode: {}", stderr), + progress: None, + }).unwrap())); + } + Err(e) => { + yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent { + event_type: "error".to_string(), + message: format!("Failed to run install script: {}", e), + progress: None, + }).unwrap())); + } + } + } +} + +/// Stream the oh-my-opencode update process. +fn stream_oh_my_opencode_update() -> impl Stream> { + async_stream::stream! { + yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent { + event_type: "log".to_string(), + message: "Starting oh-my-opencode update...".to_string(), + progress: Some(0), + }).unwrap())); + + // First, clear the bun cache for oh-my-opencode to force fetching the latest version + yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent { + event_type: "log".to_string(), + message: "Clearing package cache...".to_string(), + progress: Some(10), + }).unwrap())); + + // Clear bun cache to force re-download + let _ = Command::new("bun") + .args(["pm", "cache", "rm"]) + .output() + .await; + + yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent { + event_type: "log".to_string(), + message: "Running bunx oh-my-opencode@latest install...".to_string(), + progress: Some(20), + }).unwrap())); + + // Run the install command with @latest to force the newest version + // Enable all providers by default for updates + let install_result = Command::new("bunx") + .args([ + "oh-my-opencode@latest", + "install", + "--no-tui", + "--claude=yes", + "--chatgpt=yes", + "--gemini=yes", + ]) + .output() + .await; + + match install_result { + Ok(output) if output.status.success() => { + let stdout = String::from_utf8_lossy(&output.stdout); + yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent { + event_type: "log".to_string(), + message: format!("Installation output: {}", stdout.lines().take(5).collect::>().join("\n")), + progress: Some(80), + }).unwrap())); + + yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent { + event_type: "complete".to_string(), + message: "oh-my-opencode updated successfully!".to_string(), + progress: Some(100), + }).unwrap())); + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent { + event_type: "error".to_string(), + message: format!("Failed to update oh-my-opencode: {} {}", stderr, stdout), + progress: None, + }).unwrap())); + } + Err(e) => { + yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent { + event_type: "error".to_string(), + message: format!("Failed to run update: {}", e), + progress: None, + }).unwrap())); + } + } + } +} diff --git a/src/api/workspaces.rs b/src/api/workspaces.rs index 075172c..14f65ea 100644 --- a/src/api/workspaces.rs +++ b/src/api/workspaces.rs @@ -1047,17 +1047,18 @@ async fn get_workspace_debug( let path_exists = path.exists(); // Calculate container size (only for chroot workspaces) + // Use du -sk (kilobytes) for portability, then convert to bytes let size_bytes = if workspace.workspace_type == WorkspaceType::Chroot && path_exists { - // Use du command for quick size calculation let output = tokio::process::Command::new("du") - .args(["-sb", &path.to_string_lossy()]) + .args(["-sk", &path.to_string_lossy()]) .output() .await .ok(); output.and_then(|o| { let stdout = String::from_utf8_lossy(&o.stdout); - stdout.split_whitespace().next()?.parse::().ok() + let kb = stdout.split_whitespace().next()?.parse::().ok()?; + Some(kb * 1024) // Convert KB to bytes }) } else { None diff --git a/src/config.rs b/src/config.rs index 36bba7b..59c526b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,8 +10,6 @@ //! - `OPENCODE_AGENT` - Optional. OpenCode agent name (e.g., `build`, `plan`). //! - `OPENCODE_PERMISSIVE` - Optional. If true, auto-allows all permissions for OpenCode sessions (default: true). //! - `OPEN_AGENT_USERS` - Optional. JSON array of user accounts for multi-user auth. -//! - `SUPABASE_URL` - Optional. Supabase project URL (used by tools for file sharing/screenshots). -//! - `SUPABASE_SERVICE_ROLE_KEY` - Optional. Service role key for Supabase. //! - `LIBRARY_GIT_SSH_KEY` - Optional. SSH key path for library git operations. If set to a path, uses that key. //! If set to empty string, ignores ~/.ssh/config (useful when the config specifies a non-existent key). //! If unset, uses default SSH behavior. diff --git a/src/library/env_crypto.rs b/src/library/env_crypto.rs index 9cd403d..6a0e657 100644 --- a/src/library/env_crypto.rs +++ b/src/library/env_crypto.rs @@ -210,6 +210,92 @@ pub fn generate_private_key() -> [u8; KEY_LENGTH] { key } +// ───────────────────────────────────────────────────────────────────────────── +// Content encryption (for skill markdown files) +// ───────────────────────────────────────────────────────────────────────────── + +/// Regex to match unversioned value tags (user input format). +const UNVERSIONED_TAG_REGEX: &str = r"([^<]*)"; + +/// Regex to match versioned value tags (storage format). +const VERSIONED_TAG_REGEX: &str = r#"([^<]*)"#; + +/// Check if a value is an unversioned encrypted tag (user input format). +pub fn is_unversioned_encrypted(value: &str) -> bool { + let trimmed = value.trim(); + trimmed.starts_with("") && trimmed.ends_with("") && !trimmed.contains(" v=\"") +} + +/// Encrypt all unversioned value tags in content. +/// Transforms plaintext to ciphertext. +pub fn encrypt_content_tags(key: &[u8; KEY_LENGTH], content: &str) -> Result { + let re = regex::Regex::new(UNVERSIONED_TAG_REGEX) + .map_err(|e| anyhow!("Invalid regex: {}", e))?; + + let mut result = content.to_string(); + let mut offset: i64 = 0; + + for cap in re.captures_iter(content) { + let full_match = cap.get(0).unwrap(); + let plaintext = cap.get(1).unwrap().as_str(); + + // Skip if already versioned (shouldn't happen with this regex, but be safe) + if full_match.as_str().contains(" v=\"") { + continue; + } + + // Encrypt the plaintext value + let encrypted = encrypt_value(key, plaintext)?; + + // Calculate adjusted position with offset + let start = (full_match.start() as i64 + offset) as usize; + let end = (full_match.end() as i64 + offset) as usize; + + // Update offset for next replacement + offset += encrypted.len() as i64 - full_match.len() as i64; + + // Replace in result + result = format!("{}{}{}", &result[..start], encrypted, &result[end..]); + } + + Ok(result) +} + +/// Decrypt all versioned ciphertext tags in content. +/// Transforms ciphertext to plaintext. +pub fn decrypt_content_tags(key: &[u8; KEY_LENGTH], content: &str) -> Result { + let re = regex::Regex::new(VERSIONED_TAG_REGEX) + .map_err(|e| anyhow!("Invalid regex: {}", e))?; + + let mut result = content.to_string(); + let mut offset: i64 = 0; + + for cap in re.captures_iter(content) { + let full_match = cap.get(0).unwrap(); + let _version = cap.get(1).unwrap().as_str(); + let _ciphertext_b64 = cap.get(2).unwrap().as_str(); + + // Reconstruct the full encrypted value for decryption + let encrypted_value = full_match.as_str(); + let plaintext = decrypt_value(key, encrypted_value)?; + + // Format as unversioned tag for display + let display_tag = format!("{}", plaintext); + + // Calculate adjusted position with offset + let start = (full_match.start() as i64 + offset) as usize; + let end = (full_match.end() as i64 + offset) as usize; + + // Update offset for next replacement + offset += display_tag.len() as i64 - full_match.len() as i64; + + // Replace in result + result = format!("{}{}{}", &result[..start], display_tag, &result[end..]); + } + + Ok(result) +} + /// Load the encryption key from environment, generating one if missing. /// If a key is generated, it will be appended to the .env file at the given path. pub async fn load_or_create_private_key(env_file_path: &Path) -> Result<[u8; KEY_LENGTH]> { @@ -412,4 +498,78 @@ mod tests { assert_eq!(decrypted, plaintext); } + + #[test] + fn test_is_unversioned_encrypted() { + assert!(is_unversioned_encrypted("secret")); + assert!(is_unversioned_encrypted(" secret ")); + assert!(!is_unversioned_encrypted("secret")); + assert!(!is_unversioned_encrypted("plaintext")); + } + + #[test] + fn test_encrypt_content_tags() { + let key = test_key(); + let content = "Hello, here is my key: sk-12345 and more text."; + + let encrypted = encrypt_content_tags(&key, content).unwrap(); + + // Should have versioned tag now + assert!(encrypted.contains("")); + assert!(encrypted.contains("")); + assert!(!encrypted.contains("sk-12345")); + assert!(encrypted.starts_with("Hello, here is my key: ")); + assert!(encrypted.ends_with(" and more text.")); + } + + #[test] + fn test_decrypt_content_tags() { + let key = test_key(); + let content = "Hello, here is my key: sk-12345 and more text."; + + // First encrypt + let encrypted = encrypt_content_tags(&key, content).unwrap(); + + // Then decrypt + let decrypted = decrypt_content_tags(&key, &encrypted).unwrap(); + + // Should be back to unversioned format + assert_eq!(decrypted, content); + } + + #[test] + fn test_encrypt_decrypt_multiple_tags() { + let key = test_key(); + let content = r#" +API keys: +- OpenAI: sk-openai-key +- Anthropic: sk-ant-key + +Use them wisely. +"#; + + let encrypted = encrypt_content_tags(&key, content).unwrap(); + + // Both should be encrypted + assert!(!encrypted.contains("sk-openai-key")); + assert!(!encrypted.contains("sk-ant-key")); + + // Count versioned tags + let count = encrypted.matches("").count(); + assert_eq!(count, 2); + + // Decrypt should restore original + let decrypted = decrypt_content_tags(&key, &encrypted).unwrap(); + assert_eq!(decrypted, content); + } + + #[test] + fn test_already_encrypted_passthrough() { + let key = test_key(); + let content = "Already encrypted: abc123"; + + // Encrypting again should not double-encrypt + let result = encrypt_content_tags(&key, content).unwrap(); + assert_eq!(result, content); + } } diff --git a/src/library/mod.rs b/src/library/mod.rs index 6615674..9528424 100644 --- a/src/library/mod.rs +++ b/src/library/mod.rs @@ -36,6 +36,9 @@ struct WorkspaceTemplateConfig { skills: Vec, #[serde(default)] env_vars: HashMap, + /// Keys of env vars that should be encrypted at rest (stored alongside encrypted values) + #[serde(default)] + encrypted_keys: Vec, #[serde(default)] init_script: String, } @@ -210,6 +213,8 @@ impl LibraryStore { } /// Get a skill by name with full content. + /// Encrypted values in ... tags are decrypted + /// to ... format for display/editing. pub async fn get_skill(&self, name: &str) -> Result { Self::validate_name(name)?; let skill_dir = self.skills_dir().join(name); @@ -219,10 +224,17 @@ impl LibraryStore { anyhow::bail!("Skill not found: {}", name); } - let content = fs::read_to_string(&skill_md) + let raw_content = fs::read_to_string(&skill_md) .await .context("Failed to read SKILL.md")?; + // Decrypt any encrypted tags for display + let content = if let Some(key) = env_crypto::load_private_key_from_env()? { + env_crypto::decrypt_content_tags(&key, &raw_content)? + } else { + raw_content + }; + let (frontmatter, _body) = parse_frontmatter(&content); let description = extract_description(&frontmatter); @@ -319,8 +331,14 @@ impl LibraryStore { if file_name.ends_with(".md") { // Skip SKILL.md from the files list (it's in the content field) if file_name != "SKILL.md" { - let file_content = + let raw_content = fs::read_to_string(&entry_path).await.unwrap_or_default(); + // Decrypt any encrypted tags for display + let file_content = if let Ok(Some(key)) = env_crypto::load_private_key_from_env() { + env_crypto::decrypt_content_tags(&key, &raw_content).unwrap_or(raw_content) + } else { + raw_content + }; md_files.push(SkillFile { name: file_name, path: relative_path, @@ -337,7 +355,9 @@ impl LibraryStore { Ok(()) } - /// Save a skill's SKILL.md content. + /// Save a skill, encrypting any ... tags. + /// Unversioned value tags are encrypted to + /// ciphertext format. pub async fn save_skill(&self, name: &str, content: &str) -> Result<()> { Self::validate_name(name)?; @@ -347,7 +367,14 @@ impl LibraryStore { // Ensure directory exists fs::create_dir_all(&skill_dir).await?; - fs::write(&skill_md, content) + // Encrypt any unversioned encrypted tags + let encrypted_content = if let Some(key) = env_crypto::load_private_key_from_env()? { + env_crypto::encrypt_content_tags(&key, content)? + } else { + content.to_string() + }; + + fs::write(&skill_md, encrypted_content) .await .context("Failed to write SKILL.md")?; @@ -428,6 +455,7 @@ impl LibraryStore { } /// Get a reference file from a skill. + /// For .md files, encrypted tags are decrypted for display. pub async fn get_skill_reference(&self, skill_name: &str, ref_path: &str) -> Result { Self::validate_name(skill_name)?; let skill_dir = self.skills_dir().join(skill_name); @@ -440,12 +468,22 @@ impl LibraryStore { anyhow::bail!("Reference file not found: {}/{}", skill_name, ref_path); } - fs::read_to_string(&file_path) + let raw_content = fs::read_to_string(&file_path) .await - .context("Failed to read reference file") + .context("Failed to read reference file")?; + + // Decrypt encrypted tags in .md files + if ref_path.ends_with(".md") { + if let Some(key) = env_crypto::load_private_key_from_env()? { + return env_crypto::decrypt_content_tags(&key, &raw_content); + } + } + + Ok(raw_content) } /// Save a reference file for a skill. + /// For .md files, encrypted tags are encrypted before saving. pub async fn save_skill_reference( &self, skill_name: &str, @@ -464,7 +502,18 @@ impl LibraryStore { fs::create_dir_all(parent).await?; } - fs::write(&file_path, content) + // Encrypt tags in .md files + let content_to_write = if ref_path.ends_with(".md") { + if let Some(key) = env_crypto::load_private_key_from_env()? { + env_crypto::encrypt_content_tags(&key, content)? + } else { + content.to_string() + } + } else { + content.to_string() + }; + + fs::write(&file_path, content_to_write) .await .context("Failed to write reference file")?; @@ -1194,10 +1243,24 @@ impl LibraryStore { name ); } - config.env_vars + config.env_vars.clone() } }; + // Determine encrypted_keys: use stored list if available, otherwise detect from values + // (for backwards compatibility with old templates where all vars were encrypted) + let encrypted_keys = if !config.encrypted_keys.is_empty() { + config.encrypted_keys + } else { + // Legacy: detect which keys have encrypted values + config + .env_vars + .iter() + .filter(|(_, v)| env_crypto::is_encrypted(v)) + .map(|(k, _)| k.clone()) + .collect() + }; + Ok(WorkspaceTemplate { name: config.name.unwrap_or_else(|| name.to_string()), description: config.description, @@ -1205,12 +1268,13 @@ impl LibraryStore { distro: config.distro, skills: config.skills, env_vars, + encrypted_keys, init_script: config.init_script, }) } /// Save a workspace template. - /// Env vars are encrypted if a PRIVATE_KEY is configured. + /// Only env vars with keys in `encrypted_keys` are encrypted (if PRIVATE_KEY is configured). pub async fn save_workspace_template( &self, name: &str, @@ -1222,12 +1286,27 @@ impl LibraryStore { fs::create_dir_all(&templates_dir).await?; - // Encrypt env vars if we have a key configured + // Selectively encrypt only keys in encrypted_keys + let encrypted_set: std::collections::HashSet<_> = + template.encrypted_keys.iter().cloned().collect(); let env_vars = match env_crypto::load_private_key_from_env()? { - Some(key) => env_crypto::encrypt_env_vars(&key, &template.env_vars) - .context("Failed to encrypt template env vars")?, + Some(key) => { + let mut result = HashMap::with_capacity(template.env_vars.len()); + for (k, v) in &template.env_vars { + if encrypted_set.contains(k) { + result.insert( + k.clone(), + env_crypto::encrypt_value(&key, v) + .context("Failed to encrypt env var")?, + ); + } else { + result.insert(k.clone(), v.clone()); + } + } + result + } None => { - if !template.env_vars.is_empty() { + if !encrypted_set.is_empty() { tracing::warn!( "Saving template '{}' with plaintext env vars (PRIVATE_KEY not configured)", name @@ -1243,6 +1322,7 @@ impl LibraryStore { distro: template.distro.clone(), skills: template.skills.clone(), env_vars, + encrypted_keys: template.encrypted_keys.clone(), init_script: template.init_script.clone(), }; diff --git a/src/library/types.rs b/src/library/types.rs index ca52fe6..636962a 100644 --- a/src/library/types.rs +++ b/src/library/types.rs @@ -214,6 +214,9 @@ pub struct WorkspaceTemplate { /// Environment variables for the workspace #[serde(default)] pub env_vars: HashMap, + /// Keys of env vars that should be encrypted at rest + #[serde(default)] + pub encrypted_keys: Vec, /// Init script to run on build #[serde(default)] pub init_script: String, diff --git a/src/mcp/types.rs b/src/mcp/types.rs index 5524c3d..06193a9 100644 --- a/src/mcp/types.rs +++ b/src/mcp/types.rs @@ -170,7 +170,7 @@ pub struct ServerInfo { pub struct McpServerConfig { /// Unique identifier pub id: Uuid, - /// Human-readable name (e.g., "Supabase", "Browser Extension") + /// Human-readable name (e.g., "GitHub", "Browser Extension") pub name: String, /// Transport configuration (HTTP or stdio) pub transport: McpTransport, diff --git a/src/opencode/mod.rs b/src/opencode/mod.rs index 95cc9b3..2389e88 100644 --- a/src/opencode/mod.rs +++ b/src/opencode/mod.rs @@ -10,13 +10,17 @@ use std::collections::HashMap; use std::time::Duration; use tokio::sync::mpsc; -/// Default timeout for OpenCode HTTP requests (10 minutes). -/// This is intentionally long to allow for extended tool executions. -const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(600); +/// Default timeout for OpenCode HTTP requests (5 minutes). +/// Reduced from 10 minutes since we now have SSE inactivity detection. +const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(300); /// Interval for logging heartbeat while waiting for SSE events (30 seconds). const HEARTBEAT_LOG_INTERVAL: Duration = Duration::from_secs(30); +/// Maximum time to wait for SSE activity before considering the connection stale. +/// This prevents infinite hangs when OpenCode stops sending events mid-stream. +const SSE_INACTIVITY_TIMEOUT: Duration = Duration::from_secs(180); // 3 minutes + /// Number of retries for transient network failures. const NETWORK_RETRY_COUNT: u32 = 3; @@ -219,14 +223,66 @@ impl OpenCodeClient { tracing::warn!(session_id = %session_id_clone, "SSE curl process started, reading lines"); + let mut last_activity = std::time::Instant::now(); + loop { line.clear(); - match reader.read_line(&mut line).await { - Ok(0) => { + + // Apply inactivity timeout based on meaningful SSE activity (not just bytes). + let idle = last_activity.elapsed(); + if idle >= SSE_INACTIVITY_TIMEOUT { + let idle_secs = idle.as_secs(); + tracing::error!( + session_id = %session_id_clone, + idle_secs = idle_secs, + event_count = event_count, + "SSE inactivity timeout - OpenCode stopped sending meaningful events" + ); + let _ = event_tx + .send(OpenCodeEvent::Error { + message: format!( + "OpenCode SSE stream inactive for {} seconds - possible internal timeout or crash", + idle_secs + ), + }) + .await; + let _ = child.kill().await; + return; + } + + let remaining = SSE_INACTIVITY_TIMEOUT.saturating_sub(idle); + let read_result = + tokio::time::timeout(remaining, reader.read_line(&mut line)).await; + + match read_result { + Err(_timeout) => { + let idle_secs = last_activity.elapsed().as_secs(); + tracing::error!( + session_id = %session_id_clone, + idle_secs = idle_secs, + event_count = event_count, + "SSE inactivity timeout - OpenCode stopped sending events" + ); + // Send a timeout error event so the caller knows what happened + let _ = event_tx + .send(OpenCodeEvent::Error { + message: format!( + "OpenCode SSE stream inactive for {} seconds - possible internal timeout or crash", + idle_secs + ), + }) + .await; + let _ = child.kill().await; + return; + } + Ok(Ok(0)) => { tracing::debug!(session_id = %session_id_clone, "SSE curl stdout closed"); break; } - Ok(_) => { + Ok(Ok(_)) => { + // Note: We only update last_activity when we emit a meaningful event, + // not on every line read. This prevents heartbeat events from + // resetting the inactivity timeout. let trimmed = line.trim_end(); if trimmed.is_empty() { @@ -240,18 +296,27 @@ impl OpenCodeClient { "SSE event block received" ); - if let Some(event) = parse_sse_event( + let parsed = parse_sse_event( &data, event_name, &session_id_clone, &mut sse_state, - ) { + ); + + if parsed.activity { + last_activity = std::time::Instant::now(); + } + + if let Some(event) = parsed.event { event_count += 1; let is_complete = matches!(event, OpenCodeEvent::MessageComplete { .. }); if event_tx.send(event).await.is_err() { - tracing::debug!(session_id = %session_id_clone, "SSE receiver dropped"); + tracing::debug!( + session_id = %session_id_clone, + "SSE receiver dropped" + ); let _ = child.kill().await; return; } @@ -286,7 +351,7 @@ impl OpenCodeClient { continue; } } - Err(e) => { + Ok(Err(e)) => { tracing::warn!(session_id = %session_id_clone, error = %e, "SSE read error"); break; } @@ -432,8 +497,11 @@ impl OpenCodeClient { /// Parse a message response from OpenCode, handling various response shapes. fn parse_message_response(&self, text: &str) -> anyhow::Result { if text.trim().is_empty() { - // Newer OpenCode servers may return an empty body for message POSTs. - return Ok(OpenCodeMessageResponse::empty()); + // Empty body indicates the model was never invoked - treat as error + anyhow::bail!( + "OpenCode returned an empty response. This usually means the request failed silently \ + (e.g., provider auth issue, rate limit, or session problem). Check OpenCode logs for details." + ); } // Try the legacy response shape first. @@ -1034,13 +1102,18 @@ fn handle_tool_part_update( } } +struct ParsedSseEvent { + event: Option, + activity: bool, +} + /// Parse an SSE event line into an OpenCodeEvent. fn parse_sse_event( data_str: &str, event_name: Option<&str>, session_id: &str, state: &mut SseState, -) -> Option { +) -> ParsedSseEvent { let json: serde_json::Value = match serde_json::from_str(data_str) { Ok(value) => value, Err(err) => { @@ -1055,7 +1128,10 @@ fn parse_sse_event( data_preview = %data_str.chars().take(200).collect::(), "Failed to parse OpenCode SSE JSON payload" ); - return None; + return ParsedSseEvent { + event: None, + activity: false, + }; } } } else { @@ -1064,12 +1140,23 @@ fn parse_sse_event( data_preview = %data_str.chars().take(200).collect::(), "Failed to parse OpenCode SSE JSON payload" ); - return None; + return ParsedSseEvent { + event: None, + activity: false, + }; } } }; - let event_type = json.get("type").and_then(|v| v.as_str()).or(event_name)?; + let event_type = match json.get("type").and_then(|v| v.as_str()).or(event_name) { + Some(event_type) => event_type, + None => { + return ParsedSseEvent { + event: None, + activity: false, + } + } + }; let props = json .get("properties") .cloned() @@ -1105,11 +1192,16 @@ fn parse_sse_event( event_type = %event_type, "SKIPPING event - session ID mismatch" ); - return None; + return ParsedSseEvent { + event: None, + activity: false, + }; } } - match event_type { + let activity = event_type != "server.heartbeat"; + + let event = match event_type { // OpenAI Responses-style streaming "response.output_text.delta" => { let delta = props @@ -1119,19 +1211,19 @@ fn parse_sse_event( .and_then(|v| v.as_str()) .unwrap_or(""); if delta.is_empty() { - return None; + None + } else { + let response_id = props + .get("response") + .and_then(|v| v.get("id")) + .and_then(|v| v.as_str()); + let key = response_id.unwrap_or("response.output_text").to_string(); + let buffer = state.part_buffers.entry(key).or_default(); + buffer.push_str(delta); + Some(OpenCodeEvent::TextDelta { + content: buffer.clone(), + }) } - - let response_id = props - .get("response") - .and_then(|v| v.get("id")) - .and_then(|v| v.as_str()); - let key = response_id.unwrap_or("response.output_text").to_string(); - let buffer = state.part_buffers.entry(key).or_default(); - buffer.push_str(delta); - Some(OpenCodeEvent::TextDelta { - content: buffer.clone(), - }) } "response.completed" | "response.incomplete" => Some(OpenCodeEvent::MessageComplete { session_id: session_id.to_string(), @@ -1188,36 +1280,39 @@ fn parse_sse_event( .unwrap_or("unknown") .to_string(); if state.emitted_tool_calls.contains_key(&call_id) { - return None; - } - - let name = item - .get("name") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .or_else(|| state.response_tool_names.get(&call_id).cloned()) - .unwrap_or_else(|| "unknown".to_string()); - let args_str = item - .get("arguments") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .or_else(|| state.response_tool_args.get(&call_id).cloned()) - .unwrap_or_default(); - let args = if args_str.trim().is_empty() { - json!({}) + None } else { - serde_json::from_str(&args_str) - .unwrap_or_else(|_| json!({ "arguments": args_str })) - }; - state.emitted_tool_calls.insert(call_id.clone(), ()); - return Some(OpenCodeEvent::ToolCall { - tool_call_id: call_id, - name, - args, - }); + let name = item + .get("name") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or_else(|| state.response_tool_names.get(&call_id).cloned()) + .unwrap_or_else(|| "unknown".to_string()); + let args_str = item + .get("arguments") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or_else(|| state.response_tool_args.get(&call_id).cloned()) + .unwrap_or_default(); + let args = if args_str.trim().is_empty() { + json!({}) + } else { + serde_json::from_str(&args_str) + .unwrap_or_else(|_| json!({ "arguments": args_str })) + }; + state.emitted_tool_calls.insert(call_id.clone(), ()); + Some(OpenCodeEvent::ToolCall { + tool_call_id: call_id, + name, + args, + }) + } + } else { + None } + } else { + None } - None } // Message info updates "message.updated" => { @@ -1279,7 +1374,9 @@ fn parse_sse_event( ); None } - } + }; + + ParsedSseEvent { event, activity } } #[derive(Debug, Deserialize)] @@ -1329,14 +1426,6 @@ impl Default for OpenCodeAssistantInfo { } } -impl OpenCodeMessageResponse { - pub fn empty() -> Self { - Self { - info: OpenCodeAssistantInfo::default(), - parts: Vec::new(), - } - } -} pub fn extract_text(parts: &[serde_json::Value]) -> String { let mut out = Vec::new(); diff --git a/src/secrets/mod.rs b/src/secrets/mod.rs index d02bc22..e941add 100644 --- a/src/secrets/mod.rs +++ b/src/secrets/mod.rs @@ -31,10 +31,10 @@ //! store.unlock("my-secret-passphrase").await?; //! //! // Set a secret -//! store.set_secret("mcp-tokens", "supabase/access_token", "eyJ...", None).await?; +//! store.set_secret("mcp-tokens", "my-service/api_key", "sk-...", None).await?; //! //! // Get a secret -//! let token = store.get_secret("mcp-tokens", "supabase/access_token").await?; +//! let token = store.get_secret("mcp-tokens", "my-service/api_key").await?; //! //! // Export to workspace //! store.export_to_workspace(&workspace_path, "mcp-tokens", None).await?; diff --git a/src/tools/desktop.rs b/src/tools/desktop.rs index 0801624..a2df55b 100644 --- a/src/tools/desktop.rs +++ b/src/tools/desktop.rs @@ -317,13 +317,11 @@ impl Tool for Screenshot { } fn description(&self) -> &str { - "Take a screenshot of the virtual desktop. Automatically uploads and returns markdown to embed the image. + "Take a screenshot of the virtual desktop and save it locally. IMPORTANT: After launching applications with i3 exec commands, use wait_seconds (3-5s recommended) to let them render before capturing. Otherwise the screenshot may be black. -Set return_image=true to SEE the screenshot yourself (vision). This lets you verify the layout is correct before responding. - -You MUST copy the returned markdown (e.g., '![screenshot](https://...)') directly into your response text for the user to see the image." +Set return_image=true to SEE the screenshot yourself (vision). This lets you verify the layout is correct before responding." } fn parameters_schema(&self) -> Value { @@ -342,13 +340,9 @@ You MUST copy the returned markdown (e.g., '![screenshot](https://...)') directl "type": "boolean", "description": "If true, the screenshot image will be included in your context so you can SEE it (requires vision model). Use this to verify the desktop layout is correct. Default: false" }, - "upload": { - "type": "boolean", - "description": "Whether to upload the screenshot and return a public URL (default: true). Set to false to only save locally." - }, "description": { "type": "string", - "description": "Description for the image alt text (default: 'screenshot')" + "description": "Description for the image (default: 'screenshot')" }, "filename": { "type": "string", @@ -446,98 +440,24 @@ You MUST copy the returned markdown (e.g., '![screenshot](https://...)') directl } let metadata = std::fs::metadata(&filepath)?; - let should_upload = args["upload"].as_bool().unwrap_or(true); let return_image = args["return_image"].as_bool().unwrap_or(false); - let description = args["description"].as_str().unwrap_or("screenshot"); - // Auto-upload to Supabase if enabled and configured - if should_upload { - if let Some((url, markdown)) = - upload_screenshot_to_supabase(&filepath, description).await - { - // Include vision marker if return_image is true - // Format: [VISION_IMAGE:url] - this will be parsed by the executor to add the image to context - let vision_marker = if return_image { - format!("\n\n[VISION_IMAGE:{}]", url) - } else { - String::new() - }; + // Include vision marker if return_image is true + let vision_marker = if return_image { + format!("\n\n[VISION_IMAGE:file://{}]", filepath.display()) + } else { + String::new() + }; - // Return format that strongly encourages the LLM to include the markdown - return Ok(format!( - "Screenshot captured and uploaded successfully.\n\n\ - INCLUDE THIS IN YOUR RESPONSE TO SHOW THE IMAGE:\n{}\n\n\ - Details: path={}, size={} bytes, url={}{}", - markdown, - filepath.display(), - metadata.len(), - url, - vision_marker - )); - } - // Fall through to local-only if upload fails - } - - Ok(json!({ - "success": true, - "path": filepath.display().to_string(), - "size_bytes": metadata.len() - }) - .to_string()) + Ok(format!( + "{{\"success\": true, \"path\": \"{}\", \"size_bytes\": {}}}{}", + filepath.display(), + metadata.len(), + vision_marker + )) } } -/// Helper to upload a screenshot to Supabase Storage -async fn upload_screenshot_to_supabase( - filepath: &std::path::PathBuf, - description: &str, -) -> Option<(String, String)> { - let supabase_url = std::env::var("SUPABASE_URL").ok()?; - let service_role_key = std::env::var("SUPABASE_SERVICE_ROLE_KEY").ok()?; - - if supabase_url.is_empty() || service_role_key.is_empty() { - return None; - } - - let content = std::fs::read(filepath).ok()?; - let file_id = uuid::Uuid::new_v4(); - let upload_path = format!("{}.png", file_id); - - let storage_url = format!( - "{}/storage/v1/object/images/{}", - supabase_url.trim_end_matches('/'), - upload_path - ); - - let client = reqwest::Client::new(); - let resp = client - .post(&storage_url) - .header("apikey", &service_role_key) - .header("Authorization", format!("Bearer {}", service_role_key)) - .header("Content-Type", "image/png") - .header("x-upsert", "true") - .body(content) - .send() - .await - .ok()?; - - if !resp.status().is_success() { - return None; - } - - let public_url = format!( - "{}/storage/v1/object/public/images/{}", - supabase_url.trim_end_matches('/'), - upload_path - ); - - let markdown = format!("![{}]({})", description, public_url); - - tracing::info!(url = %public_url, "Screenshot auto-uploaded to Supabase"); - - Some((public_url, markdown)) -} - /// Send keyboard input to the desktop. pub struct TypeText; diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 98c6eb1..ba61e34 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -19,7 +19,6 @@ mod file_ops; mod index; pub mod mission; mod search; -mod storage; mod terminal; mod ui; mod web; @@ -214,9 +213,6 @@ impl ToolRegistry { tools.insert("ui_optionList".to_string(), Arc::new(ui::UiOptionList)); tools.insert("ui_dataTable".to_string(), Arc::new(ui::UiDataTable)); - // Storage (file sharing - requires Supabase) - tools.insert("share_file".to_string(), Arc::new(storage::ShareFile)); - // Composite tools (higher-level workflow operations) tools.insert( "analyze_codebase".to_string(), diff --git a/src/tools/storage.rs b/src/tools/storage.rs deleted file mode 100644 index 08df50d..0000000 --- a/src/tools/storage.rs +++ /dev/null @@ -1,312 +0,0 @@ -//! Storage tools for uploading and sharing files via cloud storage. -//! -//! Uses Supabase Storage when SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are set. -//! Files are uploaded to the appropriate bucket and returned as public URLs with -//! structured metadata for rich rendering in the dashboard. - -use std::path::Path; - -use async_trait::async_trait; -use serde_json::{json, Value}; - -use super::Tool; - -/// Get Supabase configuration from environment. -fn get_supabase_config() -> Option<(String, String)> { - let url = std::env::var("SUPABASE_URL").ok()?; - let key = std::env::var("SUPABASE_SERVICE_ROLE_KEY").ok()?; - - if url.is_empty() || key.is_empty() { - return None; - } - - Some((url, key)) -} - -/// Determine content type from file extension. -fn content_type_from_extension(extension: &str) -> &'static str { - match extension.to_lowercase().as_str() { - // Images - "png" => "image/png", - "jpg" | "jpeg" => "image/jpeg", - "gif" => "image/gif", - "webp" => "image/webp", - "svg" => "image/svg+xml", - "ico" => "image/x-icon", - "bmp" => "image/bmp", - // Documents - "pdf" => "application/pdf", - "doc" => "application/msword", - "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - "xls" => "application/vnd.ms-excel", - "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - "ppt" => "application/vnd.ms-powerpoint", - "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", - // Archives - "zip" => "application/zip", - "tar" => "application/x-tar", - "gz" | "gzip" => "application/gzip", - "rar" => "application/vnd.rar", - "7z" => "application/x-7z-compressed", - // Code/text - "txt" => "text/plain", - "md" => "text/markdown", - "json" => "application/json", - "xml" => "application/xml", - "html" | "htm" => "text/html", - "css" => "text/css", - "js" => "text/javascript", - "ts" => "text/typescript", - "py" => "text/x-python", - "rs" => "text/x-rust", - "go" => "text/x-go", - "java" => "text/x-java", - "c" | "h" => "text/x-c", - "cpp" | "hpp" | "cc" => "text/x-c++", - "sh" | "bash" => "text/x-shellscript", - "yaml" | "yml" => "text/yaml", - "toml" => "text/x-toml", - "csv" => "text/csv", - // Audio/video - "mp3" => "audio/mpeg", - "wav" => "audio/wav", - "ogg" => "audio/ogg", - "mp4" => "video/mp4", - "webm" => "video/webm", - "mov" => "video/quicktime", - // Default - _ => "application/octet-stream", - } -} - -/// Determine the file kind from content type (for UI rendering hints). -fn file_kind_from_content_type(content_type: &str) -> &'static str { - if content_type.starts_with("image/") { - "image" - } else if content_type.starts_with("text/") - || content_type.contains("json") - || content_type.contains("xml") - { - "code" - } else if content_type.contains("pdf") - || content_type.contains("document") - || content_type.contains("word") - || content_type.contains("sheet") - || content_type.contains("presentation") - { - "document" - } else if content_type.contains("zip") - || content_type.contains("tar") - || content_type.contains("gzip") - || content_type.contains("compress") - || content_type.contains("rar") - || content_type.contains("7z") - { - "archive" - } else { - "other" - } -} - -/// Share a file by uploading it to cloud storage and returning a public URL. -/// -/// This tool uploads any file type to Supabase Storage and returns structured -/// metadata that the dashboard uses for rich rendering: -/// - Images are displayed inline -/// - Documents, archives, and other files show as download cards -pub struct ShareFile; - -#[async_trait] -impl Tool for ShareFile { - fn name(&self) -> &str { - "share_file" - } - - fn description(&self) -> &str { - "Upload a file to cloud storage and get a public URL for sharing.\n\n\ - Supports any file type:\n\ - - Images (PNG, JPEG, GIF, WebP, SVG) - displayed inline in chat\n\ - - Documents (PDF, Word, Excel, etc.) - shown as download card\n\ - - Archives (ZIP, TAR, etc.) - shown as download card\n\ - - Code/text files - shown as download card\n\ - - Any other file type\n\n\ - Returns structured metadata that the dashboard uses to render the file appropriately.\n\n\ - Example:\n\ - 1. share_file{path: \"/path/to/screenshot.png\", title: \"Screenshot\"}\n\ - 2. The dashboard will automatically display the image inline" - } - - fn parameters_schema(&self) -> Value { - json!({ - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Path to the local file to upload (absolute or relative to working directory)" - }, - "title": { - "type": "string", - "description": "Optional display title for the file (defaults to filename)" - } - }, - "required": ["path"] - }) - } - - async fn execute(&self, args: Value, working_dir: &Path) -> anyhow::Result { - let (supabase_url, service_role_key) = get_supabase_config() - .ok_or_else(|| anyhow::anyhow!( - "Supabase not configured. Set SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY environment variables." - ))?; - - let path_arg = args["path"] - .as_str() - .ok_or_else(|| anyhow::anyhow!("Missing 'path' argument"))?; - - // Resolve path (relative to working_dir or absolute) - let file_path = if Path::new(path_arg).is_absolute() { - std::path::PathBuf::from(path_arg) - } else { - working_dir.join(path_arg) - }; - - // Verify file exists - if !file_path.exists() { - return Err(anyhow::anyhow!("File not found: {}", file_path.display())); - } - - // Get file metadata - let metadata = std::fs::metadata(&file_path)?; - let size_bytes = metadata.len(); - - // Get filename and extension - let file_name = file_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("file"); - - let extension = file_path - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("bin") - .to_lowercase(); - - // Use provided title or default to filename - let title = args["title"] - .as_str() - .map(|s| s.to_string()) - .unwrap_or_else(|| file_name.to_string()); - - // Determine content type - let content_type = content_type_from_extension(&extension); - let file_kind = file_kind_from_content_type(content_type); - - // Read file content - let content = std::fs::read(&file_path)?; - - // Generate a unique path for the uploaded file - let file_id = uuid::Uuid::new_v4(); - let upload_path = format!("{}.{}", file_id, extension); - - // Determine bucket based on file kind - let bucket = if file_kind == "image" { - "images" - } else { - "files" - }; - - tracing::info!( - local_path = %file_path.display(), - upload_path = %upload_path, - bucket = %bucket, - size = size_bytes, - content_type = %content_type, - kind = %file_kind, - "Uploading file to Supabase Storage" - ); - - // Upload to Supabase Storage - let storage_url = format!( - "{}/storage/v1/object/{}/{}", - supabase_url.trim_end_matches('/'), - bucket, - upload_path - ); - - let client = reqwest::Client::new(); - let resp = client - .post(&storage_url) - .header("apikey", &service_role_key) - .header("Authorization", format!("Bearer {}", service_role_key)) - .header("Content-Type", content_type) - .header("x-upsert", "true") - .body(content) - .send() - .await?; - - let status = resp.status(); - if !status.is_success() { - let error_text = resp.text().await.unwrap_or_default(); - return Err(anyhow::anyhow!( - "Failed to upload file: {} - {}", - status, - error_text - )); - } - - // Construct public URL - let public_url = format!( - "{}/storage/v1/object/public/{}/{}", - supabase_url.trim_end_matches('/'), - bucket, - upload_path - ); - - // Build response with structured metadata - let response = json!({ - "success": true, - "url": public_url, - "name": title, - "content_type": content_type, - "kind": file_kind, - "size_bytes": size_bytes, - "path": upload_path, - }); - - Ok(response.to_string()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_content_type_detection() { - assert_eq!(content_type_from_extension("png"), "image/png"); - assert_eq!(content_type_from_extension("PDF"), "application/pdf"); - assert_eq!(content_type_from_extension("zip"), "application/zip"); - assert_eq!(content_type_from_extension("rs"), "text/x-rust"); - assert_eq!( - content_type_from_extension("unknown"), - "application/octet-stream" - ); - } - - #[test] - fn test_file_kind_inference() { - assert_eq!(file_kind_from_content_type("image/png"), "image"); - assert_eq!(file_kind_from_content_type("application/pdf"), "document"); - assert_eq!(file_kind_from_content_type("application/zip"), "archive"); - assert_eq!(file_kind_from_content_type("text/x-rust"), "code"); - assert_eq!( - file_kind_from_content_type("application/octet-stream"), - "other" - ); - } - - #[test] - fn test_tool_names() { - assert_eq!(ShareFile.name(), "share_file"); - } -}