Merge pull request #1 from openswarm-ai/haik/updates-v1

Haik/updates v1
This commit is contained in:
haikdc
2026-03-15 19:56:59 -07:00
committed by GitHub
87 changed files with 9764 additions and 1269 deletions
+9 -1
View File
@@ -3,4 +3,12 @@
.env
backend/data/**
!backend/data/outputs/
!backend/data/outputs/*.json
!backend/data/outputs/*.json
# Electron build artifacts
electron/dist/
electron/python-env/
electron/node_modules/
# Frontend build output
frontend/dist/
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 ClusterLabs
Copyright (c) 2026 Haik Decie
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+204 -66
View File
@@ -1,104 +1,242 @@
# Open Swarm — Agent Orchestrator
<p align="center">
<img src="assets/icon.png" alt="Open Swarm" width="128" height="128">
</p>
A locally-running React + FastAPI application for managing multiple Claude Code instances in parallel. Designed for power users who run multiple agents simultaneously and need a unified interface to monitor, control, and coordinate them.
<h1 align="center">Open Swarm</h1>
<p align="center">
<strong>An Army of AI Agents at Your Fingertips</strong>
<br>
A locally-running orchestrator for managing multiple Claude Code instances in parallel.
<br>
Launch, monitor, and coordinate entire swarms of coding agents from a single interface.
</p>
<p align="center">
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License"></a>
<a href="#"><img src="https://img.shields.io/badge/platform-macOS-lightgrey.svg" alt="Platform"></a>
<a href="https://github.com/openswarm-ai/openswarm/stargazers"><img src="https://img.shields.io/github/stars/openswarm-ai/openswarm?style=social" alt="GitHub Stars"></a>
<a href="https://github.com/openswarm-ai/openswarm/pulls"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg" alt="PRs Welcome"></a>
</p>
<br>
<p align="center">
<img src="assets/screenshot.png" alt="Open Swarm Dashboard" width="900">
</p>
<br>
## Why Open Swarm?
Running Claude Code in a terminal works fine for one task. But when you're juggling five agents across different branches, approving tool calls in separate windows, and losing track of who's doing what — it falls apart fast.
- **Parallel agents, one screen** — Launch as many Claude Code instances as you need, arranged on a spatial canvas you can pan and zoom freely
- **Unified approval workflow** — Every tool-use request from every agent surfaces in one place. Approve or deny with a click or a keyboard shortcut.
- **Full conversation control** — Edit prior messages to fork conversations, navigate between branches, resume closed sessions
- **100% local** — Everything runs on your machine. No cloud relay, no telemetry, no third-party backend.
<br>
## Features
- **Multi-agent management** — Launch and monitor multiple Claude Code instances side by side
- **Git worktree isolation** — Each agent works on its own git worktree/branch to avoid conflicts
- **Real-time streaming** — WebSocket-based streaming of agent messages and status updates
- **HITL approvals** — Approve or deny tool usage requests from the dashboard or within each chat
- **Message branching** — Edit prior messages to fork conversations, navigate between branches
- **Prompt template library** — Reusable prompt templates with structured input fields, invoked via `/` commands
- **Skills library** — Manage skills synced to the native `~/.claude/skills/` directory
- **Tools library** — Define custom tool configurations (bash, MCP, Python)
- **Keyboard shortcuts** — Navigate between agents and approve/deny requests without a mouse
- **Diff viewer** — View uncommitted changes in each agent's worktree
**Spatial Dashboard** — Infinite canvas with drag-and-drop agent cards, view cards, and embedded browser cards. Create multiple dashboards for different workspaces.
**Agent Chat** — Full streaming chat interface powered by WebSockets. Real-time token output, cost tracking per session, and persistent history that survives restarts.
**Human-in-the-Loop Approvals**Agents request permission before executing tools. Approve or deny individually, or batch-approve from the dashboard. Configurable per-tool permissions (always allow, ask, deny).
**Message Branching**Edit any prior message to fork the conversation. Navigate freely between branches without losing context.
**Prompt Templates**Build reusable templates with structured input fields. Invoke them inline via `/` slash commands.
**Skills Library** — Manage skills that sync directly to `~/.claude/skills/`. Browse and install from the official Anthropic skills marketplace.
**Tools Library** — Configure MCP tool servers (stdio, HTTP, SSE) with automatic tool discovery. Browse the MCP registry and Google's catalog with GitHub star counts. Includes Google Workspace OAuth integration.
**Agent Modes** — Five built-in modes (Agent, Ask, Plan, View Builder, Skill Builder) plus custom user-defined modes with configurable system prompts and tool restrictions.
**Views & Outputs** — Create interactive HTML/JS/CSS artifacts rendered in iframes. Supports vibe coding (LLM-generates the view), backend Python execution, auto-run with LLM-generated data, and agent-driven data gathering.
**Git Worktree Isolation** — Each agent operates in its own git worktree and branch, preventing conflicts between parallel workstreams.
**Diff Viewer** — Inspect uncommitted changes in any agent's worktree without leaving the app.
**Cost Tracking** — Real-time USD spend tracking per agent session.
**Dark & Light Themes** — Full theme support with Claude-inspired design tokens.
**Keyboard Shortcuts** — Navigate between agents, approve/deny requests, and switch pages without touching a mouse.
<br>
## Quick Start
### Desktop App
Download the latest release for macOS from [GitHub Releases](https://github.com/openswarm-ai/openswarm/releases).
> Windows and Linux builds are planned but not yet available.
### Development Setup
**Prerequisites:** Python 3.11+, Node.js 18+, Git
```bash
git clone https://github.com/openswarm-ai/openswarm.git
cd openswarm
bash run.sh
```
This starts the backend (port 8324), frontend (port 3000), and Electron shell together. Once running, set your Anthropic API key in the in-app Settings page.
To run services individually:
```bash
bash backend/run.sh # API at http://localhost:8324 — docs at /docs
bash frontend/run.sh # App at http://localhost:3000
```
<br>
## Architecture
```
Frontend (React/TypeScript :3000) Backend (FastAPI/Python :8324)
─────────────────────────────┐ ┌──────────────────────────────┐
Dashboard │◄────►│ REST API (/api/*)
Agent Chat (per session) │ WebSocket (/ws/*)
Templates / Skills / Tools │ │ Agent Manager
Slash Command Picker │ │ └─ Claude Agent SDK
Keyboard Shortcuts │ │ Worktree Manager
Diff Viewer Library Storage (JSON/files)
└─────────────────────────────┘ └──────────────────────────────┘
Electron Shell (desktop wrapper, auto-updater)
├─────────────────────────────────────────────────────────────────────┐
Frontend (React/TypeScript :3000) Backend (FastAPI :8324)
┌───────────────────────────────┐ ┌───────────────────────┐
│ Spatial Dashboard Canvas │◄────►│ REST API (/api/*) │
│ Agent Chat (streaming) │ │ WebSocket (/ws/*)
│ Templates / Skills / Tools │ WS Agent Manager │ │
│ │ Modes / Views / Commands │◄────►│ └─ claude-agent-sdk│ │
│ │ Settings │ │ MCP Tool Discovery │ │
│ │ Redux Toolkit (state) │ │ JSON File Storage │ │
│ └───────────────────────────────┘ └───────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
## Quick Start
<br>
### Prerequisites
## Configuration
- Python 3.11+
- Node.js 18+
- Git
- An Anthropic API key (for real agent usage) — `export ANTHROPIC_API_KEY=...`
The Anthropic API key is configured in-app via the **Settings** page — no environment variable needed for normal usage.
### Backend
For advanced configuration, copy `backend/.env.example` to `backend/.env`:
```bash
bash backend/run/dev.sh
# API runs at http://localhost:8324
# Docs at http://localhost:8324/docs
```
| Variable | Purpose |
|----------|---------|
| `BACKEND_PORT` | Backend server port (default: `8324`) |
| `GOOGLE_OAUTH_CLIENT_ID` | Google Workspace integration (Gmail, Calendar, Drive) |
| `GOOGLE_OAUTH_CLIENT_SECRET` | Google Workspace integration |
| `APPLE_ID` | macOS code signing & notarization (release builds only) |
| `APPLE_APP_SPECIFIC_PASSWORD` | macOS notarization (release builds only) |
| `APPLE_TEAM_ID` | macOS code signing (release builds only) |
| `GH_TOKEN` | GitHub Releases publishing (release builds only) |
### Frontend
```bash
bash frontend/run/dev.sh
# App runs at http://localhost:3000
```
### Mock Mode
If `claude-agent-sdk` is not installed, the backend runs in **mock mode** — agents simulate tool calls and responses so you can develop and test the UI without an API key.
<br>
## Keyboard Shortcuts
| Key | Action |
|-----|--------|
| `d` | Go to Dashboard |
| `t` | Go to Templates |
| `1``9` | Open agent by position |
| `D` | Go to Dashboard |
| `T` | Go to Templates |
| `1` `9` | Open agent by position |
| `Shift+A` | Approve all pending requests |
| `Shift+D` | Deny all pending requests |
| `?` | Show shortcuts help |
## Slash Commands
Type `/` in the chat input to invoke prompt templates and skills as slash commands.
In the chat input, type `/` to invoke templates and skills:
- `/template-name` — Opens the template's input modal
- `/skill-name` — Inserts the skill content into the message
<br>
## Project Structure
```
backend/
apps/
agents/ Agent lifecycle, WebSocket, worktree management
templates/ — Prompt template CRUD (JSON file storage)
skills/ — Skills CRUD (synced to ~/.claude/skills/)
tools_lib/ — Tool definitions CRUD (JSON file storage)
health/ — Health check endpoint
config/ — FastAPI app configuration
data/ — Persistent JSON file storage (sessions, dashboards, settings, templates, tools, etc.)
agents/ Agent lifecycle, streaming, worktree management
dashboards/ Dashboard CRUD and layout persistence
dashboard_layout/ Card positions and spatial canvas state
templates/ Prompt template CRUD
skills/ Skills CRUD (synced to ~/.claude/skills/)
tools_lib/ MCP tool configuration and discovery
modes/ Agent mode definitions
outputs/ Views/outputs, vibe coding, Python executor
settings/ App settings and file browser
health/ Health check endpoint
mcp_registry/ MCP server registry proxy
skill_registry/ Anthropic skills marketplace proxy
config/ FastAPI app configuration
data/ Persistent JSON file storage
frontend/
src/
app/
components/ AppShell, NewAgentModal, SlashCommandPicker, KeyboardShortcutsHelp
components/ AppShell, Layout, shared UI
pages/
Dashboard/ — Agent overview grid with live status
AgentChat/ — Full chat UI with streaming, HITL, branching, diff viewer
Templates/ Template library with editor
Skills/ Skills library with editor
Tools/ Tools library with editor
Dashboard/ Spatial canvas with agent/view/browser cards
AgentChat/ Streaming chat, HITL approvals, branching, diff viewer
Templates/ Template library with structured input fields
Skills/ Skills library, skill builder, registry browser
Tools/ Tool config, MCP discovery, OAuth, registry browser
Modes/ Mode definitions with system prompts
Views/ Output artifacts, code editor, vibe coding
Commands/ Keyboard shortcuts reference
Settings/ App configuration
shared/
state/ Redux slices (agents, templates, skills, tools)
ws/ WebSocket manager
hooks/ Custom hooks (keyboard shortcuts)
state/ Redux slices (agents, dashboards, templates, skills, tools, modes, etc.)
ws/ WebSocket manager
hooks/ Custom hooks
styles/ Theme tokens, global styles
electron/
main.js Electron main process, auto-updater, Python env management
scripts/ Build and notarization scripts
scripts/
build-app.sh Desktop app packaging (electron-builder)
build-python-env.sh Standalone Python 3.13 environment bundler
```
<br>
## Tech Stack
**Frontend** — React 18, TypeScript, Redux Toolkit, Material UI v7, CodeMirror 6, Framer Motion, React Router v7, Webpack 5
**Backend** — FastAPI, Python 3.11+, Pydantic v2, claude-agent-sdk, Anthropic SDK, WebSockets, httpx
**Desktop** — Electron 33, electron-builder, electron-updater (auto-updates via GitHub Releases)
**Bundled Runtime** — Standalone Python 3.13 (via python-build-standalone) so end users don't need Python installed
<br>
## Contributing
Contributions are welcome. To get started:
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/your-feature`)
3. Make your changes
4. Submit a pull request
Please open an issue first for larger changes so we can discuss the approach.
<br>
## Community
- [Twitter / X](https://twitter.com/openswarm_ai)
- [Discord](https://discord.gg/openswarm)
- [Website](https://openswarm.ai)
<br>
## License
MIT — see [LICENSE](LICENSE) for details.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+12 -1
View File
@@ -3,6 +3,17 @@
# =============================================================================
BACKEND_PORT=8324
GOOGLE_OAUTH_CLIENT_ID=your-google-oauth-client-id.apps.googleusercontent.com
GOOGLE_OAUTH_CLIENT_SECRET=your-google-oauth-client-secret
# =============================================================================
# macOS Code Signing & Notarization (required for ./scripts/build-app.sh --publish)
# =============================================================================
APPLE_ID=your-apple-id@example.com
APPLE_APP_SPECIFIC_PASSWORD=xxxx-xxxx-xxxx-xxxx
APPLE_TEAM_ID=ABCDE12345
# =============================================================================
# GitHub Releases (required for --publish)
# =============================================================================
GH_TOKEN=ghp_your-github-personal-access-token
+227
View File
@@ -0,0 +1,227 @@
# Environment Variables Setup Guide
Copy `.env.example` to `.env` and fill in the values below. This guide walks you through getting every single one.
```bash
cp .env.example .env
```
---
## `BACKEND_PORT`
The port the backend server runs on. The default is fine — only change it if something else is already using port 8324.
```
BACKEND_PORT=8324
```
---
## `GOOGLE_OAUTH_CLIENT_ID` & `GOOGLE_OAUTH_CLIENT_SECRET`
These let users sign in with their Google account.
### Step 1 — Go to Google Cloud Console
1. Open https://console.cloud.google.com/
2. Sign in with your Google account (or create one).
### Step 2 — Create a project
1. Click the project dropdown at the very top of the page (it says "Select a project" or shows your current project name).
2. Click **New Project** in the top-right of the popup.
3. Name it something like `OpenSwarm`.
4. Click **Create**.
5. Wait a few seconds, then click the project dropdown again and select your new `OpenSwarm` project.
### Step 3 — Enable the Google+ API (required for OAuth)
1. In the left sidebar, click **APIs & Services** > **Library**.
2. Search for `Google+ API` (or `Google Identity`).
3. Click on it, then click **Enable**.
### Step 4 — Configure the OAuth consent screen
1. In the left sidebar, click **APIs & Services** > **OAuth consent screen**.
2. Select **External** (unless you're inside a Google Workspace org and only want internal users).
3. Click **Create**.
4. Fill in the required fields:
- **App name**: `OpenSwarm`
- **User support email**: your email
- **Developer contact email**: your email
5. Click **Save and Continue**.
6. On the **Scopes** page, click **Add or Remove Scopes**.
- Check `email` and `profile` (the `openid` scope is added automatically).
- Click **Update**, then **Save and Continue**.
7. On the **Test users** page, click **Add Users**, enter your own email, click **Add**, then **Save and Continue**.
8. Click **Back to Dashboard**.
### Step 5 — Create OAuth credentials
1. In the left sidebar, click **APIs & Services** > **Credentials**.
2. Click **+ Create Credentials** at the top.
3. Select **OAuth client ID**.
4. For **Application type**, select **Web application**.
5. **Name**: `OpenSwarm` (or anything you want).
6. Under **Authorized redirect URIs**, click **+ Add URI** and add:
```
http://localhost:8324/api/auth/google/callback
```
(Replace `8324` with your `BACKEND_PORT` if you changed it.)
7. Click **Create**.
### Step 6 — Copy the values
A popup appears with your credentials:
- **Client ID** — copy this into `GOOGLE_OAUTH_CLIENT_ID`
- **Client Secret** — copy this into `GOOGLE_OAUTH_CLIENT_SECRET`
```
GOOGLE_OAUTH_CLIENT_ID=123456789-xxxxxxxxx.apps.googleusercontent.com
GOOGLE_OAUTH_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxxxxx
```
---
## `APPLE_ID`
This is the email address you use to sign in to your Apple Developer account.
1. If you don't have one, go to https://developer.apple.com/programs/ and click **Enroll**. It costs $99/year.
2. Once enrolled, your `APPLE_ID` is just the email you signed up with.
```
APPLE_ID=you@example.com
```
---
## `APPLE_TEAM_ID`
Your 10-character Apple Developer team identifier.
1. Go to https://developer.apple.com/account
2. Sign in.
3. Look at the top-right — your name is shown. Click it, or scroll down.
4. Under **Membership Details** (or at https://developer.apple.com/account#MembershipDetailsCard), you'll see **Team ID**.
5. It looks like `ABCDE12345`. Copy it.
```
APPLE_TEAM_ID=ABCDE12345
```
---
## `APPLE_APP_SPECIFIC_PASSWORD`
Apple doesn't let you use your regular password for automated tools. You need to generate a special one-time password.
### Step 1 — Turn on two-factor authentication (if you haven't already)
1. On your Mac, go to **System Settings** > **[your name]** > **Sign-In & Security** > **Two-Factor Authentication**.
2. Turn it on and follow the prompts.
### Step 2 — Generate the app-specific password
1. Go to https://account.apple.com/
2. Sign in with your Apple ID.
3. In the **Sign-In and Security** section, click **App-Specific Passwords**.
4. Click **Generate an app-specific password** (or the **+** button).
5. Enter a label like `OpenSwarm Notarization`.
6. Click **Create**.
7. Apple shows you a password in the format `xxxx-xxxx-xxxx-xxxx`. **Copy it now** — you can't see it again.
```
APPLE_APP_SPECIFIC_PASSWORD=abcd-efgh-ijkl-mnop
```
---
## macOS Signing Certificate (no env var, but required)
Before you can sign and notarize, you need a **Developer ID Application** certificate installed in your macOS Keychain. This is what Apple uses to verify that the app was built by you.
### Step 1 — Open Xcode
1. Open **Xcode** on your Mac (install it from the Mac App Store if you don't have it).
2. Go to **Xcode** menu > **Settings** (or **Preferences** on older versions).
3. Click the **Accounts** tab.
4. Click **+** in the bottom-left and sign in with your Apple ID.
### Step 2 — Create the certificate
1. Select your account in the list, then click **Manage Certificates...** in the bottom-right.
2. Click the **+** in the bottom-left of the popup.
3. Select **Developer ID Application**.
4. Click **Create**.
That's it — the certificate is now in your macOS Keychain. `electron-builder` will auto-discover it during builds. You don't need to set any env var for this.
### Alternative — manual method (without Xcode)
1. Go to https://developer.apple.com/account/resources/certificates/list
2. Click the **+** button.
3. Select **Developer ID Application**, click **Continue**.
4. You'll be asked to upload a **Certificate Signing Request (CSR)**:
- Open **Keychain Access** on your Mac.
- In the menu bar: **Keychain Access** > **Certificate Assistant** > **Request a Certificate From a Certificate Authority**.
- Enter your email, leave CA Email blank, select **Saved to disk**, click **Continue**.
- Save the `.certSigningRequest` file.
5. Upload that file on the Apple Developer page, click **Continue**.
6. Download the `.cer` file.
7. Double-click it — it installs into your Keychain.
---
## `GH_TOKEN`
A GitHub Personal Access Token that lets the build script upload release artifacts to GitHub Releases.
### Step 1 — Go to GitHub token settings
1. Go to https://github.com/settings/tokens
2. Sign in if needed.
### Step 2 — Create a token
1. Click **Generate new token** > **Generate new token (classic)**.
2. **Note**: `OpenSwarm Releases` (or whatever you want).
3. **Expiration**: pick a duration (90 days, or "No expiration" if you don't want to rotate it).
4. **Scopes**: check the **`repo`** checkbox (this gives full access to your repositories, which is needed to create releases and upload assets).
5. Click **Generate token** at the bottom.
6. **Copy the token now** — it starts with `ghp_` and you won't be able to see it again.
```
GH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
---
## Final `.env` example
```
BACKEND_PORT=8324
GOOGLE_OAUTH_CLIENT_ID=123456789-xxxxxxxxx.apps.googleusercontent.com
GOOGLE_OAUTH_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxxxxx
APPLE_ID=you@example.com
APPLE_APP_SPECIFIC_PASSWORD=abcd-efgh-ijkl-mnop
APPLE_TEAM_ID=ABCDE12345
GH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
---
## Running a production build
Once your `.env` is filled in, just run:
```bash
./scripts/build-app.sh --publish
```
The build script automatically loads `backend/.env`, so you don't need to source it yourself. This will build the app, sign it with your certificate, notarize it with Apple, and upload the `.dmg` and `.zip` to a GitHub Release.
+31 -73
View File
@@ -2,6 +2,7 @@ import asyncio
import json
import logging
import os
import sys
import time
from datetime import datetime
from uuid import uuid4
@@ -10,7 +11,6 @@ from typing import Optional
from backend.apps.agents.models import (
AgentConfig, AgentSession, Message, MessageBranch, ApprovalRequest, ToolGroupMeta,
)
from backend.apps.agents.worktree_manager import WorktreeManager
from backend.apps.agents.ws_manager import ws_manager
from backend.apps.modes.modes import load_mode
from backend.apps.outputs.outputs import _load_all as load_all_outputs
@@ -22,16 +22,12 @@ from backend.apps.tools_lib.tools_lib import (
load_builtin_permissions,
refresh_google_token,
)
from backend.config.paths import SESSIONS_DIR
logger = logging.getLogger(__name__)
os.environ.setdefault("CLAUDE_CODE_STREAM_CLOSE_TIMEOUT", "3600000")
SESSIONS_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"data", "sessions",
)
def _save_session(session_id: str, doc_data: dict):
os.makedirs(SESSIONS_DIR, exist_ok=True)
@@ -72,9 +68,6 @@ FULL_TOOLS = [
"RenderOutput",
]
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
def _get_denied_tool_names(tool) -> set[str]:
"""Return the set of MCP sub-tool names whose permission is 'deny'."""
return {
@@ -122,7 +115,6 @@ class AgentManager:
def __init__(self):
self.sessions: dict[str, AgentSession] = {}
self.tasks: dict[str, asyncio.Task] = {}
self.worktree_mgr = WorktreeManager(REPO_ROOT)
def _resolve_mode(self, mode_id: str) -> tuple[list[str], str | None, str | None]:
"""Return (tools, system_prompt, default_folder) resolved from the mode store."""
@@ -246,10 +238,7 @@ class AgentManager:
async def launch_agent(self, config: AgentConfig) -> AgentSession:
session_id = uuid4().hex
branch_name = f"agent-{session_id[:8]}"
worktree_path = await self.worktree_mgr.create_worktree(branch_name)
mode_tools, _, mode_folder = self._resolve_mode(config.mode)
tools = mode_tools
@@ -258,7 +247,7 @@ class AgentManager:
config.target_directory
or mode_folder
or global_settings.default_folder
or str(REPO_ROOT)
or os.path.expanduser("~")
)
if config.mode in ("view-builder", "skill-builder") and not config.target_directory:
@@ -271,8 +260,6 @@ class AgentManager:
name=config.name,
model=config.model,
mode=config.mode,
worktree_path=worktree_path,
branch_name=branch_name,
system_prompt=config.system_prompt,
allowed_tools=tools,
max_turns=config.max_turns,
@@ -561,6 +548,17 @@ class AgentManager:
mcp_servers = await self._build_mcp_servers(session.allowed_tools)
browser_server_path = os.path.join(
os.path.dirname(__file__), "browser_mcp_server.py"
)
backend_port = os.environ.get("OPENSWARM_PORT", "8324")
mcp_servers["openswarm-browser"] = {
"command": sys.executable,
"args": [browser_server_path],
"env": {"OPENSWARM_PORT": backend_port},
"type": "stdio",
}
effective_allowed = [
t for t in session.allowed_tools
if _builtin_perms.get(t, "always_allow") == "always_allow"
@@ -583,6 +581,8 @@ class AgentManager:
else:
effective_allowed.append(f"mcp__{name}__*")
effective_allowed.append("mcp__openswarm-browser__*")
options_kwargs = {
"model": session.model,
"can_use_tool": can_use_tool,
@@ -593,8 +593,9 @@ class AgentManager:
"allowed_tools": effective_allowed,
"include_partial_messages": True,
}
if global_settings.anthropic_api_key:
options_kwargs["env"] = {"ANTHROPIC_API_KEY": global_settings.anthropic_api_key}
if not global_settings.anthropic_api_key:
raise ValueError("Anthropic API key not configured. Set it in Settings.")
options_kwargs["env"] = {"ANTHROPIC_API_KEY": global_settings.anthropic_api_key}
if mcp_servers:
options_kwargs["mcp_servers"] = mcp_servers
if composed_prompt:
@@ -867,7 +868,7 @@ class AgentManager:
f"I've processed your request: \"{prompt}\"\n\n"
"This is a mock response because `claude-agent-sdk` is not installed. "
"Install it with `pip install claude-agent-sdk` to use real Claude Code instances.\n\n"
f"The agent was configured with:\n- Model: {session.model}\n- Mode: {session.mode}\n- Worktree: {session.branch_name}"
f"The agent was configured with:\n- Model: {session.model}\n- Mode: {session.mode}"
)
asst_msg_id = uuid4().hex
await self._stream_text(session_id, asst_msg_id, asst_text)
@@ -946,7 +947,7 @@ class AgentManager:
task = asyncio.create_task(self._run_agent_loop(session_id, prompt, images=images, context_paths=context_paths, forced_tools=forced_tools, attached_skills=attached_skills))
self.tasks[session_id] = task
async def stop_agent(self, session_id: str, remove_worktree: bool = False):
async def stop_agent(self, session_id: str):
"""Stop a running agent."""
task = self.tasks.get(session_id)
if task and not task.done():
@@ -964,9 +965,6 @@ class AgentManager:
"status": "stopped",
"session": session.model_dump(mode="json"),
})
if remove_worktree and session and session.branch_name:
await self.worktree_mgr.remove_worktree(session.branch_name)
def handle_approval(self, request_id: str, decision: dict):
"""Resolve a pending HITL approval."""
@@ -1039,10 +1037,9 @@ class AgentManager:
try:
import anthropic
global_settings = load_settings()
client_kwargs = {}
if global_settings.anthropic_api_key:
client_kwargs["api_key"] = global_settings.anthropic_api_key
client = anthropic.AsyncAnthropic(**client_kwargs)
if not global_settings.anthropic_api_key:
raise ValueError("API key not configured")
client = anthropic.AsyncAnthropic(api_key=global_settings.anthropic_api_key)
resp = await client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=30,
@@ -1084,10 +1081,9 @@ class AgentManager:
try:
import anthropic, json as _json
global_settings = load_settings()
client_kwargs = {}
if global_settings.anthropic_api_key:
client_kwargs["api_key"] = global_settings.anthropic_api_key
client = anthropic.AsyncAnthropic(**client_kwargs)
if not global_settings.anthropic_api_key:
raise ValueError("API key not configured")
client = anthropic.AsyncAnthropic(api_key=global_settings.anthropic_api_key)
tool_desc = "\n".join(
f"- {tc.get('tool', '?')}: {tc.get('input_summary', '')}" for tc in tool_calls
@@ -1171,7 +1167,7 @@ class AgentManager:
async def close_session(self, session_id: str) -> None:
"""Close a session: pause the agent if running, persist to JSON file,
and remove from in-memory state. Worktree is kept on disk for resume."""
and remove from in-memory state."""
task = self.tasks.get(session_id)
if task and not task.done():
task.cancel()
@@ -1211,7 +1207,7 @@ class AgentManager:
logger.info(f"Session {session_id} closed and persisted")
async def delete_session(self, session_id: str) -> None:
"""Permanently delete a session: remove from memory, JSON file, worktree, and branch."""
"""Permanently delete a session: remove from memory and JSON file."""
task = self.tasks.get(session_id)
if task and not task.done():
task.cancel()
@@ -1220,21 +1216,9 @@ class AgentManager:
except asyncio.CancelledError:
pass
session = self.sessions.pop(session_id, None)
self.sessions.pop(session_id, None)
self.tasks.pop(session_id, None)
branch_name: str | None = None
if session:
branch_name = session.branch_name
else:
data = _load_session_data(session_id)
if data:
branch_name = data.get("branch_name")
if branch_name:
await self.worktree_mgr.remove_worktree(branch_name)
await self.worktree_mgr.delete_branch(branch_name)
_delete_session_file(session_id)
logger.info(f"Session {session_id} permanently deleted")
@@ -1249,17 +1233,6 @@ class AgentManager:
session = AgentSession(**data)
if session.branch_name:
worktree_path = os.path.join(self.worktree_mgr.worktrees_dir, session.branch_name)
if not os.path.exists(worktree_path):
try:
worktree_path = await self.worktree_mgr.create_worktree(session.branch_name)
except RuntimeError:
new_branch = f"agent-{session_id[:8]}"
worktree_path = await self.worktree_mgr.create_worktree(new_branch)
session.branch_name = new_branch
session.worktree_path = worktree_path
session.closed_at = None
self.sessions[session_id] = session
@@ -1349,21 +1322,6 @@ class AgentManager:
session.status = "stopped"
session.closed_at = None
session.pending_approvals = []
if session.branch_name:
worktree_path = os.path.join(
self.worktree_mgr.worktrees_dir, session.branch_name
)
if not os.path.exists(worktree_path):
try:
worktree_path = await self.worktree_mgr.create_worktree(
session.branch_name
)
except RuntimeError:
logger.warning(
f"Could not restore worktree for session {session.id}, skipping"
)
continue
session.worktree_path = worktree_path
self.sessions[session.id] = session
_delete_session_file(sid)
logger.info(f"Restored session {session.id}")
+2 -18
View File
@@ -13,7 +13,6 @@ logger = logging.getLogger(__name__)
@asynccontextmanager
async def agents_lifespan():
logger.info("Agents sub-app starting")
await agent_manager.worktree_mgr.cleanup_all_worktrees()
await agent_manager.reconcile_on_startup()
await agent_manager.restore_all_sessions()
yield
@@ -61,9 +60,8 @@ async def send_message(session_id: str, body: dict):
return {"ok": True}
@agents.router.post("/sessions/{session_id}/stop")
async def stop_agent(session_id: str, body: dict = {}):
remove_worktree = body.get("remove_worktree", False)
await agent_manager.stop_agent(session_id, remove_worktree=remove_worktree)
async def stop_agent(session_id: str):
await agent_manager.stop_agent(session_id)
return {"ok": True}
@agents.router.post("/approval")
@@ -161,17 +159,3 @@ async def resume_session(session_id: str):
raise HTTPException(status_code=404, detail=str(e))
return {"session": session.model_dump(mode="json")}
@agents.router.get("/worktrees")
async def list_worktrees():
worktrees = await agent_manager.worktree_mgr.list_worktrees()
return {"worktrees": worktrees}
@agents.router.get("/sessions/{session_id}/diff")
async def get_session_diff(session_id: str):
session = agent_manager.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if not session.branch_name:
return {"diff": ""}
diff = await agent_manager.worktree_mgr.get_worktree_diff(session.branch_name)
return {"diff": diff}
+303
View File
@@ -0,0 +1,303 @@
#!/usr/bin/env python3
"""
Minimal stdio MCP server that exposes browser interaction tools.
Launched as a subprocess by the Claude Agent SDK. Proxies tool calls
to the OpenSwarm backend via HTTP, which bridges them to the Electron
frontend via WebSocket where the actual webview lives.
"""
import base64
import json
import sys
import os
import urllib.request
import urllib.error
from io import BytesIO
try:
from PIL import Image
HAS_PIL = True
except ImportError:
HAS_PIL = False
BACKEND_PORT = os.environ.get("OPENSWARM_PORT", "8324")
BACKEND_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/browser/command"
TOOLS = [
{
"name": "BrowserScreenshot",
"description": (
"Capture a screenshot of the browser page. Returns the screenshot as a "
"base64-encoded PNG image. Use this to see what is currently displayed."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID to capture. Use the ID from the selected browser card context.",
},
},
"required": ["browser_id"],
},
},
{
"name": "BrowserGetText",
"description": (
"Get the visible text content of the browser page. Returns the page's "
"innerText (up to 15000 characters)."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID.",
},
},
"required": ["browser_id"],
},
},
{
"name": "BrowserNavigate",
"description": "Navigate the browser to a URL.",
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID.",
},
"url": {
"type": "string",
"description": "The URL to navigate to.",
},
},
"required": ["browser_id", "url"],
},
},
{
"name": "BrowserClick",
"description": (
"Click an element in the browser page identified by a CSS selector."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID.",
},
"selector": {
"type": "string",
"description": "CSS selector of the element to click.",
},
},
"required": ["browser_id", "selector"],
},
},
{
"name": "BrowserType",
"description": (
"Type text into an input element in the browser page. Clears the "
"existing value first, then types the new text."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID.",
},
"selector": {
"type": "string",
"description": "CSS selector of the input element.",
},
"text": {
"type": "string",
"description": "The text to type.",
},
},
"required": ["browser_id", "selector", "text"],
},
},
{
"name": "BrowserEvaluate",
"description": (
"Evaluate a JavaScript expression in the browser page and return the result. "
"The expression is run via executeJavaScript on the webview."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID.",
},
"expression": {
"type": "string",
"description": "JavaScript expression to evaluate.",
},
},
"required": ["browser_id", "expression"],
},
},
]
def send_response(id_, result=None, error=None):
msg = {"jsonrpc": "2.0", "id": id_}
if error is not None:
msg["error"] = error
else:
msg["result"] = result
sys.stdout.write(json.dumps(msg) + "\n")
sys.stdout.flush()
def send_notification(method, params=None):
msg = {"jsonrpc": "2.0", "method": method}
if params is not None:
msg["params"] = params
sys.stdout.write(json.dumps(msg) + "\n")
sys.stdout.flush()
def call_backend(action: str, browser_id: str, params: dict | None = None) -> dict:
payload = json.dumps({
"action": action,
"browser_id": browser_id,
"params": params or {},
}).encode()
req = urllib.request.Request(
BACKEND_URL,
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
body = e.read().decode() if e.fp else str(e)
return {"error": f"HTTP {e.code}: {body}"}
except Exception as e:
return {"error": str(e)}
MAX_IMAGE_B64_BYTES = 700_000
def compress_screenshot(b64_png: str) -> tuple[str, str] | None:
"""Resize and re-encode as JPEG to stay under the stdio buffer limit."""
if not HAS_PIL:
return None
try:
raw = base64.b64decode(b64_png)
img = Image.open(BytesIO(raw))
max_width = 1280
if img.width > max_width:
ratio = max_width / img.width
img = img.resize((max_width, int(img.height * ratio)), Image.LANCZOS)
buf = BytesIO()
img.convert("RGB").save(buf, format="JPEG", quality=55)
return base64.b64encode(buf.getvalue()).decode(), "image/jpeg"
except Exception:
return None
def handle_tool_call(tool_name: str, arguments: dict) -> dict:
browser_id = arguments.get("browser_id", "")
if not browser_id:
return {"content": [{"type": "text", "text": "Error: browser_id is required"}], "isError": True}
action_map = {
"BrowserScreenshot": "screenshot",
"BrowserGetText": "get_text",
"BrowserNavigate": "navigate",
"BrowserClick": "click",
"BrowserType": "type",
"BrowserEvaluate": "evaluate",
}
action = action_map.get(tool_name)
if not action:
return {"content": [{"type": "text", "text": f"Unknown tool: {tool_name}"}], "isError": True}
params = {k: v for k, v in arguments.items() if k != "browser_id"}
result = call_backend(action, browser_id, params)
if "error" in result:
return {"content": [{"type": "text", "text": f"Error: {result['error']}"}], "isError": True}
if action == "screenshot" and result.get("image"):
image_data = result["image"]
mime_type = "image/png"
if len(image_data) > MAX_IMAGE_B64_BYTES:
compressed = compress_screenshot(image_data)
if compressed:
image_data, mime_type = compressed
if len(image_data) > MAX_IMAGE_B64_BYTES:
return {
"content": [
{"type": "text", "text": (
f"Screenshot too large to return ({len(image_data)} bytes base64). "
f"URL: {result.get('url', 'unknown')}. "
"Use BrowserGetText to read the page content instead."
)},
],
}
return {
"content": [
{"type": "image", "data": image_data, "mimeType": mime_type},
{"type": "text", "text": f"Screenshot captured. URL: {result.get('url', 'unknown')}"},
],
}
text = result.get("text", result.get("data", json.dumps(result)))
return {"content": [{"type": "text", "text": str(text)}]}
def main():
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
except json.JSONDecodeError:
continue
method = msg.get("method")
id_ = msg.get("id")
params = msg.get("params", {})
if method == "initialize":
send_response(id_, {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {
"name": "openswarm-browser",
"version": "1.0.0",
},
})
elif method == "notifications/initialized":
pass
elif method == "tools/list":
send_response(id_, {"tools": TOOLS})
elif method == "tools/call":
tool_name = params.get("name", "")
arguments = params.get("arguments", {})
result = handle_tool_call(tool_name, arguments)
send_response(id_, result)
elif method == "ping":
send_response(id_, {})
elif id_ is not None:
send_response(id_, error={"code": -32601, "message": f"Method not found: {method}"})
if __name__ == "__main__":
main()
-2
View File
@@ -56,8 +56,6 @@ class AgentSession(BaseModel):
status: Literal["running", "waiting_approval", "completed", "error", "stopped"] = "running"
model: str = "sonnet"
mode: str = "agent"
worktree_path: Optional[str] = None
branch_name: Optional[str] = None
sdk_session_id: Optional[str] = None
system_prompt: Optional[str] = None
allowed_tools: list[str] = Field(default_factory=list)
-130
View File
@@ -1,130 +0,0 @@
import asyncio
import os
import shutil
import logging
logger = logging.getLogger(__name__)
class WorktreeManager:
def __init__(self, repo_root: str):
self.repo_root = repo_root
self.worktrees_dir = os.path.join(repo_root, ".worktrees")
os.makedirs(self.worktrees_dir, exist_ok=True)
async def create_worktree(self, branch_name: str) -> str:
"""Create a new git worktree and return its path."""
worktree_path = os.path.join(self.worktrees_dir, branch_name)
if os.path.exists(worktree_path):
return worktree_path
proc = await asyncio.create_subprocess_exec(
"git", "worktree", "add", worktree_path, "-b", branch_name,
cwd=self.repo_root,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
proc = await asyncio.create_subprocess_exec(
"git", "worktree", "add", worktree_path, branch_name,
cwd=self.repo_root,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(f"Failed to create worktree: {stderr.decode()}")
logger.info(f"Created worktree at {worktree_path} on branch {branch_name}")
return worktree_path
async def remove_worktree(self, branch_name: str) -> None:
"""Remove a git worktree."""
worktree_path = os.path.join(self.worktrees_dir, branch_name)
if not os.path.exists(worktree_path):
return
proc = await asyncio.create_subprocess_exec(
"git", "worktree", "remove", worktree_path, "--force",
cwd=self.repo_root,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await proc.communicate()
logger.info(f"Removed worktree at {worktree_path}")
async def list_worktrees(self) -> list[dict]:
"""List all worktrees with their branch and path info."""
proc = await asyncio.create_subprocess_exec(
"git", "worktree", "list", "--porcelain",
cwd=self.repo_root,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await proc.communicate()
worktrees = []
current = {}
for line in stdout.decode().strip().split("\n"):
if line.startswith("worktree "):
if current:
worktrees.append(current)
current = {"path": line.split(" ", 1)[1]}
elif line.startswith("HEAD "):
current["head"] = line.split(" ", 1)[1]
elif line.startswith("branch "):
current["branch"] = line.split(" ", 1)[1].replace("refs/heads/", "")
elif line == "":
if current:
worktrees.append(current)
current = {}
if current:
worktrees.append(current)
return worktrees
async def delete_branch(self, branch_name: str) -> None:
"""Delete a local git branch."""
proc = await asyncio.create_subprocess_exec(
"git", "branch", "-D", branch_name,
cwd=self.repo_root,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
if proc.returncode == 0:
logger.info(f"Deleted branch {branch_name}")
else:
logger.warning(f"Could not delete branch {branch_name}: {stderr.decode().strip()}")
async def cleanup_all_worktrees(self) -> None:
"""Remove all worktree directories and prune stale git worktree refs."""
if os.path.exists(self.worktrees_dir):
for entry in os.listdir(self.worktrees_dir):
entry_path = os.path.join(self.worktrees_dir, entry)
if os.path.isdir(entry_path):
shutil.rmtree(entry_path, ignore_errors=True)
logger.info("Removed all worktree directories")
proc = await asyncio.create_subprocess_exec(
"git", "worktree", "prune",
cwd=self.repo_root,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await proc.communicate()
logger.info("Pruned stale git worktree refs")
async def get_worktree_diff(self, branch_name: str) -> str:
"""Get the diff of uncommitted changes in a worktree."""
worktree_path = os.path.join(self.worktrees_dir, branch_name)
if not os.path.exists(worktree_path):
return ""
proc = await asyncio.create_subprocess_exec(
"git", "diff", "HEAD",
cwd=worktree_path,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await proc.communicate()
return stdout.decode()
+29
View File
@@ -12,6 +12,7 @@ class ConnectionManager:
self.connections: dict[str, list[WebSocket]] = {}
self.global_connections: list[WebSocket] = []
self.pending_futures: dict[str, asyncio.Future] = {}
self.browser_futures: dict[str, asyncio.Future] = {}
async def connect_session(self, session_id: str, websocket: WebSocket):
await websocket.accept()
@@ -85,4 +86,32 @@ class ConnectionManager:
if future and not future.done():
future.set_result(decision)
async def send_browser_command(
self, request_id: str, action: str, browser_id: str, params: dict
) -> dict:
"""Send a browser command to the frontend and wait for the result."""
future = asyncio.get_event_loop().create_future()
self.browser_futures[request_id] = future
await self.broadcast_global("browser:command", {
"request_id": request_id,
"action": action,
"browser_id": browser_id,
"params": params,
})
try:
result = await asyncio.wait_for(future, timeout=30.0)
return result
except asyncio.TimeoutError:
return {"error": "Browser command timed out"}
finally:
self.browser_futures.pop(request_id, None)
def resolve_browser_command(self, request_id: str, result: dict):
"""Resolve a pending browser command Future with the frontend's result."""
future = self.browser_futures.get(request_id)
if future and not future.done():
future.set_result(result)
ws_manager = ConnectionManager()
@@ -7,7 +7,8 @@ from backend.apps.dashboard_layout.models import DashboardLayout, DashboardLayou
logger = logging.getLogger(__name__)
DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "data", "dashboard_layout")
from backend.config.paths import DASHBOARD_LAYOUT_DIR as DATA_DIR
LAYOUT_FILE = os.path.join(DATA_DIR, "layout.json")
+72 -5
View File
@@ -13,16 +13,14 @@ from backend.apps.dashboards.models import (
DashboardLayout,
CardPosition,
ViewCardPosition,
BrowserCardPosition,
)
from fastapi import HTTPException
logger = logging.getLogger(__name__)
BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
DATA_DIR = os.path.join(BACKEND_DIR, "data", "dashboards")
SESSIONS_DIR = os.path.join(BACKEND_DIR, "data", "sessions")
from backend.config.paths import DASHBOARDS_DIR as DATA_DIR, SESSIONS_DIR, DASHBOARD_LAYOUT_DIR as OLD_LAYOUT_DIR
OLD_LAYOUT_DIR = os.path.join(BACKEND_DIR, "data", "dashboard_layout")
OLD_LAYOUT_FILE = os.path.join(OLD_LAYOUT_DIR, "layout.json")
@@ -115,6 +113,7 @@ async def list_dashboards():
items.append({
"id": dumped["id"],
"name": dumped.get("name", "Untitled"),
"auto_named": dumped.get("auto_named", False),
"created_at": dumped.get("created_at"),
"updated_at": dumped.get("updated_at"),
})
@@ -128,6 +127,69 @@ async def create_dashboard(body: DashboardCreate):
return dashboard.model_dump(mode="json")
@dashboards.router.post("/{dashboard_id}/generate-name")
async def generate_name(dashboard_id: str):
dashboard = _load(dashboard_id)
if not dashboard.auto_named and dashboard.name != "Untitled Dashboard":
return {"name": dashboard.name, "auto_named": dashboard.auto_named}
from backend.apps.agents.agent_manager import agent_manager
prompts = []
for session in agent_manager.sessions.values():
if getattr(session, "dashboard_id", None) != dashboard_id:
continue
for msg in session.messages:
if msg.role == "user" and isinstance(msg.content, str) and msg.content.strip():
prompts.append(msg.content.strip()[:200])
break
if not prompts:
return {"name": dashboard.name, "auto_named": dashboard.auto_named}
fallback = prompts[0][:40]
try:
import anthropic
from backend.apps.settings.settings import load_settings
global_settings = load_settings()
if not global_settings.anthropic_api_key:
raise ValueError("API key not configured")
client = anthropic.AsyncAnthropic(api_key=global_settings.anthropic_api_key)
if len(prompts) == 1:
system = (
"Generate a concise 2-5 word workspace name for a project based on this task. "
"Return only the name, nothing else."
)
user_content = prompts[0]
else:
system = (
"Generate a concise 2-5 word workspace name that captures the overall theme of these tasks. "
"Return only the name, nothing else."
)
user_content = "\n".join(f"- {p}" for p in prompts)
resp = await client.messages.create(
model="claude-haiku-4-20250414",
max_tokens=30,
system=system,
messages=[{"role": "user", "content": user_content}],
)
generated = resp.content[0].text.strip().strip('"\'')
if generated:
fallback = generated
except Exception as e:
logger.warning(f"Dashboard name generation failed, using fallback: {e}")
dashboard.name = fallback
dashboard.auto_named = True
dashboard.updated_at = datetime.now()
_save(dashboard)
return {"name": dashboard.name, "auto_named": True}
@dashboards.router.get("/{dashboard_id}")
async def get_dashboard(dashboard_id: str):
dashboard = _load(dashboard_id)
@@ -139,6 +201,7 @@ async def update_dashboard(dashboard_id: str, body: DashboardUpdate):
dashboard = _load(dashboard_id)
if body.name is not None:
dashboard.name = body.name
dashboard.auto_named = False
if body.layout is not None:
dashboard.layout = body.layout
dashboard.updated_at = datetime.now()
@@ -191,7 +254,11 @@ async def duplicate_dashboard(dashboard_id: str):
"name": f"{source_data.get('name', 'Untitled')} (copy)",
"created_at": now,
"updated_at": now,
"layout": {"cards": {}, "view_cards": source_data.get("layout", {}).get("view_cards", {})},
"layout": {
"cards": {},
"view_cards": source_data.get("layout", {}).get("view_cards", {}),
"browser_cards": source_data.get("layout", {}).get("browser_cards", {}),
},
}
with open(os.path.join(DATA_DIR, f"{new_id}.json"), "w") as f:
json.dump(new_dashboard, f, indent=2)
+12
View File
@@ -20,14 +20,26 @@ class ViewCardPosition(BaseModel):
height: float = 360
class BrowserCardPosition(BaseModel):
browser_id: str
url: str = ""
x: float = 0
y: float = 0
width: float = 640
height: float = 480
class DashboardLayout(BaseModel):
cards: dict[str, CardPosition] = Field(default_factory=dict)
view_cards: dict[str, ViewCardPosition] = Field(default_factory=dict)
browser_cards: dict[str, BrowserCardPosition] = Field(default_factory=dict)
expanded_session_ids: list[str] = Field(default_factory=list)
class Dashboard(BaseModel):
id: str = Field(default_factory=lambda: uuid4().hex)
name: str = "Untitled Dashboard"
auto_named: bool = False
created_at: datetime = Field(default_factory=datetime.now)
updated_at: datetime = Field(default_factory=datetime.now)
layout: DashboardLayout = Field(default_factory=DashboardLayout)
+1 -10
View File
@@ -1,17 +1,8 @@
import os
from pydantic import BaseModel, Field
from typing import Optional
from uuid import uuid4
OUTPUTS_WORKSPACE = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"data", "outputs_workspace",
)
SKILLS_WORKSPACE = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"data", "skills_workspace",
)
from backend.config.paths import OUTPUTS_WORKSPACE_DIR as OUTPUTS_WORKSPACE, SKILLS_WORKSPACE_DIR as SKILLS_WORKSPACE
class Mode(BaseModel):
+1 -1
View File
@@ -8,7 +8,7 @@ from backend.apps.modes.models import Mode, ModeCreate, ModeUpdate, BUILTIN_MODE
logger = logging.getLogger(__name__)
DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "data", "modes")
from backend.config.paths import MODES_DIR as DATA_DIR
@asynccontextmanager
+6 -19
View File
@@ -31,18 +31,13 @@ def _resolve_model(short_name: str) -> str:
def _get_anthropic_client():
"""Create an AsyncAnthropic client, pulling the API key from app settings if
the ANTHROPIC_API_KEY env var isn't set."""
"""Create an AsyncAnthropic client using the API key from app settings."""
import anthropic
if os.environ.get("ANTHROPIC_API_KEY"):
return anthropic.AsyncAnthropic()
settings = load_settings()
if settings.anthropic_api_key:
return anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
return anthropic.AsyncAnthropic()
if not settings.anthropic_api_key:
raise ValueError("Anthropic API key not configured. Set it in Settings.")
return anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
def _validate_against_schema(data: dict, schema: dict) -> str | None:
@@ -54,15 +49,7 @@ def _validate_against_schema(data: dict, schema: dict) -> str | None:
path = " -> ".join(str(p) for p in exc.absolute_path) if exc.absolute_path else "(root)"
return f"Schema validation failed at {path}: {exc.message}"
DATA_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"data", "outputs",
)
WORKSPACE_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"data", "outputs_workspace",
)
from backend.config.paths import OUTPUTS_DIR as DATA_DIR, OUTPUTS_WORKSPACE_DIR as WORKSPACE_DIR
def _build_data_injection(input_json: str, result_json: str) -> str:
@@ -586,7 +573,7 @@ async def auto_run_agent(body: AutoRunAgentRequest):
@outputs.router.delete("/auto-run-agent/{session_id}")
async def cleanup_auto_run_agent(session_id: str):
"""Delete a temporary auto-run agent session and its worktree."""
"""Delete a temporary auto-run agent session."""
from backend.apps.agents.agent_manager import agent_manager
try:
+1
View File
@@ -12,3 +12,4 @@ class AppSettings(BaseModel):
theme: str = "dark"
new_agent_shortcut: str = "Meta+l"
anthropic_api_key: Optional[str] = None
browser_homepage: str = "https://www.google.com"
+2 -4
View File
@@ -13,10 +13,8 @@ from backend.apps.settings.models import AppSettings
logger = logging.getLogger(__name__)
DATA_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"data", "settings",
)
from backend.config.paths import SETTINGS_DIR as DATA_DIR
SETTINGS_FILE = os.path.join(DATA_DIR, "settings.json")
+1 -4
View File
@@ -13,10 +13,7 @@ logger = logging.getLogger(__name__)
SKILLS_DIR = os.path.expanduser("~/.claude/skills")
INDEX_PATH = os.path.join(SKILLS_DIR, ".skills_index.json")
SKILLS_WORKSPACE_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"data", "skills_workspace",
)
from backend.config.paths import SKILLS_WORKSPACE_DIR
@asynccontextmanager
+1 -1
View File
@@ -8,7 +8,7 @@ from backend.apps.templates.models import PromptTemplate, PromptTemplateCreate,
logger = logging.getLogger(__name__)
DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "data", "templates")
from backend.config.paths import TEMPLATES_DIR as DATA_DIR
@asynccontextmanager
async def templates_lifespan():
+6 -6
View File
@@ -18,11 +18,9 @@ from backend.apps.tools_lib.models import ToolDefinition, ToolCreate, ToolUpdate
logger = logging.getLogger(__name__)
BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
load_dotenv(os.path.join(BACKEND_DIR, ".env"))
from backend.config.paths import BACKEND_DIR, TOOLS_DIR as DATA_DIR, BUILTIN_PERMISSIONS_PATH as BUILTIN_PERMS_PATH
DATA_DIR = os.path.join(BACKEND_DIR, "data", "tools")
BUILTIN_PERMS_PATH = os.path.join(BACKEND_DIR, "data", "builtin_permissions.json")
load_dotenv(os.path.join(BACKEND_DIR, ".env"))
@asynccontextmanager
@@ -121,7 +119,8 @@ async def oauth_callback(code: str = Query(...), state: str = Query("")):
tool = _load(tool_id)
client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "")
client_secret = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET", "")
redirect_uri = "http://localhost:8324/api/tools/oauth/callback"
_port = os.environ.get("OPENSWARM_PORT", "8324")
redirect_uri = f"http://localhost:{_port}/api/tools/oauth/callback"
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(GOOGLE_TOKEN_URL, data={
@@ -660,7 +659,8 @@ async def oauth_start(tool_id: str):
if not client_id:
raise HTTPException(status_code=400, detail="GOOGLE_OAUTH_CLIENT_ID not set in backend .env")
redirect_uri = "http://localhost:8324/api/tools/oauth/callback"
_port = os.environ.get("OPENSWARM_PORT", "8324")
redirect_uri = f"http://localhost:{_port}/api/tools/oauth/callback"
state = tool_id
_pending_oauth[state] = tool_id
+4 -1
View File
@@ -1,3 +1,5 @@
import os
from fastapi import FastAPI, APIRouter
import debug
from uuid import uuid4
@@ -30,7 +32,8 @@ class MainApp:
for sub_app in sub_apps:
debug(sub_app.name)
await stack.enter_async_context(sub_app.lifespan())
print("\nCheck out the API docs at: http://127.0.0.1:8324/docs\n")
_port = os.environ.get("OPENSWARM_PORT", "8324")
print(f"\nCheck out the API docs at: http://127.0.0.1:{_port}/docs\n")
yield
self.app = FastAPI(lifespan=lifespan)
+39
View File
@@ -0,0 +1,39 @@
"""Centralised path definitions for the OpenSwarm backend.
In dev mode (default) data lives under ``backend/data/``.
When packaged as a desktop app, Electron sets ``OPENSWARM_PACKAGED=1`` and
data is stored in a platform-appropriate location
(``~/Library/Application Support/OpenSwarm/data/`` on macOS).
"""
import os
import sys
_BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_is_packaged = os.environ.get("OPENSWARM_PACKAGED") == "1"
if _is_packaged:
if sys.platform == "darwin":
_app_support = os.path.join(os.path.expanduser("~"), "Library", "Application Support", "OpenSwarm")
elif sys.platform == "win32":
_app_support = os.path.join(os.environ.get("APPDATA", os.path.expanduser("~")), "OpenSwarm")
else:
_app_support = os.path.join(os.environ.get("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share")), "OpenSwarm")
DATA_ROOT = os.path.join(_app_support, "data")
else:
DATA_ROOT = os.path.join(_BACKEND_DIR, "data")
SESSIONS_DIR = os.path.join(DATA_ROOT, "sessions")
TOOLS_DIR = os.path.join(DATA_ROOT, "tools")
SETTINGS_DIR = os.path.join(DATA_ROOT, "settings")
MODES_DIR = os.path.join(DATA_ROOT, "modes")
TEMPLATES_DIR = os.path.join(DATA_ROOT, "templates")
DASHBOARDS_DIR = os.path.join(DATA_ROOT, "dashboards")
OUTPUTS_DIR = os.path.join(DATA_ROOT, "outputs")
OUTPUTS_WORKSPACE_DIR = os.path.join(DATA_ROOT, "outputs_workspace")
SKILLS_WORKSPACE_DIR = os.path.join(DATA_ROOT, "skills_workspace")
DASHBOARD_LAYOUT_DIR = os.path.join(DATA_ROOT, "dashboard_layout")
BUILTIN_PERMISSIONS_PATH = os.path.join(DATA_ROOT, "builtin_permissions.json")
BACKEND_DIR = _BACKEND_DIR
+52 -1
View File
@@ -1,4 +1,8 @@
import os
from uuid import uuid4
from fastapi.responses import JSONResponse
from fastapi import Request
from backend.config.Apps import MainApp
from backend.apps.health.health import health
from backend.apps.agents.agents import agents
@@ -83,9 +87,56 @@ async def websocket_dashboard(websocket: WebSocket):
"message": payload.get("message"),
"updated_input": payload.get("updated_input"),
})
elif event == "browser:result":
ws_manager.resolve_browser_command(
payload.get("request_id", ""),
payload,
)
except WebSocketDisconnect:
ws_manager.disconnect_global(websocket)
@app.post("/api/browser/command")
async def browser_command(request: Request):
"""HTTP endpoint called by the browser MCP server subprocess.
Proxies commands to the frontend via WebSocket and waits for results."""
body = await request.json()
action = body.get("action", "")
browser_id = body.get("browser_id", "")
params = body.get("params", {})
if not action or not browser_id:
return JSONResponse({"error": "action and browser_id are required"}, status_code=400)
request_id = uuid4().hex
result = await ws_manager.send_browser_command(request_id, action, browser_id, params)
return JSONResponse(result)
if __name__ == "__main__":
import argparse
import uvicorn
uvicorn.run("backend.main:app", host="0.0.0.0", port=8324, reload=True)
parser = argparse.ArgumentParser(description="OpenSwarm backend server")
parser.add_argument("--port", type=int, default=int(os.environ.get("OPENSWARM_PORT", "8324")))
parser.add_argument("--host", default=os.environ.get("OPENSWARM_HOST", "127.0.0.1"))
parser.add_argument("--reload", action="store_true", default=False)
args = parser.parse_args()
os.environ["OPENSWARM_PORT"] = str(args.port)
import uvicorn.config
class _ReadyServer(uvicorn.Server):
"""Subclass that prints a machine-readable READY line on startup."""
async def startup(self, sockets=None):
await super().startup(sockets)
print(f"READY:PORT={args.port}", flush=True)
if args.reload:
uvicorn.run("backend.main:app", host=args.host, port=args.port, reload=True)
else:
config = uvicorn.Config("backend.main:app", host=args.host, port=args.port)
server = _ReadyServer(config)
import asyncio
asyncio.run(server.serve())
+2 -1
View File
@@ -8,4 +8,5 @@ langchain-openai==0.3.12
pytest==8.3.4
pytest-asyncio==0.25.2
typeguard==4.4.2
python-dotenv==1.1.1
python-dotenv==1.1.1
Pillow
+12 -13
View File
@@ -13,14 +13,13 @@ else
# echo "NOT in macOS server START"
fi
chmod +x "$DEV_ABSPATH"
source "$(dirname "$DEV_ABSPATH")/_utils.sh"
PROJECT_ROOT_ABSPATH="$(dirname "$BACKEND_DIR_ABSPATH")"
PROJECT_ROOT_ABSPATH="$(dirname "$(dirname "$DEV_ABSPATH")")"
BACKEND_DIR_ABSPATH="$PROJECT_ROOT_ABSPATH/backend"
# Cleanup function on exit
cleanup() {
formatted_echo --yellow "Shutting down..."
echo "Shutting down..."
cd - > /dev/null 2>&1
}
trap cleanup EXIT INT TERM
@@ -28,34 +27,34 @@ trap cleanup EXIT INT TERM
# --- Create virtual environment if it doesn't exist ---
VENV_DIR="$BACKEND_DIR_ABSPATH/.venv"
if [[ ! -d "$VENV_DIR" ]]; then
formatted_echo --green "Creating virtual environment..."
echo "Creating virtual environment..."
python -m venv "$VENV_DIR"
fi
source "$VENV_DIR/bin/activate"
# --- Install custom debugger module if not already installed ---
DEBUGGER_DIR_ABSPATH="$PROJECT_ROOT_ABSPATH/debugger"
if ! pip show debug > /dev/null 2>&1; then
formatted_echo --green "Installing debugger module..."
if ! pip3 show debug > /dev/null 2>&1; then
echo "Installing debugger module..."
cd "$DEBUGGER_DIR_ABSPATH"
pip install -e .
pip3 install -e .
if [[ $? -ne 0 ]]; then
formatted_error "Failed to install debugger module."
echo "Failed to install debugger module."
exit 1
fi
fi
# --- Install Python dependencies ---
formatted_echo --green "Installing dependencies..."
echo "Installing dependencies..."
cd "$BACKEND_DIR_ABSPATH"
pip install -r requirements.txt
pip3 install -r requirements.txt
if [[ $? -ne 0 ]]; then
formatted_error "Failed to install Python dependencies."
echo "Failed to install Python dependencies."
exit 1
fi
# --- Start the backend server ---
formatted_echo --green "Starting backend server on http://0.0.0.0:8324 ..."
echo "Starting backend server on http://0.0.0.0:8324 ..."
cd "$PROJECT_ROOT_ABSPATH"
python -m uvicorn backend.main:app --host 0.0.0.0 --port 8324 --reload \
--reload-dir "$BACKEND_DIR_ABSPATH" \
-202
View File
@@ -1,202 +0,0 @@
#!/bin/bash
# Flag processing function with namespacing and global variable declaration
UTILS_FILE_ABSPATH="$(readlink -f "${BASH_SOURCE[0]}")"
if [[ "$OSTYPE" == "darwin"* ]]; then
# echo "In macOS utils sed START"
# echo "UTILS_ABSPATH: $UTILS_FILE_ABSPATH"
sed -i '' 's/\r//g' "$UTILS_FILE_ABSPATH"
# echo "In macOS utils sed END"
else
# echo "NOT in macOS utils START"
# echo "UTILS_ABSPATH: $UTILS_FILE_ABSPATH"
sed -i 's/\r//g' "$UTILS_FILE_ABSPATH"
# echo "NOT in macOS utils END"
fi
chmod +x "$UTILS_FILE_ABSPATH"
RUN_DIR_ABSPATH="$(dirname "$UTILS_FILE_ABSPATH")"
BACKEND_DIR_ABSPATH="$(dirname "$RUN_DIR_ABSPATH")"
formatted_error() {
# Arguments: error message and array of conflicting flags
local initial_message="$1"
shift
local conflicting_flags=("$@")
# Red color for the error message box
local COLOR_CODE='\033[0;31m'
local NC='\033[0m' # No Color
# Start the error message with the initial message
local error_message="$initial_message"
# Add each conflicting flag on a new line with indentation
for conflict in "${conflicting_flags[@]}"; do
error_message+="\n $conflict" # Replacing `\t` with four spaces
done
# Prepare for printing by finding max length of each line in the message
local lines=()
local max_length=0
# Use printf to interpret new lines and calculate max length with spaces instead of tabs
while IFS= read -r line; do
# Substitute tabs with spaces for consistent width measurement
local line_with_spaces="${line//$'\t'/ }"
lines+=("$line_with_spaces")
if (( ${#line_with_spaces} > max_length )); then
max_length=${#line_with_spaces}
fi
done <<< "$(printf "$error_message")"
# Create the top and bottom borders based on the maximum line length
local border=$(printf '%*s' "$((max_length + 4))" '' | tr ' ' '-')
# Print the formatted error message with a red box
printf "\n${COLOR_CODE}%s${NC}\n" "$border"
for line in "${lines[@]}"; do
printf "${COLOR_CODE}| %-*s |${NC}\n" "$max_length" "$line"
done
printf "${COLOR_CODE}| %-*s |${NC}\n" "$max_length" ""
printf "${COLOR_CODE}%s${NC}\n" "$border"
}
function process_flags() {
local -n flags_to_commands="$1" # Reference to the dictionary of flags and commands
local -n exclusives="$2" # Reference to the list of exclusive flag groups
local namespace="$3" # Unique prefix for variables
local calling_script_name="$(basename "$(readlink -f "${BASH_SOURCE[1]}")")"
local caller_id="${FUNCNAME[1]}"
local should_exit=false
if [ "$caller_id" == "main" ]; then
caller_id="$calling_script_name"
fi
# echo "caller_id: $caller_id"
# echo "Initializing flags with namespace: $namespace"
# Initialize flag variables with namespacing in the sourcing script context
for flag in "${!flags_to_commands[@]}"; do
# Convert flag to uppercase variable name and apply namespace, e.g., MYAPP_FLAG1
local flag_var="${namespace}_${flag^^}" # Prefix and uppercase
flag_var="${flag_var//-}" # Remove dashes
eval "declare -g $flag_var=false" # Initialize as global false
# Diagnostic output for each initialized variable
# echo "Initialized $flag_var as false"
done
# echo "Parsing command line arguments: $@"
# Parse command line arguments and set flags
local unsupported_flags=()
# local potential_typos=()
for arg in "$@"; do
# echo "Processing argument: $arg"
if [[ -n "${flags_to_commands[$arg]}" ]]; then
# echo "Flag found: $arg"
local flag_var="${namespace}_${arg^^}" # Prefix and uppercase
flag_var="${flag_var//-}" # Remove dashes
eval "declare -g $flag_var=true" # Set as global true
# echo "Set $flag_var to true"
# echo "Executing command for $arg: ${flags_to_commands[$arg]}"
eval "${flags_to_commands[$arg]}"
else
if [[ "$arg" == "-"* ]]; then
# echo "Flag not found: $arg"
unsupported_flags+=("$arg")
if [[ -n "${flags_to_commands[-$arg]}" ]]; then
# echo "Potential typo: $arg"
unsupported_flags+=("\t*Note: Potential typo detected.")
unsupported_flags+=("\tDid you mean: -$arg")
fi
fi
fi
done
# Check exclusive flag groups for conflicts
# echo "Checking exclusive flag groups"
for group in "${exclusives[@]}"; do
local count=0
local conflicting_flags=()
for flag in $group; do
local flag_var="${namespace}_${flag^^}"
flag_var="${flag_var//-}"
if [[ "$(eval echo "\$$flag_var")" == "true" ]]; then
conflicting_flags+=("$flag")
# count=$((count + 1))
# ec "$flag_var is true in exclusive group"
fi
done
if (( ${#conflicting_flags[@]} > 1 )); then
# echo "found conflicting flags"
formatted_error "Error: $caller_id\n-----------------------------------------\nIncompatible flags:\nThe flags below cannot be used together\n-----------------------------------------\n" "${conflicting_flags[@]}"
should_exit=true
fi
done
# echo "should_exit: $should_exit"
# echo "unsupported_flags: ${#unsupported_flags[@]}"
if (( ${#unsupported_flags[@]} > 0 )); then
# echo "found unsupported flags 2"
formatted_error "Error: $caller_id\n-------------------------------------------------\nUnsupported flags:\nThe flags below are not supported by this script\n-------------------------------------------------\n" "${unsupported_flags[@]}"
should_exit=true
fi
if [[ $should_exit == true ]]; then
exit 1
fi
}
formatted_echo() {
local COLOR_CODE='\033[0m' # No Color
local NC='\033[0m' # No Color variable
# local message="$2"
# If the second argument is empty, then theres no color specified, so we use the first argument as the message
if [[ -z "$2" ]]; then
message="$1"
else
message="$2"
fi
declare -A format_flags
format_flags=(
[--red]="COLOR_CODE='\033[0;31m'"
[--green]="COLOR_CODE='\033[0;32m'"
[--yellow]="COLOR_CODE='\033[0;33m'"
[--blue]="COLOR_CODE='\033[0;34m'"
[--purple]="COLOR_CODE='\033[0;35m'"
[--cyan]="COLOR_CODE='\033[0;36m'"
)
exclusive_format_flags=(
"--red --green --yellow --blue --purple --cyan"
)
# Pass the arguments with the namespace "FORMAT"
process_flags format_flags exclusive_format_flags "FORMAT" "$@"
# Process the message
local text
text=$(printf "%b" "$message")
# Expand any escaped characters in the input (e.g., \n)
local lines=()
local max_length=0
# Read the text line by line and find the maximum length
while IFS= read -r line; do
lines+=("$line")
if (( ${#line} > max_length )); then
max_length=${#line}
fi
done <<< "$text"
# Create the top and bottom borders based on the maximum line length
local border=$(printf '%*s' "$((max_length + 4))" '' | tr ' ' '-')
# Print the formatted box with selected color
printf "\n${COLOR_CODE}%s${NC}\n" "$border"
for line in "${lines[@]}"; do
printf "${COLOR_CODE}| %-*s |${NC}\n" "$max_length" "$line"
done
printf "${COLOR_CODE}%s${NC}\n" "$border"
}
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.inherit</key>
<true/>
</dict>
</plist>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

+226
View File
@@ -0,0 +1,226 @@
const { app, BrowserWindow, ipcMain } = require('electron');
const { autoUpdater } = require('electron-updater');
const path = require('path');
const { spawn } = require('child_process');
const getPort = require('get-port');
const http = require('http');
let mainWindow = null;
let backendProcess = null;
let backendPort = null;
const isPackaged = app.isPackaged;
const isDev = process.env.ELECTRON_DEV === '1';
const iconPath = path.join(__dirname, 'build', 'icon.png');
function getResourcePath(...segments) {
if (isPackaged) {
return path.join(process.resourcesPath, ...segments);
}
return path.join(__dirname, '..', ...segments);
}
function getPythonPath() {
if (isPackaged) {
const envPath = path.join(process.resourcesPath, 'python-env');
return path.join(envPath, 'bin', 'python3');
}
const venvPython = path.join(__dirname, '..', 'backend', '.venv', 'bin', 'python3');
return venvPython;
}
function waitForBackend(port, timeoutMs = 60000) {
const start = Date.now();
return new Promise((resolve, reject) => {
function check() {
if (Date.now() - start > timeoutMs) {
return reject(new Error('Backend startup timed out'));
}
const req = http.get(`http://127.0.0.1:${port}/api/health/check`, (res) => {
if (res.statusCode === 200) {
resolve();
} else {
setTimeout(check, 500);
}
});
req.on('error', () => setTimeout(check, 500));
req.setTimeout(2000, () => {
req.destroy();
setTimeout(check, 500);
});
}
check();
});
}
async function startBackend() {
backendPort = await getPort({ port: getPort.makeRange(8324, 8424) });
const pythonPath = getPythonPath();
const backendDir = getResourcePath('backend');
const projectRoot = isPackaged ? process.resourcesPath : path.join(__dirname, '..');
const env = {
...process.env,
OPENSWARM_PACKAGED: isPackaged ? '1' : '0',
OPENSWARM_PORT: String(backendPort),
PYTHONDONTWRITEBYTECODE: '1',
};
if (isPackaged) {
const pythonEnvSitePackages = path.join(
process.resourcesPath, 'python-env', 'lib',
'python3.13', 'site-packages'
);
const debuggerDir = getResourcePath('debugger');
env.PYTHONPATH = [projectRoot, debuggerDir, pythonEnvSitePackages].join(':');
}
console.log(`Starting backend: ${pythonPath} on port ${backendPort}`);
console.log(`Project root: ${projectRoot}`);
backendProcess = spawn(
pythonPath,
['-m', 'uvicorn', 'backend.main:app', '--host', '127.0.0.1', '--port', String(backendPort)],
{
cwd: projectRoot,
env,
stdio: ['pipe', 'pipe', 'pipe'],
}
);
backendProcess.stdout.on('data', (data) => {
const text = data.toString();
process.stdout.write(`[backend] ${text}`);
});
backendProcess.stderr.on('data', (data) => {
const text = data.toString();
process.stderr.write(`[backend] ${text}`);
});
backendProcess.on('exit', (code) => {
console.log(`Backend exited with code ${code}`);
if (code !== 0 && code !== null && mainWindow) {
mainWindow.webContents.executeJavaScript(
`document.title = "OpenSwarm (backend crashed)";`
);
}
});
await waitForBackend(backendPort);
console.log(`Backend ready on port ${backendPort}`);
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
minWidth: 800,
minHeight: 600,
title: 'OpenSwarm',
icon: iconPath,
titleBarStyle: 'hiddenInset',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false,
contextIsolation: true,
webviewTag: true,
},
});
if (isDev) {
mainWindow.loadURL(`http://localhost:3000`);
} else {
const frontendPath = getResourcePath('frontend', 'index.html');
mainWindow.loadFile(frontendPath);
}
mainWindow.on('closed', () => {
mainWindow = null;
});
}
function setupAutoUpdater() {
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.on('update-available', (info) => {
console.log(`Update available: ${info.version}`);
if (mainWindow) {
mainWindow.webContents.executeJavaScript(
`window.__OPENSWARM_UPDATE_AVAILABLE__ = ${JSON.stringify(info)};`
);
}
autoUpdater.downloadUpdate();
});
autoUpdater.on('update-downloaded', (info) => {
console.log(`Update downloaded: ${info.version}`);
if (mainWindow) {
mainWindow.webContents.executeJavaScript(
`window.__OPENSWARM_UPDATE_DOWNLOADED__ = ${JSON.stringify(info)};`
);
}
});
autoUpdater.on('error', (err) => {
console.error('Auto-update error:', err);
});
autoUpdater.checkForUpdates().catch((err) => {
console.log('Update check skipped:', err.message);
});
}
function killBackend() {
if (backendProcess) {
console.log('Killing backend process...');
backendProcess.kill('SIGTERM');
setTimeout(() => {
if (backendProcess && !backendProcess.killed) {
backendProcess.kill('SIGKILL');
}
}, 3000);
backendProcess = null;
}
}
app.whenReady().then(async () => {
if (process.platform === 'darwin' && !isPackaged) {
try { app.dock.setIcon(iconPath); } catch (_) {}
}
try {
if (isDev) {
backendPort = parseInt(process.env.OPENSWARM_PORT || '8324', 10);
console.log(`Dev mode: using existing backend on port ${backendPort}`);
} else {
await startBackend();
}
createWindow();
if (!isDev) {
setupAutoUpdater();
}
} catch (err) {
console.error('Failed to start:', err);
app.quit();
}
});
app.on('window-all-closed', () => {
if (!isDev) killBackend();
app.quit();
});
app.on('will-quit', () => {
if (!isDev) killBackend();
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0 && backendPort) {
createWindow();
}
});
ipcMain.handle('get-backend-port', () => backendPort);
+5373
View File
File diff suppressed because it is too large Load Diff
+114
View File
@@ -0,0 +1,114 @@
{
"name": "openswarm",
"version": "1.0.0",
"description": "OpenSwarm — AI Agent Orchestrator",
"main": "main.js",
"scripts": {
"start": "electron .",
"dev": "ELECTRON_DEV=1 electron .",
"dist": "electron-builder --mac --publish never",
"dist:publish": "electron-builder --mac --publish always",
"dist:all": "electron-builder --mac --win --linux"
},
"dependencies": {
"electron-updater": "^6.3.0",
"get-port": "^5.1.1"
},
"devDependencies": {
"@electron/notarize": "^3.1.1",
"electron": "^33.0.0",
"electron-builder": "^25.1.0"
},
"build": {
"appId": "com.clusterlabs.openswarm",
"productName": "OpenSwarm",
"directories": {
"output": "dist"
},
"icon": "build/icon.png",
"mac": {
"icon": "build/icon.icns",
"target": [
{
"target": "dmg",
"arch": [
"arm64",
"x64"
]
},
{
"target": "zip",
"arch": [
"arm64",
"x64"
]
}
],
"category": "public.app-category.developer-tools",
"hardenedRuntime": true,
"entitlements": "build/entitlements.mac.plist",
"entitlementsInherit": "build/entitlements.mac.plist"
},
"dmg": {
"title": "OpenSwarm",
"contents": [
{
"x": 130,
"y": 220
},
{
"x": 410,
"y": 220,
"type": "link",
"path": "/Applications"
}
]
},
"extraResources": [
{
"from": "../frontend/dist",
"to": "frontend",
"filter": [
"**/*"
]
},
{
"from": "../backend",
"to": "backend",
"filter": [
"**/*",
"!__pycache__/**",
"!**/__pycache__/**",
"!.venv/**",
"!*.pyc"
]
},
{
"from": "../debugger",
"to": "debugger",
"filter": [
"**/*",
"!__pycache__/**",
"!**/__pycache__/**",
"!*.pyc",
"!.venv/**",
"!**/.venv/**",
"!**/node_modules/**"
]
},
{
"from": "python-env",
"to": "python-env",
"filter": [
"**/*"
]
}
],
"publish": {
"provider": "github",
"owner": "openswarm-ai",
"repo": "production"
},
"afterSign": "scripts/notarize.js"
}
}
+11
View File
@@ -0,0 +1,11 @@
const { contextBridge, ipcRenderer } = require('electron');
(async () => {
const port = await ipcRenderer.invoke('get-backend-port');
contextBridge.exposeInMainWorld('__OPENSWARM_PORT__', port);
contextBridge.exposeInMainWorld('openswarm', {
getBackendPort: () => port,
});
})();
+31
View File
@@ -0,0 +1,31 @@
const { notarize } = require('@electron/notarize');
exports.default = async function notarizing(context) {
const { electronPlatformName, appOutDir } = context;
if (electronPlatformName !== 'darwin') return;
if (process.env.CSC_IDENTITY_AUTO_DISCOVERY === 'false') {
console.log('Skipping notarization (CSC_IDENTITY_AUTO_DISCOVERY=false)');
return;
}
if (!process.env.APPLE_ID || !process.env.APPLE_TEAM_ID) {
console.log('Skipping notarization (APPLE_ID or APPLE_TEAM_ID not set)');
return;
}
const appName = context.packager.appInfo.productFilename;
const appPath = `${appOutDir}/${appName}.app`;
console.log(`Notarizing ${appPath}...`);
await notarize({
appBundleId: 'com.clusterlabs.openswarm',
appPath,
appleId: process.env.APPLE_ID,
appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD,
teamId: process.env.APPLE_TEAM_ID,
});
console.log('Notarization complete.');
};
+1
View File
@@ -41,6 +41,7 @@
"@types/react-dom": "^18.2.0",
"@types/react-redux": "^7.1.34",
"babel-loader": "^9.2.1",
"copy-webpack-plugin": "^14.0.0",
"css-loader": "^6.8.0",
"css-modules-types-loader": "^0.6.10",
"html-webpack-plugin": "^5.5.0",
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Open Swarm</title>
<link rel="icon" href="/favicon.ico?v=2" sizes="16x16 32x32 48x48" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="icon" href="./favicon.ico?v=2" sizes="16x16 32x32 48x48" />
<link rel="apple-touch-icon" href="./apple-touch-icon.png" />
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
</head>
<body>
+3 -3
View File
@@ -13,14 +13,14 @@ else
# echo "NOT in macOS server START"
fi
chmod +x "$DEV_ABSPATH"
source "$(dirname "$DEV_ABSPATH")/_utils.sh"
FRONTEND_DIR_ABSPATH="$(dirname "$DEV_ABSPATH")"
formatted_echo --green "Installing dependencies..."
echo "Installing dependencies..."
cd "$FRONTEND_DIR_ABSPATH"
npm install
formatted_echo --green "Building with development mode..."
echo "Building with development mode..."
npm run dev
# exit back to the dir that we were in before
-202
View File
@@ -1,202 +0,0 @@
#!/bin/bash
# Flag processing function with namespacing and global variable declaration
UTILS_FILE_ABSPATH="$(readlink -f "${BASH_SOURCE[0]}")"
if [[ "$OSTYPE" == "darwin"* ]]; then
# echo "In macOS utils sed START"
# echo "UTILS_ABSPATH: $UTILS_FILE_ABSPATH"
sed -i '' 's/\r//g' "$UTILS_FILE_ABSPATH"
# echo "In macOS utils sed END"
else
# echo "NOT in macOS utils START"
# echo "UTILS_ABSPATH: $UTILS_FILE_ABSPATH"
sed -i 's/\r//g' "$UTILS_FILE_ABSPATH"
# echo "NOT in macOS utils END"
fi
chmod +x "$UTILS_FILE_ABSPATH"
RUN_DIR_ABSPATH="$(dirname "$UTILS_FILE_ABSPATH")"
FRONTEND_DIR_ABSPATH="$(dirname "$RUN_DIR_ABSPATH")"
formatted_error() {
# Arguments: error message and array of conflicting flags
local initial_message="$1"
shift
local conflicting_flags=("$@")
# Red color for the error message box
local COLOR_CODE='\033[0;31m'
local NC='\033[0m' # No Color
# Start the error message with the initial message
local error_message="$initial_message"
# Add each conflicting flag on a new line with indentation
for conflict in "${conflicting_flags[@]}"; do
error_message+="\n $conflict" # Replacing `\t` with four spaces
done
# Prepare for printing by finding max length of each line in the message
local lines=()
local max_length=0
# Use printf to interpret new lines and calculate max length with spaces instead of tabs
while IFS= read -r line; do
# Substitute tabs with spaces for consistent width measurement
local line_with_spaces="${line//$'\t'/ }"
lines+=("$line_with_spaces")
if (( ${#line_with_spaces} > max_length )); then
max_length=${#line_with_spaces}
fi
done <<< "$(printf "$error_message")"
# Create the top and bottom borders based on the maximum line length
local border=$(printf '%*s' "$((max_length + 4))" '' | tr ' ' '-')
# Print the formatted error message with a red box
printf "\n${COLOR_CODE}%s${NC}\n" "$border"
for line in "${lines[@]}"; do
printf "${COLOR_CODE}| %-*s |${NC}\n" "$max_length" "$line"
done
printf "${COLOR_CODE}| %-*s |${NC}\n" "$max_length" ""
printf "${COLOR_CODE}%s${NC}\n" "$border"
}
function process_flags() {
local -n flags_to_commands="$1" # Reference to the dictionary of flags and commands
local -n exclusives="$2" # Reference to the list of exclusive flag groups
local namespace="$3" # Unique prefix for variables
local calling_script_name="$(basename "$(readlink -f "${BASH_SOURCE[1]}")")"
local caller_id="${FUNCNAME[1]}"
local should_exit=false
if [ "$caller_id" == "main" ]; then
caller_id="$calling_script_name"
fi
# echo "caller_id: $caller_id"
# echo "Initializing flags with namespace: $namespace"
# Initialize flag variables with namespacing in the sourcing script context
for flag in "${!flags_to_commands[@]}"; do
# Convert flag to uppercase variable name and apply namespace, e.g., MYAPP_FLAG1
local flag_var="${namespace}_${flag^^}" # Prefix and uppercase
flag_var="${flag_var//-}" # Remove dashes
eval "declare -g $flag_var=false" # Initialize as global false
# Diagnostic output for each initialized variable
# echo "Initialized $flag_var as false"
done
# echo "Parsing command line arguments: $@"
# Parse command line arguments and set flags
local unsupported_flags=()
# local potential_typos=()
for arg in "$@"; do
# echo "Processing argument: $arg"
if [[ -n "${flags_to_commands[$arg]}" ]]; then
# echo "Flag found: $arg"
local flag_var="${namespace}_${arg^^}" # Prefix and uppercase
flag_var="${flag_var//-}" # Remove dashes
eval "declare -g $flag_var=true" # Set as global true
# echo "Set $flag_var to true"
# echo "Executing command for $arg: ${flags_to_commands[$arg]}"
eval "${flags_to_commands[$arg]}"
else
if [[ "$arg" == "-"* ]]; then
# echo "Flag not found: $arg"
unsupported_flags+=("$arg")
if [[ -n "${flags_to_commands[-$arg]}" ]]; then
# echo "Potential typo: $arg"
unsupported_flags+=("\t*Note: Potential typo detected.")
unsupported_flags+=("\tDid you mean: -$arg")
fi
fi
fi
done
# Check exclusive flag groups for conflicts
# echo "Checking exclusive flag groups"
for group in "${exclusives[@]}"; do
local count=0
local conflicting_flags=()
for flag in $group; do
local flag_var="${namespace}_${flag^^}"
flag_var="${flag_var//-}"
if [[ "$(eval echo "\$$flag_var")" == "true" ]]; then
conflicting_flags+=("$flag")
# count=$((count + 1))
# ec "$flag_var is true in exclusive group"
fi
done
if (( ${#conflicting_flags[@]} > 1 )); then
# echo "found conflicting flags"
formatted_error "Error: $caller_id\n-----------------------------------------\nIncompatible flags:\nThe flags below cannot be used together\n-----------------------------------------\n" "${conflicting_flags[@]}"
should_exit=true
fi
done
# echo "should_exit: $should_exit"
# echo "unsupported_flags: ${#unsupported_flags[@]}"
if (( ${#unsupported_flags[@]} > 0 )); then
# echo "found unsupported flags 2"
formatted_error "Error: $caller_id\n-------------------------------------------------\nUnsupported flags:\nThe flags below are not supported by this script\n-------------------------------------------------\n" "${unsupported_flags[@]}"
should_exit=true
fi
if [[ $should_exit == true ]]; then
exit 1
fi
}
formatted_echo() {
local COLOR_CODE='\033[0m' # No Color
local NC='\033[0m' # No Color variable
# local message="$2"
# If the second argument is empty, then theres no color specified, so we use the first argument as the message
if [[ -z "$2" ]]; then
message="$1"
else
message="$2"
fi
declare -A format_flags
format_flags=(
[--red]="COLOR_CODE='\033[0;31m'"
[--green]="COLOR_CODE='\033[0;32m'"
[--yellow]="COLOR_CODE='\033[0;33m'"
[--blue]="COLOR_CODE='\033[0;34m'"
[--purple]="COLOR_CODE='\033[0;35m'"
[--cyan]="COLOR_CODE='\033[0;36m'"
)
exclusive_format_flags=(
"--red --green --yellow --blue --purple --cyan"
)
# Pass the arguments with the namespace "FORMAT"
process_flags format_flags exclusive_format_flags "FORMAT" "$@"
# Process the message
local text
text=$(printf "%b" "$message")
# Expand any escaped characters in the input (e.g., \n)
local lines=()
local max_length=0
# Read the text line by line and find the maximum length
while IFS= read -r line; do
lines+=("$line")
if (( ${#line} > max_length )); then
max_length=${#line}
fi
done <<< "$text"
# Create the top and bottom borders based on the maximum line length
local border=$(printf '%*s' "$((max_length + 4))" '' | tr ' ' '-')
# Print the formatted box with selected color
printf "\n${COLOR_CODE}%s${NC}\n" "$border"
for line in "${lines[@]}"; do
printf "${COLOR_CODE}| %-*s |${NC}\n" "$max_length" "$line"
done
printf "${COLOR_CODE}%s${NC}\n" "$border"
}
+3 -3
View File
@@ -1,6 +1,6 @@
import React, { useMemo, useEffect } from 'react';
import { Provider } from 'react-redux';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { HashRouter, Routes, Route } from 'react-router-dom';
import { ThemeProvider as MuiThemeProvider, createTheme, CssBaseline } from '@mui/material';
import { store } from '../shared/state/store';
import { useAppDispatch } from '@/shared/hooks';
@@ -138,7 +138,7 @@ const ThemedApp: React.FC = () => {
return (
<MuiThemeProvider theme={muiTheme}>
<CssBaseline />
<BrowserRouter>
<HashRouter>
<ShortcutsProvider>
<SettingsLoader>
<Routes>
@@ -155,7 +155,7 @@ const ThemedApp: React.FC = () => {
</Routes>
</SettingsLoader>
</ShortcutsProvider>
</BrowserRouter>
</HashRouter>
</MuiThemeProvider>
);
};
@@ -20,8 +20,9 @@ import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutl
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { BrowseResult } from '@/shared/state/settingsSlice';
import { API_BASE } from '@/shared/config';
const API_BASE = `http://${window.location.hostname}:8324/api/settings`;
const SETTINGS_API = `${API_BASE}/settings`;
export interface ContextPath {
path: string;
@@ -48,7 +49,7 @@ const DirectoryBrowser: React.FC<DirectoryBrowserProps> = ({ open, onClose, onSe
setError(null);
setSelected(null);
try {
const res = await fetch(`${API_BASE}/browse-directories?path=${encodeURIComponent(path)}`);
const res = await fetch(`${SETTINGS_API}/browse-directories?path=${encodeURIComponent(path)}`);
if (!res.ok) {
const data = await res.json();
throw new Error(data.detail || 'Failed to browse');
@@ -9,7 +9,7 @@ export interface SelectedElement {
computedStyles: Record<string, string>;
screenshot?: string;
boundingRect: { x: number; y: number; width: number; height: number };
semanticType?: 'agent-card' | 'message' | 'tool-call' | 'tool-group' | 'view-card' | 'dom-element';
semanticType?: 'agent-card' | 'message' | 'tool-call' | 'tool-group' | 'view-card' | 'browser-card' | 'dom-element';
semanticLabel?: string;
semanticData?: Record<string, any>;
}
@@ -18,6 +18,8 @@ interface ElementSelectionContextValue {
selectMode: boolean;
toggleSelectMode: () => void;
setSelectMode: (active: boolean) => void;
excludeSelectId: string | null;
setExcludeSelectId: (id: string | null) => void;
selectedElements: SelectedElement[];
addSelectedElement: (el: SelectedElement) => void;
updateSelectedElement: (id: string, patch: Partial<SelectedElement>) => void;
@@ -34,11 +36,15 @@ export function useElementSelection() {
export const ElementSelectionProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [selectMode, setSelectMode] = useState(false);
const [excludeSelectId, setExcludeSelectId] = useState<string | null>(null);
const [selectedElements, setSelectedElements] = useState<SelectedElement[]>([]);
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const toggleSelectMode = useCallback(() => {
setSelectMode((prev) => !prev);
setSelectMode((prev) => {
if (prev) setExcludeSelectId(null);
return !prev;
});
}, []);
const addSelectedElement = useCallback((el: SelectedElement) => {
@@ -66,6 +72,8 @@ export const ElementSelectionProvider: React.FC<{ children: React.ReactNode }> =
selectMode,
toggleSelectMode,
setSelectMode,
excludeSelectId,
setExcludeSelectId,
selectedElements,
addSelectedElement,
updateSelectedElement,
@@ -10,6 +10,8 @@ const shortcuts = [
{ key: 'd', description: 'Go to Dashboard' },
{ key: 't', description: 'Go to Templates' },
{ key: '1-9', description: 'Open agent by position' },
{ key: '⌘M', description: 'Add View' },
{ key: '⌘O', description: 'History' },
{ key: 'Shift+A', description: 'Approve all pending' },
{ key: 'Shift+D', description: 'Deny all pending' },
{ key: '?', description: 'Show this help' },
+101 -20
View File
@@ -20,6 +20,9 @@ import ViewQuiltIcon from '@mui/icons-material/ViewQuilt';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import AddIcon from '@mui/icons-material/Add';
import SettingsIcon from '@mui/icons-material/Settings';
import ViewSidebarOutlinedIcon from '@mui/icons-material/ViewSidebarOutlined';
import ArrowBackOutlinedIcon from '@mui/icons-material/ArrowBackOutlined';
import ArrowForwardOutlinedIcon from '@mui/icons-material/ArrowForwardOutlined';
import Settings from '@/app/pages/Settings/Settings';
import GlobalApprovalOverlay from '@/app/components/GlobalApprovalOverlay';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
@@ -42,6 +45,7 @@ const AppShell: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const [dashboardsExpanded, setDashboardsExpanded] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const dashboardItems = useAppSelector((state) => state.dashboards.items);
const dashboardList = Object.values(dashboardItems).sort(
@@ -79,7 +83,101 @@ const AppShell: React.FC = () => {
};
return (
<Box sx={{ display: 'flex', height: '100vh', bgcolor: c.bg.page }}>
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100vh', bgcolor: c.bg.page }}>
{/* Draggable title bar */}
<Box
sx={{
height: 38,
flexShrink: 0,
bgcolor: c.bg.secondary,
borderBottom: `0.5px solid ${c.border.medium}`,
display: 'flex',
alignItems: 'center',
WebkitAppRegion: 'drag',
userSelect: 'none',
pl: '78px',
gap: 0.25,
}}
>
<Tooltip title={sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'}>
<IconButton
size="small"
onClick={() => setSidebarCollapsed((prev) => !prev)}
sx={{
WebkitAppRegion: 'no-drag',
color: c.text.tertiary,
p: 0.5,
borderRadius: 1,
'&:hover': { color: c.text.secondary, bgcolor: `${c.text.tertiary}14` },
}}
>
<ViewSidebarOutlinedIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
<Tooltip title="Back">
<IconButton
size="small"
onClick={() => navigate(-1)}
sx={{
WebkitAppRegion: 'no-drag',
color: c.text.tertiary,
p: 0.5,
borderRadius: 1,
'&:hover': { color: c.text.secondary, bgcolor: `${c.text.tertiary}14` },
}}
>
<ArrowBackOutlinedIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
<Tooltip title="Forward">
<IconButton
size="small"
onClick={() => navigate(1)}
sx={{
WebkitAppRegion: 'no-drag',
color: c.text.tertiary,
p: 0.5,
borderRadius: 1,
'&:hover': { color: c.text.secondary, bgcolor: `${c.text.tertiary}14` },
}}
>
<ArrowForwardOutlinedIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
<Box sx={{ flex: 1 }} />
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
pr: 1.5,
WebkitAppRegion: 'no-drag',
}}
>
<Box
component="img"
src="./logo.png"
alt="OpenSwarm"
sx={{ width: 18, height: 18, borderRadius: 0.5, opacity: 0.7 }}
/>
<Typography
sx={{
color: c.text.tertiary,
fontSize: '0.75rem',
fontWeight: 500,
letterSpacing: 0.3,
lineHeight: 1,
}}
>
OpenSwarm
</Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', flex: 1, minHeight: 0 }}>
{!sidebarCollapsed && (
<Box
sx={{
width: 240,
@@ -90,25 +188,6 @@ const AppShell: React.FC = () => {
flexDirection: 'column',
}}
>
<Box sx={{ p: 2.5, borderBottom: `0.5px solid ${c.border.medium}`, display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Box
component="img"
src="/logo.png"
alt="Open Swarm"
sx={{ width: 36, height: 36, borderRadius: 1, flexShrink: 0 }}
/>
<Box>
<Typography
variant="h6"
sx={{ color: c.text.primary, fontWeight: 700, letterSpacing: 0.5, lineHeight: 1.2 }}
>
Open Swarm
</Typography>
<Typography variant="caption" sx={{ color: c.text.tertiary }}>
Agent Orchestrator
</Typography>
</Box>
</Box>
<List sx={{ pt: 1, px: 1, flex: 1, overflow: 'auto'}}>
<ListItemButton
onClick={handleDashboardsClick}
@@ -289,10 +368,12 @@ const AppShell: React.FC = () => {
</Tooltip>
</Box>
</Box>
)}
<Box sx={{ flex: 1, overflow: 'hidden', bgcolor: c.bg.page }}>
<Outlet />
</Box>
</Box>
<Settings />
<GlobalApprovalOverlay />
@@ -31,12 +31,16 @@ const SEMANTIC_LABELS: Record<string, string> = {
'tool-call': 'Tool Call',
'tool-group': 'Tool Group',
'view-card': 'View',
'browser-card': 'Browser',
};
function findSelectableAncestor(target: Element): Element | null {
function findSelectableAncestor(target: Element, excludeId?: string | null): Element | null {
let current: Element | null = target;
while (current) {
if (current.hasAttribute(SELECT_ATTR)) return current;
if (current.hasAttribute(SELECT_ATTR)) {
if (excludeId && current.getAttribute(SELECT_ID_ATTR) === excludeId) return null;
return current;
}
current = current.parentElement;
}
return null;
@@ -114,6 +118,11 @@ export function useDomElementSelector(): DomSelectorState {
const isDraggingRef = useRef(false);
const dragBoundsRef = useRef<{ left: number; top: number; right: number; bottom: number } | null>(null);
const excludeIdRef = useRef<string | null>(null);
useEffect(() => {
excludeIdRef.current = ctx?.excludeSelectId ?? null;
}, [ctx?.excludeSelectId]);
const selectedIdsRef = useRef(new Map<string, string>());
useEffect(() => {
const map = new Map<string, string>();
@@ -161,10 +170,12 @@ export function useDomElementSelector(): DomSelectorState {
const allSelectables = document.querySelectorAll(`[${SELECT_ATTR}]`);
const preview: DragPreviewElement[] = [];
const seen = new Set<string>();
const excId = excludeIdRef.current;
allSelectables.forEach((el) => {
const selectId = el.getAttribute(SELECT_ID_ATTR) || '';
if (excId && selectId === excId) return;
const rect = el.getBoundingClientRect();
if (rectsIntersect(b, { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom })) {
const selectId = el.getAttribute(SELECT_ID_ATTR) || '';
if (seen.has(selectId)) return;
seen.add(selectId);
const type = el.getAttribute(SELECT_ATTR) || '';
@@ -200,7 +211,7 @@ export function useDomElementSelector(): DomSelectorState {
return;
}
const selectable = findSelectableAncestor(target);
const selectable = findSelectableAncestor(target, excludeIdRef.current);
if (!selectable) {
setOverlay(EMPTY_OVERLAY);
hoveredRef.current = null;
@@ -231,7 +242,7 @@ export function useDomElementSelector(): DomSelectorState {
if (e.button !== 0) return;
const target = e.target as Element;
// Only start drag on "empty" canvas areas (not on selectable elements)
if (target && findSelectableAncestor(target)) return;
if (target && findSelectableAncestor(target, excludeIdRef.current)) return;
dragOriginRef.current = { x: e.clientX, y: e.clientY };
isDraggingRef.current = false;
}, []);
@@ -250,7 +261,10 @@ export function useDomElementSelector(): DomSelectorState {
const allSelectables = document.querySelectorAll(`[${SELECT_ATTR}]`);
const processed = new Set<string>();
const excId = excludeIdRef.current;
allSelectables.forEach((el) => {
const selectId = el.getAttribute(SELECT_ID_ATTR) || '';
if (excId && selectId === excId) return;
const rect = el.getBoundingClientRect();
const elRect = {
left: rect.left,
@@ -260,7 +274,6 @@ export function useDomElementSelector(): DomSelectorState {
};
if (rectsIntersect(dr, elRect)) {
const selectId = el.getAttribute(SELECT_ID_ATTR) || '';
if (processed.has(selectId)) return;
processed.add(selectId);
@@ -629,6 +629,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
isRunning={!isDraft && (session.status === 'running' || session.status === 'waiting_approval')}
onStop={handleStop}
contextEstimate={contextEstimate}
sessionId={id}
/>
</Box>
</Box>
+48 -7
View File
@@ -26,6 +26,8 @@ import AttachFileIcon from '@mui/icons-material/AttachFile';
import AdsClickIcon from '@mui/icons-material/AdsClick';
import CommandPicker, { CommandPickerItem, getToolGroupIcon } from '@/app/components/CommandPicker';
import { useElementSelection, SelectedElement } from '@/app/components/ElementSelectionContext';
import { getWebview } from '@/shared/browserRegistry';
import { API_BASE } from '@/shared/config';
import { ContextPath } from '@/app/components/DirectoryBrowser';
import {
SKILL_PILL_ATTR,
@@ -70,6 +72,7 @@ interface Props {
contextEstimate?: { used: number; limit: number };
embedded?: boolean;
autoFocus?: boolean;
sessionId?: string;
}
export interface ChatInputHandle {
@@ -127,7 +130,7 @@ const ContextRing: React.FC<{ used: number; limit: number; accentColor: string;
);
};
const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode, onModeChange, model, onModelChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus }, ref) => {
const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode, onModeChange, model, onModelChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId }, ref) => {
const c = useClaudeTokens();
const editorRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
@@ -253,7 +256,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
try {
const formData = new FormData();
files.forEach((f) => formData.append('files', f));
const resp = await fetch(`http://${window.location.hostname}:8324/api/settings/upload-files`, {
const resp = await fetch(`${API_BASE}/settings/upload-files`, {
method: 'POST',
body: formData,
});
@@ -271,7 +274,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
}
}, []);
const handleSend = useCallback(() => {
const handleSend = useCallback(async () => {
const editor = editorRef.current;
if (!editor || disabled) return;
const serialized = serializeEditorContent(editor, attachedSkillsRef.current);
@@ -285,14 +288,47 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
if (selectedEls.length > 0) {
const lines: string[] = ['\n\n---\nSelected UI Elements:\n'];
selectedEls.forEach((el, i) => {
if (el.semanticType && el.semanticData) {
for (let i = 0; i < selectedEls.length; i++) {
const el = selectedEls[i];
if (el.semanticType === 'browser-card' && el.semanticData?.selectId) {
const wv = getWebview(el.semanticData.selectId as string);
if (wv) {
const url = el.semanticData.url || wv.getURL();
const title = el.semanticData.name || wv.getTitle();
lines.push(`${i + 1}. [Browser Card] ${title}`);
lines.push(` ID: ${el.semanticData.selectId}`);
lines.push(` URL: ${url}`);
try {
const nativeImage = await wv.capturePage();
const dataUrl = nativeImage.toDataURL();
const base64 = dataUrl.replace(/^data:image\/\w+;base64,/, '');
allImages.push({ data: base64, media_type: 'image/png' });
lines.push(` [Screenshot captured and attached as image]`);
} catch { /* screenshot unavailable */ }
try {
const pageText: string = await wv.executeJavaScript(
'document.body.innerText.substring(0, 15000)'
);
if (pageText?.trim()) {
lines.push(` Page text content:\n ---\n${pageText.trim().split('\n').map(l => ' ' + l).join('\n')}\n ---`);
}
} catch { /* text extraction unavailable */ }
} else {
lines.push(`${i + 1}. [Browser Card] ${el.semanticLabel || ''}`);
if (el.semanticData.selectId) lines.push(` ID: ${el.semanticData.selectId}`);
if (el.semanticData.url) lines.push(` URL: ${el.semanticData.url}`);
}
} else if (el.semanticType && el.semanticData) {
const typeLabel = {
'agent-card': 'Agent Card',
'message': 'Message',
'tool-call': 'Tool Call',
'tool-group': 'Tool Group',
'view-card': 'View Card',
'browser-card': 'Browser Card',
'dom-element': 'Element',
}[el.semanticType] || el.semanticType;
lines.push(`${i + 1}. [${typeLabel}] ${el.semanticLabel || ''}`);
@@ -318,7 +354,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
const base64 = el.screenshot.replace(/^data:image\/\w+;base64,/, '');
allImages.push({ data: base64, media_type: 'image/png' });
}
});
}
trimmed += lines.join('\n');
}
@@ -963,7 +999,12 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
<Tooltip title={elementSelection.selectMode ? 'Exit select mode' : 'Select UI element'}>
<IconButton
size="small"
onClick={elementSelection.toggleSelectMode}
onClick={() => {
if (!elementSelection.selectMode && sessionId) {
elementSelection.setExcludeSelectId(sessionId);
}
elementSelection.toggleSelectMode();
}}
sx={{
p: 0.5,
...(elementSelection.selectMode
@@ -6,8 +6,9 @@ import Tooltip from '@mui/material/Tooltip';
import RefreshIcon from '@mui/icons-material/Refresh';
import DifferenceIcon from '@mui/icons-material/Difference';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { API_BASE } from '@/shared/config';
const API_BASE = `http://${window.location.hostname}:8324/api/agents`;
const AGENTS_API = `${API_BASE}/agents`;
interface Props {
sessionId: string;
@@ -22,7 +23,7 @@ const DiffViewer: React.FC<Props> = ({ sessionId }) => {
const fetchDiff = async () => {
setLoading(true);
try {
const res = await fetch(`${API_BASE}/sessions/${sessionId}/diff`);
const res = await fetch(`${AGENTS_API}/sessions/${sessionId}/diff`);
const data = await res.json();
setDiff(data.diff || '');
} catch {
@@ -10,6 +10,7 @@ import OpenInFullIcon from '@mui/icons-material/OpenInFull';
import CloseIcon from '@mui/icons-material/Close';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { useAppSelector } from '@/shared/hooks';
import { SERVE_BASE } from '@/shared/state/outputsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import ViewPreview from '../Views/ViewPreview';
@@ -41,7 +42,7 @@ const ViewBubble: React.FC<Props> = ({ toolInput, toolResult, isStreaming }) =>
const outputColor = c.accent.primary;
const outputIcon = output?.icon || 'view_quilt';
const hasPreview = !!frontendCode.trim();
const serveUrl = outputId ? `/api/outputs/${outputId}/serve/index.html` : undefined;
const serveUrl = outputId ? `${SERVE_BASE}/${outputId}/serve/index.html` : undefined;
const inputEntries = Object.entries(inputData);
if (isStreaming && !hasPreview) {
+153 -113
View File
@@ -169,6 +169,12 @@ interface Props {
cardHeight: number;
zoom?: number;
spawnFrom?: { x: number; y: number };
isSelected?: boolean;
multiDragDelta?: { dx: number; dy: number } | null;
onCardSelect?: (id: string, type: 'agent' | 'view', shiftKey: boolean) => void;
onDragStart?: (id: string, type: 'agent' | 'view') => void;
onDragMove?: (dx: number, dy: number) => void;
onDragEnd?: (dx: number, dy: number, didDrag: boolean) => void;
}
const MIN_W = 480;
@@ -177,7 +183,10 @@ const EXPANDED_OVERLAY_H = 620;
const SPAWN_SPRING = { type: 'spring' as const, stiffness: 400, damping: 28, mass: 0.6 };
const AgentCard: React.FC<Props> = ({ session, expanded, cardX, cardY, cardWidth, cardHeight, zoom = 1, spawnFrom }) => {
const AgentCard: React.FC<Props> = ({
session, expanded, cardX, cardY, cardWidth, cardHeight, zoom = 1, spawnFrom,
isSelected = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd,
}) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
@@ -199,6 +208,7 @@ const AgentCard: React.FC<Props> = ({ session, expanded, cardX, cardY, cardWidth
const [isDragging, setIsDragging] = useState(false);
const [localDragPos, setLocalDragPos] = useState<{ x: number; y: number } | null>(null);
const didDrag = useRef(false);
const justDraggedRef = useRef(false);
const handleDragPointerDown = useCallback((e: React.PointerEvent) => {
e.preventDefault();
@@ -207,7 +217,8 @@ const AgentCard: React.FC<Props> = ({ session, expanded, cardX, cardY, cardWidth
didDrag.current = false;
setIsDragging(true);
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}, [cardX, cardY]);
onDragStart?.(session.id, 'agent');
}, [cardX, cardY, onDragStart, session.id]);
const handleDragPointerMove = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
@@ -215,31 +226,35 @@ const AgentCard: React.FC<Props> = ({ session, expanded, cardX, cardY, cardWidth
const rawDy = e.clientY - dragState.current.startY;
if (!didDrag.current && Math.sqrt(rawDx * rawDx + rawDy * rawDy) < DRAG_THRESHOLD) return;
didDrag.current = true;
const dx = rawDx / zoom;
const dy = rawDy / zoom;
setLocalDragPos({
x: dragState.current.origX + rawDx / zoom,
y: dragState.current.origY + rawDy / zoom,
x: dragState.current.origX + dx,
y: dragState.current.origY + dy,
});
}, [zoom]);
onDragMove?.(dx, dy);
}, [zoom, onDragMove]);
const handleDragPointerUp = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
const dx = (e.clientX - dragState.current.startX) / zoom;
const dy = (e.clientY - dragState.current.startY) / zoom;
if (didDrag.current) {
const dx = (e.clientX - dragState.current.startX) / zoom;
const dy = (e.clientY - dragState.current.startY) / zoom;
dispatch(setCardPosition({
sessionId: session.id,
x: dragState.current.origX + dx,
y: dragState.current.origY + dy,
}));
} else if (expanded) {
dispatch(toggleExpandSession(session.id));
justDraggedRef.current = true;
requestAnimationFrame(() => { justDraggedRef.current = false; });
}
onDragEnd?.(dx, dy, didDrag.current);
dragState.current = null;
didDrag.current = false;
setLocalDragPos(null);
setIsDragging(false);
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}, [zoom, dispatch, session.id, expanded]);
}, [zoom, dispatch, session.id, onDragEnd]);
// ---- Unified edge / corner resize ----
const resizeRef = useRef<{
@@ -351,10 +366,12 @@ const AgentCard: React.FC<Props> = ({ session, expanded, cardX, cardY, cardWidth
const pendingReq = session.pending_approvals[0];
const statusStyle = STATUS_COLORS[session.status] || { color: c.text.tertiary, bg: c.bg.secondary };
const noTransition = isDragging || isResizing;
const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta);
const activeX = localResize?.x ?? localDragPos?.x ?? cardX;
const activeY = localResize?.y ?? localDragPos?.y ?? cardY;
const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0;
const mdDy = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dy : 0;
const activeX = localResize?.x ?? localDragPos?.x ?? (cardX + mdDx);
const activeY = localResize?.y ?? localDragPos?.y ?? (cardY + mdDy);
const activeW = localResize?.w ?? cardWidth;
const activeH = localResize?.h ?? cardHeight;
@@ -380,26 +397,38 @@ const AgentCard: React.FC<Props> = ({ session, expanded, cardX, cardY, cardWidth
data-select-type="agent-card"
data-select-id={session.id}
data-select-meta={JSON.stringify({ name: session.name || session.id, status: session.status, model: session.model, mode: session.mode })}
onClick={expanded ? undefined : () => dispatch(toggleExpandSession(session.id))}
onClick={(e: React.MouseEvent) => {
if (justDraggedRef.current) return;
onCardSelect?.(session.id, 'agent', e.shiftKey);
}}
onDoubleClick={() => dispatch(toggleExpandSession(session.id))}
sx={{
position: 'relative',
width: localResize ? activeW : Math.max(cardWidth, MIN_W),
height: localResize ? activeH : (expanded ? Math.max(EXPANDED_OVERLAY_H, cardHeight) : 'auto'),
bgcolor: c.bg.surface,
border: hasPending && !expanded
? `1px solid ${c.status.warning}`
: expanded
? `1px solid ${c.border.strong}`
: `1px solid ${c.border.subtle}`,
border: isSelected
? '2px solid #3b82f6'
: hasPending && !expanded
? `1px solid ${c.status.warning}`
: expanded
? `1px solid ${c.border.strong}`
: `1px solid ${c.border.subtle}`,
borderRadius: 3,
p: 2,
cursor: expanded ? 'default' : 'pointer',
transition: noTransition ? 'none' : c.transition,
boxShadow: isDragging ? c.shadow.lg : expanded ? c.shadow.md : c.shadow.sm,
boxShadow: isDragging
? c.shadow.lg
: isSelected
? `0 0 0 1px #3b82f6, ${c.shadow.md}`
: expanded
? c.shadow.md
: c.shadow.sm,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
...(!expanded && !isDragging && {
...(!expanded && !isDragging && !isSelected && {
'&:hover': {
boxShadow: c.shadow.md,
borderColor: hasPending ? c.status.warning : c.border.strong,
@@ -426,120 +455,131 @@ const AgentCard: React.FC<Props> = ({ session, expanded, cardX, cardY, cardWidth
/>
))}
{/* Header: always visible entire bar is draggable */}
{/* Drag zone: header + metadata entire region above separator is draggable */}
<Box
onPointerDown={handleDragPointerDown}
onPointerMove={handleDragPointerMove}
onPointerUp={handleDragPointerUp}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 1,
flexShrink: 0,
mx: -2,
mt: -2,
px: 2,
pt: 2,
pb: 1.5,
cursor: isDragging ? 'grabbing' : 'grab',
touchAction: 'none',
userSelect: 'none',
flexShrink: 0,
}}
>
<Box
className="drag-handle"
sx={{
display: 'flex',
alignItems: 'center',
mr: 0.5,
color: c.text.ghost,
justifyContent: 'space-between',
mb: 1,
flexShrink: 0,
}}
>
<DragIndicatorIcon sx={{ fontSize: 16 }} />
</Box>
<Box
sx={{
flex: 1,
minWidth: 0,
display: 'flex',
alignItems: 'center',
gap: 1,
borderRadius: 1,
}}
>
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{session.name}
</Typography>
<Chip
label={session.status.replace('_', ' ')}
size="small"
<Box
className="drag-handle"
sx={{
bgcolor: statusStyle.bg,
color: statusStyle.color,
fontWeight: 600,
fontSize: '0.7rem',
height: 22,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
mr: 0.5,
color: c.text.ghost,
}}
/>
>
<DragIndicatorIcon sx={{ fontSize: 16 }} />
</Box>
<Box
sx={{
flex: 1,
minWidth: 0,
display: 'flex',
alignItems: 'center',
gap: 1,
borderRadius: 1,
}}
>
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{session.name}
</Typography>
<Chip
label={session.status.replace('_', ' ')}
size="small"
sx={{
bgcolor: statusStyle.bg,
color: statusStyle.color,
fontWeight: 600,
fontSize: '0.7rem',
height: 22,
flexShrink: 0,
}}
/>
</Box>
<Box
onPointerDown={(e) => e.stopPropagation()}
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0, ml: 0.5 }}
>
{expanded ? (
<Tooltip title="Collapse">
<IconButton
size="small"
onClick={handleCollapse}
onMouseDown={(e) => e.stopPropagation()}
sx={{
color: c.text.ghost,
p: 0.5,
'&:hover': { color: c.text.secondary, bgcolor: c.bg.secondary },
}}
>
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
) : (
<Tooltip title={isDraft ? 'Remove' : 'Close chat'}>
<IconButton
size="small"
onClick={handleRemove}
onMouseDown={(e) => e.stopPropagation()}
sx={{
color: c.text.ghost,
p: 0.5,
'&:hover': { color: c.status.error, bgcolor: `${c.status.errorBg}` },
}}
>
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
)}
</Box>
</Box>
<Box
onPointerDown={(e) => e.stopPropagation()}
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0, ml: 0.5 }}
>
{expanded ? (
<Tooltip title="Collapse">
<IconButton
size="small"
onClick={handleCollapse}
onMouseDown={(e) => e.stopPropagation()}
sx={{
color: c.text.ghost,
p: 0.5,
'&:hover': { color: c.text.secondary, bgcolor: c.bg.secondary },
}}
>
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
) : (
<Tooltip title={isDraft ? 'Remove' : 'Close chat'}>
<IconButton
size="small"
onClick={handleRemove}
onMouseDown={(e) => e.stopPropagation()}
sx={{
color: c.text.ghost,
p: 0.5,
'&:hover': { color: c.status.error, bgcolor: `${c.status.errorBg}` },
}}
>
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
{/* Metadata row */}
<Box sx={{
display: isDraft && !expanded ? 'none' : 'flex',
gap: 1.5,
flexShrink: 0,
...(isDraft && { visibility: 'hidden' }),
}}>
<Typography variant="caption" sx={{ color: c.text.tertiary }}>
{session.model}
</Typography>
<Typography variant="caption" sx={{ color: c.text.tertiary }}>
{session.mode}
</Typography>
<Typography variant="caption" sx={{ color: c.text.tertiary }}>
{formatDuration(session.created_at)}
</Typography>
{session.cost_usd > 0 && (
<Typography variant="caption" sx={{ color: c.accent.primary }}>
${session.cost_usd.toFixed(4)}
</Typography>
)}
</Box>
</Box>
{/* Metadata row */}
<Box sx={{
display: isDraft && !expanded ? 'none' : 'flex',
gap: 1.5,
mb: 1.5,
flexShrink: 0,
...(isDraft && { visibility: 'hidden' }),
}}>
<Typography variant="caption" sx={{ color: c.text.tertiary }}>
{session.model}
</Typography>
<Typography variant="caption" sx={{ color: c.text.tertiary }}>
{session.mode}
</Typography>
<Typography variant="caption" sx={{ color: c.text.tertiary }}>
{formatDuration(session.created_at)}
</Typography>
{session.cost_usd > 0 && (
<Typography variant="caption" sx={{ color: c.accent.primary }}>
${session.cost_usd.toFixed(4)}
</Typography>
)}
</Box>
{/* Expanded: inline chat fills remaining space */}
{expanded && (
<Box
@@ -0,0 +1,779 @@
import React, { useState, useRef, useCallback, useEffect } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import InputBase from '@mui/material/InputBase';
import LinearProgress from '@mui/material/LinearProgress';
import CircularProgress from '@mui/material/CircularProgress';
import LanguageIcon from '@mui/icons-material/Language';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import RefreshIcon from '@mui/icons-material/Refresh';
import CloseIcon from '@mui/icons-material/Close';
import LockIcon from '@mui/icons-material/Lock';
import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
import {
setBrowserCardPosition,
setBrowserCardSize,
removeBrowserCard,
updateBrowserCardUrl,
} from '@/shared/state/dashboardLayoutSlice';
import { useAppDispatch } from '@/shared/hooks';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { registerWebview, unregisterWebview, type BrowserWebview } from '@/shared/browserRegistry';
import { useBrowserActivity } from '@/shared/useBrowserActivity';
import { getActionLabel } from '@/shared/browserCommandHandler';
type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
const EDGE_THICKNESS = 6;
const CORNER_SIZE = 14;
const MIN_W = 400;
const MIN_H = 300;
const CURSOR_MAP: Record<ResizeDir, string> = {
n: 'ns-resize', s: 'ns-resize', e: 'ew-resize', w: 'ew-resize',
nw: 'nwse-resize', se: 'nwse-resize', ne: 'nesw-resize', sw: 'nesw-resize',
};
const HANDLE_DEFS: { dir: ResizeDir; sx: Record<string, any> }[] = [
{ dir: 'n', sx: { top: -EDGE_THICKNESS / 2, left: CORNER_SIZE, right: CORNER_SIZE, height: EDGE_THICKNESS } },
{ dir: 's', sx: { bottom: -EDGE_THICKNESS / 2, left: CORNER_SIZE, right: CORNER_SIZE, height: EDGE_THICKNESS } },
{ dir: 'w', sx: { left: -EDGE_THICKNESS / 2, top: CORNER_SIZE, bottom: CORNER_SIZE, width: EDGE_THICKNESS } },
{ dir: 'e', sx: { right: -EDGE_THICKNESS / 2, top: CORNER_SIZE, bottom: CORNER_SIZE, width: EDGE_THICKNESS } },
{ dir: 'nw', sx: { top: -EDGE_THICKNESS / 2, left: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } },
{ dir: 'ne', sx: { top: -EDGE_THICKNESS / 2, right: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } },
{ dir: 'sw', sx: { bottom: -EDGE_THICKNESS / 2, left: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } },
{ dir: 'se', sx: { bottom: -EDGE_THICKNESS / 2, right: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } },
];
const isElectron = navigator.userAgent.includes('Electron');
type WebviewElement = BrowserWebview;
interface Props {
browserId: string;
url: string;
cardX: number;
cardY: number;
cardWidth: number;
cardHeight: number;
zoom?: number;
isSelected?: boolean;
multiDragDelta?: { dx: number; dy: number } | null;
onCardSelect?: (id: string, type: 'agent' | 'view' | 'browser', shiftKey: boolean) => void;
onDragStart?: (id: string, type: 'agent' | 'view' | 'browser') => void;
onDragMove?: (dx: number, dy: number) => void;
onDragEnd?: (dx: number, dy: number, didDrag: boolean) => void;
}
function ensureProtocol(input: string): string {
const trimmed = input.trim();
if (/^https?:\/\//i.test(trimmed)) return trimmed;
if (/^[a-zA-Z0-9-]+\.[a-zA-Z]{2,}/.test(trimmed)) return `https://${trimmed}`;
return trimmed;
}
const BrowserCard: React.FC<Props> = ({
browserId, url, cardX, cardY, cardWidth, cardHeight, zoom = 1,
isSelected = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd,
}) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const webviewRef = useRef<WebviewElement | null>(null);
const activity = useBrowserActivity(browserId);
const agentActive = activity.active;
const agentAction = activity.action;
const lastAction = activity.lastAction;
const [currentUrl, setCurrentUrl] = useState(url);
const [urlBarValue, setUrlBarValue] = useState(url);
const [pageTitle, setPageTitle] = useState('');
const [loading, setLoading] = useState(false);
const [canGoBack, setCanGoBack] = useState(false);
const [canGoForward, setCanGoForward] = useState(false);
// ---- Webview event wiring ----
useEffect(() => {
if (!isElectron) return;
const wv = webviewRef.current;
if (!wv) return;
const onNavigate = () => {
const newUrl = wv.getURL();
setCurrentUrl(newUrl);
setUrlBarValue(newUrl);
setCanGoBack(wv.canGoBack());
setCanGoForward(wv.canGoForward());
dispatch(updateBrowserCardUrl({ browserId, url: newUrl }));
};
const onTitleUpdate = () => {
setPageTitle(wv.getTitle());
};
const onLoadStart = () => setLoading(true);
const onLoadStop = () => {
setLoading(false);
onNavigate();
onTitleUpdate();
};
const onNewWindow = (e: any) => {
if (e.url) wv.loadURL(e.url);
};
wv.addEventListener('did-navigate', onNavigate);
wv.addEventListener('did-navigate-in-page', onNavigate);
wv.addEventListener('page-title-updated', onTitleUpdate);
wv.addEventListener('did-start-loading', onLoadStart);
wv.addEventListener('did-stop-loading', onLoadStop);
wv.addEventListener('new-window', onNewWindow);
return () => {
wv.removeEventListener('did-navigate', onNavigate);
wv.removeEventListener('did-navigate-in-page', onNavigate);
wv.removeEventListener('page-title-updated', onTitleUpdate);
wv.removeEventListener('did-start-loading', onLoadStart);
wv.removeEventListener('did-stop-loading', onLoadStop);
wv.removeEventListener('new-window', onNewWindow);
};
}, [browserId, dispatch]);
useEffect(() => {
if (!isElectron) return;
const wv = webviewRef.current;
if (!wv) return;
registerWebview(browserId, wv);
return () => { unregisterWebview(browserId); };
}, [browserId]);
const navigate = useCallback((targetUrl: string) => {
const finalUrl = ensureProtocol(targetUrl);
setUrlBarValue(finalUrl);
if (isElectron && webviewRef.current) {
webviewRef.current.loadURL(finalUrl);
}
setCurrentUrl(finalUrl);
dispatch(updateBrowserCardUrl({ browserId, url: finalUrl }));
}, [browserId, dispatch]);
const handleUrlKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
navigate(urlBarValue);
}
}, [navigate, urlBarValue]);
const handleBack = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
webviewRef.current?.goBack();
}, []);
const handleForward = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
webviewRef.current?.goForward();
}, []);
const handleRefresh = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
webviewRef.current?.reload();
}, []);
const handleRemove = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
dispatch(removeBrowserCard(browserId));
}, [dispatch, browserId]);
// ---- Drag via header ----
const DRAG_THRESHOLD = 3;
const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [localDragPos, setLocalDragPos] = useState<{ x: number; y: number } | null>(null);
const didDrag = useRef(false);
const justDraggedRef = useRef(false);
const handleDragPointerDown = useCallback((e: React.PointerEvent) => {
e.preventDefault();
e.stopPropagation();
dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY };
didDrag.current = false;
setIsDragging(true);
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
onDragStart?.(browserId, 'browser');
}, [cardX, cardY, onDragStart, browserId]);
const handleDragPointerMove = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
const rawDx = e.clientX - dragState.current.startX;
const rawDy = e.clientY - dragState.current.startY;
if (!didDrag.current && Math.sqrt(rawDx * rawDx + rawDy * rawDy) < DRAG_THRESHOLD) return;
didDrag.current = true;
const dx = rawDx / zoom;
const dy = rawDy / zoom;
setLocalDragPos({
x: dragState.current.origX + dx,
y: dragState.current.origY + dy,
});
onDragMove?.(dx, dy);
}, [zoom, onDragMove]);
const handleDragPointerUp = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
const dx = (e.clientX - dragState.current.startX) / zoom;
const dy = (e.clientY - dragState.current.startY) / zoom;
if (didDrag.current) {
dispatch(setBrowserCardPosition({
browserId,
x: dragState.current.origX + dx,
y: dragState.current.origY + dy,
}));
justDraggedRef.current = true;
requestAnimationFrame(() => { justDraggedRef.current = false; });
}
onDragEnd?.(dx, dy, didDrag.current);
dragState.current = null;
didDrag.current = false;
setLocalDragPos(null);
setIsDragging(false);
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}, [zoom, dispatch, browserId, onDragEnd]);
// ---- Resize ----
const resizeRef = useRef<{
dir: ResizeDir; startX: number; startY: number;
origX: number; origY: number; origW: number; origH: number;
} | null>(null);
const [isResizing, setIsResizing] = useState(false);
const [localResize, setLocalResize] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
const handleResizeDown = useCallback(
(dir: ResizeDir) => (e: React.PointerEvent) => {
e.preventDefault();
e.stopPropagation();
resizeRef.current = {
dir, startX: e.clientX, startY: e.clientY,
origX: cardX, origY: cardY, origW: cardWidth, origH: cardHeight,
};
setIsResizing(true);
(e.target as HTMLElement).setPointerCapture(e.pointerId);
},
[cardX, cardY, cardWidth, cardHeight],
);
const computeResize = useCallback(
(e: React.PointerEvent) => {
if (!resizeRef.current) return null;
const { dir, startX, startY, origX, origY, origW, origH } = resizeRef.current;
const dx = (e.clientX - startX) / zoom;
const dy = (e.clientY - startY) / zoom;
let newX = origX, newY = origY, newW = origW, newH = origH;
if (dir.includes('e')) newW = origW + dx;
if (dir.includes('w')) { newW = origW - dx; newX = origX + dx; }
if (dir.includes('s')) newH = origH + dy;
if (dir.includes('n')) { newH = origH - dy; newY = origY + dy; }
if (newW < MIN_W) { if (dir.includes('w')) newX = origX + origW - MIN_W; newW = MIN_W; }
if (newH < MIN_H) { if (dir.includes('n')) newY = origY + origH - MIN_H; newH = MIN_H; }
return { x: newX, y: newY, w: newW, h: newH };
},
[zoom],
);
const handleResizeMove = useCallback(
(e: React.PointerEvent) => {
const result = computeResize(e);
if (result) setLocalResize(result);
},
[computeResize],
);
const handleResizeUp = useCallback((e: React.PointerEvent) => {
if (!resizeRef.current) return;
const result = computeResize(e);
if (result) {
dispatch(setBrowserCardPosition({ browserId, x: result.x, y: result.y }));
dispatch(setBrowserCardSize({ browserId, width: result.w, height: result.h }));
}
resizeRef.current = null;
setLocalResize(null);
setIsResizing(false);
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
}, [computeResize, dispatch, browserId]);
const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0;
const mdDy = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dy : 0;
const displayX = localResize?.x ?? localDragPos?.x ?? (cardX + mdDx);
const displayY = localResize?.y ?? localDragPos?.y ?? (cardY + mdDy);
const displayW = localResize?.w ?? cardWidth;
const displayH = localResize?.h ?? cardHeight;
const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta);
const isSecure = currentUrl.startsWith('https://');
const accentColor = c.accent.primary;
const accentHover = c.accent.hover;
const agentBorder = agentActive
? `2px solid ${accentColor}`
: isSelected ? '2px solid #3b82f6' : `1px solid ${c.border.medium}`;
const agentShadow = agentActive
? `0 0 0 2px ${accentColor}40, 0 0 18px ${accentColor}30, 0 0 40px ${accentColor}15`
: isDragging || isResizing
? c.shadow.lg
: isSelected
? `0 0 0 1px #3b82f6, ${c.shadow.md}`
: c.shadow.md;
return (
<Box
data-select-type="browser-card"
data-select-id={browserId}
data-select-meta={JSON.stringify({ name: pageTitle || 'Browser', url: currentUrl })}
onClick={(e: React.MouseEvent) => {
if (justDraggedRef.current) return;
onCardSelect?.(browserId, 'browser', e.shiftKey);
}}
sx={{
position: 'absolute',
left: displayX,
top: displayY,
width: displayW,
height: displayH,
borderRadius: `${c.radius.lg}px`,
border: agentBorder,
bgcolor: c.bg.surface,
boxShadow: agentShadow,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
zIndex: (isDragging || isResizing) ? 100 : agentActive ? 50 : 1,
transition: noTransition ? 'none' : 'box-shadow 0.4s ease, border 0.3s ease',
'&:hover .resize-handle': { opacity: 1 },
...(agentActive && {
animation: 'agent-glow-pulse 2s ease-in-out infinite',
'@keyframes agent-glow-pulse': {
'0%, 100%': {
boxShadow: `0 0 0 2px ${accentColor}40, 0 0 18px ${accentColor}30, 0 0 40px ${accentColor}15`,
},
'50%': {
boxShadow: `0 0 0 3px ${accentColor}60, 0 0 28px ${accentColor}45, 0 0 56px ${accentColor}25`,
},
},
}),
}}
>
{/* Animated border glow (top edge overlay) */}
{agentActive && (
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '2px',
zIndex: 20,
background: `linear-gradient(90deg, transparent, ${accentColor}, ${accentHover}, ${accentColor}, transparent)`,
backgroundSize: '200% 100%',
animation: 'border-shimmer 2s linear infinite',
'@keyframes border-shimmer': {
'0%': { backgroundPosition: '200% 0' },
'100%': { backgroundPosition: '-200% 0' },
},
}}
/>
)}
{/* Header / drag handle */}
<Box
onPointerDown={handleDragPointerDown}
onPointerMove={handleDragPointerMove}
onPointerUp={handleDragPointerUp}
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
px: 1,
py: 0.5,
bgcolor: agentActive ? `${accentColor}0a` : c.bg.secondary,
borderBottom: `1px solid ${agentActive ? `${accentColor}30` : c.border.subtle}`,
cursor: isDragging ? 'grabbing' : 'grab',
flexShrink: 0,
minHeight: 36,
userSelect: 'none',
transition: 'background 0.3s ease',
}}
>
<LanguageIcon sx={{ fontSize: 16, color: c.accent.primary, flexShrink: 0 }} />
<Typography
sx={{
flex: 1,
fontSize: '0.78rem',
fontWeight: 600,
color: c.text.primary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
minWidth: 0,
}}
>
{pageTitle || 'Browser'}
</Typography>
{/* Agent activity badge */}
{agentActive && (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
px: 0.75,
py: 0.25,
borderRadius: '6px',
bgcolor: `${accentColor}18`,
border: `1px solid ${accentColor}30`,
animation: 'badge-fade-in 0.25s ease-out',
'@keyframes badge-fade-in': {
'0%': { opacity: 0, transform: 'scale(0.85)' },
'100%': { opacity: 1, transform: 'scale(1)' },
},
}}
>
<Box
sx={{
width: 6,
height: 6,
borderRadius: '50%',
bgcolor: accentColor,
animation: 'badge-dot-pulse 1.4s ease-in-out infinite',
'@keyframes badge-dot-pulse': {
'0%, 100%': { opacity: 0.5, transform: 'scale(0.8)' },
'50%': { opacity: 1, transform: 'scale(1.3)' },
},
}}
/>
<Typography sx={{ fontSize: '0.65rem', fontWeight: 600, color: accentColor, lineHeight: 1 }}>
AI
</Typography>
</Box>
)}
<Tooltip title="Back" placement="top">
<span>
<IconButton
size="small"
onClick={handleBack}
onPointerDown={(e) => e.stopPropagation()}
disabled={!canGoBack}
sx={{ color: c.text.muted, p: 0.4, '&:hover': { color: c.text.primary } }}
>
<ArrowBackIcon sx={{ fontSize: 15 }} />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Forward" placement="top">
<span>
<IconButton
size="small"
onClick={handleForward}
onPointerDown={(e) => e.stopPropagation()}
disabled={!canGoForward}
sx={{ color: c.text.muted, p: 0.4, '&:hover': { color: c.text.primary } }}
>
<ArrowForwardIcon sx={{ fontSize: 15 }} />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Reload" placement="top">
<IconButton
size="small"
onClick={handleRefresh}
onPointerDown={(e) => e.stopPropagation()}
sx={{ color: c.text.muted, p: 0.4, '&:hover': { color: c.text.primary } }}
>
<RefreshIcon sx={{ fontSize: 15 }} />
</IconButton>
</Tooltip>
<Tooltip title="Close browser" placement="top">
<IconButton
size="small"
onClick={handleRemove}
onPointerDown={(e) => e.stopPropagation()}
sx={{ color: c.text.ghost, p: 0.4, '&:hover': { color: c.status.error } }}
>
<CloseIcon sx={{ fontSize: 15 }} />
</IconButton>
</Tooltip>
</Box>
{/* URL bar */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
px: 1,
py: 0.4,
bgcolor: c.bg.page,
borderBottom: `1px solid ${c.border.subtle}`,
flexShrink: 0,
}}
>
{isSecure && (
<LockIcon sx={{ fontSize: 13, color: c.status.success, flexShrink: 0 }} />
)}
<InputBase
value={urlBarValue}
onChange={(e) => setUrlBarValue(e.target.value)}
onKeyDown={handleUrlKeyDown}
onPointerDown={(e) => e.stopPropagation()}
onFocus={(e) => (e.target as HTMLInputElement).select()}
placeholder="Enter URL..."
sx={{
flex: 1,
fontSize: '0.76rem',
fontFamily: c.font.mono,
color: c.text.secondary,
py: 0,
'& input': { py: '3px' },
'& input::placeholder': { color: c.text.ghost, opacity: 1 },
}}
/>
</Box>
{/* Loading indicator — accent-colored when agent is navigating */}
{(loading || (agentActive && agentAction === 'navigate')) && (
<LinearProgress
sx={{
height: 2,
flexShrink: 0,
bgcolor: 'transparent',
'& .MuiLinearProgress-bar': {
bgcolor: agentActive ? accentColor : c.accent.primary,
},
}}
/>
)}
{/* Browser body */}
<Box sx={{ flex: 1, position: 'relative', overflow: 'hidden' }}>
{isElectron ? (
<webview
ref={webviewRef as any}
src={currentUrl}
style={{ width: '100%', height: '100%', border: 'none' }}
/>
) : (
<Box sx={{ width: '100%', height: '100%', position: 'relative' }}>
<iframe
src={currentUrl}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
style={{ width: '100%', height: '100%', border: 'none' }}
title="Browser"
/>
<Box
sx={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
bgcolor: `${c.status.warningBg}`,
borderTop: `1px solid ${c.status.warning}`,
px: 1.5,
py: 0.5,
display: 'flex',
alignItems: 'center',
gap: 0.5,
}}
>
<Typography sx={{ fontSize: '0.68rem', color: c.status.warning }}>
iframe mode some sites may not load. Use the Electron build for full browser support.
</Typography>
</Box>
</Box>
)}
{/* ===== Action micro-animations ===== */}
{/* Camera flash — screenshot */}
{(agentAction === 'screenshot' || lastAction === 'screenshot') && (
<Box
sx={{
position: 'absolute',
inset: 0,
bgcolor: '#fff',
pointerEvents: 'none',
zIndex: 15,
animation: 'camera-flash 0.4s ease-out forwards',
'@keyframes camera-flash': {
'0%': { opacity: 0.45 },
'100%': { opacity: 0 },
},
}}
/>
)}
{/* Scanning line — get_text */}
{agentAction === 'get_text' && (
<Box
sx={{
position: 'absolute',
left: 0,
right: 0,
height: '3px',
zIndex: 15,
pointerEvents: 'none',
background: `linear-gradient(180deg, transparent, ${accentColor}90, transparent)`,
boxShadow: `0 0 12px ${accentColor}60`,
animation: 'scan-sweep 1.5s ease-in-out infinite',
'@keyframes scan-sweep': {
'0%': { top: '0%' },
'100%': { top: '100%' },
},
}}
/>
)}
{/* Click ripple */}
{(agentAction === 'click' || lastAction === 'click') && (
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
width: 40,
height: 40,
borderRadius: '50%',
border: `2px solid ${accentColor}`,
transform: 'translate(-50%, -50%)',
pointerEvents: 'none',
zIndex: 15,
animation: 'click-ripple 0.5s ease-out forwards',
'@keyframes click-ripple': {
'0%': { opacity: 0.8, width: 10, height: 10, borderWidth: '2px' },
'100%': { opacity: 0, width: 60, height: 60, borderWidth: '1px' },
},
}}
/>
)}
{/* Typing indicator */}
{agentAction === 'type' && (
<Box
sx={{
position: 'absolute',
bottom: 8,
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
gap: '4px',
alignItems: 'center',
px: 1,
py: 0.5,
borderRadius: '8px',
bgcolor: `${accentColor}20`,
border: `1px solid ${accentColor}40`,
zIndex: 15,
pointerEvents: 'none',
}}
>
{[0, 1, 2].map((i) => (
<Box
key={i}
sx={{
width: 5,
height: 5,
borderRadius: '50%',
bgcolor: accentColor,
animation: `typing-dot 1s ease-in-out ${i * 0.15}s infinite`,
'@keyframes typing-dot': {
'0%, 60%, 100%': { opacity: 0.3, transform: 'scale(0.8)' },
'30%': { opacity: 1, transform: 'scale(1.2)' },
},
}}
/>
))}
</Box>
)}
{/* ===== Frosted glass overlay ===== */}
{agentActive && (
<Box
sx={{
position: 'absolute',
inset: 0,
zIndex: 16,
backdropFilter: 'blur(2px)',
bgcolor: 'rgba(0,0,0,0.15)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 1.5,
animation: 'overlay-fade-in 0.25s ease-out',
'@keyframes overlay-fade-in': {
'0%': { opacity: 0 },
'100%': { opacity: 1 },
},
}}
>
<CircularProgress
size={28}
thickness={3}
sx={{ color: accentColor }}
/>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
px: 1.5,
py: 0.75,
borderRadius: '10px',
bgcolor: 'rgba(0,0,0,0.55)',
backdropFilter: 'blur(8px)',
border: `1px solid ${accentColor}30`,
}}
>
<SmartToyOutlinedIcon sx={{ fontSize: 14, color: accentColor }} />
<Typography
sx={{
fontSize: '0.75rem',
fontWeight: 600,
color: '#fff',
letterSpacing: '0.02em',
}}
>
{getActionLabel(agentAction ?? '')}
</Typography>
</Box>
</Box>
)}
</Box>
{/* Resize handles */}
{HANDLE_DEFS.map(({ dir, sx }) => (
<Box
key={dir}
className="resize-handle"
onPointerDown={handleResizeDown(dir)}
onPointerMove={handleResizeMove}
onPointerUp={handleResizeUp}
sx={{
position: 'absolute',
cursor: CURSOR_MAP[dir],
opacity: 0,
zIndex: 10,
...sx,
}}
/>
))}
</Box>
);
};
export default BrowserCard;
+192 -13
View File
@@ -13,6 +13,7 @@ import {
launchAndSendFirstMessage,
generateTitle,
resumeSession,
setExpandedSessionIds,
} from '@/shared/state/agentsSlice';
import type { AgentConfig } from '@/shared/state/agentsSlice';
import {
@@ -21,30 +22,47 @@ import {
reconcileSessions,
tidyLayout,
addViewCard,
addBrowserCard,
moveCards,
resetLayout,
} from '@/shared/state/dashboardLayoutSlice';
import { fetchOutputs } from '@/shared/state/outputsSlice';
import { generateDashboardName } from '@/shared/state/dashboardsSlice';
import { dashboardWs } from '@/shared/ws/WebSocketManager';
import { initBrowserCommandHandler } from '@/shared/browserCommandHandler';
import AgentCard from './AgentCard';
import DashboardViewCard from './DashboardViewCard';
import BrowserCard from './BrowserCard';
import CanvasControls from './CanvasControls';
import DashboardToolbar from './DashboardToolbar';
import { useCanvasControls } from './useCanvasControls';
import { useDashboardSelection } from './useDashboardSelection';
import type { CardType } from './useDashboardSelection';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import type { ContextPath } from '@/app/components/DirectoryBrowser';
import { ElementSelectionProvider, useElementSelection } from '@/app/components/ElementSelectionContext';
import { ElementSelectionProvider } from '@/app/components/ElementSelectionContext';
import { useDomElementSelector } from '@/app/components/useDomElementSelector';
import SelectionOverlay from '@/app/components/SelectionOverlay';
const SELECT_ATTR = 'data-select-type';
const DashboardSelectionOverlay: React.FC = () => {
const { overlay, dragRect, dragPreview } = useDomElementSelector();
return <SelectionOverlay overlay={overlay} dragRect={dragRect} dragPreview={dragPreview} />;
};
function isCardTarget(target: EventTarget | null, boundary: EventTarget | null): boolean {
let el = target as HTMLElement | null;
while (el && el !== boundary) {
if (el.hasAttribute(SELECT_ATTR)) return true;
el = el.parentElement;
}
return false;
}
const DashboardInner: React.FC = () => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const elementSelection = useElementSelection();
const { id: dashboardId } = useParams<{ id: string }>();
const dashboardName = useAppSelector((state) =>
dashboardId ? state.dashboards.items[dashboardId]?.name : undefined,
@@ -53,30 +71,106 @@ const DashboardInner: React.FC = () => {
const expandedSessionIds = useAppSelector((state) => state.agents.expandedSessionIds);
const cards = useAppSelector((state) => state.dashboardLayout.cards);
const viewCards = useAppSelector((state) => state.dashboardLayout.viewCards);
const browserCards = useAppSelector((state) => state.dashboardLayout.browserCards);
const layoutInitialized = useAppSelector((state) => state.dashboardLayout.initialized);
const persistedExpandedSessionIds = useAppSelector((state) => state.dashboardLayout.persistedExpandedSessionIds);
const zoomSensitivity = useAppSelector((state) => state.settings.data.zoom_sensitivity);
const newAgentShortcut = useAppSelector((state) => state.settings.data.new_agent_shortcut);
const browserHomepage = useAppSelector((state) => state.settings.data.browser_homepage);
const outputs = useAppSelector((state) => state.outputs.items);
const sessionList = Object.values(sessions);
const selectModeActive = elementSelection?.selectMode ?? false;
const canvas = useCanvasControls(zoomSensitivity, selectModeActive);
const canvas = useCanvasControls(zoomSensitivity);
const selection = useDashboardSelection(
{ panX: canvas.panX, panY: canvas.panY, zoom: canvas.zoom, viewportRef: canvas.viewportRef },
cards,
viewCards,
browserCards,
);
const toolbarRef = useRef<HTMLDivElement>(null);
const [toolbarOpen, setToolbarOpen] = useState(false);
const spawnOriginsRef = useRef<Record<string, { x: number; y: number }>>({});
const hasFittedRef = useRef(false);
const restoredExpandedRef = useRef(false);
// ---- Multi-drag coordination ----
const [multiDragDelta, setMultiDragDelta] = useState<{ dx: number; dy: number } | null>(null);
const activeDragCardRef = useRef<string | null>(null);
const isMultiDragRef = useRef(false);
const handleCardDragStart = useCallback((id: string, _type: CardType) => {
if (selection.isSelected(id)) {
activeDragCardRef.current = id;
isMultiDragRef.current = true;
} else {
selection.deselectAll();
activeDragCardRef.current = null;
isMultiDragRef.current = false;
}
}, [selection]);
const handleCardDragMove = useCallback((dx: number, dy: number) => {
if (isMultiDragRef.current) {
setMultiDragDelta({ dx, dy });
}
}, []);
const handleCardDragEnd = useCallback((dx: number, dy: number, didDrag: boolean) => {
if (isMultiDragRef.current && didDrag) {
const items = selection.selectedArray()
.filter((s) => s.id !== activeDragCardRef.current);
if (items.length > 0) {
dispatch(moveCards({ items, dx, dy }));
}
}
activeDragCardRef.current = null;
isMultiDragRef.current = false;
setMultiDragDelta(null);
}, [selection, dispatch]);
const handleCardSelect = useCallback((id: string, type: CardType, shiftKey: boolean) => {
selection.selectCard(id, type, shiftKey);
}, [selection]);
// ---- Viewport event handlers (compose pan + marquee) ----
const handleViewportMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button === 1) {
canvas.handlers.onMouseDown(e);
return;
}
if (e.button !== 0) return;
if (isCardTarget(e.target, e.currentTarget)) return;
if (e.metaKey || e.ctrlKey || canvas.spaceHeld) {
selection.handleCanvasMouseDown(e.nativeEvent);
} else {
canvas.handlers.onMouseDown(e);
}
}, [canvas, selection]);
const handleViewportMouseMove = useCallback((e: React.MouseEvent) => {
canvas.handlers.onMouseMove(e);
selection.handleCanvasMouseMove(e.nativeEvent);
}, [canvas.handlers, selection]);
const handleViewportMouseUp = useCallback((e: React.MouseEvent) => {
canvas.handlers.onMouseUp();
selection.handleCanvasMouseUp(e.nativeEvent);
}, [canvas.handlers, selection]);
useEffect(() => {
if (!dashboardId) return;
hasFittedRef.current = false;
restoredExpandedRef.current = false;
dispatch(resetLayout());
dispatch(fetchSessions({ dashboardId }));
dispatch(fetchHistory({ dashboardId }));
dispatch(fetchLayout(dashboardId));
dispatch(fetchOutputs());
dashboardWs.connect();
return () => dashboardWs.disconnect();
const cleanupBrowserHandler = initBrowserCommandHandler();
return () => { cleanupBrowserHandler(); dashboardWs.disconnect(); };
}, [dispatch, dashboardId]);
useEffect(() => {
@@ -86,6 +180,14 @@ const DashboardInner: React.FC = () => {
return () => clearTimeout(timer);
}, [layoutInitialized, canvas.actions]);
useEffect(() => {
if (!layoutInitialized || restoredExpandedRef.current) return;
restoredExpandedRef.current = true;
if (persistedExpandedSessionIds.length > 0) {
dispatch(setExpandedSessionIds(persistedExpandedSessionIds));
}
}, [layoutInitialized, persistedExpandedSessionIds, dispatch]);
const prevSessionIdsRef = useRef<string>('');
useEffect(() => {
@@ -101,6 +203,8 @@ const DashboardInner: React.FC = () => {
const cardsJson = JSON.stringify(cards);
const viewCardsJson = JSON.stringify(viewCards);
const browserCardsJson = JSON.stringify(browserCards);
const expandedJson = JSON.stringify(expandedSessionIds);
const skipInitialSave = useRef(true);
useEffect(() => {
if (!layoutInitialized || !dashboardId) return;
@@ -108,8 +212,8 @@ const DashboardInner: React.FC = () => {
skipInitialSave.current = false;
return;
}
dispatch(saveLayout({ dashboardId, cards, viewCards }));
}, [cardsJson, viewCardsJson, layoutInitialized, dashboardId]);
dispatch(saveLayout({ dashboardId, cards, viewCards, browserCards, expandedSessionIds }));
}, [cardsJson, viewCardsJson, browserCardsJson, expandedJson, layoutInitialized, dashboardId]);
useEffect(() => {
const parts = newAgentShortcut.toLowerCase().split('+');
@@ -188,6 +292,22 @@ const DashboardInner: React.FC = () => {
dispatch(generateTitle({ sessionId: realId, prompt }));
spawnOriginsRef.current[realId] = spawnOriginsRef.current[draftId];
delete spawnOriginsRef.current[draftId];
if (dashboardId) {
const currentSessions = store.getState().agents.sessions;
const agentCount = Object.values(currentSessions).filter(
(s) => s.status !== 'draft' && s.dashboard_id === dashboardId,
).length;
const NAME_GEN_TRIGGERS = [1, 3, 6];
const currentDash = store.getState().dashboards.items[dashboardId];
const canAutoName =
currentDash &&
(currentDash.auto_named || currentDash.name === 'Untitled Dashboard');
if (NAME_GEN_TRIGGERS.includes(agentCount) && canAutoName) {
dispatch(generateDashboardName(dashboardId));
}
}
} else {
delete spawnOriginsRef.current[draftId];
}
@@ -200,6 +320,10 @@ const DashboardInner: React.FC = () => {
dispatch(addViewCard({ outputId }));
}, [dispatch]);
const handleAddBrowser = useCallback(() => {
dispatch(addBrowserCard({ url: browserHomepage }));
}, [dispatch, browserHomepage]);
const handleHistoryResume = useCallback((sessionId: string) => {
dispatch(resumeSession({ sessionId })).then((action) => {
if (resumeSession.fulfilled.match(action)) {
@@ -212,10 +336,11 @@ const DashboardInner: React.FC = () => {
dispatch(collapseAllSessions());
dispatch(tidyLayout());
const { cards: tidied, viewCards: tidiedViews } = store.getState().dashboardLayout;
const { cards: tidied, viewCards: tidiedViews, browserCards: tidiedBrowsers } = store.getState().dashboardLayout;
const allRects = [
...Object.values(tidied).map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })),
...Object.values(tidiedViews).map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })),
...Object.values(tidiedBrowsers).map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })),
];
canvas.actions.fitToCards(allRects);
}, [dispatch, canvas.actions]);
@@ -279,14 +404,20 @@ const DashboardInner: React.FC = () => {
{/* Canvas viewport */}
<Box
ref={canvas.viewportRef}
onMouseDown={canvas.handlers.onMouseDown}
onMouseMove={canvas.handlers.onMouseMove}
onMouseUp={canvas.handlers.onMouseUp}
onMouseDown={handleViewportMouseDown}
onMouseMove={handleViewportMouseMove}
onMouseUp={handleViewportMouseUp}
sx={{
position: 'absolute',
inset: 0,
overflow: 'hidden',
cursor: canvas.isPanning ? 'grabbing' : canvas.spaceHeld ? 'grab' : selectModeActive ? 'crosshair' : 'default',
cursor: canvas.isPanning
? 'grabbing'
: (canvas.spaceHeld || canvas.cmdHeld)
? 'crosshair'
: selection.marquee
? 'crosshair'
: 'default',
}}
>
{/* Dot grid background */}
@@ -301,7 +432,7 @@ const DashboardInner: React.FC = () => {
}}
/>
{sessionList.length === 0 && Object.keys(viewCards).length === 0 ? (
{sessionList.length === 0 && Object.keys(viewCards).length === 0 && Object.keys(browserCards).length === 0 ? (
<Box
sx={{
position: 'absolute',
@@ -346,6 +477,12 @@ const DashboardInner: React.FC = () => {
cardHeight={card.height}
zoom={canvas.zoom}
spawnFrom={origin}
isSelected={selection.isSelected(session.id)}
multiDragDelta={multiDragDelta}
onCardSelect={handleCardSelect}
onDragStart={handleCardDragStart}
onDragMove={handleCardDragMove}
onDragEnd={handleCardDragEnd}
/>
);
})}
@@ -361,9 +498,50 @@ const DashboardInner: React.FC = () => {
cardWidth={vc.width}
cardHeight={vc.height}
zoom={canvas.zoom}
isSelected={selection.isSelected(vc.output_id)}
multiDragDelta={multiDragDelta}
onCardSelect={handleCardSelect}
onDragStart={handleCardDragStart}
onDragMove={handleCardDragMove}
onDragEnd={handleCardDragEnd}
/>
);
})}
{Object.values(browserCards).map((bc) => (
<BrowserCard
key={`browser-${bc.browser_id}`}
browserId={bc.browser_id}
url={bc.url}
cardX={bc.x}
cardY={bc.y}
cardWidth={bc.width}
cardHeight={bc.height}
zoom={canvas.zoom}
isSelected={selection.isSelected(bc.browser_id)}
multiDragDelta={multiDragDelta}
onCardSelect={handleCardSelect}
onDragStart={handleCardDragStart}
onDragMove={handleCardDragMove}
onDragEnd={handleCardDragEnd}
/>
))}
{/* Marquee selection rectangle */}
{selection.marquee && (
<div
style={{
position: 'absolute',
left: selection.marquee.x,
top: selection.marquee.y,
width: selection.marquee.width,
height: selection.marquee.height,
border: '1.5px dashed rgba(59, 130, 246, 0.6)',
background: 'rgba(59, 130, 246, 0.08)',
borderRadius: 2,
pointerEvents: 'none',
zIndex: 9999,
}}
/>
)}
</div>
)}
</Box>
@@ -378,6 +556,7 @@ const DashboardInner: React.FC = () => {
onSend={handleToolbarSend}
onAddView={handleAddView}
onHistoryResume={handleHistoryResume}
onAddBrowser={handleAddBrowser}
dashboardId={dashboardId}
/>
</Box>
@@ -10,6 +10,7 @@ import AddIcon from '@mui/icons-material/Add';
import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded';
import StickyNote2OutlinedIcon from '@mui/icons-material/StickyNote2Outlined';
import HistoryRoundedIcon from '@mui/icons-material/HistoryRounded';
import LanguageIcon from '@mui/icons-material/Language';
import SearchIcon from '@mui/icons-material/Search';
import { motion } from 'framer-motion';
import ChatInput from '@/app/pages/AgentChat/ChatInput';
@@ -36,6 +37,7 @@ interface Props {
) => void;
onAddView: (outputId: string) => void;
onHistoryResume: (sessionId: string) => void;
onAddBrowser: () => void;
dashboardId?: string;
}
@@ -79,7 +81,7 @@ function formatRelativeTime(dateStr: string | null): string {
}
const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
({ inputOpen, onNewAgent, onCancel, onSend, onAddView, onHistoryResume, dashboardId }, ref) => {
({ inputOpen, onNewAgent, onCancel, onSend, onAddView, onHistoryResume, onAddBrowser, dashboardId }, ref) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const elementSelection = useElementSelection();
@@ -156,16 +158,32 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
}, [onAddView]);
const handleOpenViewPicker = useCallback(() => {
if (viewPickerOpen) {
setViewPickerOpen(false);
setViewSearch('');
return;
}
setHistoryOpen(false);
setHistoryQuery('');
dispatch(clearHistorySearch());
setViewPickerOpen(true);
setViewSearch('');
}, []);
}, [viewPickerOpen, dispatch]);
const handleOpenHistory = useCallback(() => {
if (historyOpen) {
setHistoryOpen(false);
setHistoryQuery('');
dispatch(clearHistorySearch());
return;
}
setViewPickerOpen(false);
setViewSearch('');
setHistoryOpen(true);
setHistoryQuery('');
dispatch(clearHistorySearch());
dispatch(searchHistory({ q: '', limit: HISTORY_PAGE_SIZE, offset: 0, dashboardId }));
}, [dispatch, dashboardId]);
}, [historyOpen, dispatch, dashboardId]);
const handleHistorySelect = useCallback((sessionId: string) => {
onHistoryResume(sessionId);
@@ -184,10 +202,12 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
const isExpanded = inputOpen || viewPickerOpen || historyOpen;
const prevInputOpenRef = useRef(inputOpen);
useEffect(() => {
if (!inputOpen && elementSelection?.selectMode) {
if (prevInputOpenRef.current && !inputOpen && elementSelection?.selectMode) {
elementSelection.setSelectMode(false);
}
prevInputOpenRef.current = inputOpen;
}, [inputOpen, elementSelection]);
useEffect(() => {
@@ -234,6 +254,21 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
}
}, [historyOpen]);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.metaKey && e.key.toLowerCase() === 'm' && !e.ctrlKey && !e.shiftKey && !e.altKey) {
e.preventDefault();
handleOpenViewPicker();
}
if (e.metaKey && e.key.toLowerCase() === 'o' && !e.ctrlKey && !e.shiftKey && !e.altKey) {
e.preventDefault();
handleOpenHistory();
}
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [handleOpenViewPicker, handleOpenHistory]);
useEffect(() => {
if (!historyOpen) return;
const timer = setTimeout(() => {
@@ -526,8 +561,6 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
</Box>
</WarmTooltip>
<Box sx={{ width: '1px', height: 24, bgcolor: c.border.medium, mx: '6px', flexShrink: 0 }} />
<WarmTooltip
tokens={c}
placement="top"
@@ -535,7 +568,7 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
enterDelay={200}
title={
<Box sx={{ textAlign: 'center' }}>
<Box sx={{ fontWeight: 600 }}>Add View</Box>
<Box sx={{ fontWeight: 600 }}>Add View M</Box>
</Box>
}
>
@@ -568,7 +601,7 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
enterDelay={200}
title={
<Box sx={{ textAlign: 'center' }}>
<Box sx={{ fontWeight: 600 }}>History</Box>
<Box sx={{ fontWeight: 600 }}>History O</Box>
</Box>
}
>
@@ -594,6 +627,39 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
</Box>
</WarmTooltip>
<WarmTooltip
tokens={c}
placement="top"
arrow
enterDelay={200}
title={
<Box sx={{ textAlign: 'center' }}>
<Box sx={{ fontWeight: 600 }}>Browser</Box>
</Box>
}
>
<Box
role="button"
aria-label="Browser"
tabIndex={0}
onClick={onAddBrowser}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: BTN,
height: BTN,
borderRadius: `${c.radius.md}px`,
color: c.text.tertiary,
cursor: 'pointer',
transition: 'opacity 0.15s, background-color 0.15s',
'&:hover': { opacity: 1, bgcolor: c.bg.secondary, color: c.accent.primary },
}}
>
<LanguageIcon sx={{ fontSize: 22 }} />
</Box>
</WarmTooltip>
{placeholderItems.map(({ icon: PlaceholderIcon, label, sub }) => (
<WarmTooltip
key={label}
@@ -8,7 +8,7 @@ import RefreshIcon from '@mui/icons-material/Refresh';
import BoltIcon from '@mui/icons-material/Bolt';
import CloseIcon from '@mui/icons-material/Close';
import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded';
import { Output, autoRunOutput, autoRunAgentOutput, executeOutput, OutputExecuteResult, getBackendCode } from '@/shared/state/outputsSlice';
import { Output, autoRunOutput, autoRunAgentOutput, executeOutput, OutputExecuteResult, getBackendCode, SERVE_BASE } from '@/shared/state/outputsSlice';
import { setViewCardPosition, setViewCardSize, removeViewCard } from '@/shared/state/dashboardLayoutSlice';
import { useAppDispatch } from '@/shared/hooks';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
@@ -45,9 +45,18 @@ interface Props {
cardWidth: number;
cardHeight: number;
zoom?: number;
isSelected?: boolean;
multiDragDelta?: { dx: number; dy: number } | null;
onCardSelect?: (id: string, type: 'agent' | 'view', shiftKey: boolean) => void;
onDragStart?: (id: string, type: 'agent' | 'view') => void;
onDragMove?: (dx: number, dy: number) => void;
onDragEnd?: (dx: number, dy: number, didDrag: boolean) => void;
}
const DashboardViewCard: React.FC<Props> = ({ output, cardX, cardY, cardWidth, cardHeight, zoom = 1 }) => {
const DashboardViewCard: React.FC<Props> = ({
output, cardX, cardY, cardWidth, cardHeight, zoom = 1,
isSelected = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd,
}) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const previewRef = useRef<ViewPreviewHandle>(null);
@@ -64,6 +73,7 @@ const DashboardViewCard: React.FC<Props> = ({ output, cardX, cardY, cardWidth, c
const [isDragging, setIsDragging] = useState(false);
const [localDragPos, setLocalDragPos] = useState<{ x: number; y: number } | null>(null);
const didDrag = useRef(false);
const justDraggedRef = useRef(false);
const handleDragPointerDown = useCallback((e: React.PointerEvent) => {
e.preventDefault();
@@ -72,7 +82,8 @@ const DashboardViewCard: React.FC<Props> = ({ output, cardX, cardY, cardWidth, c
didDrag.current = false;
setIsDragging(true);
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}, [cardX, cardY]);
onDragStart?.(output.id, 'view');
}, [cardX, cardY, onDragStart, output.id]);
const handleDragPointerMove = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
@@ -80,29 +91,35 @@ const DashboardViewCard: React.FC<Props> = ({ output, cardX, cardY, cardWidth, c
const rawDy = e.clientY - dragState.current.startY;
if (!didDrag.current && Math.sqrt(rawDx * rawDx + rawDy * rawDy) < DRAG_THRESHOLD) return;
didDrag.current = true;
const dx = rawDx / zoom;
const dy = rawDy / zoom;
setLocalDragPos({
x: dragState.current.origX + rawDx / zoom,
y: dragState.current.origY + rawDy / zoom,
x: dragState.current.origX + dx,
y: dragState.current.origY + dy,
});
}, [zoom]);
onDragMove?.(dx, dy);
}, [zoom, onDragMove]);
const handleDragPointerUp = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
const dx = (e.clientX - dragState.current.startX) / zoom;
const dy = (e.clientY - dragState.current.startY) / zoom;
if (didDrag.current) {
const dx = (e.clientX - dragState.current.startX) / zoom;
const dy = (e.clientY - dragState.current.startY) / zoom;
dispatch(setViewCardPosition({
outputId: output.id,
x: dragState.current.origX + dx,
y: dragState.current.origY + dy,
}));
justDraggedRef.current = true;
requestAnimationFrame(() => { justDraggedRef.current = false; });
}
onDragEnd?.(dx, dy, didDrag.current);
dragState.current = null;
didDrag.current = false;
setLocalDragPos(null);
setIsDragging(false);
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}, [zoom, dispatch, output.id]);
}, [zoom, dispatch, output.id, onDragEnd]);
// ---- Resize ----
const resizeRef = useRef<{
@@ -223,17 +240,23 @@ const DashboardViewCard: React.FC<Props> = ({ output, cardX, cardY, cardWidth, c
}
};
// Compute display position (prefer local drag/resize during interaction)
const displayX = localResize?.x ?? localDragPos?.x ?? cardX;
const displayY = localResize?.y ?? localDragPos?.y ?? cardY;
const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0;
const mdDy = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dy : 0;
const displayX = localResize?.x ?? localDragPos?.x ?? (cardX + mdDx);
const displayY = localResize?.y ?? localDragPos?.y ?? (cardY + mdDy);
const displayW = localResize?.w ?? cardWidth;
const displayH = localResize?.h ?? cardHeight;
const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta);
return (
<Box
data-select-type="view-card"
data-select-id={output.id}
data-select-meta={JSON.stringify({ name: output.name, description: output.description })}
onClick={(e: React.MouseEvent) => {
if (justDraggedRef.current) return;
onCardSelect?.(output.id, 'view', e.shiftKey);
}}
sx={{
position: 'absolute',
left: displayX,
@@ -241,14 +264,18 @@ const DashboardViewCard: React.FC<Props> = ({ output, cardX, cardY, cardWidth, c
width: displayW,
height: displayH,
borderRadius: `${c.radius.lg}px`,
border: `1px solid ${c.border.medium}`,
border: isSelected ? '2px solid #3b82f6' : `1px solid ${c.border.medium}`,
bgcolor: c.bg.surface,
boxShadow: (isDragging || isResizing) ? c.shadow.lg : c.shadow.md,
boxShadow: isDragging || isResizing
? c.shadow.lg
: isSelected
? `0 0 0 1px #3b82f6, ${c.shadow.md}`
: c.shadow.md,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
zIndex: (isDragging || isResizing) ? 100 : 1,
transition: (isDragging || isResizing) ? 'none' : 'box-shadow 0.2s',
transition: noTransition ? 'none' : 'box-shadow 0.2s',
'&:hover .resize-handle': { opacity: 1 },
}}
>
@@ -329,7 +356,7 @@ const DashboardViewCard: React.FC<Props> = ({ output, cardX, cardY, cardWidth, c
<Box sx={{ flex: 1, position: 'relative', overflow: 'hidden' }}>
<ViewPreview
ref={previewRef}
serveUrl={`/api/outputs/${output.id}/serve/index.html`}
serveUrl={`${SERVE_BASE}/${output.id}/serve/index.html`}
frontendCode={output.files?.['index.html'] ?? ''}
inputData={inputData}
backendResult={backendResult}
@@ -22,16 +22,18 @@ function clamp(val: number, min: number, max: number) {
return Math.min(max, Math.max(min, val));
}
export function useCanvasControls(zoomSensitivity: number = 50, panDisabled: boolean = false) {
export function useCanvasControls(zoomSensitivity: number = 50) {
const viewportRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const [state, setState] = useState<CanvasState>({ panX: 0, panY: 0, zoom: 1 });
const [isPanning, setIsPanning] = useState(false);
const [spaceHeld, setSpaceHeld] = useState(false);
const [cmdHeld, setCmdHeld] = useState(false);
const panStartRef = useRef<{ x: number; y: number; panX: number; panY: number } | null>(null);
const spaceRef = useRef(false);
const cmdRef = useRef(false);
const sensitivityRef = useRef(zoomSensitivity);
sensitivityRef.current = zoomSensitivity;
@@ -109,6 +111,10 @@ export function useCanvasControls(zoomSensitivity: number = 50, panDisabled: boo
spaceRef.current = true;
setSpaceHeld(true);
}
if ((e.key === 'Meta' || e.key === 'Control') && !e.repeat) {
cmdRef.current = true;
setCmdHeld(true);
}
if (e.ctrlKey || e.metaKey) {
if (e.key === '0') {
e.preventDefault();
@@ -145,6 +151,10 @@ export function useCanvasControls(zoomSensitivity: number = 50, panDisabled: boo
spaceRef.current = false;
setSpaceHeld(false);
}
if (e.key === 'Meta' || e.key === 'Control') {
cmdRef.current = false;
setCmdHeld(false);
}
};
window.addEventListener('keydown', onKeyDown);
@@ -156,23 +166,15 @@ export function useCanvasControls(zoomSensitivity: number = 50, panDisabled: boo
}, []);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
const isMiddle = e.button === 1;
const isBackgroundLeft = e.button === 0 && e.target === viewportRef.current;
const isSpaceDrag = e.button === 0 && spaceRef.current;
if (panDisabled && !isMiddle && !isSpaceDrag) return;
if (isMiddle || isBackgroundLeft || isSpaceDrag) {
e.preventDefault();
setIsPanning(true);
panStartRef.current = {
x: e.clientX,
y: e.clientY,
panX: state.panX,
panY: state.panY,
};
}
}, [state.panX, state.panY, panDisabled]);
e.preventDefault();
setIsPanning(true);
panStartRef.current = {
x: e.clientX,
y: e.clientY,
panX: state.panX,
panY: state.panY,
};
}, [state.panX, state.panY]);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
const start = panStartRef.current;
@@ -309,6 +311,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, panDisabled: boo
...state,
isPanning,
spaceHeld,
cmdHeld,
viewportRef,
contentRef,
handlers: {
@@ -0,0 +1,233 @@
import { useState, useCallback, useRef, useEffect, RefObject } from 'react';
import type { CardPosition, ViewCardPosition, BrowserCardPosition } from '@/shared/state/dashboardLayoutSlice';
export type CardType = 'agent' | 'view' | 'browser';
export interface SelectedCard {
id: string;
type: CardType;
}
export interface MarqueeRect {
x: number;
y: number;
width: number;
height: number;
}
interface ScreenToCanvas {
panX: number;
panY: number;
zoom: number;
viewportRef: RefObject<HTMLDivElement | null>;
}
const DRAG_THRESHOLD = 4;
function rectsIntersect(
a: { x: number; y: number; width: number; height: number },
b: { x: number; y: number; width: number; height: number },
): boolean {
return (
a.x < b.x + b.width &&
a.x + a.width > b.x &&
a.y < b.y + b.height &&
a.y + a.height > b.y
);
}
export function useDashboardSelection(
canvas: ScreenToCanvas,
cards: Record<string, CardPosition>,
viewCards: Record<string, ViewCardPosition>,
browserCards: Record<string, BrowserCardPosition> = {},
) {
const [selectedIds, setSelectedIds] = useState<Map<string, CardType>>(new Map());
const [marquee, setMarquee] = useState<MarqueeRect | null>(null);
const marqueeOriginRef = useRef<{ screenX: number; screenY: number } | null>(null);
const isDraggingMarqueeRef = useRef(false);
const shiftHeldRef = useRef(false);
const selectionBeforeMarqueeRef = useRef<Map<string, CardType>>(new Map());
const screenToCanvas = useCallback(
(screenX: number, screenY: number) => {
const vp = canvas.viewportRef.current;
if (!vp) return { x: 0, y: 0 };
const rect = vp.getBoundingClientRect();
return {
x: (screenX - rect.left - canvas.panX) / canvas.zoom,
y: (screenY - rect.top - canvas.panY) / canvas.zoom,
};
},
[canvas.panX, canvas.panY, canvas.zoom, canvas.viewportRef],
);
const isSelected = useCallback((id: string) => selectedIds.has(id), [selectedIds]);
const deselectAll = useCallback(() => setSelectedIds(new Map()), []);
const selectCard = useCallback(
(id: string, type: CardType, shiftKey: boolean) => {
setSelectedIds((prev) => {
if (shiftKey) {
const next = new Map(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.set(id, type);
}
return next;
}
return new Map([[id, type]]);
});
},
[],
);
const selectedArray = useCallback((): SelectedCard[] => {
return Array.from(selectedIds.entries()).map(([id, type]) => ({ id, type }));
}, [selectedIds]);
const computeMarqueeSelection = useCallback(
(rect: MarqueeRect, shiftKey: boolean) => {
const intersecting = new Map<string, CardType>();
for (const card of Object.values(cards)) {
if (
rectsIntersect(rect, {
x: card.x,
y: card.y,
width: card.width,
height: card.height,
})
) {
intersecting.set(card.session_id, 'agent');
}
}
for (const vc of Object.values(viewCards)) {
if (
rectsIntersect(rect, {
x: vc.x,
y: vc.y,
width: vc.width,
height: vc.height,
})
) {
intersecting.set(vc.output_id, 'view');
}
}
for (const bc of Object.values(browserCards)) {
if (
rectsIntersect(rect, {
x: bc.x,
y: bc.y,
width: bc.width,
height: bc.height,
})
) {
intersecting.set(bc.browser_id, 'browser');
}
}
if (shiftKey) {
const base = selectionBeforeMarqueeRef.current;
const next = new Map(base);
for (const [id, type] of intersecting) {
if (next.has(id)) {
next.delete(id);
} else {
next.set(id, type);
}
}
return next;
}
return intersecting;
},
[cards, viewCards, browserCards],
);
const handleCanvasMouseDown = useCallback(
(e: MouseEvent) => {
if (e.button !== 0) return;
marqueeOriginRef.current = { screenX: e.clientX, screenY: e.clientY };
isDraggingMarqueeRef.current = false;
shiftHeldRef.current = e.shiftKey;
selectionBeforeMarqueeRef.current = new Map(selectedIds);
},
[selectedIds],
);
const handleCanvasMouseMove = useCallback(
(e: MouseEvent) => {
const origin = marqueeOriginRef.current;
if (!origin) return;
const dx = e.clientX - origin.screenX;
const dy = e.clientY - origin.screenY;
if (!isDraggingMarqueeRef.current) {
if (Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD) return;
isDraggingMarqueeRef.current = true;
}
const start = screenToCanvas(origin.screenX, origin.screenY);
const end = screenToCanvas(e.clientX, e.clientY);
const rect: MarqueeRect = {
x: Math.min(start.x, end.x),
y: Math.min(start.y, end.y),
width: Math.abs(end.x - start.x),
height: Math.abs(end.y - start.y),
};
setMarquee(rect);
setSelectedIds(computeMarqueeSelection(rect, shiftHeldRef.current));
},
[screenToCanvas, computeMarqueeSelection],
);
const handleCanvasMouseUp = useCallback(
(e: MouseEvent) => {
const origin = marqueeOriginRef.current;
if (!origin) return;
if (!isDraggingMarqueeRef.current) {
if (!e.shiftKey) {
deselectAll();
}
}
marqueeOriginRef.current = null;
isDraggingMarqueeRef.current = false;
setMarquee(null);
},
[deselectAll],
);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
deselectAll();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [deselectAll]);
return {
selectedIds,
selectedArray,
marquee,
isSelected,
selectCard,
deselectAll,
handleCanvasMouseDown,
handleCanvasMouseMove,
handleCanvasMouseUp,
};
}
+213 -159
View File
@@ -1,7 +1,6 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Paper from '@mui/material/Paper';
import TextField from '@mui/material/TextField';
import Button from '@mui/material/Button';
import FormControl from '@mui/material/FormControl';
@@ -26,6 +25,7 @@ import DarkModeIcon from '@mui/icons-material/DarkMode';
import SaveIcon from '@mui/icons-material/Save';
import CloseIcon from '@mui/icons-material/Close';
import KeyboardIcon from '@mui/icons-material/Keyboard';
import LanguageIcon from '@mui/icons-material/Language';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { updateSettings, closeSettingsModal, AppSettings } from '@/shared/state/settingsSlice';
import { fetchModes } from '@/shared/state/modesSlice';
@@ -96,11 +96,56 @@ const Settings: React.FC = () => {
const fieldSx = {
'& .MuiOutlinedInput-root': {
bgcolor: c.bg.page,
fontSize: '0.85rem',
},
};
const sectionSx = {
fontSize: '0.7rem',
fontWeight: 600,
letterSpacing: '0.06em',
textTransform: 'uppercase' as const,
color: c.text.tertiary,
mb: 0.5,
mt: 0.5,
};
const rowSx = {
py: 2,
borderBottom: `1px solid ${c.border.subtle}`,
};
const rowLastSx = {
py: 2,
};
const inlineRowSx = {
...rowSx,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
};
const inlineRowLastSx = {
...rowLastSx,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
};
const labelSx = {
color: c.text.primary,
fontWeight: 500,
fontSize: '0.875rem',
lineHeight: 1.4,
};
const descSx = {
color: c.text.tertiary,
fontSize: '0.75rem',
lineHeight: 1.4,
};
return (
<>
<Dialog
@@ -109,12 +154,12 @@ const Settings: React.FC = () => {
maxWidth={false}
PaperProps={{
sx: {
width: 800,
width: 660,
maxHeight: '85vh',
bgcolor: c.bg.page,
borderRadius: 4,
borderRadius: 2,
border: `1px solid ${c.border.subtle}`,
boxShadow: c.shadow.lg,
boxShadow: c.shadow.md,
},
}}
>
@@ -125,38 +170,35 @@ const Settings: React.FC = () => {
justifyContent: 'space-between',
borderBottom: `1px solid ${c.border.subtle}`,
px: 3,
py: 2,
py: 1.5,
}}
>
<Box>
<Typography variant="h6" sx={{ color: c.text.primary, fontWeight: 700 }}>
Settings
</Typography>
<Typography sx={{ color: c.text.tertiary, fontSize: '0.8rem' }}>
Global defaults and application configuration.
</Typography>
</Box>
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '1rem' }}>
Settings
</Typography>
<IconButton onClick={handleRequestClose} size="small" sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }}>
<CloseIcon sx={{ fontSize: 20 }} />
<CloseIcon sx={{ fontSize: 18 }} />
</IconButton>
</DialogTitle>
<DialogContent sx={{
p: 3,
px: 3,
py: 0,
'&::-webkit-scrollbar': { width: 6 },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': { background: c.border.medium, borderRadius: 3, '&:hover': { background: c.border.strong } },
scrollbarWidth: 'thin',
scrollbarColor: `${c.border.medium} transparent`,
}}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 640, mx: 'auto', pt: 1 }}>
{/* Default System Prompt */}
<Paper sx={{ bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: 3, p: 3, boxShadow: c.shadow.sm }}>
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem', mb: 0.5 }}>
Default System Prompt
</Typography>
<Typography sx={{ color: c.text.tertiary, fontSize: '0.8rem', mb: 2 }}>
A global system prompt prepended to every agent session, before any mode-specific instructions.
<Box sx={{ display: 'flex', flexDirection: 'column', pt: 2.5, pb: 1 }}>
{/* ── Agent Defaults ── */}
<Typography sx={sectionSx}>Agent Defaults</Typography>
<Box sx={rowSx}>
<Typography sx={labelSx}>System prompt</Typography>
<Typography sx={{ ...descSx, mb: 1.5 }}>
Prepended to every agent session before mode-specific instructions.
</Typography>
<TextField
value={form.default_system_prompt ?? ''}
@@ -165,7 +207,7 @@ const Settings: React.FC = () => {
fullWidth
multiline
minRows={3}
maxRows={10}
maxRows={8}
placeholder="Enter a default system prompt..."
sx={{
...fieldSx,
@@ -183,15 +225,12 @@ const Settings: React.FC = () => {
},
}}
/>
</Paper>
</Box>
{/* Default Folder */}
<Paper sx={{ bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: 3, p: 3, boxShadow: c.shadow.sm }}>
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem', mb: 0.5 }}>
Default Folder
</Typography>
<Typography sx={{ color: c.text.tertiary, fontSize: '0.8rem', mb: 2 }}>
The working directory agents start in by default. Modes can override this per-mode.
<Box sx={rowSx}>
<Typography sx={labelSx}>Working directory</Typography>
<Typography sx={{ ...descSx, mb: 1.5 }}>
Default folder agents start in. Modes can override per-mode.
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<TextField
@@ -211,33 +250,32 @@ const Settings: React.FC = () => {
<Button
variant="outlined"
onClick={() => setBrowseOpen(true)}
startIcon={<FolderOpenIcon />}
startIcon={<FolderOpenIcon sx={{ fontSize: 16 }} />}
sx={{
color: c.accent.primary,
color: c.text.tertiary,
borderColor: c.border.medium,
textTransform: 'none',
whiteSpace: 'nowrap',
minWidth: 'auto',
fontSize: '0.8rem',
'&:hover': { color: c.accent.primary, borderColor: c.accent.primary },
}}
>
Browse
</Button>
</Box>
</Paper>
</Box>
{/* Default Model */}
<Paper sx={{ bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: 3, p: 3, boxShadow: c.shadow.sm }}>
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem', mb: 0.5 }}>
Default Model
</Typography>
<Typography sx={{ color: c.text.tertiary, fontSize: '0.8rem', mb: 2 }}>
The default model for new agent sessions.
</Typography>
<FormControl fullWidth size="small">
<Box sx={inlineRowSx}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>Model</Typography>
<Typography sx={descSx}>Default model for new sessions.</Typography>
</Box>
<FormControl size="small" sx={{ minWidth: 170 }}>
<Select
value={form.default_model}
onChange={(e) => setForm({ ...form, default_model: e.target.value })}
sx={{ bgcolor: c.bg.page }}
sx={{ fontSize: '0.85rem' }}
MenuProps={{ PaperProps: { sx: { bgcolor: c.bg.surface, color: c.text.primary } } }}
>
<MenuItem value="sonnet">Sonnet 4.6</MenuItem>
@@ -245,21 +283,18 @@ const Settings: React.FC = () => {
<MenuItem value="haiku">Haiku 3.5</MenuItem>
</Select>
</FormControl>
</Paper>
</Box>
{/* Default Mode */}
<Paper sx={{ bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: 3, p: 3, boxShadow: c.shadow.sm }}>
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem', mb: 0.5 }}>
Default Mode
</Typography>
<Typography sx={{ color: c.text.tertiary, fontSize: '0.8rem', mb: 2 }}>
The default interaction mode for new agent sessions.
</Typography>
<FormControl fullWidth size="small">
<Box sx={inlineRowSx}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>Mode</Typography>
<Typography sx={descSx}>Default interaction mode for new sessions.</Typography>
</Box>
<FormControl size="small" sx={{ minWidth: 170 }}>
<Select
value={form.default_mode}
onChange={(e) => setForm({ ...form, default_mode: e.target.value })}
sx={{ bgcolor: c.bg.page }}
sx={{ fontSize: '0.85rem' }}
MenuProps={{ PaperProps: { sx: { bgcolor: c.bg.surface, color: c.text.primary } } }}
>
{modesList.map((m) => (
@@ -267,35 +302,68 @@ const Settings: React.FC = () => {
))}
</Select>
</FormControl>
</Paper>
</Box>
{/* Default Max Turns */}
<Paper sx={{ bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: 3, p: 3, boxShadow: c.shadow.sm }}>
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem', mb: 0.5 }}>
Default Max Turns
</Typography>
<Typography sx={{ color: c.text.tertiary, fontSize: '0.8rem', mb: 2 }}>
Maximum number of agent turns before auto-stopping. Leave empty for unlimited.
</Typography>
<Box sx={inlineRowLastSx}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>Max turns</Typography>
<Typography sx={descSx}>Auto-stop after this many turns. Empty = unlimited.</Typography>
</Box>
<TextField
type="number"
value={form.default_max_turns ?? ''}
onChange={(e) => setForm({ ...form, default_max_turns: e.target.value ? parseInt(e.target.value) : null })}
size="small"
fullWidth
placeholder="Unlimited"
placeholder="∞"
inputProps={{ min: 1 }}
sx={fieldSx}
sx={{ ...fieldSx, width: 100 }}
/>
</Paper>
</Box>
{/* Zoom Sensitivity */}
<Paper sx={{ bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: 3, p: 3, boxShadow: c.shadow.sm }}>
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem', mb: 0.5 }}>
Zoom Sensitivity
</Typography>
<Typography sx={{ color: c.text.tertiary, fontSize: '0.8rem', mb: 2 }}>
Controls how responsive scroll-to-zoom is on the dashboard canvas. Lower values suit trackpads; higher values suit mouse wheels.
{/* ── Interface ── */}
<Typography sx={{ ...sectionSx, mt: 3 }}>Interface</Typography>
<Box sx={inlineRowSx}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>Theme</Typography>
<Typography sx={descSx}>Application color scheme.</Typography>
</Box>
<ToggleButtonGroup
value={form.theme}
exclusive
onChange={(_, v) => { if (v) setForm({ ...form, theme: v }); }}
size="small"
sx={{
'& .MuiToggleButton-root': {
color: c.text.muted,
borderColor: c.border.medium,
textTransform: 'none',
px: 2,
py: 0.5,
gap: 0.5,
fontSize: '0.8rem',
'&.Mui-selected': {
bgcolor: `${c.accent.primary}15`,
color: c.accent.primary,
borderColor: c.accent.primary,
'&:hover': { bgcolor: `${c.accent.primary}20` },
},
},
}}
>
<ToggleButton value="light">
<LightModeIcon sx={{ fontSize: 16 }} /> Light
</ToggleButton>
<ToggleButton value="dark">
<DarkModeIcon sx={{ fontSize: 16 }} /> Dark
</ToggleButton>
</ToggleButtonGroup>
</Box>
<Box sx={rowSx}>
<Typography sx={labelSx}>Zoom sensitivity</Typography>
<Typography sx={{ ...descSx, mb: 1 }}>
Scroll-to-zoom responsiveness. Lower for trackpads, higher for mouse wheels.
</Typography>
<Box sx={{ px: 1 }}>
<Slider
@@ -312,21 +380,18 @@ const Settings: React.FC = () => {
]}
sx={{
color: c.accent.primary,
'& .MuiSlider-markLabel': { color: c.text.tertiary, fontSize: '0.75rem' },
'& .MuiSlider-markLabel': { color: c.text.tertiary, fontSize: '0.7rem' },
'& .MuiSlider-valueLabel': { bgcolor: c.accent.primary },
}}
/>
</Box>
</Paper>
</Box>
{/* New Agent Shortcut */}
<Paper sx={{ bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: 3, p: 3, boxShadow: c.shadow.sm }}>
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem', mb: 0.5 }}>
New Agent Shortcut
</Typography>
<Typography sx={{ color: c.text.tertiary, fontSize: '0.8rem', mb: 2 }}>
Keyboard shortcut to open the new agent input on the Dashboard.
</Typography>
<Box sx={inlineRowLastSx}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>New agent shortcut</Typography>
<Typography sx={descSx}>Keyboard shortcut to create an agent.</Typography>
</Box>
<Box
tabIndex={0}
onKeyDown={(e) => {
@@ -347,25 +412,24 @@ const Settings: React.FC = () => {
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 1,
px: 2,
py: 1,
borderRadius: `${c.radius.md}px`,
gap: 0.75,
px: 1.5,
py: 0.75,
borderRadius: `${c.radius.sm}px`,
border: `1px solid ${recordingShortcut ? c.accent.primary : c.border.medium}`,
bgcolor: c.bg.page,
cursor: 'pointer',
outline: 'none',
transition: 'border-color 0.15s',
'&:hover': { borderColor: c.accent.primary },
}}
>
<KeyboardIcon sx={{ fontSize: 18, color: recordingShortcut ? c.accent.primary : c.text.tertiary }} />
<KeyboardIcon sx={{ fontSize: 16, color: recordingShortcut ? c.accent.primary : c.text.tertiary }} />
{recordingShortcut ? (
<Typography sx={{ fontSize: '0.85rem', color: c.accent.primary, fontWeight: 500 }}>
<Typography sx={{ fontSize: '0.8rem', color: c.accent.primary, fontWeight: 500 }}>
Press shortcut
</Typography>
) : (
<Typography sx={{ fontSize: '0.85rem', color: c.text.primary, fontFamily: c.font.mono, fontWeight: 500 }}>
<Typography sx={{ fontSize: '0.8rem', color: c.text.primary, fontFamily: c.font.mono, fontWeight: 500 }}>
{form.new_agent_shortcut
.split('+')
.map((p) => {
@@ -379,53 +443,42 @@ const Settings: React.FC = () => {
</Typography>
)}
</Box>
</Paper>
</Box>
{/* Theme */}
<Paper sx={{ bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: 3, p: 3, boxShadow: c.shadow.sm }}>
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem', mb: 0.5 }}>
Theme
{/* ── Browser ── */}
<Typography sx={{ ...sectionSx, mt: 3 }}>Browser</Typography>
<Box sx={rowLastSx}>
<Typography sx={labelSx}>Default homepage</Typography>
<Typography sx={{ ...descSx, mb: 1.5 }}>
URL loaded when opening a new browser card on the dashboard.
</Typography>
<Typography sx={{ color: c.text.tertiary, fontSize: '0.8rem', mb: 2 }}>
Application color scheme.
</Typography>
<ToggleButtonGroup
value={form.theme}
exclusive
onChange={(_, v) => { if (v) setForm({ ...form, theme: v }); }}
size="small"
sx={{
'& .MuiToggleButton-root': {
color: c.text.muted,
borderColor: c.border.medium,
textTransform: 'none',
px: 2.5,
gap: 0.75,
'&.Mui-selected': {
bgcolor: `${c.accent.primary}15`,
color: c.accent.primary,
borderColor: c.accent.primary,
'&:hover': { bgcolor: `${c.accent.primary}20` },
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<LanguageIcon sx={{ fontSize: 18, color: c.text.tertiary, flexShrink: 0 }} />
<TextField
value={form.browser_homepage}
onChange={(e) => setForm({ ...form, browser_homepage: e.target.value })}
size="small"
fullWidth
placeholder="https://www.google.com"
sx={{
...fieldSx,
'& .MuiOutlinedInput-root': {
...fieldSx['& .MuiOutlinedInput-root'],
fontFamily: c.font.mono,
},
},
}}
>
<ToggleButton value="light">
<LightModeIcon sx={{ fontSize: 18 }} /> Light
</ToggleButton>
<ToggleButton value="dark">
<DarkModeIcon sx={{ fontSize: 18 }} /> Dark
</ToggleButton>
</ToggleButtonGroup>
</Paper>
}}
/>
</Box>
</Box>
{/* Anthropic API Key */}
<Paper sx={{ bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: 3, p: 3, boxShadow: c.shadow.sm }}>
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem', mb: 0.5 }}>
Anthropic API Key
</Typography>
<Typography sx={{ color: c.text.tertiary, fontSize: '0.8rem', mb: 2 }}>
Your API key for the Anthropic Claude API. Stored securely in the database.
{/* ── API ── */}
<Typography sx={{ ...sectionSx, mt: 3 }}>API</Typography>
<Box sx={rowLastSx}>
<Typography sx={labelSx}>Anthropic API key</Typography>
<Typography sx={{ ...descSx, mb: 1.5 }}>
Stored securely in the local database.
</Typography>
<TextField
type={showApiKey ? 'text' : 'password'}
@@ -450,27 +503,27 @@ const Settings: React.FC = () => {
size="small"
sx={{ color: c.text.tertiary }}
>
{showApiKey ? <VisibilityOffIcon sx={{ fontSize: 18 }} /> : <VisibilityIcon sx={{ fontSize: 18 }} />}
{showApiKey ? <VisibilityOffIcon sx={{ fontSize: 16 }} /> : <VisibilityIcon sx={{ fontSize: 16 }} />}
</IconButton>
</InputAdornment>
),
}}
/>
</Paper>
</Box>
</Box>
</DialogContent>
<DialogActions sx={{ borderTop: `1px solid ${c.border.subtle}`, px: 3, py: 2, justifyContent: 'flex-end' }}>
<DialogActions sx={{ borderTop: `1px solid ${c.border.subtle}`, px: 3, py: 1.5, justifyContent: 'flex-end' }}>
<Button
onClick={handleRequestClose}
sx={{ color: c.text.muted, textTransform: 'none' }}
sx={{ color: c.text.muted, textTransform: 'none', fontSize: '0.85rem' }}
>
Cancel
</Button>
<Button
variant="contained"
startIcon={<SaveIcon />}
startIcon={<SaveIcon sx={{ fontSize: 16 }} />}
onClick={handleSave}
disabled={!hasChanges}
sx={{
@@ -478,11 +531,12 @@ const Settings: React.FC = () => {
'&:hover': { bgcolor: c.accent.pressed },
'&.Mui-disabled': { bgcolor: c.bg.secondary, color: c.text.ghost },
textTransform: 'none',
borderRadius: 2,
px: 3,
borderRadius: 1.5,
px: 2.5,
fontSize: '0.85rem',
}}
>
Save Settings
Save
</Button>
</DialogActions>
@@ -500,43 +554,42 @@ const Settings: React.FC = () => {
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert onClose={() => setSaved(false)} severity="success" sx={{ bgcolor: c.bg.surface, color: c.text.primary, border: `1px solid ${c.status.success}` }}>
Settings saved successfully
Settings saved
</Alert>
</Snackbar>
</Dialog>
{/* Discard changes confirmation */}
<Dialog
open={confirmDiscard}
onClose={() => setConfirmDiscard(false)}
PaperProps={{
sx: {
bgcolor: c.bg.surface,
borderRadius: 3,
bgcolor: c.bg.page,
borderRadius: 2,
border: `1px solid ${c.border.subtle}`,
boxShadow: c.shadow.lg,
maxWidth: 400,
boxShadow: c.shadow.md,
maxWidth: 380,
},
}}
>
<DialogTitle sx={{ color: c.text.primary, fontWeight: 600, fontSize: '1rem', pb: 0.5 }}>
Unsaved Changes
<DialogTitle sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem', pb: 0.5, px: 3, pt: 2.5 }}>
Unsaved changes
</DialogTitle>
<DialogContent>
<Typography sx={{ color: c.text.muted, fontSize: '0.875rem' }}>
<DialogContent sx={{ px: 3 }}>
<Typography sx={{ color: c.text.muted, fontSize: '0.85rem' }}>
You have unsaved changes. Would you like to save them before closing?
</Typography>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2, gap: 1 }}>
<Button
onClick={handleConfirmDiscard}
sx={{ color: c.status.error, textTransform: 'none' }}
sx={{ color: c.status.error, textTransform: 'none', fontSize: '0.85rem' }}
>
Discard
</Button>
<Button
onClick={() => setConfirmDiscard(false)}
sx={{ color: c.text.muted, textTransform: 'none' }}
sx={{ color: c.text.muted, textTransform: 'none', fontSize: '0.85rem' }}
>
Cancel
</Button>
@@ -547,7 +600,8 @@ const Settings: React.FC = () => {
bgcolor: c.accent.primary,
'&:hover': { bgcolor: c.accent.pressed },
textTransform: 'none',
borderRadius: 2,
borderRadius: 1.5,
fontSize: '0.85rem',
}}
>
Save & Close
@@ -16,8 +16,9 @@ import { createDraftSession, removeDraftSession } from '@/shared/state/agentsSli
import { createSkill } from '@/shared/state/skillsSlice';
import AgentChat from '../AgentChat/AgentChat';
import { ContextPath } from '@/app/components/DirectoryBrowser';
import { API_BASE } from '@/shared/config';
const SKILLS_WORKSPACE_API = `http://${window.location.hostname}:8324/api/skills`;
const SKILLS_WORKSPACE_API = `${API_BASE}/skills`;
const POLL_INTERVAL_MS = 2000;
export interface SkillPreviewData {
+6 -7
View File
@@ -398,7 +398,7 @@ const Tools: React.FC = () => {
// Registry browser
const [registryOpen, setRegistryOpen] = useState(false);
const [regQuery, setRegQuery] = useState('');
const [regSort, setRegSort] = useState<'name' | 'stars'>('name');
const [regSort, setRegSort] = useState<'name' | 'stars'>('stars');
const [regSource, setRegSource] = useState<'' | 'community' | 'google'>('');
const [expandedServer, setExpandedServer] = useState<string | null>(null);
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity?: 'success' | 'error' }>({ open: false, message: '' });
@@ -609,11 +609,11 @@ const Tools: React.FC = () => {
handleMenuClose();
setRegistryOpen(true);
setRegQuery('');
setRegSort('name');
setRegSort('stars');
setRegSource('');
setExpandedServer(null);
dispatch(fetchRegistryStats());
dispatch(searchRegistry({ q: '', limit: 20, offset: 0, sort: 'name', source: '' }));
dispatch(searchRegistry({ q: '', limit: 20, offset: 0, sort: 'stars', source: '' }));
};
// --------------- Tool CRUD ---------------
@@ -681,8 +681,7 @@ const Tools: React.FC = () => {
try { parsedConfig = JSON.parse(mcpConfigJson); } catch { setMcpConfigError('Invalid JSON'); return; }
const f = serverToToolForm(mcpConfigServer);
const isStdioConfig = parsedConfig.type === 'stdio' || !!parsedConfig.command;
const authStatus = (mcpAuthType !== 'none' || isStdioConfig) ? 'configured' : 'none';
const authStatus = 'configured';
await dispatch(createTool({
name: f.name,
@@ -1038,7 +1037,7 @@ const Tools: React.FC = () => {
const isExpanded = expandedToolId === tool.id;
const isMcp = tool.mcp_config && Object.keys(tool.mcp_config).length > 0;
const isStdio = isMcp && (tool.mcp_config.type === 'stdio' || !!tool.mcp_config.command);
const canDiscover = isMcp && (isStdio || tool.auth_status !== 'none');
const canDiscover = isMcp;
const perms = tool.tool_permissions || {};
const services = perms._services as Record<string, { read?: string[]; write?: string[] }> | undefined;
const descriptions = (perms._tool_descriptions || {}) as Record<string, string>;
@@ -1303,7 +1302,7 @@ const Tools: React.FC = () => {
Discover Tools
</Button>
{!canDiscover && (
<Typography sx={{ color: c.text.ghost, fontSize: '0.72rem' }}>Connect the tool first to discover available permissions</Typography>
<Typography sx={{ color: c.text.ghost, fontSize: '0.72rem' }}>Add an MCP configuration to enable tool discovery</Typography>
)}
</Box>
) : (
+118 -18
View File
@@ -41,8 +41,9 @@ import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import CodeEditor from './CodeEditor';
import { ElementSelectionProvider } from '@/app/components/ElementSelectionContext';
import { captureViewThumbnail } from './captureViewThumbnail';
import { API_BASE } from '@/shared/config';
const WORKSPACE_API = `http://${window.location.hostname}:8324/api/outputs/workspace`;
const WORKSPACE_API = `${API_BASE}/outputs/workspace`;
const POLL_INTERVAL_MS = 2000;
function getFileIcon(filename: string): React.ReactNode {
@@ -477,7 +478,11 @@ interface Props {
const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const isNew = !output;
const [createdId, setCreatedId] = useState<string | null>(null);
const createdIdRef = useRef<string | null>(null);
const effectiveId = output?.id ?? createdId;
const isNew = !effectiveId;
const [name, setName] = useState(output?.name ?? '');
const [description, setDescription] = useState(output?.description ?? '');
@@ -502,6 +507,10 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
const [activeTab, setActiveTab] = useState(TAB_PREVIEW);
const [activeFile, setActiveFile] = useState('index.html');
const [saving, setSaving] = useState(false);
const [saveStatus, setSaveStatus] = useState<'idle' | 'unsaved' | 'saving' | 'saved'>('idle');
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const savedStatusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const savingRef = useRef(false);
const [executeResult, setExecuteResult] = useState<OutputExecuteResult | null>(null);
const [showConsole, setShowConsole] = useState(false);
const [consoleEntry, setConsoleEntry] = useState<ConsoleEntry | null>(null);
@@ -609,6 +618,9 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
const isAgentActive = agentStatus === 'running' || agentStatus === 'waiting_approval';
const workspaceId = workspacePath ? stableWorkspaceId : null;
const workspaceIdRef = useRef<string | null>(null);
workspaceIdRef.current = workspaceId;
const wsPushTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const initialContextPaths = useMemo(
() => workspacePath ? [{ path: workspacePath, type: 'directory' as const }] : undefined,
@@ -742,36 +754,58 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
.catch(() => {});
};
const handleSave = async (close = true) => {
const performSaveRef = useRef<((close: boolean) => Promise<void>) | null>(null);
performSaveRef.current = async (close: boolean) => {
if (savingRef.current) return;
savingRef.current = true;
setSaving(true);
setSaveStatus('saving');
try {
const body = buildBody();
const eid = output?.id ?? createdIdRef.current;
let savedId: string;
if (output) {
await dispatch(updateOutput({ id: output.id, ...body })).unwrap();
savedId = output.id;
if (eid) {
await dispatch(updateOutput({ id: eid, ...body })).unwrap();
savedId = eid;
} else {
const created = await dispatch(createOutput(body)).unwrap();
savedId = created.id;
createdIdRef.current = savedId;
setCreatedId(savedId);
}
savedRef.current = true;
setSaveStatus('saved');
if (savedStatusTimerRef.current) clearTimeout(savedStatusTimerRef.current);
savedStatusTimerRef.current = setTimeout(() => setSaveStatus('idle'), 3000);
if (close) onClose();
else previewRef.current?.reload();
captureThumbnailAsync(savedId);
} catch (err: any) {
console.error('Failed to save output:', err);
setSaveStatus('unsaved');
} finally {
setSaving(false);
savingRef.current = false;
}
};
const handleSave = async (close = true) => {
if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current);
await performSaveRef.current?.(close);
};
const handleClose = async () => {
if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current);
if (savedStatusTimerRef.current) clearTimeout(savedStatusTimerRef.current);
const eid = output?.id ?? createdIdRef.current;
if (!savedRef.current && (files['index.html'] ?? '').trim()) {
try {
const body = buildBody();
let savedId: string;
if (output) {
await dispatch(updateOutput({ id: output.id, ...body })).unwrap();
savedId = output.id;
if (eid) {
await dispatch(updateOutput({ id: eid, ...body })).unwrap();
savedId = eid;
} else {
const created = await dispatch(createOutput(body)).unwrap();
savedId = created.id;
@@ -783,7 +817,8 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
};
const handleRunPreview = async () => {
if (!output) {
const eid = output?.id ?? createdIdRef.current;
if (!eid) {
setExecuteResult(null);
return;
}
@@ -791,7 +826,7 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
setHasNewConsoleOutput(true);
try {
const res = await dispatch(
executeOutput({ output_id: output.id, input_data: testInput })
executeOutput({ output_id: eid, input_data: testInput })
).unwrap();
setExecuteResult(res);
setConsoleEntry({ timestamp: Date.now(), inputData: res.input_data, stdout: res.stdout ?? null, stderr: res.stderr ?? null, backendResult: res.backend_result, error: res.error, source: 'execute' });
@@ -809,12 +844,13 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
try { schema = JSON.parse(schemaText); } catch { schema = { type: 'object', properties: {} }; }
const forcedToolNames = config.forcedTools.flatMap((ft) => ft.tools);
if (forcedToolNames.length > 0 && output?.id) {
const eid = output?.id ?? createdIdRef.current;
if (forcedToolNames.length > 0 && eid) {
try {
const res = await dispatch(autoRunAgentOutput({
prompt: config.prompt,
input_schema: schema,
output_id: output.id,
output_id: eid,
model: autoRunModel,
forced_tools: forcedToolNames,
context_paths: config.contextPaths.map((cp) => ({ path: cp.path, type: cp.type })),
@@ -840,7 +876,7 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
if (res.input_data) {
setTestInput(res.input_data);
setExecuteResult({
output_id: output?.id ?? '',
output_id: output?.id ?? createdIdRef.current ?? '',
output_name: name,
frontend_code: files['index.html'] ?? '',
input_data: res.input_data,
@@ -869,7 +905,7 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
if (tc.tool !== 'RenderOutput' || !tc.input?.input_data) continue;
setTestInput(tc.input.input_data);
setExecuteResult({
output_id: output?.id ?? '',
output_id: output?.id ?? createdIdRef.current ?? '',
output_name: name,
frontend_code: files['index.html'] ?? '',
input_data: tc.input.input_data,
@@ -890,7 +926,7 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
if (lastSys) {
const errMsg = typeof lastSys.content === 'string' ? lastSys.content : JSON.stringify(lastSys.content);
setExecuteResult({
output_id: output?.id ?? '',
output_id: output?.id ?? createdIdRef.current ?? '',
output_name: name,
frontend_code: files['index.html'] ?? '',
input_data: {},
@@ -939,6 +975,21 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
const updateFile = useCallback((path: string, content: string) => {
setFiles(prev => ({ ...prev, [path]: content }));
const wsId = workspaceIdRef.current;
if (wsId) {
const existing = wsPushTimers.current.get(path);
if (existing) clearTimeout(existing);
wsPushTimers.current.set(path, setTimeout(() => {
wsPushTimers.current.delete(path);
fetch(`${WORKSPACE_API}/${wsId}/file/${encodeURIComponent(path)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
})
.then(() => previewRef.current?.reload())
.catch(() => {});
}, 300));
}
}, []);
const [newFileName, setNewFileName] = useState('');
@@ -986,6 +1037,34 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
const activeFileContent = files[activeFile] ?? '';
const autoSaveInitRef = useRef(true);
useEffect(() => {
if (autoSaveInitRef.current) {
autoSaveInitRef.current = false;
return;
}
const hasContent = name.trim() || (files['index.html'] ?? '').trim();
if (!hasContent) return;
if (!savingRef.current) {
setSaveStatus('unsaved');
}
if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current);
autoSaveTimerRef.current = setTimeout(() => {
performSaveRef.current?.(false);
}, 1500);
return () => {
if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current);
};
}, [files, name, description, autoRunEnabled, autoRunMode, autoRunModel]);
useEffect(() => {
return () => {
if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current);
if (savedStatusTimerRef.current) clearTimeout(savedStatusTimerRef.current);
wsPushTimers.current.forEach(t => clearTimeout(t));
};
}, []);
return (
<ElementSelectionProvider>
<Box sx={{ height: '100%', display: 'flex', overflow: 'hidden' }}>
@@ -1108,6 +1187,27 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
{autoRunning ? 'Running…' : 'Auto Run'}
</Button>
)}
{saveStatus === 'unsaved' && (
<Typography sx={{ fontSize: '0.72rem', color: c.text.ghost, fontStyle: 'italic', whiteSpace: 'nowrap' }}>
Unsaved changes
</Typography>
)}
{saveStatus === 'saving' && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<CircularProgress size={12} sx={{ color: c.text.ghost }} />
<Typography sx={{ fontSize: '0.72rem', color: c.text.ghost, whiteSpace: 'nowrap' }}>
Saving
</Typography>
</Box>
)}
{saveStatus === 'saved' && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<CheckCircleOutlineIcon sx={{ fontSize: 14, color: c.accent.primary }} />
<Typography sx={{ fontSize: '0.72rem', color: c.accent.primary, whiteSpace: 'nowrap' }}>
Saved
</Typography>
</Box>
)}
<Button
variant="contained"
startIcon={<SaveIcon sx={{ fontSize: 16 }} />}
@@ -1123,7 +1223,7 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
'&:hover': { bgcolor: c.accent.hover },
}}
>
{saving ? 'Saving...' : isNew ? 'Create' : 'Save'}
{saving ? 'Saving' : isNew ? 'Create' : 'Save'}
</Button>
</Box>
@@ -1172,7 +1272,7 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
<RefreshIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
{output && (
{effectiveId && (
<Tooltip title="Execute with backend code">
<IconButton
size="small"
@@ -7,7 +7,7 @@ import Button from '@mui/material/Button';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import CircularProgress from '@mui/material/CircularProgress';
import { Output, executeOutput, OutputExecuteResult, getFrontendCode, getBackendCode, buildServeUrl } from '@/shared/state/outputsSlice';
import { Output, executeOutput, OutputExecuteResult, getFrontendCode, getBackendCode, buildServeUrl, SERVE_BASE } from '@/shared/state/outputsSlice';
import { useAppDispatch } from '@/shared/hooks';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import InputSchemaForm, { getDefault } from './InputSchemaForm';
@@ -109,14 +109,14 @@ const ViewRunDialog: React.FC<Props> = ({ output, onClose }) => {
)}
{result ? (
<ViewPreview
serveUrl={`/api/outputs/${output.id}/serve/index.html`}
serveUrl={`${SERVE_BASE}/${output.id}/serve/index.html`}
frontendCode={result.frontend_code}
inputData={result.input_data}
backendResult={result.backend_result}
/>
) : (
<ViewPreview
serveUrl={`/api/outputs/${output.id}/serve/index.html`}
serveUrl={`${SERVE_BASE}/${output.id}/serve/index.html`}
frontendCode={getFrontendCode(output)}
inputData={inputData}
/>
@@ -0,0 +1,159 @@
import { getWebview, type BrowserWebview } from './browserRegistry';
import { dashboardWs } from './ws/WebSocketManager';
let initialized = false;
export type BrowserAction = 'screenshot' | 'get_text' | 'navigate' | 'click' | 'type' | 'evaluate';
export interface BrowserActivity {
action: BrowserAction;
detail?: string;
}
type ActivityListener = (browserId: string, activity: BrowserActivity | null) => void;
const activityMap = new Map<string, BrowserActivity>();
const listeners = new Set<ActivityListener>();
function setActivity(browserId: string, activity: BrowserActivity | null) {
if (activity) {
activityMap.set(browserId, activity);
} else {
activityMap.delete(browserId);
}
listeners.forEach((fn) => fn(browserId, activity));
}
export function getActivity(browserId: string): BrowserActivity | null {
return activityMap.get(browserId) ?? null;
}
export function subscribeActivity(fn: ActivityListener): () => void {
listeners.add(fn);
return () => { listeners.delete(fn); };
}
const ACTION_LABELS: Record<string, string> = {
screenshot: 'Capturing...',
get_text: 'Reading...',
navigate: 'Navigating...',
click: 'Clicking...',
type: 'Typing...',
evaluate: 'Evaluating...',
};
export function getActionLabel(action: string): string {
return ACTION_LABELS[action] ?? 'Working...';
}
async function handleScreenshot(wv: BrowserWebview): Promise<Record<string, any>> {
const nativeImage = await wv.capturePage();
const dataUrl = nativeImage.toDataURL();
const base64 = dataUrl.replace(/^data:image\/\w+;base64,/, '');
return { image: base64, url: wv.getURL(), title: wv.getTitle() };
}
async function handleGetText(wv: BrowserWebview): Promise<Record<string, any>> {
const text: string = await wv.executeJavaScript(
'document.body.innerText.substring(0, 15000)'
);
return { text, url: wv.getURL(), title: wv.getTitle() };
}
async function handleNavigate(wv: BrowserWebview, params: Record<string, any>): Promise<Record<string, any>> {
const url = params.url as string;
if (!url) return { error: 'url parameter is required' };
await wv.loadURL(url);
return { text: `Navigated to ${url}`, url };
}
async function handleClick(wv: BrowserWebview, params: Record<string, any>): Promise<Record<string, any>> {
const selector = params.selector as string;
if (!selector) return { error: 'selector parameter is required' };
const safeSelector = JSON.stringify(selector);
const code = `(()=>{const el=document.querySelector(${safeSelector});if(!el)return{error:'Element not found: '+${safeSelector}};el.click();return{text:'Clicked element: '+el.tagName.toLowerCase()+(el.id?'#'+el.id:'')}})()`;
const result = await wv.executeJavaScript(code);
return result;
}
async function handleType(wv: BrowserWebview, params: Record<string, any>): Promise<Record<string, any>> {
const selector = params.selector as string;
const text = params.text as string;
if (!selector) return { error: 'selector parameter is required' };
if (text == null) return { error: 'text parameter is required' };
const safeSelector = JSON.stringify(selector);
const safeText = JSON.stringify(text);
const code = `(()=>{const el=document.querySelector(${safeSelector});if(!el)return{error:'Element not found: '+${safeSelector}};el.focus();el.value=${safeText};el.dispatchEvent(new Event('input',{bubbles:true}));el.dispatchEvent(new Event('change',{bubbles:true}));return{text:'Typed into: '+el.tagName.toLowerCase()+(el.id?'#'+el.id:'')}})()`;
const result = await wv.executeJavaScript(code);
return result;
}
async function handleEvaluate(wv: BrowserWebview, params: Record<string, any>): Promise<Record<string, any>> {
const expression = params.expression as string;
if (!expression) return { error: 'expression parameter is required' };
try {
const result = await wv.executeJavaScript(expression);
const text = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
return { text: text ?? 'undefined', url: wv.getURL() };
} catch (err: any) {
return { error: `JS evaluation error: ${err?.message || String(err)}` };
}
}
async function handleBrowserCommand(data: Record<string, any>) {
const { request_id, action, browser_id, params = {} } = data;
if (!request_id) return;
const wv = getWebview(browser_id);
if (!wv) {
dashboardWs.send('browser:result', {
request_id,
error: `Browser card '${browser_id}' not found or not an Electron webview`,
});
return;
}
const detail = params.url || params.selector || params.expression || undefined;
setActivity(browser_id, { action: action as BrowserAction, detail });
let result: Record<string, any>;
try {
switch (action) {
case 'screenshot':
result = await handleScreenshot(wv);
break;
case 'get_text':
result = await handleGetText(wv);
break;
case 'navigate':
result = await handleNavigate(wv, params);
break;
case 'click':
result = await handleClick(wv, params);
break;
case 'type':
result = await handleType(wv, params);
break;
case 'evaluate':
result = await handleEvaluate(wv, params);
break;
default:
result = { error: `Unknown browser action: ${action}` };
}
} catch (err: any) {
result = { error: `Browser command failed: ${err?.message || String(err)}` };
}
setActivity(browser_id, null);
dashboardWs.send('browser:result', { request_id, ...result });
}
export function initBrowserCommandHandler(): () => void {
if (initialized) return () => {};
initialized = true;
const unsub = dashboardWs.on('browser:command', handleBrowserCommand);
return () => {
unsub();
initialized = false;
};
}
+37
View File
@@ -0,0 +1,37 @@
export interface BrowserWebview extends HTMLElement {
src: string;
loadURL: (url: string) => Promise<void>;
goBack: () => void;
goForward: () => void;
reload: () => void;
canGoBack: () => boolean;
canGoForward: () => boolean;
getURL: () => string;
getTitle: () => string;
capturePage: (rect?: { x: number; y: number; width: number; height: number }) => Promise<{
toDataURL: () => string;
toPNG: () => Buffer;
}>;
executeJavaScript: (code: string) => Promise<any>;
sendInputEvent: (event: any) => void;
addEventListener: (event: string, listener: (...args: any[]) => void) => void;
removeEventListener: (event: string, listener: (...args: any[]) => void) => void;
}
const registry = new Map<string, BrowserWebview>();
export function registerWebview(browserId: string, wv: BrowserWebview): void {
registry.set(browserId, wv);
}
export function unregisterWebview(browserId: string): void {
registry.delete(browserId);
}
export function getWebview(browserId: string): BrowserWebview | undefined {
return registry.get(browserId);
}
export function getAllWebviews(): Map<string, BrowserWebview> {
return new Map(registry);
}
+5
View File
@@ -0,0 +1,5 @@
const port = (window as any).__OPENSWARM_PORT__ || 8324;
const host = window.location.hostname || 'localhost';
export const API_BASE = `http://${host}:${port}/api`;
export const WS_BASE = `ws://${host}:${port}`;
@@ -1,14 +0,0 @@
const is_local = true; // NOTE: Change this to false when deploying to production
const LOCAL_API_URL = 'http://localhost:8324/api';
const PROD_API_URL = 'https://your-domain.com/api';
// HEALTH - Endpoints
export const HEALTH_CHECK_URL = (is_local ? LOCAL_API_URL : PROD_API_URL) + '/health/check';
// ITEM DB - Endpoints
export const CREATE_ITEM_URL = (is_local ? LOCAL_API_URL : PROD_API_URL) + '/item_db/create';
export const LIST_ITEMS_URL = (is_local ? LOCAL_API_URL : PROD_API_URL) + '/item_db/list';
export const GET_ITEM_URL = (is_local ? LOCAL_API_URL : PROD_API_URL) + '/item_db/get';
export const UPDATE_ITEM_URL = (is_local ? LOCAL_API_URL : PROD_API_URL) + '/item_db/update';
export const DELETE_ITEM_URL = (is_local ? LOCAL_API_URL : PROD_API_URL) + '/item_db/delete';
+26 -20
View File
@@ -1,6 +1,7 @@
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import { API_BASE } from '@/shared/config';
const API_BASE = `http://${window.location.hostname}:8324/api/agents`;
const AGENTS_API = `${API_BASE}/agents`;
export interface AgentMessage {
id: string;
@@ -124,14 +125,14 @@ export const fetchSessions = createAsyncThunk(
const params = new URLSearchParams();
if (dashboardId) params.set('dashboard_id', dashboardId);
const qs = params.toString();
const res = await fetch(`${API_BASE}/sessions${qs ? `?${qs}` : ''}`);
const res = await fetch(`${AGENTS_API}/sessions${qs ? `?${qs}` : ''}`);
const data = await res.json();
return data.sessions as AgentSession[];
},
);
export const launchAgent = createAsyncThunk('agents/launchAgent', async (config: AgentConfig) => {
const res = await fetch(`${API_BASE}/launch`, {
const res = await fetch(`${AGENTS_API}/launch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
@@ -154,7 +155,7 @@ export interface SendMessagePayload {
export const sendMessage = createAsyncThunk(
'agents/sendMessage',
async ({ sessionId, prompt, mode, model, images, contextPaths, forcedTools, attachedSkills }: SendMessagePayload) => {
await fetch(`${API_BASE}/sessions/${sessionId}/message`, {
await fetch(`${AGENTS_API}/sessions/${sessionId}/message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, mode, model, images, context_paths: contextPaths, forced_tools: forcedTools, attached_skills: attachedSkills }),
@@ -166,7 +167,7 @@ export const sendMessage = createAsyncThunk(
export const stopAgent = createAsyncThunk(
'agents/stopAgent',
async ({ sessionId, removeWorktree = false }: { sessionId: string; removeWorktree?: boolean }) => {
await fetch(`${API_BASE}/sessions/${sessionId}/stop`, {
await fetch(`${AGENTS_API}/sessions/${sessionId}/stop`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ remove_worktree: removeWorktree }),
@@ -178,7 +179,7 @@ export const stopAgent = createAsyncThunk(
export const editMessage = createAsyncThunk(
'agents/editMessage',
async ({ sessionId, messageId, content }: { sessionId: string; messageId: string; content: string }) => {
await fetch(`${API_BASE}/sessions/${sessionId}/edit_message`, {
await fetch(`${AGENTS_API}/sessions/${sessionId}/edit_message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message_id: messageId, content }),
@@ -190,7 +191,7 @@ export const editMessage = createAsyncThunk(
export const switchBranch = createAsyncThunk(
'agents/switchBranch',
async ({ sessionId, branchId }: { sessionId: string; branchId: string }) => {
await fetch(`${API_BASE}/sessions/${sessionId}/switch_branch`, {
await fetch(`${AGENTS_API}/sessions/${sessionId}/switch_branch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ branch_id: branchId }),
@@ -215,7 +216,7 @@ export interface LaunchAndSendPayload {
export const fetchSession = createAsyncThunk(
'agents/fetchSession',
async (sessionId: string) => {
const res = await fetch(`${API_BASE}/sessions/${sessionId}`);
const res = await fetch(`${AGENTS_API}/sessions/${sessionId}`);
const session = await res.json();
return session as AgentSession;
}
@@ -224,7 +225,7 @@ export const fetchSession = createAsyncThunk(
export const launchAndSendFirstMessage = createAsyncThunk(
'agents/launchAndSendFirstMessage',
async ({ draftId, config, prompt, mode, model, images, contextPaths, forcedTools, attachedSkills }: LaunchAndSendPayload) => {
const launchRes = await fetch(`${API_BASE}/launch`, {
const launchRes = await fetch(`${AGENTS_API}/launch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
@@ -232,13 +233,13 @@ export const launchAndSendFirstMessage = createAsyncThunk(
const launchData = await launchRes.json();
const session = launchData.session as AgentSession;
await fetch(`${API_BASE}/sessions/${session.id}/message`, {
await fetch(`${AGENTS_API}/sessions/${session.id}/message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, mode, model, images, context_paths: contextPaths, forced_tools: forcedTools, attached_skills: attachedSkills }),
});
const refreshRes = await fetch(`${API_BASE}/sessions/${session.id}`);
const refreshRes = await fetch(`${AGENTS_API}/sessions/${session.id}`);
const updatedSession = await refreshRes.json() as AgentSession;
return { draftId, session: updatedSession };
@@ -248,7 +249,7 @@ export const launchAndSendFirstMessage = createAsyncThunk(
export const generateTitle = createAsyncThunk(
'agents/generateTitle',
async ({ sessionId, prompt }: { sessionId: string; prompt: string }) => {
const res = await fetch(`${API_BASE}/sessions/${sessionId}/generate-title`, {
const res = await fetch(`${AGENTS_API}/sessions/${sessionId}/generate-title`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
@@ -269,7 +270,7 @@ export interface GenerateGroupMetaPayload {
export const generateGroupMeta = createAsyncThunk(
'agents/generateGroupMeta',
async ({ sessionId, groupId, toolCalls, resultsSummary, isRefinement }: GenerateGroupMetaPayload) => {
const res = await fetch(`${API_BASE}/sessions/${sessionId}/generate-group-meta`, {
const res = await fetch(`${AGENTS_API}/sessions/${sessionId}/generate-group-meta`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -287,7 +288,7 @@ export const generateGroupMeta = createAsyncThunk(
export const updateSystemPrompt = createAsyncThunk(
'agents/updateSystemPrompt',
async ({ sessionId, systemPrompt }: { sessionId: string; systemPrompt: string }) => {
await fetch(`${API_BASE}/sessions/${sessionId}`, {
await fetch(`${AGENTS_API}/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ system_prompt: systemPrompt }),
@@ -309,7 +310,7 @@ export const handleApproval = createAsyncThunk(
message?: string;
updatedInput?: Record<string, any>;
}) => {
await fetch(`${API_BASE}/approval`, {
await fetch(`${AGENTS_API}/approval`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ request_id: requestId, behavior, message, updated_input: updatedInput }),
@@ -321,7 +322,7 @@ export const handleApproval = createAsyncThunk(
export const closeSession = createAsyncThunk(
'agents/closeSession',
async ({ sessionId }: { sessionId: string }) => {
await fetch(`${API_BASE}/sessions/${sessionId}/close`, { method: 'POST' });
await fetch(`${AGENTS_API}/sessions/${sessionId}/close`, { method: 'POST' });
return sessionId;
}
);
@@ -329,7 +330,7 @@ export const closeSession = createAsyncThunk(
export const deleteSession = createAsyncThunk(
'agents/deleteSession',
async ({ sessionId }: { sessionId: string }) => {
await fetch(`${API_BASE}/sessions/${sessionId}`, { method: 'DELETE' });
await fetch(`${AGENTS_API}/sessions/${sessionId}`, { method: 'DELETE' });
return sessionId;
}
);
@@ -339,7 +340,7 @@ export const fetchHistory = createAsyncThunk(
async ({ dashboardId }: { dashboardId?: string } = {}) => {
const params = new URLSearchParams({ limit: '10000' });
if (dashboardId) params.set('dashboard_id', dashboardId);
const res = await fetch(`${API_BASE}/history?${params}`);
const res = await fetch(`${AGENTS_API}/history?${params}`);
const data = await res.json();
return data.sessions as HistorySession[];
},
@@ -357,7 +358,7 @@ export const searchHistory = createAsyncThunk(
async ({ q = '', limit = 20, offset = 0, dashboardId }: SearchHistoryParams) => {
const params = new URLSearchParams({ q, limit: String(limit), offset: String(offset) });
if (dashboardId) params.set('dashboard_id', dashboardId);
const res = await fetch(`${API_BASE}/history?${params}`);
const res = await fetch(`${AGENTS_API}/history?${params}`);
const data = await res.json();
return {
sessions: data.sessions as HistorySession[],
@@ -372,7 +373,7 @@ export const searchHistory = createAsyncThunk(
export const resumeSession = createAsyncThunk(
'agents/resumeSession',
async ({ sessionId }: { sessionId: string }) => {
const res = await fetch(`${API_BASE}/sessions/${sessionId}/resume`, { method: 'POST' });
const res = await fetch(`${AGENTS_API}/sessions/${sessionId}/resume`, { method: 'POST' });
const data = await res.json();
return data.session as AgentSession;
}
@@ -454,6 +455,10 @@ const agentsSlice = createSlice({
state.expandedSessionIds = [];
},
setExpandedSessionIds(state, action: PayloadAction<string[]>) {
state.expandedSessionIds = action.payload;
},
updateSessionName(state, action: PayloadAction<{ sessionId: string; name: string }>) {
const session = state.sessions[action.payload.sessionId];
if (session) {
@@ -838,6 +843,7 @@ export const {
expandSession,
collapseSession,
collapseAllSessions,
setExpandedSessionIds,
updateSessionName,
updateGroupMeta,
setDraftSystemPrompt,
@@ -1,12 +1,15 @@
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import { launchAndSendFirstMessage } from './agentsSlice';
import { API_BASE } from '@/shared/config';
const API_BASE = `http://${window.location.hostname}:8324/api/dashboards`;
const DASHBOARDS_API = `${API_BASE}/dashboards`;
export const DEFAULT_CARD_W = 480;
export const DEFAULT_CARD_H = 280;
export const DEFAULT_VIEW_CARD_W = 480;
export const DEFAULT_VIEW_CARD_H = 360;
export const DEFAULT_BROWSER_CARD_W = 640;
export const DEFAULT_BROWSER_CARD_H = 480;
const GRID_GAP = 24;
const GRID_ORIGIN = { x: 40, y: 100 };
const GRID_COLS_FALLBACK = 4;
@@ -27,9 +30,20 @@ export interface ViewCardPosition {
height: number;
}
export interface BrowserCardPosition {
browser_id: string;
url: string;
x: number;
y: number;
width: number;
height: number;
}
export interface DashboardLayoutState {
cards: Record<string, CardPosition>;
viewCards: Record<string, ViewCardPosition>;
browserCards: Record<string, BrowserCardPosition>;
persistedExpandedSessionIds: string[];
loading: boolean;
initialized: boolean;
}
@@ -37,6 +51,8 @@ export interface DashboardLayoutState {
const initialState: DashboardLayoutState = {
cards: {},
viewCards: {},
browserCards: {},
persistedExpandedSessionIds: [],
loading: false,
initialized: false,
};
@@ -44,17 +60,21 @@ const initialState: DashboardLayoutState = {
interface LayoutPayload {
cards: Record<string, CardPosition>;
viewCards: Record<string, ViewCardPosition>;
browserCards: Record<string, BrowserCardPosition>;
expandedSessionIds: string[];
}
export const fetchLayout = createAsyncThunk(
'dashboardLayout/fetch',
async (dashboardId: string) => {
const res = await fetch(`${API_BASE}/${dashboardId}`);
const res = await fetch(`${DASHBOARDS_API}/${dashboardId}`);
const data = await res.json();
const layout = data.layout ?? {};
return {
cards: (layout.cards ?? {}) as Record<string, CardPosition>,
viewCards: (layout.view_cards ?? {}) as Record<string, ViewCardPosition>,
browserCards: (layout.browser_cards ?? {}) as Record<string, BrowserCardPosition>,
expandedSessionIds: (layout.expanded_session_ids ?? []) as string[],
} satisfies LayoutPayload;
},
);
@@ -71,11 +91,16 @@ export const saveLayout = createAsyncThunk(
if (saveTimeout) clearTimeout(saveTimeout);
return new Promise<SaveLayoutPayload>((resolve) => {
saveTimeout = setTimeout(async () => {
await fetch(`${API_BASE}/${payload.dashboardId}`, {
await fetch(`${DASHBOARDS_API}/${payload.dashboardId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
layout: { cards: payload.cards, view_cards: payload.viewCards },
layout: {
cards: payload.cards,
view_cards: payload.viewCards,
browser_cards: payload.browserCards,
expanded_session_ids: payload.expandedSessionIds,
},
}),
});
resolve(payload);
@@ -165,10 +190,14 @@ const dashboardLayoutSlice = createSlice({
}
const hasDraftCard = Object.keys(state.cards).some((id) => id.startsWith('draft-'));
const extraOccupied: Record<string, { x: number; y: number }> = {
...Object.fromEntries(Object.values(state.viewCards).map((c) => [c.output_id, c])),
...Object.fromEntries(Object.values(state.browserCards).map((c) => [c.browser_id, c])),
};
const newIds = action.payload.filter((id) => !state.cards[id]);
for (const id of newIds) {
if (hasDraftCard && !id.startsWith('draft-')) continue;
const pos = findOpenGridCell(state.cards, new Set(), state.viewCards);
const pos = findOpenGridCell(state.cards, new Set(), extraOccupied);
state.cards[id] = {
session_id: id,
x: pos.x,
@@ -182,12 +211,14 @@ const dashboardLayoutSlice = createSlice({
tidyLayout(state) {
const agentCards = Object.values(state.cards);
const viewCards = Object.values(state.viewCards);
const total = agentCards.length + viewCards.length;
const bCards = Object.values(state.browserCards);
const total = agentCards.length + viewCards.length + bCards.length;
if (total === 0) return;
const allItems = [
...agentCards.map((c) => ({ kind: 'agent' as const, id: c.session_id, x: c.x, y: c.y })),
...viewCards.map((c) => ({ kind: 'view' as const, id: c.output_id, x: c.x, y: c.y })),
...bCards.map((c) => ({ kind: 'browser' as const, id: c.browser_id, x: c.x, y: c.y })),
];
allItems.sort((a, b) => a.y - b.y || a.x - b.x);
@@ -210,9 +241,12 @@ const dashboardLayoutSlice = createSlice({
if (item.kind === 'agent') {
const card = state.cards[item.id];
if (card) { card.x = nx; card.y = ny; card.width = DEFAULT_CARD_W; card.height = DEFAULT_CARD_H; }
} else {
} else if (item.kind === 'view') {
const card = state.viewCards[item.id];
if (card) { card.x = nx; card.y = ny; card.width = DEFAULT_VIEW_CARD_W; card.height = DEFAULT_VIEW_CARD_H; }
} else {
const card = state.browserCards[item.id];
if (card) { card.x = nx; card.y = ny; card.width = DEFAULT_BROWSER_CARD_W; card.height = DEFAULT_BROWSER_CARD_H; }
}
});
},
@@ -255,6 +289,89 @@ const dashboardLayoutSlice = createSlice({
delete state.viewCards[action.payload];
},
addBrowserCard(state, action: PayloadAction<{ url: string }>) {
const id = `browser-${Date.now().toString(36)}`;
const allOccupied: Record<string, { x: number; y: number }> = {
...Object.fromEntries(Object.values(state.viewCards).map((c) => [c.output_id, c])),
...Object.fromEntries(Object.values(state.browserCards).map((c) => [c.browser_id, c])),
};
const pos = findOpenGridCell(state.cards, new Set(), allOccupied);
state.browserCards[id] = {
browser_id: id,
url: action.payload.url,
x: pos.x,
y: pos.y,
width: DEFAULT_BROWSER_CARD_W,
height: DEFAULT_BROWSER_CARD_H,
};
},
setBrowserCardPosition(
state,
action: PayloadAction<{ browserId: string; x: number; y: number }>
) {
const { browserId, x, y } = action.payload;
const card = state.browserCards[browserId];
if (card) { card.x = x; card.y = y; }
},
setBrowserCardSize(
state,
action: PayloadAction<{ browserId: string; width: number; height: number }>
) {
const { browserId, width, height } = action.payload;
const card = state.browserCards[browserId];
if (card) {
card.width = Math.max(400, width);
card.height = Math.max(300, height);
}
},
removeBrowserCard(state, action: PayloadAction<string>) {
delete state.browserCards[action.payload];
},
updateBrowserCardUrl(
state,
action: PayloadAction<{ browserId: string; url: string }>
) {
const { browserId, url } = action.payload;
const card = state.browserCards[browserId];
if (card) { card.url = url; }
},
moveCards(
state,
action: PayloadAction<{
items: Array<{ id: string; type: 'agent' | 'view' | 'browser' }>;
dx: number;
dy: number;
}>,
) {
const { items, dx, dy } = action.payload;
for (const item of items) {
if (item.type === 'agent') {
const card = state.cards[item.id];
if (card) {
card.x += dx;
card.y += dy;
}
} else if (item.type === 'view') {
const card = state.viewCards[item.id];
if (card) {
card.x += dx;
card.y += dy;
}
} else {
const card = state.browserCards[item.id];
if (card) {
card.x += dx;
card.y += dy;
}
}
}
},
replaceDraftId(
state,
action: PayloadAction<{ oldId: string; newId: string }>
@@ -270,6 +387,8 @@ const dashboardLayoutSlice = createSlice({
resetLayout(state) {
state.cards = {};
state.viewCards = {};
state.browserCards = {};
state.persistedExpandedSessionIds = [];
state.initialized = false;
},
@@ -284,6 +403,8 @@ const dashboardLayoutSlice = createSlice({
state.initialized = true;
state.cards = action.payload.cards;
state.viewCards = action.payload.viewCards;
state.browserCards = action.payload.browserCards;
state.persistedExpandedSessionIds = action.payload.expandedSessionIds;
})
.addCase(fetchLayout.rejected, (state) => {
state.loading = false;
@@ -311,6 +432,12 @@ export const {
setViewCardPosition,
setViewCardSize,
removeViewCard,
addBrowserCard,
setBrowserCardPosition,
setBrowserCardSize,
removeBrowserCard,
updateBrowserCardUrl,
moveCards,
resetLayout,
} = dashboardLayoutSlice.actions;
+32 -7
View File
@@ -1,10 +1,12 @@
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import { API_BASE } from '@/shared/config';
const API_BASE = `http://${window.location.hostname}:8324/api/dashboards`;
const DASHBOARDS_API = `${API_BASE}/dashboards`;
export interface Dashboard {
id: string;
name: string;
auto_named: boolean;
created_at: string;
updated_at: string;
}
@@ -20,7 +22,7 @@ const initialState: DashboardsState = {
};
export const fetchDashboards = createAsyncThunk('dashboards/fetchAll', async () => {
const res = await fetch(`${API_BASE}/list`);
const res = await fetch(`${DASHBOARDS_API}/list`);
const data = await res.json();
return data.dashboards as Dashboard[];
});
@@ -28,7 +30,7 @@ export const fetchDashboards = createAsyncThunk('dashboards/fetchAll', async ()
export const createDashboard = createAsyncThunk(
'dashboards/create',
async (name: string) => {
const res = await fetch(`${API_BASE}/create`, {
const res = await fetch(`${DASHBOARDS_API}/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
@@ -40,7 +42,7 @@ export const createDashboard = createAsyncThunk(
export const renameDashboard = createAsyncThunk(
'dashboards/rename',
async ({ id, name }: { id: string; name: string }) => {
const res = await fetch(`${API_BASE}/${id}`, {
const res = await fetch(`${DASHBOARDS_API}/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
@@ -52,7 +54,7 @@ export const renameDashboard = createAsyncThunk(
export const deleteDashboard = createAsyncThunk(
'dashboards/delete',
async (id: string) => {
await fetch(`${API_BASE}/${id}`, { method: 'DELETE' });
await fetch(`${DASHBOARDS_API}/${id}`, { method: 'DELETE' });
return id;
},
);
@@ -60,11 +62,22 @@ export const deleteDashboard = createAsyncThunk(
export const duplicateDashboard = createAsyncThunk(
'dashboards/duplicate',
async (id: string) => {
const res = await fetch(`${API_BASE}/${id}/duplicate`, { method: 'POST' });
const res = await fetch(`${DASHBOARDS_API}/${id}/duplicate`, { method: 'POST' });
return (await res.json()) as Dashboard;
},
);
export const generateDashboardName = createAsyncThunk(
'dashboards/generateName',
async (dashboardId: string) => {
const res = await fetch(`${DASHBOARDS_API}/${dashboardId}/generate-name`, {
method: 'POST',
});
const data = await res.json();
return { id: dashboardId, name: data.name as string, auto_named: data.auto_named as boolean };
},
);
const dashboardsSlice = createSlice({
name: 'dashboards',
initialState,
@@ -91,7 +104,12 @@ const dashboardsSlice = createSlice({
.addCase(renameDashboard.fulfilled, (state, action) => {
const d = action.payload;
if (state.items[d.id]) {
state.items[d.id] = { ...state.items[d.id], name: d.name, updated_at: d.updated_at };
state.items[d.id] = {
...state.items[d.id],
name: d.name,
auto_named: d.auto_named ?? false,
updated_at: d.updated_at,
};
}
})
.addCase(deleteDashboard.fulfilled, (state, action) => {
@@ -99,6 +117,13 @@ const dashboardsSlice = createSlice({
})
.addCase(duplicateDashboard.fulfilled, (state, action) => {
state.items[action.payload.id] = action.payload;
})
.addCase(generateDashboardName.fulfilled, (state, action) => {
const { id, name, auto_named } = action.payload;
if (state.items[id]) {
state.items[id].name = name;
state.items[id].auto_named = auto_named;
}
});
},
});
@@ -1,6 +1,7 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { API_BASE } from '@/shared/config';
const API_BASE = `http://${window.location.hostname}:8324/api/mcp-registry`;
const MCP_REGISTRY_API = `${API_BASE}/mcp-registry`;
export interface McpServer {
name: string;
@@ -48,20 +49,20 @@ export const searchRegistry = createAsyncThunk(
'mcpRegistry/search',
async ({ q, limit = 20, offset = 0, sort = 'name', source = '' }: { q: string; limit?: number; offset?: number; sort?: string; source?: string }) => {
const params = new URLSearchParams({ q, limit: String(limit), offset: String(offset), sort, source });
const res = await fetch(`${API_BASE}/search?${params}`);
const res = await fetch(`${MCP_REGISTRY_API}/search?${params}`);
return (await res.json()) as { servers: McpServer[]; total: number; offset: number; limit: number };
}
);
export const fetchRegistryStats = createAsyncThunk('mcpRegistry/stats', async () => {
const res = await fetch(`${API_BASE}/stats`);
const res = await fetch(`${MCP_REGISTRY_API}/stats`);
return (await res.json()) as { total: number; google: number; community: number; lastUpdated: number };
});
export const fetchServerDetail = createAsyncThunk(
'mcpRegistry/detail',
async (name: string) => {
const res = await fetch(`${API_BASE}/detail/${encodeURIComponent(name)}`);
const res = await fetch(`${MCP_REGISTRY_API}/detail/${encodeURIComponent(name)}`);
const data = await res.json();
return data.server as McpServerDetail;
}
+6 -5
View File
@@ -1,6 +1,7 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { API_BASE } from '@/shared/config';
const API_BASE = `http://${window.location.hostname}:8324/api/modes`;
const MODES_API = `${API_BASE}/modes`;
export interface Mode {
id: string;
@@ -26,7 +27,7 @@ const initialState: ModesState = { items: {}, loading: false, loaded: false };
export const fetchModes = createAsyncThunk(
'modes/fetch',
async () => {
const res = await fetch(`${API_BASE}/list`);
const res = await fetch(`${MODES_API}/list`);
const data = await res.json();
return data.modes as Mode[];
},
@@ -36,7 +37,7 @@ export const fetchModes = createAsyncThunk(
export const createMode = createAsyncThunk(
'modes/create',
async (body: Omit<Mode, 'id' | 'is_builtin'>) => {
const res = await fetch(`${API_BASE}/create`, {
const res = await fetch(`${MODES_API}/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
@@ -49,7 +50,7 @@ export const createMode = createAsyncThunk(
export const updateMode = createAsyncThunk(
'modes/update',
async ({ id, ...updates }: Partial<Mode> & { id: string }) => {
const res = await fetch(`${API_BASE}/${id}`, {
const res = await fetch(`${MODES_API}/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
@@ -60,7 +61,7 @@ export const updateMode = createAsyncThunk(
);
export const deleteMode = createAsyncThunk('modes/delete', async (id: string) => {
await fetch(`${API_BASE}/${id}`, { method: 'DELETE' });
await fetch(`${MODES_API}/${id}`, { method: 'DELETE' });
return id;
});
+11 -11
View File
@@ -1,10 +1,10 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { API_BASE } from '@/shared/config';
const API_BASE = `http://${window.location.hostname}:8324/api/outputs`;
const OUTPUTS_API = `${API_BASE}/outputs`;
export const SERVE_BASE = `http://${window.location.hostname}:8324/api/outputs`;
export const SERVE_BASE = `${API_BASE}/outputs`;
export const IFRAME_SERVE_BASE = '/api/outputs';
export interface AutoRunConfig {
enabled: boolean;
@@ -79,7 +79,7 @@ const initialState: OutputsState = { items: {}, loading: false, loaded: false };
export const fetchOutputs = createAsyncThunk(
'outputs/fetch',
async () => {
const res = await fetch(`${API_BASE}/list`);
const res = await fetch(`${OUTPUTS_API}/list`);
const data = await res.json();
return data.outputs as Output[];
},
@@ -89,7 +89,7 @@ export const fetchOutputs = createAsyncThunk(
export const createOutput = createAsyncThunk(
'outputs/create',
async (body: Omit<Output, 'id' | 'created_at' | 'updated_at' | 'permission'>) => {
const res = await fetch(`${API_BASE}/create`, {
const res = await fetch(`${OUTPUTS_API}/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
@@ -103,7 +103,7 @@ export const createOutput = createAsyncThunk(
export const updateOutput = createAsyncThunk(
'outputs/update',
async ({ id, ...updates }: Partial<Output> & { id: string }) => {
const res = await fetch(`${API_BASE}/${id}`, {
const res = await fetch(`${OUTPUTS_API}/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
@@ -115,14 +115,14 @@ export const updateOutput = createAsyncThunk(
);
export const deleteOutput = createAsyncThunk('outputs/delete', async (id: string) => {
await fetch(`${API_BASE}/${id}`, { method: 'DELETE' });
await fetch(`${OUTPUTS_API}/${id}`, { method: 'DELETE' });
return id;
});
export const executeOutput = createAsyncThunk(
'outputs/execute',
async (body: { output_id: string; input_data: Record<string, any> }) => {
const res = await fetch(`${API_BASE}/execute`, {
const res = await fetch(`${OUTPUTS_API}/execute`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
@@ -142,7 +142,7 @@ export interface AutoRunResult {
export const autoRunOutput = createAsyncThunk(
'outputs/autoRun',
async (body: { prompt: string; input_schema: Record<string, any>; backend_code?: string | null; context_paths?: Array<{ path: string; type: string }>; forced_tools?: string[]; model?: string }) => {
const res = await fetch(`${API_BASE}/auto-run`, {
const res = await fetch(`${OUTPUTS_API}/auto-run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
@@ -165,7 +165,7 @@ export const autoRunAgentOutput = createAsyncThunk(
forced_tools?: string[];
context_paths?: Array<{ path: string; type: string }>;
}) => {
const res = await fetch(`${API_BASE}/auto-run-agent`, {
const res = await fetch(`${OUTPUTS_API}/auto-run-agent`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
@@ -176,7 +176,7 @@ export const autoRunAgentOutput = createAsyncThunk(
);
export async function cleanupAutoRunAgent(sessionId: string): Promise<void> {
await fetch(`${API_BASE}/auto-run-agent/${sessionId}`, { method: 'DELETE' });
await fetch(`${OUTPUTS_API}/auto-run-agent/${sessionId}`, { method: 'DELETE' });
}
const outputsSlice = createSlice({
+7 -4
View File
@@ -1,6 +1,7 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { API_BASE } from '@/shared/config';
const API_BASE = `http://${window.location.hostname}:8324/api/settings`;
const SETTINGS_API = `${API_BASE}/settings`;
export interface AppSettings {
default_system_prompt: string | null;
@@ -12,6 +13,7 @@ export interface AppSettings {
theme: 'light' | 'dark';
new_agent_shortcut: string;
anthropic_api_key: string | null;
browser_homepage: string;
}
export interface BrowseResult {
@@ -39,6 +41,7 @@ const initialState: SettingsState = {
theme: 'dark',
new_agent_shortcut: 'Meta+l',
anthropic_api_key: null,
browser_homepage: 'https://www.google.com',
},
loading: false,
loaded: false,
@@ -46,14 +49,14 @@ const initialState: SettingsState = {
};
export const fetchSettings = createAsyncThunk('settings/fetch', async () => {
const res = await fetch(API_BASE);
const res = await fetch(SETTINGS_API);
return (await res.json()) as AppSettings;
});
export const updateSettings = createAsyncThunk(
'settings/update',
async (settings: AppSettings) => {
const res = await fetch(API_BASE, {
const res = await fetch(SETTINGS_API, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
@@ -66,7 +69,7 @@ export const updateSettings = createAsyncThunk(
export const browseDirectories = createAsyncThunk(
'settings/browseDirectories',
async (path: string) => {
const res = await fetch(`${API_BASE}/browse-directories?path=${encodeURIComponent(path)}`);
const res = await fetch(`${SETTINGS_API}/browse-directories?path=${encodeURIComponent(path)}`);
if (!res.ok) throw new Error((await res.json()).detail);
return (await res.json()) as BrowseResult;
}
@@ -1,6 +1,7 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { API_BASE } from '@/shared/config';
const API_BASE = `http://${window.location.hostname}:8324/api/skill-registry`;
const SKILL_REGISTRY_API = `${API_BASE}/skill-registry`;
export interface RegistrySkill {
name: string;
@@ -40,13 +41,13 @@ export const searchSkillRegistry = createAsyncThunk(
'skillRegistry/search',
async ({ q, limit = 20, offset = 0, sort = 'name', category = '' }: { q: string; limit?: number; offset?: number; sort?: string; category?: string }) => {
const params = new URLSearchParams({ q, limit: String(limit), offset: String(offset), sort, category });
const res = await fetch(`${API_BASE}/search?${params}`);
const res = await fetch(`${SKILL_REGISTRY_API}/search?${params}`);
return (await res.json()) as { skills: RegistrySkill[]; total: number; offset: number; limit: number };
},
);
export const fetchSkillRegistryStats = createAsyncThunk('skillRegistry/stats', async () => {
const res = await fetch(`${API_BASE}/stats`);
const res = await fetch(`${SKILL_REGISTRY_API}/stats`);
return (await res.json()) as { total: number; categories: Record<string, number>; lastUpdated: number };
});
@@ -54,7 +55,7 @@ export const fetchAllRegistrySkills = createAsyncThunk(
'skillRegistry/fetchAll',
async () => {
const params = new URLSearchParams({ q: '', limit: '100', offset: '0', sort: 'name', category: '' });
const res = await fetch(`${API_BASE}/search?${params}`);
const res = await fetch(`${SKILL_REGISTRY_API}/search?${params}`);
return (await res.json()) as { skills: RegistrySkill[]; total: number; offset: number; limit: number };
},
);
@@ -62,7 +63,7 @@ export const fetchAllRegistrySkills = createAsyncThunk(
export const fetchSkillDetail = createAsyncThunk(
'skillRegistry/detail',
async (name: string) => {
const res = await fetch(`${API_BASE}/detail/${encodeURIComponent(name)}`);
const res = await fetch(`${SKILL_REGISTRY_API}/detail/${encodeURIComponent(name)}`);
const data = await res.json();
return data.skill as RegistrySkillDetail;
},
+6 -5
View File
@@ -1,6 +1,7 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { API_BASE } from '@/shared/config';
const API_BASE = `http://${window.location.hostname}:8324/api/skills`;
const SKILLS_API = `${API_BASE}/skills`;
export interface Skill {
id: string;
@@ -22,7 +23,7 @@ const initialState: SkillsState = { items: {}, loading: false, loaded: false };
export const fetchSkills = createAsyncThunk(
'skills/fetch',
async () => {
const res = await fetch(`${API_BASE}/list`);
const res = await fetch(`${SKILLS_API}/list`);
const data = await res.json();
return data.skills as Skill[];
},
@@ -32,7 +33,7 @@ export const fetchSkills = createAsyncThunk(
export const createSkill = createAsyncThunk(
'skills/create',
async (body: { name: string; description?: string; content: string; command?: string }) => {
const res = await fetch(`${API_BASE}/create`, {
const res = await fetch(`${SKILLS_API}/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
@@ -45,7 +46,7 @@ export const createSkill = createAsyncThunk(
export const updateSkill = createAsyncThunk(
'skills/update',
async ({ id, ...updates }: Partial<Skill> & { id: string }) => {
const res = await fetch(`${API_BASE}/${id}`, {
const res = await fetch(`${SKILLS_API}/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
@@ -56,7 +57,7 @@ export const updateSkill = createAsyncThunk(
);
export const deleteSkill = createAsyncThunk('skills/delete', async (id: string) => {
await fetch(`${API_BASE}/${id}`, { method: 'DELETE' });
await fetch(`${SKILLS_API}/${id}`, { method: 'DELETE' });
return id;
});
+7 -6
View File
@@ -1,6 +1,7 @@
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import { API_BASE } from '@/shared/config';
const API_BASE = `http://${window.location.hostname}:8324/api/templates`;
const TEMPLATES_API = `${API_BASE}/templates`;
export interface TemplateField {
name: string;
@@ -34,7 +35,7 @@ const initialState: TemplatesState = {
export const fetchTemplates = createAsyncThunk(
'templates/fetch',
async () => {
const res = await fetch(`${API_BASE}/list`);
const res = await fetch(`${TEMPLATES_API}/list`);
const data = await res.json();
return data.templates as PromptTemplate[];
},
@@ -44,7 +45,7 @@ export const fetchTemplates = createAsyncThunk(
export const createTemplate = createAsyncThunk(
'templates/create',
async (body: Omit<PromptTemplate, 'id'>) => {
const res = await fetch(`${API_BASE}/create`, {
const res = await fetch(`${TEMPLATES_API}/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
@@ -57,7 +58,7 @@ export const createTemplate = createAsyncThunk(
export const updateTemplate = createAsyncThunk(
'templates/update',
async ({ id, ...updates }: Partial<PromptTemplate> & { id: string }) => {
const res = await fetch(`${API_BASE}/${id}`, {
const res = await fetch(`${TEMPLATES_API}/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
@@ -68,14 +69,14 @@ export const updateTemplate = createAsyncThunk(
);
export const deleteTemplate = createAsyncThunk('templates/delete', async (id: string) => {
await fetch(`${API_BASE}/${id}`, { method: 'DELETE' });
await fetch(`${TEMPLATES_API}/${id}`, { method: 'DELETE' });
return id;
});
export const renderTemplate = createAsyncThunk(
'templates/render',
async ({ templateId, values }: { templateId: string; values: Record<string, any> }) => {
const res = await fetch(`${API_BASE}/render`, {
const res = await fetch(`${TEMPLATES_API}/render`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ template_id: templateId, values }),
+12 -11
View File
@@ -1,6 +1,7 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { API_BASE } from '@/shared/config';
const API_BASE = `http://${window.location.hostname}:8324/api/tools`;
const TOOLS_API = `${API_BASE}/tools`;
export interface ToolDefinition {
id: string;
@@ -38,7 +39,7 @@ const initialState: ToolsState = { items: {}, builtinTools: [], builtinPermissio
export const fetchTools = createAsyncThunk(
'tools/fetch',
async () => {
const res = await fetch(`${API_BASE}/list`);
const res = await fetch(`${TOOLS_API}/list`);
const data = await res.json();
return data.tools as ToolDefinition[];
},
@@ -48,7 +49,7 @@ export const fetchTools = createAsyncThunk(
export const fetchBuiltinTools = createAsyncThunk(
'tools/fetchBuiltin',
async () => {
const res = await fetch(`${API_BASE}/builtin`);
const res = await fetch(`${TOOLS_API}/builtin`);
const data = await res.json();
return data.tools as BuiltinTool[];
},
@@ -58,7 +59,7 @@ export const fetchBuiltinTools = createAsyncThunk(
export const createTool = createAsyncThunk(
'tools/create',
async (body: Partial<Omit<ToolDefinition, 'id'>> & { name: string }) => {
const res = await fetch(`${API_BASE}/create`, {
const res = await fetch(`${TOOLS_API}/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
@@ -71,7 +72,7 @@ export const createTool = createAsyncThunk(
export const updateTool = createAsyncThunk(
'tools/update',
async ({ id, ...updates }: Partial<ToolDefinition> & { id: string }) => {
const res = await fetch(`${API_BASE}/${id}`, {
const res = await fetch(`${TOOLS_API}/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
@@ -82,14 +83,14 @@ export const updateTool = createAsyncThunk(
);
export const deleteTool = createAsyncThunk('tools/delete', async (id: string) => {
await fetch(`${API_BASE}/${id}`, { method: 'DELETE' });
await fetch(`${TOOLS_API}/${id}`, { method: 'DELETE' });
return id;
});
export const startOAuth = createAsyncThunk(
'tools/startOAuth',
async (toolId: string) => {
const res = await fetch(`${API_BASE}/${toolId}/oauth/start`, { method: 'POST' });
const res = await fetch(`${TOOLS_API}/${toolId}/oauth/start`, { method: 'POST' });
if (!res.ok) throw new Error('Failed to start OAuth');
const data = await res.json();
return data as { auth_url: string };
@@ -99,7 +100,7 @@ export const startOAuth = createAsyncThunk(
export const fetchToolStatus = createAsyncThunk(
'tools/fetchStatus',
async (toolId: string) => {
const res = await fetch(`${API_BASE}/${toolId}`);
const res = await fetch(`${TOOLS_API}/${toolId}`);
const data = await res.json();
return data as ToolDefinition;
}
@@ -108,7 +109,7 @@ export const fetchToolStatus = createAsyncThunk(
export const discoverTools = createAsyncThunk(
'tools/discover',
async (toolId: string) => {
const res = await fetch(`${API_BASE}/${toolId}/discover`, { method: 'POST' });
const res = await fetch(`${TOOLS_API}/${toolId}/discover`, { method: 'POST' });
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Discovery failed' }));
throw new Error(err.detail || 'Discovery failed');
@@ -119,7 +120,7 @@ export const discoverTools = createAsyncThunk(
);
export const fetchBuiltinPermissions = createAsyncThunk('tools/fetchBuiltinPermissions', async () => {
const res = await fetch(`${API_BASE}/builtin/permissions`);
const res = await fetch(`${TOOLS_API}/builtin/permissions`);
const data = await res.json();
return data.permissions as Record<string, string>;
});
@@ -127,7 +128,7 @@ export const fetchBuiltinPermissions = createAsyncThunk('tools/fetchBuiltinPermi
export const updateBuiltinPermissions = createAsyncThunk(
'tools/updateBuiltinPermissions',
async (permissions: Record<string, string>) => {
const res = await fetch(`${API_BASE}/builtin/permissions`, {
const res = await fetch(`${TOOLS_API}/builtin/permissions`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ permissions }),
+66
View File
@@ -0,0 +1,66 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import {
subscribeActivity,
getActivity,
type BrowserActivity,
type BrowserAction,
} from './browserCommandHandler';
export interface BrowserActivityState {
active: boolean;
action: BrowserAction | null;
detail: string | null;
/** The action that just completed — stays set briefly for exit animations */
lastAction: BrowserAction | null;
}
const EMPTY: BrowserActivityState = { active: false, action: null, detail: null, lastAction: null };
export function useBrowserActivity(browserId: string): BrowserActivityState {
const [state, setState] = useState<BrowserActivityState>(() => {
const current = getActivity(browserId);
return current
? { active: true, action: current.action, detail: current.detail ?? null, lastAction: null }
: EMPTY;
});
const lastActionTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleChange = useCallback(
(changedId: string, activity: BrowserActivity | null) => {
if (changedId !== browserId) return;
if (activity) {
if (lastActionTimer.current) clearTimeout(lastActionTimer.current);
setState({
active: true,
action: activity.action,
detail: activity.detail ?? null,
lastAction: null,
});
} else {
setState((prev) => ({
active: false,
action: null,
detail: null,
lastAction: prev.action,
}));
lastActionTimer.current = setTimeout(() => {
setState((prev) => (prev.active ? prev : { ...prev, lastAction: null }));
}, 600);
}
},
[browserId],
);
useEffect(() => {
return subscribeActivity(handleChange);
}, [handleChange]);
useEffect(() => {
return () => {
if (lastActionTimer.current) clearTimeout(lastActionTimer.current);
};
}, []);
return state;
}
+1 -1
View File
@@ -281,7 +281,7 @@ class WebSocketManager {
}
}
const WS_BASE = `ws://${window.location.hostname}:8324`;
import { WS_BASE } from '@/shared/config';
export const dashboardWs = new WebSocketManager(`${WS_BASE}/ws/dashboard`, { skipStreamEvents: true });
+14
View File
@@ -0,0 +1,14 @@
declare namespace JSX {
interface IntrinsicElements {
webview: React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement> & {
src?: string;
preload?: string;
partition?: string;
allowpopups?: string;
nodeintegration?: string;
},
HTMLElement
>;
}
}
File diff suppressed because one or more lines are too long
+12 -1
View File
@@ -1,5 +1,6 @@
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
module.exports = (env, argv) => {
const isDevelopment = argv.mode === 'development';
@@ -10,6 +11,7 @@ module.exports = (env, argv) => {
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js',
publicPath: isDevelopment ? '/' : './',
clean: true
},
@@ -60,7 +62,16 @@ module.exports = (env, argv) => {
template: './public/index.html',
filename: 'index.html',
favicon: './public/favicon.ico'
})
}),
new CopyWebpackPlugin({
patterns: [
{
from: 'public',
to: '.',
globOptions: { ignore: ['**/index.html', '**/favicon.ico'] },
},
],
}),
],
devtool: isDevelopment ? 'source-map' : false,
Executable
+162
View File
@@ -0,0 +1,162 @@
#!/bin/bash
# The comment above is shebang, DO NOT REMOVE
SCRIPT_ABSPATH="$(readlink -f "${BASH_SOURCE[0]}")"
if [[ "$OSTYPE" == "darwin"* ]]; then
sed -i '' 's/\r//g' "$SCRIPT_ABSPATH"
else
sed -i 's/\r//g' "$SCRIPT_ABSPATH"
fi
chmod +x "$SCRIPT_ABSPATH"
PROJECT_ROOT="$(dirname "$SCRIPT_ABSPATH")"
BLUE='\033[0;34m'
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[0;33m'
BOLD='\033[1m'
RESET='\033[0m'
BACKEND_PID=""
FRONTEND_PID=""
ELECTRON_PID=""
SHUTTING_DOWN=false
kill_tree() {
local pid=$1 sig=${2:-TERM}
local children
children=$(pgrep -P "$pid" 2>/dev/null)
for child in $children; do
kill_tree "$child" "$sig"
done
kill -"$sig" "$pid" 2>/dev/null
}
cleanup() {
$SHUTTING_DOWN && return
SHUTTING_DOWN=true
echo ""
echo -e "${YELLOW}${BOLD}Gracefully shutting down all services...${RESET}"
for pid in $ELECTRON_PID $BACKEND_PID $FRONTEND_PID; do
[[ -n "$pid" ]] && kill_tree "$pid" TERM
done
local elapsed=0
while (( elapsed < 5 )); do
local alive=false
for pid in $ELECTRON_PID $BACKEND_PID $FRONTEND_PID; do
[[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null && alive=true
done
$alive || break
sleep 1
((elapsed++))
done
for pid in $ELECTRON_PID $BACKEND_PID $FRONTEND_PID; do
[[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null && kill_tree "$pid" KILL
done
wait 2>/dev/null
echo -e "${GREEN}${BOLD}All services stopped.${RESET}"
}
trap 'cleanup; exit 0' INT TERM
trap cleanup EXIT
# --- Start backend ---
echo -e "${BLUE}${BOLD}[backend]${RESET} Starting backend server..."
bash "$PROJECT_ROOT/backend/run.sh" > >(
while IFS= read -r line; do
printf "${BLUE}${BOLD}[backend]${RESET} %s\n" "$line"
done
) 2>&1 &
BACKEND_PID=$!
# --- Wait for backend to become healthy ---
echo -e "${YELLOW}${BOLD}Waiting for backend (http://localhost:8324) to be ready...${RESET}"
MAX_WAIT=120
elapsed=0
while (( elapsed < MAX_WAIT )); do
if curl -s -o /dev/null --connect-timeout 1 http://localhost:8324/ 2>/dev/null; then
echo -e "${GREEN}${BOLD}Backend is ready!${RESET}"
break
fi
if ! kill -0 "$BACKEND_PID" 2>/dev/null; then
echo -e "${RED}${BOLD}Backend process exited before becoming ready.${RESET}"
exit 1
fi
sleep 2
((elapsed += 2))
done
if (( elapsed >= MAX_WAIT )); then
echo -e "${RED}${BOLD}Backend did not become ready within ${MAX_WAIT}s. Aborting.${RESET}"
exit 1
fi
# --- Start frontend ---
echo -e "${GREEN}${BOLD}[frontend]${RESET} Starting frontend dev server..."
bash "$PROJECT_ROOT/frontend/run.sh" > >(
while IFS= read -r line; do
printf "${GREEN}${BOLD}[frontend]${RESET} %s\n" "$line"
done
) 2>&1 &
FRONTEND_PID=$!
# --- Wait for frontend dev server to become available ---
echo -e "${YELLOW}${BOLD}Waiting for frontend (http://localhost:3000) to be ready...${RESET}"
FRONTEND_MAX_WAIT=60
frontend_elapsed=0
while (( frontend_elapsed < FRONTEND_MAX_WAIT )); do
if curl -s -o /dev/null --connect-timeout 1 http://localhost:3000/ 2>/dev/null; then
echo -e "${GREEN}${BOLD}Frontend is ready!${RESET}"
break
fi
if ! kill -0 "$FRONTEND_PID" 2>/dev/null; then
echo -e "${RED}${BOLD}Frontend process exited before becoming ready.${RESET}"
exit 1
fi
sleep 2
((frontend_elapsed += 2))
done
if (( frontend_elapsed >= FRONTEND_MAX_WAIT )); then
echo -e "${RED}${BOLD}Frontend did not become ready within ${FRONTEND_MAX_WAIT}s. Aborting.${RESET}"
exit 1
fi
# --- Start Electron in dev mode ---
MAGENTA='\033[0;35m'
echo -e "${MAGENTA}${BOLD}[electron]${RESET} Launching Electron dev shell..."
(cd "$PROJECT_ROOT/electron" && ELECTRON_DEV=1 npx electron .) > >(
while IFS= read -r line; do
printf "${MAGENTA}${BOLD}[electron]${RESET} %s\n" "$line"
done
) 2>&1 &
ELECTRON_PID=$!
echo ""
echo -e "${BOLD}All services are running. Press Ctrl+C to stop.${RESET}"
echo -e " Backend: ${BLUE}http://localhost:8324${RESET}"
echo -e " Frontend: ${GREEN}http://localhost:3000${RESET}"
echo -e " Electron: ${MAGENTA}dev shell (pid $ELECTRON_PID)${RESET}"
echo ""
# --- Monitor: if any service exits, tear down all ---
while ! $SHUTTING_DOWN; do
if ! kill -0 "$BACKEND_PID" 2>/dev/null; then
echo -e "${RED}${BOLD}Backend process exited unexpectedly. Shutting down...${RESET}"
exit 1
fi
if [[ -n "$FRONTEND_PID" ]] && ! kill -0 "$FRONTEND_PID" 2>/dev/null; then
echo -e "${RED}${BOLD}Frontend process exited unexpectedly. Shutting down...${RESET}"
exit 1
fi
if [[ -n "$ELECTRON_PID" ]] && ! kill -0 "$ELECTRON_PID" 2>/dev/null; then
echo -e "${YELLOW}${BOLD}Electron process exited. Shutting down...${RESET}"
exit 0
fi
sleep 3
done
+94
View File
@@ -0,0 +1,94 @@
#!/bin/bash
set -euo pipefail
# Master build script for the OpenSwarm desktop app.
#
# Usage:
# bash scripts/build-app.sh Local dev build (unsigned)
# bash scripts/build-app.sh --publish Production build (signed, notarized, published to GitHub Releases)
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
ENV_FILE="$PROJECT_ROOT/backend/.env"
if [[ -f "$ENV_FILE" ]]; then
set -a
source "$ENV_FILE"
set +a
fi
PUBLISH_MODE=false
if [[ "${1:-}" == "--publish" ]]; then
PUBLISH_MODE=true
fi
echo "========================================"
echo " OpenSwarm Desktop App Builder"
if $PUBLISH_MODE; then
echo " Mode: PRODUCTION (sign + notarize + publish)"
else
echo " Mode: LOCAL (unsigned)"
fi
echo "========================================"
echo ""
if $PUBLISH_MODE; then
missing_vars=()
[[ -z "${APPLE_ID:-}" ]] && missing_vars+=("APPLE_ID")
[[ -z "${APPLE_APP_SPECIFIC_PASSWORD:-}" ]] && missing_vars+=("APPLE_APP_SPECIFIC_PASSWORD")
[[ -z "${APPLE_TEAM_ID:-}" ]] && missing_vars+=("APPLE_TEAM_ID")
[[ -z "${GH_TOKEN:-}" ]] && missing_vars+=("GH_TOKEN")
if [[ ${#missing_vars[@]} -gt 0 ]]; then
echo "ERROR: Missing required environment variables for --publish mode:"
printf ' - %s\n' "${missing_vars[@]}"
echo ""
echo "See script header for details."
exit 1
fi
fi
# Step 1: Build frontend
echo "[1/3] Building frontend..."
cd "$PROJECT_ROOT/frontend"
npm ci
npm run build
if [[ ! -f "$PROJECT_ROOT/frontend/dist/index.html" ]]; then
echo "ERROR: Frontend build failed — dist/index.html not found"
exit 1
fi
echo "Frontend build complete."
echo ""
# Step 2: Build Python environment
echo "[2/3] Building Python environment..."
bash "$SCRIPT_DIR/build-python-env.sh"
if [[ ! -d "$PROJECT_ROOT/electron/python-env" ]]; then
echo "ERROR: Python environment not found at electron/python-env/"
exit 1
fi
echo "Python environment ready."
echo ""
# Step 3: Package with electron-builder
echo "[3/3] Packaging with electron-builder..."
cd "$PROJECT_ROOT/electron"
npm install
if $PUBLISH_MODE; then
npx electron-builder --mac --publish always
else
export CSC_IDENTITY_AUTO_DISCOVERY=false
npx electron-builder --mac --publish never
fi
echo ""
echo "========================================"
echo " Build Complete!"
echo "========================================"
echo ""
echo "Output files:"
ls -lh "$PROJECT_ROOT/electron/dist/"*.dmg 2>/dev/null || true
ls -lh "$PROJECT_ROOT/electron/dist/"*.zip 2>/dev/null || true
echo ""
+121
View File
@@ -0,0 +1,121 @@
#!/bin/bash
set -euo pipefail
# Build an embedded Python environment for the Electron app.
#
# Downloads a standalone Python build from python-build-standalone,
# creates a venv, and installs all backend dependencies.
# The resulting python-env/ directory is bundled into the Electron app.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
ELECTRON_DIR="$PROJECT_ROOT/electron"
PYTHON_ENV_DIR="$ELECTRON_DIR/python-env"
PYTHON_VERSION="3.13"
PYTHON_FULL_VERSION="3.13.2"
ARCH="$(uname -m)"
if [[ "$ARCH" == "arm64" ]]; then
PLATFORM_TAG="aarch64-apple-darwin"
elif [[ "$ARCH" == "x86_64" ]]; then
PLATFORM_TAG="x86_64-apple-darwin"
else
echo "Unsupported architecture: $ARCH"
exit 1
fi
RELEASE_TAG="20250212"
TARBALL_NAME="cpython-${PYTHON_FULL_VERSION}+${RELEASE_TAG}-${PLATFORM_TAG}-install_only_stripped.tar.gz"
DOWNLOAD_URL="https://github.com/indygreg/python-build-standalone/releases/download/${RELEASE_TAG}/${TARBALL_NAME}"
TEMP_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$TEMP_DIR"
}
trap cleanup EXIT
echo "=== Building Python Environment ==="
echo "Architecture: $ARCH ($PLATFORM_TAG)"
echo "Python: $PYTHON_FULL_VERSION"
# Remove old env if present
if [[ -d "$PYTHON_ENV_DIR" ]]; then
echo "Removing old python-env..."
rm -rf "$PYTHON_ENV_DIR"
fi
# Download standalone Python
echo "Downloading standalone Python from python-build-standalone..."
echo "URL: $DOWNLOAD_URL"
curl -fSL --progress-bar -o "$TEMP_DIR/python.tar.gz" "$DOWNLOAD_URL"
echo "Extracting..."
tar xzf "$TEMP_DIR/python.tar.gz" -C "$TEMP_DIR"
# The tarball extracts to python/
EXTRACTED_DIR="$TEMP_DIR/python"
if [[ ! -d "$EXTRACTED_DIR" ]]; then
echo "Error: Expected extracted directory at $EXTRACTED_DIR"
ls -la "$TEMP_DIR"
exit 1
fi
# Move into place
mv "$EXTRACTED_DIR" "$PYTHON_ENV_DIR"
echo "Python installed to $PYTHON_ENV_DIR"
PYTHON_BIN="$PYTHON_ENV_DIR/bin/python${PYTHON_VERSION}"
if [[ ! -f "$PYTHON_BIN" ]]; then
PYTHON_BIN="$PYTHON_ENV_DIR/bin/python3"
fi
echo "Python binary: $PYTHON_BIN"
"$PYTHON_BIN" --version
# Install pip (standalone builds may not include it)
if ! "$PYTHON_BIN" -m pip --version &>/dev/null; then
echo "Installing pip..."
"$PYTHON_BIN" -m ensurepip --upgrade
fi
# Install backend dependencies
echo "Installing backend dependencies..."
"$PYTHON_BIN" -m pip install --upgrade pip
"$PYTHON_BIN" -m pip install -r "$PROJECT_ROOT/backend/requirements.txt"
# Install the debugger module
echo "Installing debugger module..."
"$PYTHON_BIN" -m pip install -e "$PROJECT_ROOT/debugger"
# Verify claude-agent-sdk and its bundled binary
echo "Verifying claude-agent-sdk..."
"$PYTHON_BIN" -c "import claude_agent_sdk; print(f'claude-agent-sdk installed')"
CLAUDE_BIN=$("$PYTHON_BIN" -c "
from pathlib import Path
import claude_agent_sdk
sdk_dir = Path(claude_agent_sdk.__file__).parent
bundled = sdk_dir / '_bundled' / 'claude'
print(bundled)
")
if [[ -f "$CLAUDE_BIN" ]]; then
echo "Claude binary found: $CLAUDE_BIN"
chmod +x "$CLAUDE_BIN"
else
echo "WARNING: Claude binary not found at $CLAUDE_BIN"
fi
# Clean up build artifacts to reduce size
echo "Cleaning up..."
find "$PYTHON_ENV_DIR" -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
find "$PYTHON_ENV_DIR" -name "*.pyc" -delete 2>/dev/null || true
find "$PYTHON_ENV_DIR" -type d -name "tests" -exec rm -rf {} + 2>/dev/null || true
find "$PYTHON_ENV_DIR" -type d -name "test" -exec rm -rf {} + 2>/dev/null || true
TOTAL_SIZE=$(du -sh "$PYTHON_ENV_DIR" | cut -f1)
echo ""
echo "=== Python Environment Ready ==="
echo "Location: $PYTHON_ENV_DIR"
echo "Size: $TOTAL_SIZE"
echo ""