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:
Thomas Marchand
2026-01-16 09:41:11 +00:00
committed by GitHub
parent 169e82821a
commit b519f02b62
63 changed files with 4887 additions and 1806 deletions

View File

@@ -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.

View File

@@ -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)
# =============================================================================

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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=="],

View File

@@ -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",

View File

@@ -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} />
) : (

View File

@@ -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} />
) : (

View File

@@ -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>
);
}

View File

@@ -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>
</>

View File

@@ -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' && (

View File

@@ -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>

View File

@@ -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
/>

View File

@@ -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));

View File

@@ -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) => {

View File

@@ -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
/>

View File

@@ -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 />

View File

@@ -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">

View File

@@ -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"

View File

@@ -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&#10;# 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&#10;# 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>

View File

@@ -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, '&lt;')
.replace(/>/g, '&gt;');
/**
* 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;">&lt;encrypted&gt;</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;">&lt;/encrypted&gt;</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>

View File

@@ -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" }}

View 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>
);
}

View File

@@ -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 = () => {

View 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>
);
}

View File

@@ -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";

View 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>
);
}

View 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>
);
}

View File

@@ -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');
}
}

View 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;
};

View File

@@ -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

View File

@@ -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 |

View File

@@ -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;
};

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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] {

View File

@@ -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

View File

@@ -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"
}
}

View File

@@ -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 {

View 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: {}
)
}

View File

@@ -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

View File

@@ -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()
}
}

View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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,

View File

@@ -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(),
};

View File

@@ -31,6 +31,7 @@ pub mod opencode;
mod providers;
mod routes;
pub mod secrets;
pub mod system;
pub mod types;
pub mod workspaces;

View File

@@ -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
View 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()));
}
}
}
}

View File

@@ -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

View File

@@ -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.

View File

@@ -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);
}
}

View File

@@ -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(),
};

View File

@@ -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,

View File

@@ -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,

View File

@@ -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();

View File

@@ -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?;

View File

@@ -317,13 +317,11 @@ impl Tool for Screenshot {
}
fn description(&self) -> &str {
"Take a screenshot of the virtual desktop. Automatically uploads and returns markdown to embed the image.
"Take a screenshot of the virtual desktop and save it locally.
IMPORTANT: After launching applications with i3 exec commands, use wait_seconds (3-5s recommended) to let them render before capturing. Otherwise the screenshot may be black.
Set return_image=true to SEE the screenshot yourself (vision). This lets you verify the layout is correct before responding.
You MUST copy the returned markdown (e.g., '![screenshot](https://...)') directly into your response text for the user to see the image."
Set return_image=true to SEE the screenshot yourself (vision). This lets you verify the layout is correct before responding."
}
fn parameters_schema(&self) -> Value {
@@ -342,13 +340,9 @@ You MUST copy the returned markdown (e.g., '![screenshot](https://...)') directl
"type": "boolean",
"description": "If true, the screenshot image will be included in your context so you can SEE it (requires vision model). Use this to verify the desktop layout is correct. Default: false"
},
"upload": {
"type": "boolean",
"description": "Whether to upload the screenshot and return a public URL (default: true). Set to false to only save locally."
},
"description": {
"type": "string",
"description": "Description for the image alt text (default: 'screenshot')"
"description": "Description for the image (default: 'screenshot')"
},
"filename": {
"type": "string",
@@ -446,98 +440,24 @@ You MUST copy the returned markdown (e.g., '![screenshot](https://...)') directl
}
let metadata = std::fs::metadata(&filepath)?;
let should_upload = args["upload"].as_bool().unwrap_or(true);
let return_image = args["return_image"].as_bool().unwrap_or(false);
let description = args["description"].as_str().unwrap_or("screenshot");
// Auto-upload to Supabase if enabled and configured
if should_upload {
if let Some((url, markdown)) =
upload_screenshot_to_supabase(&filepath, description).await
{
// Include vision marker if return_image is true
// Format: [VISION_IMAGE:url] - this will be parsed by the executor to add the image to context
let vision_marker = if return_image {
format!("\n\n[VISION_IMAGE:{}]", url)
} else {
String::new()
};
// Include vision marker if return_image is true
let vision_marker = if return_image {
format!("\n\n[VISION_IMAGE:file://{}]", filepath.display())
} else {
String::new()
};
// Return format that strongly encourages the LLM to include the markdown
return Ok(format!(
"Screenshot captured and uploaded successfully.\n\n\
INCLUDE THIS IN YOUR RESPONSE TO SHOW THE IMAGE:\n{}\n\n\
Details: path={}, size={} bytes, url={}{}",
markdown,
filepath.display(),
metadata.len(),
url,
vision_marker
));
}
// Fall through to local-only if upload fails
}
Ok(json!({
"success": true,
"path": filepath.display().to_string(),
"size_bytes": metadata.len()
})
.to_string())
Ok(format!(
"{{\"success\": true, \"path\": \"{}\", \"size_bytes\": {}}}{}",
filepath.display(),
metadata.len(),
vision_marker
))
}
}
/// Helper to upload a screenshot to Supabase Storage
async fn upload_screenshot_to_supabase(
filepath: &std::path::PathBuf,
description: &str,
) -> Option<(String, String)> {
let supabase_url = std::env::var("SUPABASE_URL").ok()?;
let service_role_key = std::env::var("SUPABASE_SERVICE_ROLE_KEY").ok()?;
if supabase_url.is_empty() || service_role_key.is_empty() {
return None;
}
let content = std::fs::read(filepath).ok()?;
let file_id = uuid::Uuid::new_v4();
let upload_path = format!("{}.png", file_id);
let storage_url = format!(
"{}/storage/v1/object/images/{}",
supabase_url.trim_end_matches('/'),
upload_path
);
let client = reqwest::Client::new();
let resp = client
.post(&storage_url)
.header("apikey", &service_role_key)
.header("Authorization", format!("Bearer {}", service_role_key))
.header("Content-Type", "image/png")
.header("x-upsert", "true")
.body(content)
.send()
.await
.ok()?;
if !resp.status().is_success() {
return None;
}
let public_url = format!(
"{}/storage/v1/object/public/images/{}",
supabase_url.trim_end_matches('/'),
upload_path
);
let markdown = format!("![{}]({})", description, public_url);
tracing::info!(url = %public_url, "Screenshot auto-uploaded to Supabase");
Some((public_url, markdown))
}
/// Send keyboard input to the desktop.
pub struct TypeText;

View File

@@ -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(),

View File

@@ -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");
}
}