Th0rgal/ios compat review (#37)
* Add hardcoded Google/Gemini OAuth credentials Use the same client credentials as Gemini CLI for seamless OAuth flow. This removes the need for GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET env vars. * Add iOS Settings view and first-launch setup flow - Add SetupSheet for configuring server URL on first launch - Add SettingsView for managing server URL and appearance - Add isConfigured flag to APIService to detect unconfigured state - Show setup sheet automatically when no server URL is configured * Add iOS global workspace state management - Add WorkspaceState singleton for shared workspace selection - Refactor ControlView to use global workspace state - Refactor FilesView with workspace picker in toolbar - Refactor HistoryView with workspace picker in toolbar - Refactor TerminalView with workspace picker and improved UI - Update Xcode project with new files * Add reusable EnvVarsEditor component and fix page scrolling - Extract EnvVarsEditor as reusable component with password masking - Refactor workspaces page to use EnvVarsEditor component - Refactor workspace-templates page to use EnvVarsEditor component - Fix workspace-templates page to use h-screen with overflow-hidden - Add min-h-0 to flex containers to enable proper internal scrolling - Environment and Init Script tabs now scroll internally * Improve workspace creation UX and build log auto-scroll - Auto-scroll build log to bottom when new content arrives - Fix chroot workspace creation to show correct building status immediately - Prevent status flicker by triggering build before closing dialog * Improve iOS control view empty state and input styling - Show workspace name in empty state subtitle - Distinguish between host and isolated workspaces - Refine input field alignment and padding * Add production security and self-hosting documentation - Add Section 10: TLS + Reverse Proxy setup (Caddy and Nginx examples) - Add Section 11: Authentication modes documentation (disabled, single tenant, multi-user) - Add Section 12: Dashboard configuration (web and iOS) - Add Section 13: OAuth provider setup information - Add Production Deployment Checklist * fix: wip * wip * Improve settings sync UX and fix failed mission display Settings page: - Add out-of-sync warning when Library and System settings differ - Add post-save modal prompting to restart OpenCode - Load both Library and System settings for comparison Control client: - Fix missionHistoryToItems to show "Failed" status for failed missions - Last assistant message now inherits mission's failed status - Show resume button for failed resumable missions * Fix: restore original URL on connection failure in SetupSheet Previously, SetupSheet.connectToServer() persisted the URL before validation. If the health check failed, the invalid URL remained in UserDefaults, causing the app to skip the setup flow on next launch and attempt to connect to an unreachable server. Now the original URL is restored on failure, matching the behavior in SettingsView.testConnection(). * Fix: restore queueLength on failed removal in ControlView The removeFromQueue function now properly saves and restores both queuedItems and queueLength on API error, matching the behavior of clearQueue. Previously only queuedItems was refreshed via loadQueueItems() while queueLength remained incorrectly decremented until the next SSE event. * Add selective encryption for template environment variables - Add lock/unlock icon to each env var row for encryption toggle - When locking, automatically hide value and show eye icon - Auto-enable encryption when key matches sensitive patterns - Backend selectively encrypts only keys in encrypted_keys array - Backwards compatible: detects encrypted values in legacy templates - Refactor workspaces page to use SWR for data fetching Frontend: - env-vars-editor.tsx: Add encrypted field, lock toggle, getEncryptedKeys() - api.ts: Add encrypted_keys to WorkspaceTemplate types - workspaces/page.tsx: Use SWR, pass encrypted_keys on save - workspace-templates/page.tsx: Load/save encrypted_keys Backend: - library/types.rs: Add encrypted_keys field to WorkspaceTemplate - library/mod.rs: Selective encryption logic + legacy detection - api/library.rs: Accept encrypted_keys in save request * Fix: Settings Cancel restores URL and queue ops refresh on error SettingsView: - Store original URL at view init and restore it on Cancel - Ensures Cancel properly discards unsaved changes including tested URLs ControlView: - Queue operations now refresh from server on error instead of restoring captured state, avoiding race conditions with concurrent operations * Fix: preserve undefined for encrypted_keys to enable auto-detection Passing `template.encrypted_keys || []` converted undefined to an empty array, which broke the auto-detection logic in toEnvRows. The nullish coalescing in `encryptedKeys?.includes(key) ?? secret` only falls back to `secret` when encryptedKeys is undefined, not when it's an empty array. * Add Queue button and fix SSE/desktop session handling - Dashboard: Show Queue button when agent is busy to allow message queuing - OpenCode: Fix SSE inactivity timeout to only reset on meaningful events, not heartbeats, preventing false timeout resets - Desktop: Deduplicate sessions by display to prevent showing duplicate entries - Docs: Add dashboard password to installation prerequisites * Fix race conditions in default agent selection and workspace creation - Fix default agent config being ignored: wait for config to finish loading before setting defaults to prevent race between agents and config SWR fetches - Fix workspace list not refreshing after build failure: move mutateWorkspaces call to immediately after createWorkspace, add try/catch around getWorkspace * Fix encryption lock icon and add skill content encryption - Fix lock icon showing unlocked for sensitive keys when encrypted_keys is empty: now falls back to auto-detection based on key name patterns - Add showEncryptionToggle prop to EnvVarsEditor to conditionally show encryption toggle (only for workspace templates) - Add skill content encryption with <encrypted>...</encrypted> tags - Update config pages with consistent styling and encryption support
This commit is contained in:
@@ -84,6 +84,78 @@ curl "http://localhost:3000/api/control/missions/<mission_id>/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.
|
||||
|
||||
@@ -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)
|
||||
# =============================================================================
|
||||
|
||||
263
INSTALL.md
263
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@<server-ip> "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
|
||||
|
||||
24
README.md
24
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 `<IP>` 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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
<encrypted v="1">BASE64(nonce||ciphertext)</encrypted>
|
||||
```
|
||||
- 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)
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -168,7 +168,7 @@ Describe what this command does.
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col p-6 max-w-7xl mx-auto space-y-4">
|
||||
<div className="h-screen flex flex-col p-6 gap-4 overflow-hidden">
|
||||
{libraryUnavailable ? (
|
||||
<LibraryUnavailable message={libraryUnavailableMessage} onConfigured={refresh} />
|
||||
) : (
|
||||
|
||||
@@ -187,7 +187,7 @@ Describe what this rule does.
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col p-6 max-w-7xl mx-auto space-y-4">
|
||||
<div className="h-screen flex flex-col p-6 gap-4 overflow-hidden">
|
||||
{libraryUnavailable ? (
|
||||
<LibraryUnavailable message={libraryUnavailableMessage} onConfigured={refresh} />
|
||||
) : (
|
||||
|
||||
@@ -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<string>('');
|
||||
const [originalSettings, setOriginalSettings] = useState<string>('');
|
||||
const [systemSettings, setSystemSettings] = useState<string>('');
|
||||
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<OpenAgentConfig>({
|
||||
@@ -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 (
|
||||
<div className="min-h-screen flex flex-col p-6 max-w-5xl mx-auto space-y-6">
|
||||
{/* Git Status Bar */}
|
||||
{status && (
|
||||
<div className="p-4 rounded-xl bg-white/[0.02] border border-white/[0.06]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4 text-white/40" />
|
||||
<span className="text-sm font-medium text-white">{status.branch}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{status.clean ? (
|
||||
<span className="flex items-center gap-1 text-xs text-emerald-400">
|
||||
<Check className="h-3 w-3" />
|
||||
Clean
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-xs text-amber-400">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{status.modified_files.length} modified
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(status.ahead > 0 || status.behind > 0) && (
|
||||
<div className="text-xs text-white/40">
|
||||
{status.ahead > 0 && <span className="text-emerald-400">+{status.ahead}</span>}
|
||||
{status.ahead > 0 && status.behind > 0 && ' / '}
|
||||
{status.behind > 0 && <span className="text-amber-400">-{status.behind}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={syncing}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-xs font-medium text-white/70 hover:text-white bg-white/[0.04] hover:bg-white/[0.08] rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={cn('h-3 w-3', syncing && 'animate-spin')} />
|
||||
Sync
|
||||
</button>
|
||||
{!status.clean && (
|
||||
<button
|
||||
onClick={() => setShowCommitDialog(true)}
|
||||
disabled={committing}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-xs font-medium text-white/70 hover:text-white bg-white/[0.04] hover:bg-white/[0.08] rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Save className="h-3 w-3" />
|
||||
Commit
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handlePush}
|
||||
disabled={pushing || status.ahead === 0}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-xs font-medium text-white/70 hover:text-white bg-white/[0.04] hover:bg-white/[0.08] rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Upload className="h-3 w-3" />
|
||||
Push
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -240,6 +380,72 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Out of Sync Warning */}
|
||||
{isOutOfSync && (
|
||||
<div className="p-4 rounded-lg bg-amber-500/10 border border-amber-500/20 flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-amber-400">Settings out of sync</p>
|
||||
<p className="text-sm text-amber-400/80 mt-1">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Restart Modal */}
|
||||
{showRestartModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md mx-4 p-6 rounded-xl bg-[#1a1a1f] border border-white/10 shadow-2xl">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-emerald-500/10">
|
||||
<Check className="h-5 w-5 text-emerald-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white">Settings Saved</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSkipRestart}
|
||||
className="p-1 text-white/40 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-white/60 mb-6">
|
||||
Your settings have been saved to the Library and synced to the system.
|
||||
OpenCode needs to be restarted for the changes to take effect.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleSkipRestart}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium text-white/70 bg-white/[0.04] hover:bg-white/[0.08] rounded-lg transition-colors"
|
||||
>
|
||||
Restart Later
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRestartFromModal}
|
||||
disabled={restarting}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-indigo-500 hover:bg-indigo-600 rounded-lg transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{restarting ? (
|
||||
<>
|
||||
<Loader className="h-4 w-4 animate-spin" />
|
||||
Restarting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Restart Now
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OpenCode Settings Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -396,6 +602,50 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Commit Dialog */}
|
||||
{showCommitDialog && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md px-4">
|
||||
<div className="w-full max-w-md rounded-2xl bg-[#161618] border border-white/[0.06] shadow-[0_25px_100px_rgba(0,0,0,0.7)] overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">Commit Changes</p>
|
||||
<p className="text-xs text-white/40">Describe your configuration changes.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCommitDialog(false)}
|
||||
className="p-2 rounded-lg text-white/40 hover:text-white/70 hover:bg-white/[0.06]"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<label className="text-xs text-white/40 block mb-2">Commit Message</label>
|
||||
<input
|
||||
value={commitMessage}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-5 pb-5 flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setShowCommitDialog(false)}
|
||||
className="px-4 py-2 text-xs text-white/60 hover:text-white/80"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCommit}
|
||||
disabled={!commitMessage.trim() || committing}
|
||||
className="flex items-center gap-2 px-4 py-2 text-xs font-medium text-white bg-indigo-500 hover:bg-indigo-600 rounded-lg disabled:opacity-50"
|
||||
>
|
||||
{committing ? <Loader className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
|
||||
Commit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -934,7 +934,7 @@ Describe what this skill does.
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col p-6 max-w-7xl mx-auto space-y-4">
|
||||
<div className="h-screen flex flex-col p-6 gap-4 overflow-hidden">
|
||||
{/* Git Status Bar */}
|
||||
{status && (
|
||||
<div className="p-4 rounded-xl bg-white/[0.02] border border-white/[0.06]">
|
||||
@@ -1093,7 +1093,7 @@ Describe what this skill does.
|
||||
)}
|
||||
|
||||
{/* Editor */}
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div className="flex-1 min-h-0 min-w-0 flex flex-col">
|
||||
{selectedSkill && selectedFile ? (
|
||||
<>
|
||||
<div className="p-3 border-b border-white/[0.06] flex items-center justify-between">
|
||||
@@ -1128,13 +1128,13 @@ Describe what this skill does.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 p-3 overflow-hidden flex flex-col gap-3">
|
||||
<div className="flex-1 min-h-0 p-3 overflow-y-auto">
|
||||
{loadingFile ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader className="h-5 w-5 animate-spin text-white/40" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-3">
|
||||
{selectedFile === 'SKILL.md' && (
|
||||
<FrontmatterEditor
|
||||
frontmatter={frontmatter}
|
||||
@@ -1142,7 +1142,7 @@ Describe what this skill does.
|
||||
disabled={saving}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div>
|
||||
<label className="block text-xs text-white/40 mb-1.5">
|
||||
{selectedFile === 'SKILL.md' ? 'Body Content' : 'Content'}
|
||||
</label>
|
||||
@@ -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')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -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<string, string>): 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<string, string> = {};
|
||||
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 (
|
||||
<div className="min-h-screen flex flex-col p-6 max-w-7xl mx-auto space-y-4">
|
||||
<div className="h-screen flex flex-col overflow-hidden p-6 max-w-7xl mx-auto space-y-4">
|
||||
{/* Git Status Bar */}
|
||||
{status && (
|
||||
<div className="p-4 rounded-xl bg-white/[0.02] border border-white/[0.06]">
|
||||
@@ -431,9 +414,9 @@ export default function WorkspaceTemplatesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-12 gap-4 flex-1">
|
||||
<div className="grid grid-cols-12 gap-4 flex-1 min-h-0">
|
||||
{/* Template List */}
|
||||
<div className="col-span-4 rounded-xl bg-white/[0.02] border border-white/[0.06] flex flex-col min-h-[560px]">
|
||||
<div className="col-span-4 rounded-xl bg-white/[0.02] border border-white/[0.06] flex flex-col min-h-0">
|
||||
<div className="px-4 py-3 border-b border-white/[0.06] flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<LayoutTemplate className="h-4 w-4 text-indigo-400" />
|
||||
@@ -504,7 +487,7 @@ export default function WorkspaceTemplatesPage() {
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<div className="col-span-8 rounded-xl bg-white/[0.02] border border-white/[0.06] flex flex-col min-h-[560px]">
|
||||
<div className="col-span-8 rounded-xl bg-white/[0.02] border border-white/[0.06] flex flex-col min-h-0">
|
||||
<div className="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">Workspace</p>
|
||||
@@ -569,8 +552,10 @@ export default function WorkspaceTemplatesPage() {
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"flex-1 min-h-0 overflow-y-auto p-5",
|
||||
activeTab === 'init' ? "flex flex-col" : "space-y-4"
|
||||
"flex-1 min-h-0 p-5",
|
||||
activeTab === 'environment' || activeTab === 'init'
|
||||
? "flex flex-col overflow-hidden"
|
||||
: "overflow-y-auto space-y-4"
|
||||
)}>
|
||||
{activeTab === 'overview' && (
|
||||
<div className="space-y-4">
|
||||
@@ -680,78 +665,13 @@ export default function WorkspaceTemplatesPage() {
|
||||
)}
|
||||
|
||||
{activeTab === 'environment' && (
|
||||
<div className="rounded-xl bg-white/[0.02] border border-white/[0.05] overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-white/[0.05] flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-indigo-400" />
|
||||
<p className="text-xs text-white/50 font-medium">Environment Variables</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
setEnvRows((rows) => [
|
||||
...rows,
|
||||
{ id: Math.random().toString(36).slice(2), key: '', value: '' },
|
||||
])
|
||||
}
|
||||
className="text-xs text-indigo-400 hover:text-indigo-300 font-medium"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
{envRows.length === 0 ? (
|
||||
<div className="py-6 text-center">
|
||||
<p className="text-xs text-white/40">No environment variables</p>
|
||||
<button
|
||||
onClick={() =>
|
||||
setEnvRows([{ id: Math.random().toString(36).slice(2), key: '', value: '' }])
|
||||
}
|
||||
className="mt-3 text-xs text-indigo-400 hover:text-indigo-300"
|
||||
>
|
||||
Add first variable
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{envRows.map((row) => (
|
||||
<div key={row.id} className="flex items-center gap-2">
|
||||
<input
|
||||
value={row.key}
|
||||
onChange={(e) =>
|
||||
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"
|
||||
/>
|
||||
<input
|
||||
value={row.value}
|
||||
onChange={(e) =>
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setEnvRows((rows) => rows.filter((r) => r.id !== row.id))}
|
||||
className="p-2 rounded-lg text-white/40 hover:text-white/70 hover:bg-white/[0.06] transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{envRows.length > 0 && (
|
||||
<p className="text-xs text-white/35 mt-4 pt-3 border-t border-white/[0.04]">
|
||||
Injected into workspace shells and MCP tool runs.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<EnvVarsEditor
|
||||
rows={envRows}
|
||||
onChange={setEnvRows}
|
||||
className="flex-1"
|
||||
description="Injected into workspace shells and MCP tool runs. Sensitive values (keys, tokens, passwords) are encrypted at rest."
|
||||
showEncryptionToggle
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'init' && (
|
||||
|
||||
@@ -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<typeof item, { kind: "user" }> =>
|
||||
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() {
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
className="mx-auto flex max-w-3xl gap-3 items-end"
|
||||
>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="p-3 rounded-xl border border-white/[0.06] bg-white/[0.02] text-white/40 hover:text-white/70 hover:bg-white/[0.04] transition-colors shrink-0"
|
||||
title="Attach files"
|
||||
>
|
||||
<Paperclip className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUrlInput(!showUrlInput)}
|
||||
className={`p-3 rounded-xl border border-white/[0.06] bg-white/[0.02] text-white/40 hover:text-white/70 hover:bg-white/[0.04] transition-colors shrink-0 ${showUrlInput ? 'text-indigo-400 border-indigo-500/30' : ''}`}
|
||||
title="Download from URL"
|
||||
>
|
||||
<Link2 className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<EnhancedInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSubmit={handleEnhancedSubmit}
|
||||
placeholder="Message the root agent… (paste files to upload)"
|
||||
<div className="mx-auto max-w-3xl w-full space-y-2">
|
||||
{/* Queue Strip - shows queued messages when present */}
|
||||
<QueueStrip
|
||||
items={queuedItems}
|
||||
onRemove={handleRemoveFromQueue}
|
||||
onClearAll={handleClearQueue}
|
||||
/>
|
||||
|
||||
{isBusy ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStop}
|
||||
className="flex items-center gap-2 rounded-xl bg-red-500 hover:bg-red-600 px-5 py-3 text-sm font-medium text-white transition-colors shrink-0"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
Stop
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim()}
|
||||
className="flex items-center gap-2 rounded-xl bg-indigo-500 hover:bg-indigo-600 px-5 py-3 text-sm font-medium text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
Send
|
||||
</button>
|
||||
<form
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
className="flex gap-3 items-end"
|
||||
>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="p-3 rounded-xl border border-white/[0.06] bg-white/[0.02] text-white/40 hover:text-white/70 hover:bg-white/[0.04] transition-colors shrink-0"
|
||||
title="Attach files"
|
||||
>
|
||||
<Paperclip className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUrlInput(!showUrlInput)}
|
||||
className={`p-3 rounded-xl border border-white/[0.06] bg-white/[0.02] text-white/40 hover:text-white/70 hover:bg-white/[0.04] transition-colors shrink-0 ${showUrlInput ? 'text-indigo-400 border-indigo-500/30' : ''}`}
|
||||
title="Download from URL"
|
||||
>
|
||||
<Link2 className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<EnhancedInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSubmit={handleEnhancedSubmit}
|
||||
placeholder="Message the root agent… (paste files to upload)"
|
||||
/>
|
||||
|
||||
{isBusy ? (
|
||||
<>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim()}
|
||||
className="flex items-center gap-2 rounded-xl bg-indigo-500/80 hover:bg-indigo-600 px-5 py-3 text-sm font-medium text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
|
||||
>
|
||||
<ListPlus className="h-4 w-4" />
|
||||
Queue
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStop}
|
||||
className="flex items-center gap-2 rounded-xl bg-red-500 hover:bg-red-600 px-5 py-3 text-sm font-medium text-white transition-colors shrink-0"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
Stop
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim()}
|
||||
className="flex items-center gap-2 rounded-xl bg-indigo-500 hover:bg-indigo-600 px-5 py-3 text-sm font-medium text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
Send
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
@@ -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<ToolInfo[]>([]);
|
||||
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));
|
||||
|
||||
@@ -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<string, unknown>): AgentNode {
|
||||
}
|
||||
|
||||
export default function HistoryPage() {
|
||||
const [missions, setMissions] = useState<Mission[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<string>("all");
|
||||
const [search, setSearch] = useState("");
|
||||
const [sortField, setSortField] = useState<SortField>("date");
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>("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<string | null>(null);
|
||||
@@ -127,25 +132,6 @@ export default function HistoryPage() {
|
||||
const [deletingMissionId, setDeletingMissionId] = useState<string | null>(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) => {
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
@@ -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<StatsResponse | null>(null);
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
||||
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 */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{loading ? (
|
||||
{statsLoading ? (
|
||||
<>
|
||||
<ShimmerStat />
|
||||
<ShimmerStat />
|
||||
|
||||
@@ -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<string, { color: string; icon: string }> = {
|
||||
@@ -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<HealthResponse | null>(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<string | null>(null);
|
||||
const [repoError, setRepoError] = useState<string | null>(null);
|
||||
|
||||
// AI Providers state
|
||||
const [providers, setProviders] = useState<AIProvider[]>([]);
|
||||
const [providerTypes, setProviderTypes] = useState<AIProviderTypeInfo[]>([]);
|
||||
const [providersLoading, setProvidersLoading] = useState(true);
|
||||
// Modal/edit state
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [authenticatingProviderId, setAuthenticatingProviderId] = useState<string | null>(null);
|
||||
const [editingProvider, setEditingProvider] = useState<string | null>(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() {
|
||||
<AddProviderModal
|
||||
open={showAddModal}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
onSuccess={loadProviders}
|
||||
onSuccess={() => mutateProviders()}
|
||||
providerTypes={providerTypes}
|
||||
/>
|
||||
|
||||
@@ -349,74 +331,17 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
{/* API Connection */}
|
||||
<div className="rounded-xl bg-white/[0.02] border border-white/[0.04] p-5">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-indigo-500/10">
|
||||
<Server className="h-5 w-5 text-indigo-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-white">API Connection</h2>
|
||||
<p className="text-xs text-white/40">Configure server endpoint</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-white/60 mb-1.5">
|
||||
API URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={apiUrl}
|
||||
onChange={(e) => {
|
||||
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 && <p className="mt-1.5 text-xs text-red-400">{urlError}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-white/40">Status:</span>
|
||||
{healthLoading ? (
|
||||
<span className="flex items-center gap-1.5 text-xs text-white/40">
|
||||
<RefreshCw className="h-3 w-3 animate-spin" />
|
||||
Checking...
|
||||
</span>
|
||||
) : health ? (
|
||||
<span className="flex items-center gap-1.5 text-xs text-emerald-400">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
|
||||
Connected (v{health.version})
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5 text-xs text-red-400">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-red-400" />
|
||||
Disconnected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={testApiConnection}
|
||||
disabled={testingConnection}
|
||||
className="flex items-center gap-2 rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-1.5 text-xs text-white/70 hover:bg-white/[0.04] transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn('h-3 w-3', testingConnection && 'animate-spin')}
|
||||
/>
|
||||
Test Connection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Server Connection & System Components */}
|
||||
<ServerConnectionCard
|
||||
apiUrl={apiUrl}
|
||||
setApiUrl={setApiUrl}
|
||||
urlError={urlError}
|
||||
validateUrl={validateUrl}
|
||||
health={health ?? null}
|
||||
healthLoading={healthLoading}
|
||||
testingConnection={testingConnection}
|
||||
testApiConnection={testApiConnection}
|
||||
/>
|
||||
|
||||
{/* AI Providers */}
|
||||
<div className="rounded-xl bg-white/[0.02] border border-white/[0.04] p-5">
|
||||
|
||||
@@ -552,7 +552,7 @@ export default function SecretsPage() {
|
||||
<label className="block text-sm text-white/60 mb-1">Key</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g., supabase/access_token"
|
||||
placeholder="e.g., service/api_key"
|
||||
value={newSecretKey}
|
||||
onChange={(e) => 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"
|
||||
|
||||
@@ -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<Workspace[]>([]);
|
||||
const [selectedWorkspace, setSelectedWorkspace] = useState<Workspace | null>(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<WorkspaceTemplateSummary[]>([]);
|
||||
const [templatesError, setTemplatesError] = useState<string | null>(null);
|
||||
const [availableSkills, setAvailableSkills] = useState<SkillSummary[]>([]);
|
||||
const [skillsError, setSkillsError] = useState<string | null>(null);
|
||||
const [skillsFilter, setSkillsFilter] = useState('');
|
||||
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
|
||||
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<ChrootDistro>('ubuntu-noble');
|
||||
const [buildDebug, setBuildDebug] = useState<WorkspaceDebugInfo | null>(null);
|
||||
const [buildLog, setBuildLog] = useState<InitLogResponse | null>(null);
|
||||
const [showBuildLogs, setShowBuildLogs] = useState(false);
|
||||
const buildLogRef = useRef<HTMLPreElement>(null);
|
||||
|
||||
// Workspace settings state
|
||||
const [envRows, setEnvRows] = useState<{ id: string; key: string; value: string; secret: boolean; visible: boolean }[]>([]);
|
||||
const [envRows, setEnvRows] = useState<EnvRow[]>([]);
|
||||
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<string, string>) =>
|
||||
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<string, string> = {};
|
||||
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 */}
|
||||
<div className="px-6 pt-5 pb-4 border-b border-white/[0.06]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-11 w-11 rounded-xl bg-gradient-to-br from-indigo-500/20 to-indigo-600/10 border border-indigo-500/20 flex items-center justify-center">
|
||||
<Server className="h-5 w-5 text-indigo-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-white">{selectedWorkspace.name}</h3>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-xs text-white/40 font-mono">
|
||||
{formatWorkspaceType(selectedWorkspace.workspace_type)}
|
||||
</span>
|
||||
<span className="text-white/20">·</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs font-medium',
|
||||
selectedWorkspace.status === 'ready'
|
||||
? 'text-emerald-400'
|
||||
: selectedWorkspace.status === 'building' || selectedWorkspace.status === 'pending'
|
||||
? 'text-amber-400'
|
||||
: 'text-red-400'
|
||||
)}
|
||||
>
|
||||
{selectedWorkspace.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Header - Compact */}
|
||||
<div className="px-5 pt-4 pb-3 border-b border-white/[0.06]">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-base font-medium text-white">{selectedWorkspace.name}</h3>
|
||||
<span className="text-white/20">·</span>
|
||||
<span className="text-xs text-white/40">
|
||||
{formatWorkspaceType(selectedWorkspace.workspace_type)}
|
||||
</span>
|
||||
<span className="text-white/20">·</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs font-medium',
|
||||
selectedWorkspace.status === 'ready'
|
||||
? 'text-emerald-400'
|
||||
: selectedWorkspace.status === 'building' || selectedWorkspace.status === 'pending'
|
||||
? 'text-amber-400'
|
||||
: 'text-red-400'
|
||||
)}
|
||||
>
|
||||
{selectedWorkspace.status === 'building' && (
|
||||
<Loader className="inline h-3 w-3 animate-spin mr-1" />
|
||||
)}
|
||||
{selectedWorkspace.status}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedWorkspace(null)}
|
||||
className="p-2 -mr-1 rounded-lg text-white/40 hover:text-white/70 hover:bg-white/[0.06] transition-colors"
|
||||
className="p-1.5 -mr-1 rounded-lg text-white/40 hover:text-white/70 hover:bg-white/[0.06] transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mt-4 flex items-center gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
{workspaceTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setWorkspaceTab(tab.id)}
|
||||
className={cn(
|
||||
'px-3.5 py-1.5 text-xs font-medium rounded-lg transition-all',
|
||||
'px-3 py-1.5 text-xs font-medium rounded-lg transition-all',
|
||||
workspaceTab === tab.id
|
||||
? 'bg-white/[0.08] text-white'
|
||||
: 'text-white/50 hover:text-white/80 hover:bg-white/[0.04]'
|
||||
@@ -574,193 +569,209 @@ export default function WorkspacesPage() {
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{workspaceTab === 'overview' && (
|
||||
<div className="px-6 py-5 space-y-5">
|
||||
{/* Quick Info Grid */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3.5 rounded-xl bg-white/[0.02] border border-white/[0.05]">
|
||||
<p className="text-[10px] text-white/40 uppercase tracking-wider mb-1">Template</p>
|
||||
<p className="text-sm text-white/90 font-medium">
|
||||
{selectedWorkspace.template || 'None'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3.5 rounded-xl bg-white/[0.02] border border-white/[0.05]">
|
||||
<p className="text-[10px] text-white/40 uppercase tracking-wider mb-1">Distribution</p>
|
||||
<p className="text-sm text-white/90 font-medium">
|
||||
{selectedWorkspace.distro || 'Default'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-5 py-4 space-y-4">
|
||||
{/* Quick Info - Inline badges */}
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
{selectedWorkspace.template && (
|
||||
<span className="px-2.5 py-1 rounded-md bg-white/[0.04] border border-white/[0.06] text-white/70">
|
||||
Template: <span className="text-white/90">{selectedWorkspace.template}</span>
|
||||
</span>
|
||||
)}
|
||||
{selectedWorkspace.distro && (
|
||||
<span className="px-2.5 py-1 rounded-md bg-white/[0.04] border border-white/[0.06] text-white/70">
|
||||
Distro: <span className="text-white/90">{selectedWorkspace.distro}</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="px-2.5 py-1 rounded-md bg-white/[0.04] border border-white/[0.06] text-white/50">
|
||||
{formatDate(selectedWorkspace.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Details Section */}
|
||||
<div className="rounded-xl bg-white/[0.02] border border-white/[0.05] overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-white/[0.05]">
|
||||
<p className="text-xs text-white/50 font-medium">Details</p>
|
||||
</div>
|
||||
<div className="divide-y divide-white/[0.04]">
|
||||
<div className="px-4 py-3 flex items-start justify-between gap-4">
|
||||
<span className="text-xs text-white/40 shrink-0">Path</span>
|
||||
<code className="text-xs text-white/70 font-mono break-all text-right">
|
||||
{selectedWorkspace.path}
|
||||
</code>
|
||||
</div>
|
||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<span className="text-xs text-white/40">ID</span>
|
||||
<code className="text-xs text-white/70 font-mono">
|
||||
{selectedWorkspace.id}
|
||||
</code>
|
||||
</div>
|
||||
<div className="px-4 py-3 flex items-center justify-between">
|
||||
<span className="text-xs text-white/40">Created</span>
|
||||
<span className="text-xs text-white/70">
|
||||
{formatDate(selectedWorkspace.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Path - Minimal */}
|
||||
<div className="text-xs text-white/40">
|
||||
<code className="font-mono text-white/60">{selectedWorkspace.path}</code>
|
||||
</div>
|
||||
|
||||
{selectedWorkspace.error_message && (
|
||||
<div className="rounded-xl bg-red-500/5 border border-red-500/15 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-lg bg-red-500/5 border border-red-500/15 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-red-400 shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-300">{extractErrorSummary(selectedWorkspace.error_message)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Build Environment */}
|
||||
{selectedWorkspace.workspace_type === 'chroot' && (
|
||||
<div className="rounded-xl bg-white/[0.02] border border-white/[0.05] overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-white/[0.05] flex items-center justify-between">
|
||||
<p className="text-xs text-white/50 font-medium">Build Environment</p>
|
||||
{selectedWorkspace.status === 'building' && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-amber-400">
|
||||
<Loader className="h-3 w-3 animate-spin" />
|
||||
Building...
|
||||
</span>
|
||||
)}
|
||||
{/* Action hint for chroot workspaces */}
|
||||
{selectedWorkspace.workspace_type === 'chroot' && selectedWorkspace.status !== 'building' && selectedWorkspace.status !== 'ready' && (
|
||||
<div className="rounded-lg bg-amber-500/5 border border-amber-500/15 p-3">
|
||||
<p className="text-xs text-amber-300/80">
|
||||
Go to the <button onClick={() => setWorkspaceTab('build')} className="underline hover:text-amber-200">Build</button> tab to create the container environment.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{workspaceTab === 'build' && selectedWorkspace.workspace_type === 'chroot' && (
|
||||
<div className="px-5 py-4 flex flex-col h-full">
|
||||
{/* Build controls - shown when not building */}
|
||||
{selectedWorkspace.status !== 'building' && (
|
||||
<div className="space-y-4 mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={selectedDistro}
|
||||
onChange={(e) => setSelectedDistro(e.target.value as ChrootDistro)}
|
||||
disabled={building}
|
||||
className="px-3 py-2 rounded-lg bg-black/20 border border-white/[0.06] text-sm text-white focus:outline-none focus:border-indigo-500/50 disabled:opacity-50 appearance-none cursor-pointer"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e\")",
|
||||
backgroundPosition: 'right 0.5rem center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: '1.25em 1.25em',
|
||||
paddingRight: '2rem',
|
||||
}}
|
||||
>
|
||||
{CHROOT_DISTROS.map((distro) => (
|
||||
<option key={distro.value} value={distro.value} className="bg-[#161618]">
|
||||
{distro.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => handleBuildWorkspace(selectedWorkspace.status === 'ready')}
|
||||
disabled={building}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-indigo-500 hover:bg-indigo-600 rounded-lg disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{building ? (
|
||||
<>
|
||||
<Loader className="h-4 w-4 animate-spin" />
|
||||
{selectedWorkspace.status === 'ready' ? 'Rebuilding...' : 'Building...'}
|
||||
</>
|
||||
) : selectedWorkspace.status === 'ready' ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Rebuild
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Hammer className="h-4 w-4" />
|
||||
Build
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<span className="text-xs text-white/40">
|
||||
{selectedWorkspace.status === 'ready'
|
||||
? 'Destroys container and reruns init script'
|
||||
: 'Creates isolated Linux filesystem'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Show build controls when not building */}
|
||||
{selectedWorkspace.status !== 'building' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="text-xs text-white/40 block mb-2">Linux Distribution</label>
|
||||
<select
|
||||
value={selectedDistro}
|
||||
onChange={(e) => setSelectedDistro(e.target.value as ChrootDistro)}
|
||||
disabled={building}
|
||||
className="w-full px-3 py-2.5 rounded-lg bg-black/20 border border-white/[0.06] text-sm text-white focus:outline-none focus:border-indigo-500/50 disabled:opacity-50 appearance-none cursor-pointer"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e\")",
|
||||
backgroundPosition: 'right 0.75rem center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: '1.25em 1.25em',
|
||||
}}
|
||||
>
|
||||
{CHROOT_DISTROS.map((distro) => (
|
||||
<option key={distro.value} value={distro.value} className="bg-[#161618]">
|
||||
{distro.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => handleBuildWorkspace(selectedWorkspace.status === 'ready')}
|
||||
disabled={building}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-indigo-500 hover:bg-indigo-600 rounded-lg disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{building ? (
|
||||
<>
|
||||
<Loader className="h-4 w-4 animate-spin" />
|
||||
{selectedWorkspace.status === 'ready' ? 'Rebuilding...' : 'Building...'}
|
||||
</>
|
||||
) : selectedWorkspace.status === 'ready' ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Rebuild
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Hammer className="h-4 w-4" />
|
||||
Build
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<p className="text-xs text-white/40 flex-1">
|
||||
{selectedWorkspace.status === 'ready'
|
||||
? 'Destroys container and reruns init script'
|
||||
: 'Creates isolated Linux filesystem'}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Build Progress Logs - shown when building */}
|
||||
{selectedWorkspace.status === 'building' && (
|
||||
<div className="space-y-3">
|
||||
{/* Header with size */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-3.5 w-3.5 text-amber-400" />
|
||||
<span className="text-xs text-white/70 font-medium">Build Output</span>
|
||||
</div>
|
||||
{buildDebug?.size_bytes != null && buildDebug.size_bytes > 0 && (
|
||||
<span className="text-[10px] text-white/40 font-mono">
|
||||
{buildDebug.size_bytes >= 1024 * 1024 * 1024
|
||||
? `${(buildDebug.size_bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
|
||||
: `${(buildDebug.size_bytes / 1024 / 1024).toFixed(1)} MB`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Container Status Badges */}
|
||||
{buildDebug && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{buildDebug.has_bash && (
|
||||
<span className="px-2 py-0.5 text-[10px] font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 rounded">
|
||||
bash ready
|
||||
</span>
|
||||
)}
|
||||
{buildDebug.init_script_exists && (
|
||||
<span className="px-2 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-400 border border-blue-500/20 rounded">
|
||||
init script running
|
||||
</span>
|
||||
)}
|
||||
{buildDebug.distro && (
|
||||
<span className="px-2 py-0.5 text-[10px] font-mono text-white/40 bg-white/[0.04] border border-white/[0.06] rounded">
|
||||
{buildDebug.distro}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Init Log Output */}
|
||||
{buildLog?.exists && buildLog.content ? (
|
||||
<div className="rounded-lg bg-black/30 border border-white/[0.06] overflow-hidden">
|
||||
<div className="px-3 py-1.5 border-b border-white/[0.06] flex items-center justify-between">
|
||||
<span className="text-[10px] text-white/40 font-mono">{buildLog.log_path}</span>
|
||||
{buildLog.total_lines && (
|
||||
<span className="text-[10px] text-white/30">{buildLog.total_lines} lines</span>
|
||||
)}
|
||||
</div>
|
||||
<pre className="p-3 text-[11px] font-mono text-white/70 overflow-x-auto max-h-64 overflow-y-auto whitespace-pre-wrap break-all">
|
||||
{buildLog.content.split('\n').slice(-50).join('\n')}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 py-6 justify-center text-xs text-white/40">
|
||||
<Loader className="h-3 w-3 animate-spin" />
|
||||
<span>Waiting for build output...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Init Script */}
|
||||
<div className="rounded-xl bg-white/[0.02] border border-white/[0.05] overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-white/[0.05] flex items-center gap-2">
|
||||
<Terminal className="h-4 w-4 text-indigo-400" />
|
||||
<p className="text-xs text-white/50 font-medium">Init Script</p>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<ConfigCodeEditor
|
||||
value={initScript}
|
||||
onChange={setInitScript}
|
||||
language="bash"
|
||||
placeholder="#!/usr/bin/env bash # Install packages or setup files here"
|
||||
className="min-h-[180px]"
|
||||
minHeight={180}
|
||||
/>
|
||||
<p className="text-xs text-white/35 mt-3">
|
||||
Runs during build. Save changes, then Rebuild to apply.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Build Progress - shown when building or on error */}
|
||||
{(selectedWorkspace.status === 'building' || selectedWorkspace.status === 'error') && (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* Error message */}
|
||||
{selectedWorkspace.status === 'error' && selectedWorkspace.error_message && (
|
||||
<div className="rounded-lg bg-red-500/10 border border-red-500/20 p-3 mb-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-red-400 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-red-300 font-medium">Build Failed</p>
|
||||
<p className="text-xs text-red-300/70 mt-1 break-words">{selectedWorkspace.error_message}</p>
|
||||
{selectedWorkspace.error_message.includes('signal KILL') && (
|
||||
<p className="text-xs text-red-300/50 mt-2">
|
||||
SIGKILL usually indicates out-of-memory. Try reducing packages installed or increasing server memory.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{buildDebug?.has_bash && (
|
||||
<span className="px-2 py-0.5 text-[10px] font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 rounded">
|
||||
bash ready
|
||||
</span>
|
||||
)}
|
||||
{selectedWorkspace.status === 'building' && buildDebug?.init_script_exists && (
|
||||
<span className="px-2 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-400 border border-blue-500/20 rounded">
|
||||
init script running
|
||||
</span>
|
||||
)}
|
||||
{buildDebug?.distro && (
|
||||
<span className="px-2 py-0.5 text-[10px] font-mono text-white/40 bg-white/[0.04] border border-white/[0.06] rounded">
|
||||
{buildDebug.distro}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{buildDebug?.size_bytes != null && buildDebug.size_bytes > 0 && (
|
||||
<span className="text-[10px] text-white/40 font-mono">
|
||||
{formatBytes(buildDebug.size_bytes)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Log output - constrained height with internal scroll */}
|
||||
{buildLog?.exists && buildLog.content ? (
|
||||
<div className="max-h-64 rounded-lg bg-black/30 border border-white/[0.06] overflow-hidden flex flex-col">
|
||||
<div className="px-3 py-1.5 border-b border-white/[0.06] flex items-center justify-between shrink-0">
|
||||
<span className="text-[10px] text-white/40 font-mono">{buildLog.log_path}</span>
|
||||
{buildLog.total_lines && (
|
||||
<span className="text-[10px] text-white/30">{buildLog.total_lines} lines</span>
|
||||
)}
|
||||
</div>
|
||||
<pre
|
||||
ref={buildLogRef}
|
||||
className="flex-1 min-h-0 p-3 text-[11px] font-mono text-white/70 overflow-auto whitespace-pre-wrap break-all"
|
||||
>
|
||||
{buildLog.content.split('\n').slice(-100).join('\n')}
|
||||
</pre>
|
||||
</div>
|
||||
) : selectedWorkspace.status === 'error' ? (
|
||||
<div className="h-20 flex items-center justify-center text-xs text-white/40">
|
||||
<span>No build log available</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-32 flex items-center justify-center text-xs text-white/40">
|
||||
<Loader className="h-3 w-3 animate-spin mr-2" />
|
||||
<span>Waiting for build output...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ready state info */}
|
||||
{selectedWorkspace.status === 'ready' && (
|
||||
<div className="text-xs text-white/40 mt-2">
|
||||
Container is ready. Use Rebuild to recreate with updated init script or distro.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -786,7 +797,7 @@ export default function WorkspacesPage() {
|
||||
/>
|
||||
|
||||
{skillsError ? (
|
||||
<p className="text-xs text-red-400 py-4 text-center">{skillsError}</p>
|
||||
<p className="text-xs text-red-400 py-4 text-center">{skillsError instanceof Error ? skillsError.message : 'Failed to load skills'}</p>
|
||||
) : availableSkills.length === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<Sparkles className="h-8 w-8 text-white/10 mx-auto mb-2" />
|
||||
@@ -840,138 +851,14 @@ export default function WorkspacesPage() {
|
||||
|
||||
{workspaceTab === 'environment' && (
|
||||
<div className="px-6 py-5 space-y-4">
|
||||
{/* Environment Variables */}
|
||||
<div className="rounded-xl bg-white/[0.02] border border-white/[0.05] overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-white/[0.05] flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-indigo-400" />
|
||||
<p className="text-xs text-white/50 font-medium">Environment Variables</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
setEnvRows((rows) => [
|
||||
...rows,
|
||||
{ id: Math.random().toString(36).slice(2), key: '', value: '', secret: false, visible: true },
|
||||
])
|
||||
}
|
||||
className="text-xs text-indigo-400 hover:text-indigo-300 font-medium"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
{envRows.length === 0 ? (
|
||||
<div className="py-6 text-center">
|
||||
<p className="text-xs text-white/40">No environment variables</p>
|
||||
<button
|
||||
onClick={() =>
|
||||
setEnvRows([{ id: Math.random().toString(36).slice(2), key: '', value: '', secret: false, visible: true }])
|
||||
}
|
||||
className="text-xs text-indigo-400 hover:text-indigo-300 mt-2"
|
||||
>
|
||||
Add your first variable
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{envRows.map((row) => (
|
||||
<div key={row.id} className="flex items-center gap-2">
|
||||
<input
|
||||
value={row.key}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
<span className="text-white/20">=</span>
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
type={row.secret && !row.visible ? 'password' : 'text'}
|
||||
value={row.value}
|
||||
onChange={(e) =>
|
||||
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 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setEnvRows((rows) =>
|
||||
rows.map((r) =>
|
||||
r.id === row.id ? { ...r, visible: !r.visible } : r
|
||||
)
|
||||
)
|
||||
}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
|
||||
>
|
||||
{row.visible ? (
|
||||
<EyeOff className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{row.secret && (
|
||||
<span title="Sensitive value - will be encrypted">
|
||||
<Lock className="h-3.5 w-3.5 text-amber-400/60" />
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setEnvRows((rows) => rows.filter((r) => r.id !== row.id))}
|
||||
className="p-2 text-white/30 hover:text-red-400 hover:bg-red-500/10 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{envRows.length > 0 && (
|
||||
<p className="text-xs text-white/35 mt-4 pt-3 border-t border-white/[0.04]">
|
||||
Injected into workspace shells and MCP tool runs. Sensitive values (<Lock className="h-3 w-3 inline-block text-amber-400/60 -mt-0.5" />) are encrypted at rest.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Init Script */}
|
||||
<div className="rounded-xl bg-white/[0.02] border border-white/[0.05] overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-white/[0.05] flex items-center gap-2">
|
||||
<Terminal className="h-4 w-4 text-indigo-400" />
|
||||
<p className="text-xs text-white/50 font-medium">Init Script</p>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<ConfigCodeEditor
|
||||
value={initScript}
|
||||
onChange={setInitScript}
|
||||
language="bash"
|
||||
placeholder="#!/usr/bin/env bash # Install packages or setup files here"
|
||||
className="min-h-[220px]"
|
||||
minHeight={220}
|
||||
/>
|
||||
<p className="text-xs text-white/35 mt-3">
|
||||
Runs during build. Changes require rebuild to take effect.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<EnvVarsEditor
|
||||
rows={envRows}
|
||||
onChange={setEnvRows}
|
||||
description="Injected into workspace shells and MCP tool runs. Use workspace templates to configure encryption for sensitive values."
|
||||
/>
|
||||
<p className="text-xs text-white/35">
|
||||
Applied to new missions automatically. Running missions keep their original values.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1147,7 +1034,7 @@ export default function WorkspacesPage() {
|
||||
))}
|
||||
</select>
|
||||
{templatesError && (
|
||||
<p className="text-xs text-red-400 mt-1.5">{templatesError}</p>
|
||||
<p className="text-xs text-red-400 mt-1.5">{templatesError instanceof Error ? templatesError.message : 'Failed to load templates'}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -20,6 +20,10 @@ interface ConfigCodeEditorProps {
|
||||
minHeight?: number | string;
|
||||
language?: SupportedLanguage;
|
||||
padding?: number;
|
||||
/** Enable highlighting of <encrypted>...</encrypted> tags */
|
||||
highlightEncrypted?: boolean;
|
||||
/** Whether the editor should scroll internally. Set to false when parent handles scrolling. */
|
||||
scrollable?: boolean;
|
||||
}
|
||||
|
||||
const languageMap: Record<SupportedLanguage, Prism.Grammar | undefined> = {
|
||||
@@ -35,6 +39,37 @@ const escapeHtml = (code: string) =>
|
||||
.replace(/</g, '<')
|
||||
.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(?:\s+v="\d+")?>(.*?)<\/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'), '<span class="encrypted-tag" style="color: #fbbf24;"><encrypted></span>')
|
||||
.replace(new RegExp(MARKER_VALUE_START, 'g'), '<span class="encrypted-value" style="color: #f59e0b; background: rgba(251, 191, 36, 0.1); padding: 0 2px; border-radius: 2px;">')
|
||||
.replace(new RegExp(MARKER_VALUE_END, 'g'), '</span>')
|
||||
.replace(new RegExp(MARKER_CLOSE, 'g'), '<span class="encrypted-tag" style="color: #fbbf24;"></encrypted></span>');
|
||||
};
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg bg-[#0d0d0e] border border-white/[0.06] overflow-auto focus-within:border-indigo-500/50 transition-colors',
|
||||
'rounded-lg bg-[#0d0d0e] border border-white/[0.06] focus-within:border-indigo-500/50 transition-colors',
|
||||
scrollable ? 'overflow-auto' : 'overflow-hidden',
|
||||
disabled && 'opacity-60',
|
||||
className
|
||||
)}
|
||||
@@ -71,6 +122,7 @@ export function ConfigCodeEditor({
|
||||
spellCheck={false}
|
||||
className={cn('config-code-editor', editorClassName)}
|
||||
textareaClassName="focus:outline-none"
|
||||
preClassName="whitespace-pre-wrap break-words"
|
||||
style={{
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
||||
@@ -78,7 +130,8 @@ export function ConfigCodeEditor({
|
||||
lineHeight: 1.6,
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
minHeight,
|
||||
height: '100%',
|
||||
wordBreak: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -344,7 +344,7 @@ export function EnhancedInput({
|
||||
<div className="relative flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start gap-2 w-full rounded-xl border border-white/[0.06] bg-white/[0.02] px-4 py-3 transition-[border-color] duration-150 ease-out focus-within:border-indigo-500/50",
|
||||
"flex items-center gap-2 w-full rounded-xl border border-white/[0.06] bg-white/[0.02] px-4 py-3 transition-[border-color] duration-150 ease-out focus-within:border-indigo-500/50",
|
||||
className
|
||||
)}
|
||||
style={{ minHeight: "46px" }}
|
||||
|
||||
211
dashboard/src/components/env-vars-editor.tsx
Normal file
211
dashboard/src/components/env-vars-editor.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
'use client';
|
||||
|
||||
import { Eye, EyeOff, FileText, X, Lock, Unlock } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type EnvRow = {
|
||||
id: string;
|
||||
key: string;
|
||||
value: string;
|
||||
secret: boolean;
|
||||
visible: boolean;
|
||||
encrypted: boolean;
|
||||
};
|
||||
|
||||
const SENSITIVE_PATTERNS = [
|
||||
'KEY', 'TOKEN', 'SECRET', 'PASSWORD', 'PASS', 'CREDENTIAL', 'AUTH',
|
||||
'PRIVATE', 'API_KEY', 'ACCESS_TOKEN', 'B64', 'BASE64', 'ENCRYPTED',
|
||||
];
|
||||
|
||||
export const isSensitiveKey = (key: string): boolean => {
|
||||
const upperKey = key.toUpperCase();
|
||||
return SENSITIVE_PATTERNS.some(pattern => upperKey.includes(pattern));
|
||||
};
|
||||
|
||||
export const toEnvRows = (env: Record<string, string>, 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<string, string> => {
|
||||
const env: Record<string, string> = {};
|
||||
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 (
|
||||
<div className={cn("rounded-xl bg-white/[0.02] border border-white/[0.05] overflow-hidden flex flex-col", className)}>
|
||||
<div className="px-4 py-3 border-b border-white/[0.05] flex items-center justify-between flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-indigo-400" />
|
||||
<p className="text-xs text-white/50 font-medium">Environment Variables</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddRow}
|
||||
className="text-xs text-indigo-400 hover:text-indigo-300 font-medium"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 flex flex-col flex-1 min-h-0">
|
||||
{rows.length === 0 ? (
|
||||
<div className="py-6 text-center flex-1 flex flex-col items-center justify-center">
|
||||
<p className="text-xs text-white/40">No environment variables</p>
|
||||
<button
|
||||
onClick={handleAddRow}
|
||||
className="mt-3 text-xs text-indigo-400 hover:text-indigo-300"
|
||||
>
|
||||
Add first variable
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 flex-1 overflow-y-auto min-h-[200px]">
|
||||
{rows.map((row) => (
|
||||
<div key={row.id} className="flex items-center gap-2">
|
||||
{showEncryptionToggle && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleEncrypted(row.id)}
|
||||
className={cn(
|
||||
"p-2 rounded-lg transition-colors",
|
||||
row.encrypted
|
||||
? "text-amber-400 hover:text-amber-300 hover:bg-amber-500/10"
|
||||
: "text-white/30 hover:text-white/50 hover:bg-white/[0.06]"
|
||||
)}
|
||||
title={row.encrypted ? "Encrypted at rest (click to disable)" : "Not encrypted (click to enable)"}
|
||||
>
|
||||
{row.encrypted ? (
|
||||
<Lock className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Unlock className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
value={row.key}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<span className="text-white/20">=</span>
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
type={(row.encrypted || row.secret) && !row.visible ? 'password' : 'text'}
|
||||
value={row.value}
|
||||
onChange={(e) => 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) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleVisibility(row.id)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
|
||||
>
|
||||
{row.visible ? (
|
||||
<EyeOff className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemoveRow(row.id)}
|
||||
className="p-2 rounded-lg text-white/40 hover:text-white/70 hover:bg-white/[0.06] transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{rows.length > 0 && description && (
|
||||
<p className="text-xs text-white/35 mt-4 pt-3 border-t border-white/[0.04] flex-shrink-0">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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> | 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<string[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [defaultSet, setDefaultSet] = useState(false);
|
||||
const dialogRef = useRef<HTMLDivElement>(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 = () => {
|
||||
|
||||
136
dashboard/src/components/queue-strip.tsx
Normal file
136
dashboard/src/components/queue-strip.tsx
Normal file
@@ -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 (
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-lg bg-amber-500/10 border border-amber-500/20 text-xs",
|
||||
className
|
||||
)}>
|
||||
<span className="text-amber-400 font-medium shrink-0">Queue (1)</span>
|
||||
<span className="text-white/60 truncate flex-1">
|
||||
{item.agent && <span className="text-emerald-400">@{item.agent} </span>}
|
||||
{truncate(item.content, 60)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onRemove(item.id)}
|
||||
className="p-1 rounded hover:bg-white/10 text-white/40 hover:text-white/70 transition-colors shrink-0"
|
||||
title="Remove from queue"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Collapsed view with multiple items
|
||||
if (!expanded) {
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-lg bg-amber-500/10 border border-amber-500/20 text-xs",
|
||||
className
|
||||
)}>
|
||||
<span className="text-amber-400 font-medium shrink-0">Queue ({items.length})</span>
|
||||
<span className="text-white/50 truncate flex-1">
|
||||
{truncate(items[0].content, 40)}
|
||||
{items.length > 1 && <span className="text-white/30"> +{items.length - 1} more</span>}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setExpanded(true)}
|
||||
className="p-1 rounded hover:bg-white/10 text-white/40 hover:text-white/70 transition-colors shrink-0"
|
||||
title="Expand queue"
|
||||
>
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Expanded view
|
||||
return (
|
||||
<div className={cn(
|
||||
"rounded-lg bg-amber-500/10 border border-amber-500/20 overflow-hidden",
|
||||
className
|
||||
)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-amber-500/20">
|
||||
<span className="text-amber-400 font-medium text-xs">Queued Messages ({items.length})</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{items.length > 1 && (
|
||||
<button
|
||||
onClick={onClearAll}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-[10px] text-red-400 hover:bg-red-500/10 transition-colors"
|
||||
title="Clear all queued messages"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Clear All
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setExpanded(false)}
|
||||
className="p-1 rounded hover:bg-white/10 text-white/40 hover:text-white/70 transition-colors"
|
||||
title="Collapse"
|
||||
>
|
||||
<ChevronUp className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Queue items */}
|
||||
<div className="max-h-40 overflow-y-auto">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"flex items-start gap-2 px-3 py-2 text-xs",
|
||||
index < items.length - 1 && "border-b border-amber-500/10"
|
||||
)}
|
||||
>
|
||||
<span className="text-white/30 font-mono shrink-0 w-4">{index + 1}.</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white/70 break-words">
|
||||
{item.agent && <span className="text-emerald-400">@{item.agent} </span>}
|
||||
{item.content}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onRemove(item.id)}
|
||||
className="p-1 rounded hover:bg-white/10 text-white/40 hover:text-red-400 transition-colors shrink-0"
|
||||
title="Remove from queue"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, typeof Clock> = {
|
||||
@@ -38,30 +37,18 @@ const statusColors: Record<string, string> = {
|
||||
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<Mission[]>([]);
|
||||
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 (
|
||||
<div className="flex flex-col h-full">
|
||||
@@ -73,13 +60,13 @@ export function RecentTasks() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
{isLoading ? (
|
||||
<p className="text-xs text-white/40">Loading...</p>
|
||||
) : missions.length === 0 ? (
|
||||
) : sortedMissions.length === 0 ? (
|
||||
<p className="text-xs text-white/40">No missions yet</p>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto space-y-2 min-h-0">
|
||||
{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";
|
||||
|
||||
333
dashboard/src/components/server-connection-card.tsx
Normal file
333
dashboard/src/components/server-connection-card.tsx
Normal file
@@ -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<string, string> = {
|
||||
open_agent: 'Open Agent',
|
||||
opencode: 'OpenCode',
|
||||
oh_my_opencode: 'oh-my-opencode',
|
||||
};
|
||||
|
||||
// Component icons
|
||||
const componentIcons: Record<string, string> = {
|
||||
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<string | null>(null);
|
||||
const [updateLogs, setUpdateLogs] = useState<UpdateLog[]>([]);
|
||||
|
||||
// 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 <Loader className="h-3.5 w-3.5 animate-spin text-indigo-400" />;
|
||||
}
|
||||
if (component.status === 'update_available') {
|
||||
return <ArrowUp className="h-3.5 w-3.5 text-amber-400" />;
|
||||
}
|
||||
if (component.status === 'not_installed' || component.status === 'error') {
|
||||
return <AlertCircle className="h-3.5 w-3.5 text-red-400" />;
|
||||
}
|
||||
return <Check className="h-3.5 w-3.5 text-emerald-400" />;
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="rounded-xl bg-white/[0.02] border border-white/[0.04] p-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-indigo-500/10">
|
||||
<Server className="h-5 w-5 text-indigo-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-white">Server Connection</h2>
|
||||
<p className="text-xs text-white/40">Backend endpoint & system components</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API URL Input */}
|
||||
<div className="space-y-2">
|
||||
{/* Header row: Label + Status + Refresh */}
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-white/60">
|
||||
API URL
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Status indicator */}
|
||||
{healthLoading ? (
|
||||
<span className="flex items-center gap-1.5 text-xs text-white/40">
|
||||
<RefreshCw className="h-3 w-3 animate-spin" />
|
||||
Checking...
|
||||
</span>
|
||||
) : health ? (
|
||||
<span className="flex items-center gap-1.5 text-xs text-emerald-400">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
|
||||
Connected (v{health.version})
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5 text-xs text-red-400">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-red-400" />
|
||||
Disconnected
|
||||
</span>
|
||||
)}
|
||||
{/* Refresh button */}
|
||||
<button
|
||||
onClick={testApiConnection}
|
||||
disabled={testingConnection}
|
||||
className="p-1 rounded-md text-white/40 hover:text-white/60 hover:bg-white/[0.04] transition-colors cursor-pointer disabled:opacity-50"
|
||||
title="Test connection"
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn('h-3.5 w-3.5', testingConnection && 'animate-spin')}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* URL input */}
|
||||
<input
|
||||
type="text"
|
||||
value={apiUrl}
|
||||
onChange={(e) => {
|
||||
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 && <p className="mt-1.5 text-xs text-red-400">{urlError}</p>}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-white/[0.06] my-4" />
|
||||
|
||||
{/* System Components Section */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-white/60">System Components</span>
|
||||
<span className="text-xs text-white/30">OpenCode stack</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => mutate()}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-white/[0.06] bg-white/[0.02] px-2.5 py-1 text-xs text-white/70 hover:bg-white/[0.04] transition-colors cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={cn('h-3 w-3', loading && 'animate-spin')} />
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setComponentsExpanded(!componentsExpanded)}
|
||||
className="p-1 rounded-lg text-white/40 hover:text-white/60 hover:bg-white/[0.04] transition-colors cursor-pointer"
|
||||
>
|
||||
{componentsExpanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{componentsExpanded && (
|
||||
<div className="space-y-2">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader className="h-5 w-5 animate-spin text-white/40" />
|
||||
</div>
|
||||
) : (
|
||||
components.map((component) => (
|
||||
<div
|
||||
key={component.name}
|
||||
className="group rounded-lg border border-white/[0.06] bg-white/[0.01] hover:bg-white/[0.02] transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3 px-3 py-2.5">
|
||||
{/* Icon */}
|
||||
<span className="text-base">
|
||||
{componentIcons[component.name] || '📦'}
|
||||
</span>
|
||||
|
||||
{/* Name & Version */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-white/80">
|
||||
{componentNames[component.name] || component.name}
|
||||
</span>
|
||||
{component.version && (
|
||||
<span className="text-xs text-white/40">
|
||||
v{component.version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{component.update_available && (
|
||||
<div className="text-xs text-amber-400/80 mt-0.5">
|
||||
v{component.update_available} available
|
||||
</div>
|
||||
)}
|
||||
{!component.installed && (
|
||||
<div className="text-xs text-red-400/80 mt-0.5">
|
||||
Not installed
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusIcon(component)}
|
||||
<span className={cn('h-1.5 w-1.5 rounded-full', getStatusDot(component))} />
|
||||
</div>
|
||||
|
||||
{/* Update button */}
|
||||
{component.status === 'update_available' && component.name !== 'open_agent' && (
|
||||
<button
|
||||
onClick={() => handleUpdate(component)}
|
||||
disabled={updatingComponent !== null}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-indigo-500/20 border border-indigo-500/30 px-2.5 py-1 text-xs text-indigo-300 hover:bg-indigo-500/30 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
Update
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Update logs */}
|
||||
{updatingComponent === component.name && updateLogs.length > 0 && (
|
||||
<div className="border-t border-white/[0.06] px-3 py-2">
|
||||
<div className="max-h-32 overflow-y-auto text-xs space-y-1 font-mono">
|
||||
{updateLogs.map((log, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'flex items-start gap-2',
|
||||
log.type === 'error' && 'text-red-400',
|
||||
log.type === 'complete' && 'text-emerald-400',
|
||||
log.type === 'log' && 'text-white/50'
|
||||
)}
|
||||
>
|
||||
{log.progress !== undefined && (
|
||||
<span className="text-white/30">[{log.progress}%]</span>
|
||||
)}
|
||||
<span className="break-all">{log.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
253
dashboard/src/components/system-components-card.tsx
Normal file
253
dashboard/src/components/system-components-card.tsx
Normal file
@@ -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<string, string> = {
|
||||
open_agent: 'Open Agent',
|
||||
opencode: 'OpenCode',
|
||||
oh_my_opencode: 'oh-my-opencode',
|
||||
};
|
||||
|
||||
// Component icons
|
||||
const componentIcons: Record<string, string> = {
|
||||
open_agent: '🚀',
|
||||
opencode: '⚡',
|
||||
oh_my_opencode: '🎭',
|
||||
};
|
||||
|
||||
interface UpdateLog {
|
||||
message: string;
|
||||
progress?: number;
|
||||
type: 'log' | 'complete' | 'error';
|
||||
}
|
||||
|
||||
export function SystemComponentsCard() {
|
||||
const [components, setComponents] = useState<ComponentInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [updatingComponent, setUpdatingComponent] = useState<string | null>(null);
|
||||
const [updateLogs, setUpdateLogs] = useState<UpdateLog[]>([]);
|
||||
|
||||
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 <Loader className="h-3.5 w-3.5 animate-spin text-indigo-400" />;
|
||||
}
|
||||
if (component.status === 'update_available') {
|
||||
return <ArrowUp className="h-3.5 w-3.5 text-amber-400" />;
|
||||
}
|
||||
if (component.status === 'not_installed') {
|
||||
return <AlertCircle className="h-3.5 w-3.5 text-red-400" />;
|
||||
}
|
||||
if (component.status === 'error') {
|
||||
return <AlertCircle className="h-3.5 w-3.5 text-red-400" />;
|
||||
}
|
||||
return <Check className="h-3.5 w-3.5 text-emerald-400" />;
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="rounded-xl bg-white/[0.02] border border-white/[0.04] p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-cyan-500/10">
|
||||
<Cpu className="h-5 w-5 text-cyan-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-white">System Components</h2>
|
||||
<p className="text-xs text-white/40">OpenCode stack versions</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={loadComponents}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-1.5 text-xs text-white/70 hover:bg-white/[0.04] transition-colors cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={cn('h-3 w-3', loading && 'animate-spin')} />
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="p-1.5 rounded-lg text-white/40 hover:text-white/60 hover:bg-white/[0.04] transition-colors cursor-pointer"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="space-y-2">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader className="h-5 w-5 animate-spin text-white/40" />
|
||||
</div>
|
||||
) : (
|
||||
components.map((component) => (
|
||||
<div
|
||||
key={component.name}
|
||||
className="group rounded-lg border border-white/[0.06] bg-white/[0.01] hover:bg-white/[0.02] transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3 px-3 py-2.5">
|
||||
{/* Icon */}
|
||||
<span className="text-base">
|
||||
{componentIcons[component.name] || '📦'}
|
||||
</span>
|
||||
|
||||
{/* Name & Version */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-white/80">
|
||||
{componentNames[component.name] || component.name}
|
||||
</span>
|
||||
{component.version && (
|
||||
<span className="text-xs text-white/40">
|
||||
v{component.version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{component.update_available && (
|
||||
<div className="text-xs text-amber-400/80 mt-0.5">
|
||||
v{component.update_available} available
|
||||
</div>
|
||||
)}
|
||||
{!component.installed && (
|
||||
<div className="text-xs text-red-400/80 mt-0.5">
|
||||
Not installed
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusIcon(component)}
|
||||
<span className={cn('h-1.5 w-1.5 rounded-full', getStatusDot(component))} />
|
||||
</div>
|
||||
|
||||
{/* Update button */}
|
||||
{component.status === 'update_available' && component.name !== 'open_agent' && (
|
||||
<button
|
||||
onClick={() => handleUpdate(component)}
|
||||
disabled={updatingComponent !== null}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-indigo-500/20 border border-indigo-500/30 px-2.5 py-1 text-xs text-indigo-300 hover:bg-indigo-500/30 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
Update
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Update logs */}
|
||||
{updatingComponent === component.name && updateLogs.length > 0 && (
|
||||
<div className="border-t border-white/[0.06] px-3 py-2">
|
||||
<div className="max-h-32 overflow-y-auto text-xs space-y-1 font-mono">
|
||||
{updateLogs.map((log, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'flex items-start gap-2',
|
||||
log.type === 'error' && 'text-red-400',
|
||||
log.type === 'complete' && 'text-emerald-400',
|
||||
log.type === 'log' && 'text-white/50'
|
||||
)}
|
||||
>
|
||||
{log.progress !== undefined && (
|
||||
<span className="text-white/30">[{log.progress}%]</span>
|
||||
)}
|
||||
<span className="break-all">{log.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -635,6 +635,32 @@ export async function cancelControl(): Promise<void> {
|
||||
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<QueuedMessage[]> {
|
||||
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<void> {
|
||||
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<string, string>;
|
||||
encrypted_keys: string[];
|
||||
init_script: string;
|
||||
}
|
||||
|
||||
@@ -1745,6 +1772,7 @@ export async function saveWorkspaceTemplate(
|
||||
distro?: string;
|
||||
skills?: string[];
|
||||
env_vars?: Record<string, string>;
|
||||
encrypted_keys?: string[];
|
||||
init_script?: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
@@ -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<OperationRespons
|
||||
if (!res.ok) throw new Error('Failed to cleanup orphaned sessions');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// System Components API
|
||||
// ============================================
|
||||
|
||||
export type ComponentStatus = 'ok' | 'update_available' | 'not_installed' | 'error';
|
||||
|
||||
export interface ComponentInfo {
|
||||
name: string;
|
||||
version: string | null;
|
||||
installed: boolean;
|
||||
update_available: string | null;
|
||||
path: string | null;
|
||||
status: ComponentStatus;
|
||||
}
|
||||
|
||||
export interface SystemComponentsResponse {
|
||||
components: ComponentInfo[];
|
||||
}
|
||||
|
||||
export interface UpdateProgressEvent {
|
||||
event_type: 'log' | 'progress' | 'complete' | 'error';
|
||||
message: string;
|
||||
progress: number | null;
|
||||
}
|
||||
|
||||
// Get all system components and their versions
|
||||
export async function getSystemComponents(): Promise<SystemComponentsResponse> {
|
||||
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<void> {
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
225
dashboard/src/lib/skill-encryption.ts
Normal file
225
dashboard/src/lib/skill-encryption.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Skill content encryption utilities.
|
||||
*
|
||||
* Handles detection and marking of sensitive values in skill markdown content.
|
||||
* Values are wrapped in <encrypted v="1">...</encrypted> tags for highlighting
|
||||
* and backend encryption.
|
||||
*/
|
||||
|
||||
/** Pattern to match encrypted tags */
|
||||
export const ENCRYPTED_TAG_REGEX = /<encrypted(?:\s+v="\d+")?>([^<]*)<\/encrypted>/g;
|
||||
|
||||
/** Pattern for unversioned encrypted tags (for editing display) */
|
||||
export const ENCRYPTED_DISPLAY_REGEX = /<encrypted>([^<]*)<\/encrypted>/g;
|
||||
|
||||
/** Check if a value looks like an encrypted tag */
|
||||
export const isEncryptedTag = (value: string): boolean => {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.startsWith('<encrypted') && trimmed.endsWith('</encrypted>');
|
||||
};
|
||||
|
||||
/** Extract the value from an encrypted tag */
|
||||
export const extractEncryptedValue = (tag: string): string | null => {
|
||||
const match = tag.match(/<encrypted(?:\s+v="\d+")?>(.*?)<\/encrypted>/);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
/** Wrap a value in an encrypted tag for display/editing */
|
||||
export const wrapEncrypted = (value: string): string => {
|
||||
return `<encrypted>${value}</encrypted>`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 <encrypted> tags
|
||||
const alreadyEncrypted = new Set<string>();
|
||||
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 <encrypted> tag
|
||||
const beforeText = content.slice(0, match.index);
|
||||
const lastOpenTag = beforeText.lastIndexOf('<encrypted');
|
||||
const lastCloseTag = beforeText.lastIndexOf('</encrypted>');
|
||||
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 <encrypted> 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;
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 = "<group>"; };
|
||||
5267DE67017A858357F68424 /* GlassButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassButton.swift; sourceTree = "<group>"; };
|
||||
52DDF35DB8CD7D70F3CFC4A6 /* TerminalState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalState.swift; sourceTree = "<group>"; };
|
||||
WORKSPACEREF1234567890AB /* WorkspaceState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceState.swift; sourceTree = "<group>"; };
|
||||
5908645A518F48B501390AB8 /* FilesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilesView.swift; sourceTree = "<group>"; };
|
||||
5A09A33A3A1A99446C8A88DC /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
|
||||
66A48A20D2178760301256C9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
@@ -82,15 +86,25 @@
|
||||
CD6FB2E54DC07BE7A1EB08F8 /* StatusBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBadge.swift; sourceTree = "<group>"; };
|
||||
CD8D224B6758B664864F3987 /* ANSIParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ANSIParser.swift; sourceTree = "<group>"; };
|
||||
D1A8191F935AB50463216395 /* NewMissionSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewMissionSheet.swift; sourceTree = "<group>"; };
|
||||
QUEUESHEETREF1234567890 /* QueueSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueueSheet.swift; sourceTree = "<group>"; };
|
||||
D4AB47CF121ABA1946A4D879 /* Mission.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mission.swift; sourceTree = "<group>"; };
|
||||
E7C1198DDF17571DE85F5ABA /* Workspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Workspace.swift; sourceTree = "<group>"; };
|
||||
E7FC053808661C9A0E21E83C /* RunningMissionsBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunningMissionsBar.swift; sourceTree = "<group>"; };
|
||||
EB5A4720378F06807FDE73E1 /* GlassCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassCard.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
SETTINGSVIEWREF123456789 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
SETTINGSGROUP123456789A /* Settings */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
SETTINGSVIEWREF123456789 /* SettingsView.swift */,
|
||||
);
|
||||
path = Settings;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
@@ -249,6 +265,7 @@
|
||||
A07EFDD6964AA3B251967041 /* DesktopStreamService.swift */,
|
||||
3729F39FBF53046124D05BC1 /* NavigationState.swift */,
|
||||
52DDF35DB8CD7D70F3CFC4A6 /* TerminalState.swift */,
|
||||
WORKSPACEREF1234567890AB /* WorkspaceState.swift */,
|
||||
);
|
||||
path = Services;
|
||||
sourceTree = "<group>";
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
164
ios_dashboard/OpenAgentDashboard/Views/Control/QueueSheet.swift
Normal file
164
ios_dashboard/OpenAgentDashboard/Views/Control/QueueSheet.swift
Normal file
@@ -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: {}
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<Vec<Uuid>>,
|
||||
},
|
||||
/// Get the current message queue
|
||||
GetQueue {
|
||||
respond: oneshot::Sender<Vec<QueuedMessage>>,
|
||||
},
|
||||
/// Remove a message from the queue
|
||||
RemoveFromQueue {
|
||||
message_id: Uuid,
|
||||
respond: oneshot::Sender<bool>, // true if removed, false if not found
|
||||
},
|
||||
/// Clear all messages from the queue
|
||||
ClearQueue {
|
||||
respond: oneshot::Sender<usize>, // number of messages cleared
|
||||
},
|
||||
}
|
||||
|
||||
// ==================== Mission Types ====================
|
||||
@@ -622,7 +643,7 @@ pub struct ControlState {
|
||||
pub running_missions: Arc<RwLock<Vec<super::mission_runner::RunningMissionInfo>>>,
|
||||
/// Max parallel missions allowed
|
||||
pub max_parallel: usize,
|
||||
/// Mission persistence (in-memory or Supabase-backed)
|
||||
/// Mission persistence (SQLite-backed)
|
||||
pub mission_store: Arc<dyn MissionStore>,
|
||||
}
|
||||
|
||||
@@ -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<Arc<AppState>>,
|
||||
Extension(user): Extension<AuthUser>,
|
||||
) -> Result<Json<Vec<QueuedMessage>>, (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<Arc<AppState>>,
|
||||
Extension(user): Extension<AuthUser>,
|
||||
Path(message_id): Path<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, (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<Arc<AppState>>,
|
||||
Extension(user): Extension<AuthUser>,
|
||||
) -> Result<Json<serde_json::Value>, (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<QueuedMessage> = 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)
|
||||
|
||||
@@ -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<Arc<AppState>>) -> Json<O
|
||||
|
||||
/// Collect all desktop sessions from all missions with status information.
|
||||
async fn collect_desktop_sessions(state: &Arc<AppState>) -> Vec<DesktopSessionDetail> {
|
||||
let mut sessions = Vec::new();
|
||||
let mut sessions_by_display: HashMap<String, DesktopSessionDetail> = 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<AppState>) -> Vec<DesktopSessionDe
|
||||
Ok(m) => 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<AppState>) -> Vec<DesktopSessionDe
|
||||
None
|
||||
};
|
||||
|
||||
sessions.push(DesktopSessionDetail {
|
||||
let detail = DesktopSessionDetail {
|
||||
display: session.display.clone(),
|
||||
status,
|
||||
mission_id: session.mission_id.or(Some(mission.id)),
|
||||
@@ -326,7 +327,18 @@ async fn collect_desktop_sessions(state: &Arc<AppState>) -> Vec<DesktopSessionDe
|
||||
keep_alive_until: session.keep_alive_until.clone(),
|
||||
auto_close_in_secs,
|
||||
process_running,
|
||||
});
|
||||
};
|
||||
|
||||
match sessions_by_display.get(&detail.display) {
|
||||
Some(existing) => {
|
||||
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<AppState>) -> Vec<DesktopSessionDe
|
||||
let running_displays = get_running_xvfb_displays().await;
|
||||
for display in running_displays {
|
||||
// Check if this display is already in our list
|
||||
if !sessions.iter().any(|s| s.display == display) {
|
||||
sessions.push(DesktopSessionDetail {
|
||||
display: display.clone(),
|
||||
status: DesktopSessionStatus::Unknown,
|
||||
mission_id: None,
|
||||
mission_title: None,
|
||||
mission_status: None,
|
||||
started_at: "unknown".to_string(),
|
||||
stopped_at: None,
|
||||
keep_alive_until: None,
|
||||
auto_close_in_secs: None,
|
||||
process_running: true,
|
||||
});
|
||||
}
|
||||
sessions_by_display.entry(display.clone()).or_insert_with(|| DesktopSessionDetail {
|
||||
display: display.clone(),
|
||||
status: DesktopSessionStatus::Unknown,
|
||||
mission_id: None,
|
||||
mission_title: None,
|
||||
mission_status: None,
|
||||
started_at: "unknown".to_string(),
|
||||
stopped_at: None,
|
||||
keep_alive_until: None,
|
||||
auto_close_in_secs: None,
|
||||
process_running: true,
|
||||
});
|
||||
}
|
||||
|
||||
let mut sessions: Vec<DesktopSessionDetail> = 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,
|
||||
|
||||
@@ -289,6 +289,7 @@ pub struct SaveWorkspaceTemplateRequest {
|
||||
pub distro: Option<String>,
|
||||
pub skills: Option<Vec<String>>,
|
||||
pub env_vars: Option<HashMap<String, String>>,
|
||||
pub encrypted_keys: Option<Vec<String>>,
|
||||
pub init_script: Option<String>,
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ pub mod opencode;
|
||||
mod providers;
|
||||
mod routes;
|
||||
pub mod secrets;
|
||||
pub mod system;
|
||||
pub mod types;
|
||||
pub mod workspaces;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
537
src/api/system.rs
Normal file
537
src/api/system.rs
Normal file
@@ -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<String>,
|
||||
pub installed: bool,
|
||||
pub update_available: Option<String>,
|
||||
pub path: Option<String>,
|
||||
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<ComponentInfo>,
|
||||
}
|
||||
|
||||
/// 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<u8>, // 0-100
|
||||
}
|
||||
|
||||
// Type alias for the boxed stream to avoid opaque type mismatch
|
||||
type UpdateStream = Pin<Box<dyn Stream<Item = Result<Event, std::convert::Infallible>> + Send>>;
|
||||
|
||||
/// Create routes for system management.
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
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<Arc<AppState>>) -> Json<SystemComponentsResponse> {
|
||||
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::<serde_json::Value>().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<String> {
|
||||
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<u32> {
|
||||
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<String> {
|
||||
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::<serde_json::Value>(&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<String> {
|
||||
// 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<Arc<AppState>>,
|
||||
Path(name): Path<String>,
|
||||
) -> Result<Sse<UpdateStream>, (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<Item = Result<Event, std::convert::Infallible>> {
|
||||
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<Item = Result<Event, std::convert::Infallible>> {
|
||||
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::<Vec<_>>().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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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::<u64>().ok()
|
||||
let kb = stdout.split_whitespace().next()?.parse::<u64>().ok()?;
|
||||
Some(kb * 1024) // Convert KB to bytes
|
||||
})
|
||||
} else {
|
||||
None
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -210,6 +210,92 @@ pub fn generate_private_key() -> [u8; KEY_LENGTH] {
|
||||
key
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Content encryption (for skill markdown files)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Regex to match unversioned <encrypted>value</encrypted> tags (user input format).
|
||||
const UNVERSIONED_TAG_REGEX: &str = r"<encrypted>([^<]*)</encrypted>";
|
||||
|
||||
/// Regex to match versioned <encrypted v="N">value</encrypted> tags (storage format).
|
||||
const VERSIONED_TAG_REGEX: &str = r#"<encrypted v="(\d+)">([^<]*)</encrypted>"#;
|
||||
|
||||
/// 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("<encrypted>") && trimmed.ends_with("</encrypted>") && !trimmed.contains(" v=\"")
|
||||
}
|
||||
|
||||
/// Encrypt all unversioned <encrypted>value</encrypted> tags in content.
|
||||
/// Transforms <encrypted>plaintext</encrypted> to <encrypted v="1">ciphertext</encrypted>.
|
||||
pub fn encrypt_content_tags(key: &[u8; KEY_LENGTH], content: &str) -> Result<String> {
|
||||
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 <encrypted v="N">ciphertext</encrypted> tags in content.
|
||||
/// Transforms <encrypted v="1">ciphertext</encrypted> to <encrypted>plaintext</encrypted>.
|
||||
pub fn decrypt_content_tags(key: &[u8; KEY_LENGTH], content: &str) -> Result<String> {
|
||||
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!("<encrypted>{}</encrypted>", 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("<encrypted>secret</encrypted>"));
|
||||
assert!(is_unversioned_encrypted(" <encrypted>secret</encrypted> "));
|
||||
assert!(!is_unversioned_encrypted("<encrypted v=\"1\">secret</encrypted>"));
|
||||
assert!(!is_unversioned_encrypted("plaintext"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_content_tags() {
|
||||
let key = test_key();
|
||||
let content = "Hello, here is my key: <encrypted>sk-12345</encrypted> and more text.";
|
||||
|
||||
let encrypted = encrypt_content_tags(&key, content).unwrap();
|
||||
|
||||
// Should have versioned tag now
|
||||
assert!(encrypted.contains("<encrypted v=\"1\">"));
|
||||
assert!(encrypted.contains("</encrypted>"));
|
||||
assert!(!encrypted.contains("<encrypted>sk-12345</encrypted>"));
|
||||
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: <encrypted>sk-12345</encrypted> 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: <encrypted>sk-openai-key</encrypted>
|
||||
- Anthropic: <encrypted>sk-ant-key</encrypted>
|
||||
|
||||
Use them wisely.
|
||||
"#;
|
||||
|
||||
let encrypted = encrypt_content_tags(&key, content).unwrap();
|
||||
|
||||
// Both should be encrypted
|
||||
assert!(!encrypted.contains("<encrypted>sk-openai-key</encrypted>"));
|
||||
assert!(!encrypted.contains("<encrypted>sk-ant-key</encrypted>"));
|
||||
|
||||
// Count versioned tags
|
||||
let count = encrypted.matches("<encrypted v=\"1\">").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: <encrypted v=\"1\">abc123</encrypted>";
|
||||
|
||||
// Encrypting again should not double-encrypt
|
||||
let result = encrypt_content_tags(&key, content).unwrap();
|
||||
assert_eq!(result, content);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,9 @@ struct WorkspaceTemplateConfig {
|
||||
skills: Vec<String>,
|
||||
#[serde(default)]
|
||||
env_vars: HashMap<String, String>,
|
||||
/// Keys of env vars that should be encrypted at rest (stored alongside encrypted values)
|
||||
#[serde(default)]
|
||||
encrypted_keys: Vec<String>,
|
||||
#[serde(default)]
|
||||
init_script: String,
|
||||
}
|
||||
@@ -210,6 +213,8 @@ impl LibraryStore {
|
||||
}
|
||||
|
||||
/// Get a skill by name with full content.
|
||||
/// Encrypted values in <encrypted v="N">...</encrypted> tags are decrypted
|
||||
/// to <encrypted>...</encrypted> format for display/editing.
|
||||
pub async fn get_skill(&self, name: &str) -> Result<Skill> {
|
||||
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 <encrypted>...</encrypted> tags.
|
||||
/// Unversioned <encrypted>value</encrypted> tags are encrypted to
|
||||
/// <encrypted v="1">ciphertext</encrypted> 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<String> {
|
||||
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(),
|
||||
};
|
||||
|
||||
|
||||
@@ -214,6 +214,9 @@ pub struct WorkspaceTemplate {
|
||||
/// Environment variables for the workspace
|
||||
#[serde(default)]
|
||||
pub env_vars: HashMap<String, String>,
|
||||
/// Keys of env vars that should be encrypted at rest
|
||||
#[serde(default)]
|
||||
pub encrypted_keys: Vec<String>,
|
||||
/// Init script to run on build
|
||||
#[serde(default)]
|
||||
pub init_script: String,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<OpenCodeMessageResponse> {
|
||||
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<OpenCodeEvent>,
|
||||
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<OpenCodeEvent> {
|
||||
) -> 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::<String>(),
|
||||
"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::<String>(),
|
||||
"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();
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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., '') 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., '') 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., '') 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;
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<String> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user