[eric] merge eric/app-ux: app builder UX + browser tab drag-out + CreateApp fold-in

This commit is contained in:
ciregenz
2026-07-02 16:10:56 -07:00
68 changed files with 1738 additions and 2715 deletions
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""Stdio MCP server letting ANY agent create an OpenSwarm App on the canvas.
One tool, CreateApp, backed by /api/outputs/agent-create. Always on, no
activation gate: app-building is a core capability, not a third-party MCP.
The backend seeds a React/Vite workspace, registers the App, links it to the
calling session, and drops a live preview card next to the agent on the
dashboard. The tool result hands back the workspace path + the full App
Builder reference so the agent can start writing code immediately."""
import json
import os
import sys
import urllib.error
import urllib.request
BACKEND_PORT = os.environ.get("OPENSWARM_PORT", "8324")
BACKEND_AUTH = os.environ.get("OPENSWARM_AUTH_TOKEN", "")
BACKEND_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/outputs"
PARENT_SESSION_ID = os.environ.get("OPENSWARM_PARENT_SESSION_ID", "")
TOOLS = [
{
"name": "CreateApp",
"description": (
"Create a new OpenSwarm App: a real React 18 + TypeScript + Vite web app "
"(optional FastAPI backend) that appears as a live preview card on the "
"user's dashboard, next to you. Use this whenever the user asks you to "
"build/make an app, tool, game, dashboard, tracker, visualizer, or any "
"interactive UI. Returns the workspace path to write code in plus the "
"App Builder reference (stack, layout, rules) — follow it. To EDIT an "
"app that already exists, don't call this; edit its workspace files "
"directly (the path is in your context when the user selects the app, "
"and the preview hot-reloads on save)."
),
"inputSchema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Short human title for the app, e.g. 'Pomodoro Timer'.",
},
"description": {
"type": "string",
"description": "One sentence on what the app does.",
},
},
"required": ["name"],
"additionalProperties": False,
},
},
]
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 call_backend(action: str, payload: dict) -> dict:
full = {**payload, "parent_session_id": PARENT_SESSION_ID}
body = json.dumps(full).encode()
headers = {"Content-Type": "application/json"}
if BACKEND_AUTH:
headers["Authorization"] = f"Bearer {BACKEND_AUTH}"
req = urllib.request.Request(
f"{BACKEND_URL}/{action}", data=body, headers=headers, method="POST"
)
try:
# Seeding copies the template + links the warm node_modules cache; give it room.
with urllib.request.urlopen(req, timeout=120) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
detail = e.read().decode() if e.fp else str(e)
return {"error": f"HTTP {e.code}: {detail}"}
except Exception as e:
return {"error": str(e)}
def handle_tool_call(tool_name: str, arguments: dict) -> dict:
if tool_name == "CreateApp":
name = str(arguments.get("name") or "").strip()
if not name:
return {"content": [{"type": "text", "text": "Error: `name` is required."}], "isError": True}
result = call_backend("agent-create", {
"name": name,
"description": str(arguments.get("description") or "").strip(),
})
if "error" in result:
return {"content": [{"type": "text", "text": f"Error: {result['error']}"}], "isError": True}
path = result.get("path")
# Point at SKILL.md instead of inlining the ~6.5k-token reference: it's written to the workspace at seed time, so a one-time Read keeps it out of the transcript (where an inline copy would rot for the rest of the session). Read it BEFORE building.
lines = [
f"App '{name}' created and its live preview card is now on the user's dashboard.",
f"- workspace: {path}",
f"- output_id: {result.get('output_id')}",
"",
f"NEXT: read {path}/SKILL.md now — it's the full App Builder reference (stack, layout, rules); follow it.",
"Then build by writing files under the workspace path; the preview hot-reloads on save.",
"Housekeeping: write meta.json (name/description) first; `bash restart.sh` restarts the runtime; `.openswarm/terminal.log` is the live terminal (check it before declaring done).",
]
return {"content": [{"type": "text", "text": "\n".join(lines)}]}
return {"content": [{"type": "text", "text": f"Unknown tool: {tool_name}"}], "isError": True}
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-apps", "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", {})
try:
send_response(id_, handle_tool_call(tool_name, arguments))
except Exception as e:
send_response(id_, error={"code": -32000, "message": str(e)})
elif method == "resources/list":
send_response(id_, {"resources": []})
elif method == "prompts/list":
send_response(id_, {"prompts": []})
elif id_ is not None:
send_response(id_, error={"code": -32601, "message": f"Method not found: {method}"})
if __name__ == "__main__":
main()
@@ -73,6 +73,17 @@ def compose_turn_system_prompt(
from backend.apps.outputs.view_builder_templates import load_app_builder_skill
skill_block = f"<app_builder_reference>\n{load_app_builder_skill()}\n</app_builder_reference>"
composed_prompt = f"{composed_prompt}\n\n{skill_block}" if composed_prompt else skill_block
else:
# Every other mode gets one line of discovery instead of the whole reference: CreateApp's result carries the reference when actually used, so the base prompt stays cheap.
apps_note = (
"<apps_capability>\n"
"You can build real web apps for the user: when they ask for an app, tool, game, or dashboard, "
"call the CreateApp tool — it seeds a workspace and puts a live preview card on their dashboard, "
"then you write the code. To change an existing app, have the user select its card (or use the "
"workspace path in your context) and edit the files directly.\n"
"</apps_capability>"
)
composed_prompt = f"{composed_prompt}\n\n{apps_note}" if composed_prompt else apps_note
# App cards the user picked via the dashboard element picker: give the agent each app's on-disk path + meta + SKILL.md pointer so it can edit them in place (the dashboard card's runtime live-reloads). Additive and independent of view-builder mode above.
app_ctx = build_selected_app_context(selected_app_output_ids)
@@ -129,6 +129,19 @@ def register_builtin_mcp_servers(
"type": "stdio",
}
# Always-on apps server: CreateApp lets ANY agent spin up a live App card on the canvas. This replaced the standalone App Builder page; the tool result carries the App Builder reference so no mode switch is needed.
apps_server_path = os.path.join(agents_dir, "apps_mcp_server.py")
mcp_servers["openswarm-apps"] = {
"command": sys.executable,
"args": [apps_server_path],
"env": {
"OPENSWARM_PORT": os.environ.get("OPENSWARM_PORT", "8324"),
"OPENSWARM_AUTH_TOKEN": get_auth_token(),
"OPENSWARM_PARENT_SESSION_ID": session.id,
},
"type": "stdio",
}
# Always-on schedule server: ScheduleWorkflow + CRUD + AddWorkflowStep/EditWorkflowStep so the agent (and the workflow Edit Agent) can build and schedule recurring work via the native scheduler instead of cron/launchctl. The 4 scheduling tools are force-gated in path_gate; Cron* is denied in build_effective_tool_lists.
schedule_server_path = os.path.join(
agents_dir, "schedule_mcp_server.py"
+2
View File
@@ -11,6 +11,8 @@ class CardPosition(BaseModel):
class ViewCardPosition(BaseModel):
output_id: str
# Which instance of the app this card is (1 = primary). Persisted or Pydantic strips it on save and a reloaded second-instance card collapses onto the primary's runtime (same failure shape as the browser-card dashboard_id bleed).
instance: int = 1
x: float = 0
y: float = 0
width: float = 480
+2
View File
@@ -14,6 +14,8 @@ class CardPosition(BaseModel):
class ViewCardPosition(BaseModel):
output_id: str
# Which instance of the app this card is (1 = primary). Persisted or Pydantic strips it on save and a reloaded second-instance card collapses onto the primary's runtime (same failure shape as the browser-card dashboard_id bleed).
instance: int = 1
x: float = 0
y: float = 0
width: float = 480
-3
View File
@@ -2,14 +2,11 @@ from backend.config.Apps import SubApp
from contextlib import asynccontextmanager
from fastapi.responses import PlainTextResponse
from typeguard import typechecked
import debug
from fastapi import status, HTTPException
@asynccontextmanager
async def health_lifespan():
debug("START")
yield
debug("END")
health = SubApp("health", health_lifespan)
+21 -13
View File
@@ -83,6 +83,7 @@ workspace/
│ # when you change either)
├── run.sh # OpenSwarm's runtime spawns this; you don't
├── backend_init.sh # Run this when you need a backend (see below)
├── restart.sh # Run this to restart the app runtime (see below)
├── SKILL.md # This document
└── frontend/
├── package.json # React 18, MUI v7, Redux Toolkit, Framer
@@ -258,9 +259,8 @@ bash backend_init.sh
This script COPIES the canonical backend scaffold (FastAPI + SubApp pattern
+ swarm-debug pre-installed) into your workspace, allocates a free port,
and flips `BACKEND_PORT` in both `.env` and `.env.example`. Then **hard-
reload the preview** (right-click the reload button) so the runtime
restarts and brings the backend up.
and flips `BACKEND_PORT` in both `.env` and `.env.example`. Then run
**`bash restart.sh`** so the runtime restarts and brings the backend up.
**You MUST NOT roll your own backend.** Do not:
- Hand-write a `backend/main.py` from scratch.
@@ -447,7 +447,7 @@ Spotify-style player) don't need this: their actions are already at agent cadenc
## Debugging — use `swarm_debug`, not `print()`
The backend has `swarm_debug` pre-installed. It's a colored frame-aware
logger that lands in the App Builder's **Terminal** tab under `[BACKEND]`.
logger that lands in the app card's **Terminal** view under `[BACKEND]`.
```python
from swarm_debug import debug
@@ -462,10 +462,18 @@ reference. `print()` works too but lacks the variable-name inference and
colorization.
Frontend `console.log/warn/error` calls land in the Terminal pane under
`[FRONTEND]` via the App Builder's webview-preload bridge. Same chronological
`[FRONTEND]` via the app card's webview-preload bridge. Same chronological
stream as `[BACKEND]` lines, so you can correlate cause and effect across
the two halves of your stack.
**Read the terminal yourself: `.openswarm/terminal.log`** at the workspace
root is a live tee of everything the Terminal pane shows — `[BACKEND]` /
`[BACKEND:stderr]` stdout+stderr, `[RUNTIME]` events, and `[FRONTEND]` /
`[FRONTEND:warn]` / `[FRONTEND:error]` console lines from the running app.
It resets on every app (re)start. When something misbehaves, don't guess —
`tail -100 .openswarm/terminal.log` (or grep it for `error`) and look at
what actually happened.
---
## Adding npm packages
@@ -502,8 +510,9 @@ Common deps already in the template:
## Workflow tips
- **Edits are auto-saved**. As soon as you write a file via the Edit/Write tool, it's on disk. Vite HMR re-renders the preview within ~100ms.
- **Hard Reload (right-click the reload button)** restarts the runtime — useful after you `bash backend_init.sh` or change `.env` values.
- **`meta.json`** at workspace root drives the app's name + description in the OpenSwarm sidebar, App Builder header, and Apps page. Write it FIRST when starting a new app (see step 1 of the Quick start checklist), and revise it any time the app's purpose shifts.
- **`bash restart.sh` restarts the app runtime yourself** — backend + vite, no user action needed. Use it after `bash backend_init.sh`, after editing `.env`, or whenever backend code must reload (uvicorn runs WITHOUT --reload, so backend edits do NOT hot-apply). Never ask the user to restart for you, and never try to kill/rerun run.sh — the harness owns the process. If `restart.sh` is missing (older app), `mkdir -p .openswarm && touch .openswarm/restart-requested` does the same thing.
- After a restart, wait a few seconds and check `.openswarm/terminal.log` to confirm the boot looked clean.
- **`meta.json`** at workspace root drives the app's name + description in the OpenSwarm sidebar and on the app's live card on the dashboard. Write it FIRST when starting a new app (see step 1 of the Quick start checklist), and revise it any time the app's purpose shifts.
---
@@ -514,8 +523,8 @@ runtime errors as a visible red error card AND mirrors the error into
the Terminal pane as a `[FRONTEND]` line tagged `[openswarm:app-error]`.
After substantial edits — especially anything that touches imports,
hooks, or React state — **always check the most recent `[FRONTEND]`
lines in your Terminal output before saying "done"**. If you see one,
fix it before claiming the app is ready.
lines before saying "done"** (`tail -50 .openswarm/terminal.log`).
If you see one, fix it before claiming the app is ready.
The three most common ways agent edits crash a React preview:
@@ -543,10 +552,9 @@ The three most common ways agent edits crash a React preview:
rm -rf frontend/node_modules && rm -rf frontend/.vite-cache
```
Then trigger Hard Reload on the preview (right-click the reload
button in the toolbar). The workspace's `frontend/node_modules`
Then run `bash restart.sh`. The workspace's `frontend/node_modules`
will re-symlink to the shared warm cache on next vite boot — only
ONE React copy exists across all App Builder apps, so the
ONE React copy exists across all OpenSwarm apps, so the
duplicate is gone. The `.vite-cache` wipe is important because
vite caches pre-bundled deps including the duplicate React.
@@ -580,7 +588,7 @@ When making a new app from scratch:
1. **WRITE `meta.json` FIRST**, before any other tool call. Put a 1-3 word product
name (Title Case) in `name` and a one-sentence description in `description`.
The Apps sidebar and the App Builder header show this name to the user; until
The sidebar and the app's dashboard card show this name to the user; until
you write it, both surfaces sit at "Untitled App". Don't wait until the end of
the turn to fill it in, pick a name from the user's prompt and ship it now.
Example: prompt "make doodle jump" → `{"name": "Doodle Jumper", "description":
+7
View File
@@ -169,6 +169,13 @@ class OutputExecuteResult(BaseModel):
code_preview: Optional[str] = None
class AgentCreateAppRequest(BaseModel):
name: str
description: str = ""
# The calling agent session (injected by the apps MCP server); persisted on the Output so the dashboard drops the card next to that agent and the Building overlay tracks its turns.
parent_session_id: str = ""
class WorkspaceSeedRequest(BaseModel):
workspace_id: str
files: Optional[dict[str, str]] = None
+79 -20
View File
@@ -11,7 +11,7 @@ from backend.auth import get_auth_token
from backend.config.Apps import SubApp
from backend.apps.outputs.models import (
Output, OutputCreate, OutputUpdate, OutputExecute, OutputExecuteResult,
VibeCodeRequest, WorkspaceSeedRequest,
VibeCodeRequest, WorkspaceSeedRequest, AgentCreateAppRequest,
PublishPreflightRequest, PublishRequest, PublishPreflightResponse,
PublishResult, PublishReview,
)
@@ -241,6 +241,47 @@ def ensure_webapp_workspace_seeded_and_registered(
return None
@outputs.router.post("/agent-create")
async def agent_create_app(body: AgentCreateAppRequest):
"""The CreateApp MCP tool's backend: seed a webapp-template workspace, register
the Output linked to the calling agent session, name it, and broadcast so the
dashboard drops a live card next to the agent. Any agent, any mode."""
from uuid import uuid4
workspace_id = uuid4().hex
folder = os.path.join(WORKSPACE_DIR, workspace_id)
output_id = ensure_webapp_workspace_seeded_and_registered(
workspace_id=workspace_id,
folder=folder,
session_id=body.parent_session_id or None,
)
if not output_id:
raise HTTPException(status_code=500, detail="workspace seed/registration failed")
output = load(output_id)
output.name = body.name.strip() or "Untitled App"
output.description = body.description.strip()
output.updated_at = datetime.now().isoformat()
save(output)
# Keep meta.json in agreement so the agent's own naming pass and the sidebar read the same values.
try:
with open(os.path.join(folder, "meta.json"), "w", encoding="utf-8") as f:
json.dump({"name": output.name, "description": output.description}, f, indent=2)
except OSError:
logger.exception("agent-create meta.json write failed for %s", workspace_id)
from backend.apps.agents.core.ws_manager import ws_manager
try:
await ws_manager.broadcast_global("agent:output_upserted", {
"output": output.model_dump(mode="json"),
})
except Exception:
logger.exception("agent-create output_upserted broadcast failed")
# The reference isn't returned here: it's written to <folder>/SKILL.md at seed time and the agent reads it on demand, keeping the ~6.5k-token blob out of both this response and the agent transcript.
return {
"ok": True,
"output_id": output_id,
"path": os.path.abspath(folder),
}
@outputs.router.post("/workspace/seed")
async def seed_workspace(body: WorkspaceSeedRequest):
"""Create a workspace folder and pre-seed it.
@@ -357,10 +398,10 @@ async def seed_workspace(body: WorkspaceSeedRequest):
# --------------------------------------------------------------------------- Persistent app-backend runtime control. backend.py runs as a long-lived subprocess for the lifetime of the App being open; auto-allocated port, log streaming via WebSocket. See runtime.py for the manager. ---------------------------------------------------------------------------
def runtime_status_payload(workspace_id: str) -> dict:
def runtime_status_payload(workspace_id: str, instance: int = 1) -> dict:
from backend.apps.outputs.runtime import manager as runtime_manager
from backend.apps.outputs.runtime import is_new_mode
rt = runtime_manager.get(workspace_id)
rt = runtime_manager.get(workspace_id, instance)
if not rt:
# Even without a live runtime, the editor needs is_new_mode to decide whether the preview pane should fall back to the legacy /serve/index.html URL (old-mode flat workspaces) or show the "starting preview…" placeholder (new-mode webapp_template). Compute from disk so a failed runtime/start still gives the client the right hint instead of dumping it onto a 404.
folder = os.path.join(WORKSPACE_DIR, workspace_id)
@@ -388,44 +429,44 @@ def runtime_status_payload(workspace_id: str) -> dict:
@outputs.router.post("/workspace/{workspace_id}/runtime/start")
async def runtime_start(workspace_id: str):
async def runtime_start(workspace_id: str, instance: int = 1):
folder = os.path.join(WORKSPACE_DIR, workspace_id)
if not os.path.isdir(folder):
raise HTTPException(status_code=404, detail="Workspace not found")
from backend.apps.outputs.runtime import manager as runtime_manager
await runtime_manager.attach(workspace_id, os.path.abspath(folder))
return runtime_status_payload(workspace_id)
await runtime_manager.attach(workspace_id, os.path.abspath(folder), instance)
return runtime_status_payload(workspace_id, instance)
@outputs.router.post("/workspace/{workspace_id}/runtime/stop")
async def runtime_stop(workspace_id: str):
async def runtime_stop(workspace_id: str, instance: int = 1):
from backend.apps.outputs.runtime import manager as runtime_manager
await runtime_manager.detach(workspace_id)
return runtime_status_payload(workspace_id)
await runtime_manager.detach(workspace_id, instance)
return runtime_status_payload(workspace_id, instance)
@outputs.router.post("/workspace/{workspace_id}/runtime/restart")
async def runtime_restart(workspace_id: str):
async def runtime_restart(workspace_id: str, instance: int = 1):
folder = os.path.join(WORKSPACE_DIR, workspace_id)
if not os.path.isdir(folder):
raise HTTPException(status_code=404, detail="Workspace not found")
from backend.apps.outputs.runtime import manager as runtime_manager
# Restart only if something's attached; otherwise this is a no-op silently (a hard-reload click while the runtime was already torn down; we'd rather not silently respawn an orphan).
rt = runtime_manager.get(workspace_id)
rt = runtime_manager.get(workspace_id, instance)
if rt:
await runtime_manager.restart(workspace_id, os.path.abspath(folder))
return runtime_status_payload(workspace_id)
await runtime_manager.restart(workspace_id, os.path.abspath(folder), instance)
return runtime_status_payload(workspace_id, instance)
@outputs.router.get("/workspace/{workspace_id}/runtime/status")
async def runtime_get_status(workspace_id: str):
return runtime_status_payload(workspace_id)
async def runtime_get_status(workspace_id: str, instance: int = 1):
return runtime_status_payload(workspace_id, instance)
@outputs.router.post("/workspace/{workspace_id}/runtime/report-error")
async def runtime_report_error(workspace_id: str, body: dict):
async def runtime_report_error(workspace_id: str, body: dict, instance: int = 1):
from backend.apps.outputs.runtime import manager as runtime_manager
rt = runtime_manager.get(workspace_id)
rt = runtime_manager.get(workspace_id, instance)
if rt is None:
return {"ok": False, "recorded": 0}
message = (body.get("message") or "").strip()
@@ -439,10 +480,28 @@ async def runtime_report_error(workspace_id: str, body: dict):
return {"ok": True, "recorded": 1}
@outputs.router.post("/workspace/{workspace_id}/runtime/report-ready")
async def runtime_report_ready(workspace_id: str):
@outputs.router.post("/workspace/{workspace_id}/runtime/console-log")
async def runtime_console_log(workspace_id: str, body: dict, instance: int = 1):
"""Fold webview console lines into the runtime's terminal stream so they reach the Terminal panes AND the agent-readable .openswarm/terminal.log. Renderer batches; body is {lines: [{level, text}, ...]}."""
from backend.apps.outputs.runtime import manager as runtime_manager
rt = runtime_manager.get(workspace_id)
rt = runtime_manager.get(workspace_id, instance)
if rt is None:
return {"ok": False, "recorded": 0}
lines = body.get("lines") or []
recorded = 0
for entry in lines[:200]:
text = str(entry.get("text") or "").strip()
if not text:
continue
rt.record_frontend_log(str(entry.get("level") or "log"), text)
recorded += 1
return {"ok": True, "recorded": recorded}
@outputs.router.post("/workspace/{workspace_id}/runtime/report-ready")
async def runtime_report_ready(workspace_id: str, instance: int = 1):
from backend.apps.outputs.runtime import manager as runtime_manager
rt = runtime_manager.get(workspace_id, instance)
if rt is None:
return {"ok": False}
rt.set_render_ok()
+173 -52
View File
@@ -49,16 +49,41 @@ logger = logging.getLogger(__name__)
# Module-level lock so only ONE vite optimizeDeps runs at a time; must be acquired before manager.p_lock to avoid deadlock with manager.attach.
p_vite_boot_lock = asyncio.Lock()
# How often the manager stats each attached workspace for a restart sentinel; one stat/sec/runtime is noise.
RESTART_SENTINEL_POLL_SECONDS = 1.0
# The agent-facing restart handshake: the workspace's restart.sh touches this and the manager restarts the runtime, so agents never need an API token to bounce their own app.
RESTART_SENTINEL_NAME = "restart-requested"
@dataclass
class LogLine:
stream: str # "stdout" | "stderr" | "runtime" (internal status lines)
stream: str # "stdout" | "stderr" | "runtime" (internal) | "frontend[-warn|-error]" (webview console via the console-log beacon)
text: str
# Byte cap for the on-disk terminal tee; past this we rewrite the file from the ring buffer so an HMR-spammy session can't grow it unbounded.
TERMINAL_LOG_MAX_BYTES = 4 * 1024 * 1024
# Human/agent-facing prefixes for the terminal.log tee; mirrors the Terminal pane's labels so skill docs describe both with one vocabulary.
TERMINAL_LOG_PREFIXES = {
"stdout": "[BACKEND]",
"stderr": "[BACKEND:stderr]",
"runtime": "[RUNTIME]",
"frontend": "[FRONTEND]",
"frontend-warn": "[FRONTEND:warn]",
"frontend-error": "[FRONTEND:error]",
}
LogSubscriber = Callable[[LogLine], None]
def runtime_key(workspace_id: str, instance: int) -> str:
"""Registry key for a workspace instance. Instance 1 keeps the bare workspace_id so every existing get()/attach() caller keeps addressing the primary."""
return workspace_id if instance <= 1 else f"{workspace_id}#{instance}"
class AppRuntime:
"""Manages one workspace's backend.py subprocess.
@@ -71,9 +96,11 @@ class AppRuntime:
- `log_buffer` is the replay source for new subscribers.
"""
def __init__(self, workspace_id: str, workspace_path: str):
def __init__(self, workspace_id: str, workspace_path: str, instance: int = 1):
self.workspace_id = workspace_id
self.workspace_path = workspace_path
# Instance 1 is the primary (uses the .env-pinned ports); >1 are extra dashboard cards of the same app, fully independent processes on fresh ports that must never rewrite the shared .env.
self.instance = max(1, instance)
# Old-mode: `port` is the backend.py port. New-mode: `port` is the workspace's optional FastAPI backend (only set if BACKEND_PORT!=NONE) and `frontend_port` is the Vite dev server port. Both Nones until start() decides what's there.
self.port: Optional[int] = None
self.frontend_port: Optional[int] = None
@@ -83,6 +110,10 @@ class AppRuntime:
self.p_suspended: bool = False
self.process: Optional[asyncio.subprocess.Process] = None
self.log_buffer: deque[LogLine] = deque(maxlen=LOG_BUFFER_LINES)
# On-disk tee of the ring buffer so the App Builder agent can inspect terminal output itself (Read/grep); reset on every start(). Secondary instances get a suffixed file so they don't clobber the primary's.
log_name = "terminal.log" if self.instance <= 1 else f"terminal-{self.instance}.log"
self.p_terminal_log_path = os.path.join(workspace_path, ".openswarm", log_name)
self.p_terminal_log_bytes = 0
self.p_subscribers: set[LogSubscriber] = set()
# Recent build/runtime errors scraped from stderr; drained by the agent's post-tool hook after Write/Edit so the agent sees vite/babel/uvicorn errors in its next turn and can self-fix instead of leaving the user with a red iframe overlay.
self.recent_errors: deque[str] = deque(maxlen=RECENT_ERRORS_MAX)
@@ -160,6 +191,7 @@ class AppRuntime:
if self.running:
return True
self.p_reset_terminal_log()
if self.is_new_mode:
# Acquire the module-level boot lock BEFORE the spawn so only one new-mode workspace is mid-bundle at a time. The lock is released by the bind-poll task the moment vite emits "frontend ready" (or its 180s timeout fires), which is the moment the next workspace can start its own vite without competing for the same CPU. See `p_await_frontend_bind` for the release.
await p_vite_boot_lock.acquire()
@@ -178,40 +210,50 @@ class AppRuntime:
env_path = os.path.join(self.workspace_path, ".env")
fp_raw = read_env_value(env_path, "FRONTEND_PORT")
bp_raw = read_env_value(env_path, "BACKEND_PORT")
# FRONTEND_PORT is allocated by seed_workspace; should always be a number. If missing, fall back to a fresh allocation (rare edge case: workspace seeded by an older OpenSwarm).
try:
self.frontend_port = int(fp_raw) if fp_raw else find_free_port()
except ValueError:
if self.instance > 1:
# Secondary instance: fresh ports always (the primary owns the .env-pinned ones), and NEVER rewrite the shared .env. .env is read only to learn whether the app has a backend at all.
self.frontend_port = find_free_port()
# Port-collision safety net: if a ghost subprocess from a prior OpenSwarm run is still bound to the persisted port (force-quit, crash, OS killed the parent before stop_all could reap), Vite would EADDRINUSE silently. Re-probe and reallocate, then rewrite .env so the bash run.sh subprocess reads the new port.
if self.frontend_port and not is_port_free(self.frontend_port):
new_port = find_free_port()
self.p_broadcast(LogLine(
"runtime",
f"[runtime] persisted FRONTEND_PORT {self.frontend_port} is in use; reallocating to {new_port}",
))
self.frontend_port = new_port
write_env_value(env_path, "FRONTEND_PORT", str(new_port))
# BACKEND_PORT may be the literal string "NONE" (frontend-only app; the common case) or a number once `backend_init.sh` has run. Only populate self.port when there's a real backend.
if bp_raw and bp_raw != "NONE":
self.port = find_free_port() if (bp_raw and bp_raw != "NONE") else None
else:
# FRONTEND_PORT is allocated by seed_workspace; should always be a number. If missing, fall back to a fresh allocation (rare edge case: workspace seeded by an older OpenSwarm).
try:
self.port = int(bp_raw)
self.frontend_port = int(fp_raw) if fp_raw else find_free_port()
except ValueError:
self.port = None
# Same collision check for the backend port; a leaked uvicorn from a prior session would otherwise block the new spawn.
if self.port and not is_port_free(self.port):
self.frontend_port = find_free_port()
# Port-collision safety net: if a ghost subprocess from a prior OpenSwarm run is still bound to the persisted port (force-quit, crash, OS killed the parent before stop_all could reap), Vite would EADDRINUSE silently. Re-probe and reallocate, then rewrite .env so the bash run.sh subprocess reads the new port.
if self.frontend_port and not is_port_free(self.frontend_port):
new_port = find_free_port()
self.p_broadcast(LogLine(
"runtime",
f"[runtime] persisted BACKEND_PORT {self.port} is in use; reallocating to {new_port}",
f"[runtime] persisted FRONTEND_PORT {self.frontend_port} is in use; reallocating to {new_port}",
))
self.port = new_port
write_env_value(env_path, "BACKEND_PORT", str(new_port))
else:
self.port = None
self.frontend_port = new_port
write_env_value(env_path, "FRONTEND_PORT", str(new_port))
# BACKEND_PORT may be the literal string "NONE" (frontend-only app; the common case) or a number once `backend_init.sh` has run. Only populate self.port when there's a real backend.
if bp_raw and bp_raw != "NONE":
try:
self.port = int(bp_raw)
except ValueError:
self.port = None
# Same collision check for the backend port; a leaked uvicorn from a prior session would otherwise block the new spawn.
if self.port and not is_port_free(self.port):
new_port = find_free_port()
self.p_broadcast(LogLine(
"runtime",
f"[runtime] persisted BACKEND_PORT {self.port} is in use; reallocating to {new_port}",
))
self.port = new_port
write_env_value(env_path, "BACKEND_PORT", str(new_port))
else:
self.port = None
env = self.p_spawn_env_base()
# bash run.sh reads .env itself; we don't need to set FRONTEND_PORT / BACKEND_PORT here. We DO export the install paths so the template's `backend/run.sh` can find our debugger to satisfy its `from swarm_debug import debug`. (Also written into .env at seed time, but env-var path is the more reliable read site for subshells.) NOTE: keep these in sync with seed_webapp_template_workspace.
if self.instance > 1:
# run.sh applies these AFTER sourcing .env, so the secondary boots on its own ports. Older workspaces (pre-override run.sh) ignore them; their secondary instance EADDRINUSEs visibly in the Terminal instead of silently sharing the primary.
env["OPENSWARM_FORCE_FRONTEND_PORT"] = str(self.frontend_port)
if self.port:
env["OPENSWARM_FORCE_BACKEND_PORT"] = str(self.port)
# bash run.sh reads .env itself; we don't need to set FRONTEND_PORT / BACKEND_PORT here. We DO export the install paths as env vars (the more reliable read site for subshells). OPENSWARM_DEBUGGER_PATH is legacy-only: workspaces seeded before the PyPI swap have a run.sh that editable-installs the bundled debugger from it; new templates resolve `swarm-debug` from PyPI via pyproject.
from backend.apps.outputs.view_builder_templates import (
DEBUGGER_PATH,
TEMPLATE_BACKEND_PATH,
@@ -426,6 +468,7 @@ class AppRuntime:
def p_broadcast(self, line: LogLine) -> None:
self.log_buffer.append(line)
self.p_append_terminal_log(line)
# Snapshot subscribers; they can self-remove during dispatch.
for cb in list(self.p_subscribers):
try:
@@ -433,6 +476,42 @@ class AppRuntime:
except Exception:
pass
def announce(self, text: str) -> None:
"""Emit a [runtime]-stream status line; the public door for the manager (p_broadcast is class-private)."""
self.p_broadcast(LogLine("runtime", text))
def record_frontend_log(self, level: str, text: str) -> None:
"""Fold a webview console line into the terminal stream (ring buffer, WS subscribers, terminal.log). Called by the console-log beacon endpoint."""
stream = {"warn": "frontend-warn", "error": "frontend-error"}.get(level, "frontend")
self.p_broadcast(LogLine(stream, text))
def p_reset_terminal_log(self) -> None:
try:
os.makedirs(os.path.dirname(self.p_terminal_log_path), exist_ok=True)
with open(self.p_terminal_log_path, "w", encoding="utf-8") as f:
f.write("# App terminal output (backend stdout/stderr, runtime events, frontend console). Reset on every app start.\n")
self.p_terminal_log_bytes = 0
except Exception:
logger.exception("terminal.log reset failed for %s", self.workspace_id)
def p_append_terminal_log(self, line: LogLine) -> None:
# Failures must never break the log pipeline; the file is a convenience tee.
try:
prefix = TERMINAL_LOG_PREFIXES.get(line.stream, f"[{line.stream}]")
rendered = f"{prefix} {line.text}\n"
if self.p_terminal_log_bytes > TERMINAL_LOG_MAX_BYTES:
# Rewrite from the ring buffer so the file self-heals to the last LOG_BUFFER_LINES lines instead of growing unbounded.
with open(self.p_terminal_log_path, "w", encoding="utf-8") as f:
for old in list(self.log_buffer):
f.write(f"{TERMINAL_LOG_PREFIXES.get(old.stream, f'[{old.stream}]')} {old.text}\n")
self.p_terminal_log_bytes = os.path.getsize(self.p_terminal_log_path)
return
with open(self.p_terminal_log_path, "a", encoding="utf-8") as f:
f.write(rendered)
self.p_terminal_log_bytes += len(rendered.encode("utf-8", errors="replace"))
except Exception:
pass
def p_maybe_capture_error(self, text: str) -> None:
if ERROR_PATTERNS.search(text):
self.recent_errors.append(text.rstrip())
@@ -487,20 +566,58 @@ class AppRuntimeManager:
# workspace_id → AppRuntime with no subscribers but still alive. OrderedDict gives O(1) move_to_end + popitem(last=False) for LRU semantics.
self.idle_lru: "OrderedDict[str, AppRuntime]" = OrderedDict()
self.p_lock = asyncio.Lock()
# Public: tests cancel it during teardown.
self.restart_watch_task: Optional[asyncio.Task] = None
async def attach(self, workspace_id: str, workspace_path: str) -> AppRuntime:
def p_ensure_restart_watcher(self) -> None:
"""Lazy-start the sentinel watcher on first attach; __init__ runs at import time, before any event loop exists."""
if self.restart_watch_task is None or self.restart_watch_task.done():
self.restart_watch_task = asyncio.create_task(self.p_watch_restart_sentinels())
async def p_watch_restart_sentinels(self) -> None:
"""The agent-side restart path: a workspace's restart.sh (or a bare `touch
.openswarm/restart-requested`) asks the harness to bounce the app runtime,
which the agent cannot do itself (no API token, and uvicorn runs without
--reload). Consume the sentinel FIRST so restart.sh's wait loop unblocks,
then restart every attached instance of that workspace."""
while True:
await asyncio.sleep(RESTART_SENTINEL_POLL_SECONDS)
try:
seen_paths: set[str] = set()
for rt in list(self.runtimes.values()):
ws_path = rt.workspace_path
if ws_path in seen_paths or rt.p_suspended or not rt.running:
continue
sentinel = os.path.join(ws_path, ".openswarm", RESTART_SENTINEL_NAME)
if not os.path.exists(sentinel):
continue
seen_paths.add(ws_path)
try:
os.remove(sentinel)
except OSError:
continue
for peer in list(self.runtimes.values()):
if peer.workspace_path == ws_path and peer.running and not peer.p_suspended:
peer.announce("[runtime] restart requested from the workspace (restart.sh); restarting...")
asyncio.create_task(peer.restart())
except Exception:
logger.exception("restart-sentinel watcher tick failed")
async def attach(self, workspace_id: str, workspace_path: str, instance: int = 1) -> AppRuntime:
self.p_ensure_restart_watcher()
key = runtime_key(workspace_id, instance)
revived = False
# Defined here so every code path below leaves it bound; the revive-idle branch used to skip the assignment, leaving the post-lock `if dead is not None:` check throwing UnboundLocalError.
dead: Optional[AppRuntime] = None
async with self.p_lock:
rt = self.runtimes.get(workspace_id)
rt = self.runtimes.get(key)
if rt is None:
# Maybe the runtime is sitting idle in the LRU; revive it without paying the spawn cost again.
idle_rt = self.idle_lru.pop(workspace_id, None)
idle_rt = self.idle_lru.pop(key, None)
if idle_rt is not None and idle_rt.running:
rt = idle_rt
rt.workspace_path = workspace_path
self.runtimes[workspace_id] = rt
self.runtimes[key] = rt
revived = True
# SIGCONT the process tree if A2 had it paused while idle. Pair with the SIGSTOP in detach() below.
resume_process_tree(rt.process)
@@ -509,12 +626,12 @@ class AppRuntimeManager:
if idle_rt is not None:
# Stale idle entry; process died while idling. Drop and spawn a fresh one below; old one gets stopped outside the lock.
dead = idle_rt
rt = AppRuntime(workspace_id, workspace_path)
self.runtimes[workspace_id] = rt
rt = AppRuntime(workspace_id, workspace_path, instance)
self.runtimes[key] = rt
else:
# Workspace paths shouldn't change for a given id, but if somehow they did (e.g. the user moved the workspace folder), trust the latest caller; they have the current truth.
rt.workspace_path = workspace_path
self.p_attached[workspace_id] = self.p_attached.get(workspace_id, 0) + 1
self.p_attached[key] = self.p_attached.get(key, 0) + 1
if not revived and not rt.running:
await rt.start()
# Stop any dead idle runtime outside the lock to avoid blocking.
@@ -522,27 +639,28 @@ class AppRuntimeManager:
try:
await dead.stop()
except Exception:
logger.exception("failed to reap dead idle runtime %s", workspace_id)
logger.exception("failed to reap dead idle runtime %s", key)
return rt
async def detach(self, workspace_id: str) -> None:
async def detach(self, workspace_id: str, instance: int = 1) -> None:
key = runtime_key(workspace_id, instance)
to_idle: Optional[AppRuntime] = None
to_reap: list[AppRuntime] = []
async with self.p_lock:
count = self.p_attached.get(workspace_id, 0) - 1
count = self.p_attached.get(key, 0) - 1
if count > 0:
self.p_attached[workspace_id] = count
self.p_attached[key] = count
return
self.p_attached.pop(workspace_id, None)
rt = self.runtimes.pop(workspace_id, None)
self.p_attached.pop(key, None)
rt = self.runtimes.pop(key, None)
if rt is None:
return
# If the process is already dead, no point keeping it around; just clean up. Otherwise move to the LRU AND SIGSTOP the process tree so it consumes 0% CPU while idle. The matching SIGCONT lives in attach() above.
if not rt.running:
to_reap.append(rt)
else:
self.idle_lru[workspace_id] = rt
self.idle_lru.move_to_end(workspace_id)
self.idle_lru[key] = rt
self.idle_lru.move_to_end(key)
suspend_process_tree(rt.process)
rt.p_suspended = True
while len(self.idle_lru) > MAX_IDLE_RUNTIMES:
@@ -557,16 +675,17 @@ class AppRuntimeManager:
try:
await old.stop()
except Exception:
logger.exception("failed to reap idle runtime %s", workspace_id)
logger.exception("failed to reap idle runtime %s", key)
if to_idle is not None:
logger.debug("workspace %s idled (LRU size now %d)", workspace_id, len(self.idle_lru))
logger.debug("workspace %s idled (LRU size now %d)", key, len(self.idle_lru))
def get(self, workspace_id: str) -> Optional[AppRuntime]:
def get(self, workspace_id: str, instance: int = 1) -> Optional[AppRuntime]:
key = runtime_key(workspace_id, instance)
# Active subscribers see the live runtime; idle-pool members are also accessible so a status probe between detach and the next attach still works.
rt = self.runtimes.get(workspace_id)
rt = self.runtimes.get(key)
if rt is not None:
return rt
return self.idle_lru.get(workspace_id)
return self.idle_lru.get(key)
def drain_errors_for_path(self, file_path: str) -> list[str]:
"""If `file_path` falls under one of the live workspace
@@ -581,15 +700,16 @@ class AppRuntimeManager:
abs_path = os.path.abspath(file_path)
except Exception:
return []
# Walk both active and idle runtimes; the user might have navigated away from the workspace mid-build, but the agent could still be editing files; the LRU keeps the runtime alive for ~3 idle slots.
# Walk both active and idle runtimes; the user might have navigated away from the workspace mid-build, but the agent could still be editing files; the LRU keeps the runtime alive for ~3 idle slots. Aggregate across instances: with two cards of the same app open, either instance's build errors matter to the agent.
drained: list[str] = []
for rt in (*self.runtimes.values(), *self.idle_lru.values()):
try:
ws_root = os.path.abspath(rt.workspace_path)
except Exception:
continue
if abs_path == ws_root or abs_path.startswith(ws_root + os.sep):
return rt.drain_errors()
return []
drained.extend(rt.drain_errors())
return drained
def get_render_state_for_workspace(self, workspace_id: str) -> tuple[Optional[str], str]:
rt = self.runtimes.get(workspace_id) or self.idle_lru.get(workspace_id)
@@ -602,8 +722,9 @@ class AppRuntimeManager:
if rt is not None:
rt.reset_render_state()
async def restart(self, workspace_id: str, workspace_path: Optional[str] = None) -> Optional[AppRuntime]:
rt = self.runtimes.get(workspace_id) or self.idle_lru.get(workspace_id)
async def restart(self, workspace_id: str, workspace_path: Optional[str] = None, instance: int = 1) -> Optional[AppRuntime]:
key = runtime_key(workspace_id, instance)
rt = self.runtimes.get(key) or self.idle_lru.get(key)
if rt is None:
return None
if workspace_path:
+51 -11
View File
@@ -1,9 +1,9 @@
# swarm-debug — OpenSwarm's logger for App backends
`swarm_debug` (also importable as `debug` for legacy reasons) is OpenSwarm's
opinionated `print()` replacement for the App Builder's backend code. It
prints colored, indented, frame-aware log lines that read at a glance and
land in the App Builder's **Terminal** tab under the `[BACKEND]` prefix.
`swarm_debug` (the `swarm-debug` package on PyPI) is OpenSwarm's opinionated
`print()` replacement for the App Builder's backend code. It prints colored,
indented, frame-aware log lines that read at a glance and land in the App
Builder's **Terminal** tab under the `[BACKEND]` prefix.
It's pre-installed in every App Builder workspace that has a backend (i.e.
after `bash backend_init.sh`). Use it instead of `print()`.
@@ -100,13 +100,46 @@ debug(huge_payload, override_max_chars=True)
## Modes (custom log levels)
`debug` accepts a `mode` kwarg that maps to a configurable log channel.
Default is `'debug'`. The Terminal pane shows all modes; if you want to
hide a category, configure it in `Debugleton` (see `debugger_backend/`).
`debug` accepts a `mode` kwarg that maps to a log channel. Valid values are
`'all'` (always shown), `'debug'` (the default), and `'test'` (high
priority). Anything else raises.
```python
debug(payload, mode='info')
debug(suspicious_input, mode='warning')
debug(payload, mode='all')
debug(flaky_result, mode='test')
```
---
## More tools (pretty-print, tables, diffs, timing)
```python
debug(my_dict) # structured values pretty-print by default
debug(my_dict, pretty=False) # force flat single-line output
debug(sql_query, lang="sql") # syntax-highlight a string (sql, json, html, ...)
debug(x, y, z) # 2+ data args auto-render as a Name|Type|Value table
debug(x, y, z, table=False) # force per-line instead
debug("a", "b", sep=", ") # join args into one line, like print(sep=...)
debug("about to retry", error=True) # force red error styling on a non-exception
debug.diff(old_value, new_value) # unified diff of two values
with debug.time("fetch users"): # times the block, prints the duration
rows = fetch_users()
```
---
## Visibility (why output might not show)
Output is gated per-file: only files toggled ON print. OpenSwarm re-toggles
every file ON at each backend boot, and new code needs a backend restart to
load anyway (no auto-reload), so in practice your `debug()` lines are always
visible after the restart that loads them. If you ever need to manage this
yourself, the CLI lives in the workspace venv:
```bash
.venv/bin/swarm-debug status # what's toggled where
.venv/bin/swarm-debug toggle on --all # everything visible (run from the workspace root)
```
---
@@ -149,6 +182,10 @@ pane in real time. Frontend `console.log` calls in the running app land in
the same Terminal pane prefixed `[FRONTEND]`. Use this to correlate cause
and effect across the two halves of your stack.
The same stream is tee'd to **`.openswarm/terminal.log`** at the workspace
root (reset on every app start), so you can read your own `debug()` output
directly: `tail -100 .openswarm/terminal.log`.
---
## Quick reference
@@ -159,5 +196,8 @@ and effect across the two halves of your stack.
| Log several values in one call | `debug(a, b, c)` |
| Log an exception with red coloring | `debug(err)` (variable name must contain "err" or "error", or pass an `Exception` instance) |
| Avoid truncation | `debug(value, override_max_chars=True)` |
| Use a different log channel | `debug(value, mode='info')` |
| Same thing, legacy import | `import debug; debug(value)` (function and module share the name — see swarm_debug.py shim) |
| Always-shown log channel | `debug(value, mode='all')` |
| Diff two values | `debug.diff(old, new)` |
| Time a block | `with debug.time("label"): ...` |
| Flat instead of pretty-printed | `debug(value, pretty=False)` |
| Syntax-highlight a string | `debug(query, lang="sql")` |
+9 -11
View File
@@ -472,12 +472,12 @@ def p_ensure_warm_python_venv() -> str | None:
logger.warning("warm-venv create failed: %s", r.stderr[-1500:])
return None
# Install the template's dependencies (fastapi[standard], typeguard, transitives); NOT the workspace's own backend, which gets editable-installed per-workspace by run.sh after the cache copy. The venv layout differs by platform: POSIX puts executables in `bin/`, Windows in `Scripts/`, and the executable name itself gets `.exe`.
# Install the template's dependencies (fastapi[standard], typeguard, swarm-debug, transitives); keep this list in sync with webapp_template/backend/pyproject.toml. NOT the workspace's own backend, which gets editable-installed per-workspace by run.sh after the cache copy. The venv layout differs by platform: POSIX puts executables in `bin/`, Windows in `Scripts/`, and the executable name itself gets `.exe`.
if os.name == "nt":
pip = os.path.join(venv_dir, "Scripts", "pip.exe")
else:
pip = os.path.join(venv_dir, "bin", "pip")
deps = ["fastapi[standard]", "typeguard==4.4.2"]
deps = ["fastapi[standard]", "typeguard==4.4.2", "swarm-debug"]
r = subprocess.run(
[pip, "install", "--disable-pip-version-check", *deps],
capture_output=True, text=True, timeout=600,
@@ -557,14 +557,13 @@ def seed_webapp_template_workspace(workspace_dir: str, frontend_port: int) -> No
2. Sed both `.env` and `.env.example` to set `FRONTEND_PORT=<port>`.
BACKEND_PORT stays NONE in both (per spec; the agent flips it
via backend_init.sh when it needs a backend).
3. Append two install-specific paths to `.env` ONLY (NOT
`.env.example`; these are absolute paths on the current
machine, not template defaults):
3. Append an install-specific path to `.env` ONLY (NOT
`.env.example`; it is an absolute path on the current
machine, not a template default):
OPENSWARM_TEMPLATE_BACKEND_PATH=<abs path to master template's backend/>
OPENSWARM_DEBUGGER_PATH=<abs path to OpenSwarm's debugger/ package>
The first is read by `backend_init.sh`; the second is read by
the template's `backend/run.sh` to install our local debugger
before `pip install -e .`.
It is read by `backend_init.sh`. The debugger is no longer
seeded from a local path; the template's `pip install -e .`
resolves `swarm-debug` from PyPI.
Idempotent within reason; re-running over an existing workspace
overwrites template files and re-asserts the env values.
@@ -593,12 +592,11 @@ def seed_webapp_template_workspace(workspace_dir: str, frontend_port: int) -> No
# Install-specific paths; .env only.
patch_env_port(env_path, "OPENSWARM_TEMPLATE_BACKEND_PATH", TEMPLATE_BACKEND_PATH)
patch_env_port(env_path, "OPENSWARM_DEBUGGER_PATH", DEBUGGER_PATH)
# Backend-venv warm-cache path; backend_init.sh checks this for a pre-populated `.venv/` to cp -aR into the workspace instead of paying the ~25s venv-create + pip-install cost. Written even if the cache isn't ready yet; backend_init.sh re-checks at run time.
patch_env_port(env_path, "OPENSWARM_BACKEND_VENV_CACHE", warm_venv_dir())
# Make the shipped scripts executable. tarball/git extracts may strip the +x bit depending on how the snapshot was vendored.
for script in ("run.sh", "backend_init.sh", "frontend/run.sh"):
for script in ("run.sh", "backend_init.sh", "restart.sh", "frontend/run.sh"):
p = os.path.join(workspace_dir, script)
if os.path.exists(p):
os.chmod(p, 0o755)
@@ -6,3 +6,4 @@ __pycache__/
*.pyc
dist/
build/
.openswarm/
@@ -6,6 +6,7 @@ requires-python = ">=3.10"
dependencies = [
"fastapi[standard]",
"typeguard==4.4.2",
"swarm-debug",
]
[tool.setuptools]
@@ -92,10 +92,6 @@ else
# --- Install Python dependencies ---
echo "Installing dependencies..."
cd "$BACKEND_DIR_ABSPATH"
if [[ -n "${OPENSWARM_DEBUGGER_PATH:-}" && -d "$OPENSWARM_DEBUGGER_PATH" ]]; then
echo "Installing OpenSwarm debugger (swarm_debug) from $OPENSWARM_DEBUGGER_PATH"
"$VENV_PY" -m pip install -e "$OPENSWARM_DEBUGGER_PATH"
fi
"$VENV_PY" -m pip install -e .
if [[ $? -ne 0 ]]; then
echo "Error: Failed to install Python dependencies."
@@ -112,6 +108,10 @@ fi
# the backend to pick up new code it can hit OpenSwarm's
# /api/outputs/workspace/{ws}/runtime/restart endpoint, which sends a
# clean SIGTERM and restarts via this same script.
# swarm-debug gates output on per-file toggles that default OFF; force all ON each boot so agent-added files show in the Terminal.
if [[ "$IS_WIN" == "1" ]]; then SWARM_DEBUG_BIN="$VENV_DIR/Scripts/swarm-debug.exe"; else SWARM_DEBUG_BIN="$VENV_DIR/bin/swarm-debug"; fi
( cd "$BACKEND_DIR_ABSPATH/.." && "$SWARM_DEBUG_BIN" toggle on --all >/dev/null 2>&1 ) || true
echo "Starting backend server on http://0.0.0.0:${BACKEND_PORT:-8324} ..."
cd "$BACKEND_DIR_ABSPATH/.."
"$VENV_PY" -m uvicorn backend.main:app --host 0.0.0.0 --port "${BACKEND_PORT:-8324}"
@@ -6,9 +6,8 @@
# code; it copies the master template's backend/ into the workspace
# and flips BACKEND_PORT in both .env files to a free port.
#
# After running this, hard-reload the preview (right-click the reload
# button in the App Builder) so the runtime restarts with the new
# BACKEND_PORT and `bash run.sh` brings the backend up.
# After running this, run `bash restart.sh` so the runtime restarts
# with the new BACKEND_PORT and `bash run.sh` brings the backend up.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -38,7 +37,7 @@ if [[ -d ./backend ]]; then
fi
# Resolve master template backend/ path. OPENSWARM_TEMPLATE_BACKEND_PATH
# is written into .env at seed time; OPENSWARM_DEBUGGER_PATH the same.
# is written into .env at seed time.
if [[ -z "${OPENSWARM_TEMPLATE_BACKEND_PATH:-}" ]]; then
echo "ERROR: OPENSWARM_TEMPLATE_BACKEND_PATH not set in .env. This" >&2
echo " workspace was seeded by an older OpenSwarm; ask the" >&2
@@ -100,5 +99,4 @@ fi
echo ""
echo "Backend enabled on port $PORT."
echo "Hard-reload the preview (right-click the reload button in"
echo "the App Builder) to bring it up."
echo "Run 'bash restart.sh' to bring it up (restarts the app runtime)."
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Restart this app's runtime (backend + vite), managed by the OpenSwarm harness.
#
# The runtime is spawned and owned by OpenSwarm, so you can't just kill/rerun
# run.sh from here. This script writes a sentinel the harness watches; the
# harness consumes it and restarts the whole runtime. No API token needed.
# Use after `bash backend_init.sh`, after editing `.env`, or whenever the
# backend must reload code/schema (uvicorn runs WITHOUT --reload on purpose).
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
mkdir -p "$HERE/.openswarm"
SENTINEL="$HERE/.openswarm/restart-requested"
touch "$SENTINEL"
echo "Restart requested; waiting for the OpenSwarm harness to pick it up..."
for _ in $(seq 1 30); do
if [[ ! -f "$SENTINEL" ]]; then
echo "Restart under way. The runtime takes a few seconds to come back;"
echo "then check .openswarm/terminal.log for boot output:"
sleep 6
tail -n 20 "$HERE/.openswarm/terminal.log" 2>/dev/null || true
exit 0
fi
sleep 1
done
rm -f "$SENTINEL"
echo "ERROR: the harness didn't pick up the restart within 30s." >&2
echo "The runtime only runs while the app is open in OpenSwarm (preview card or" >&2
echo "App Builder). If you're running this app standalone via 'bash run.sh'," >&2
echo "just Ctrl-C that process and rerun it instead." >&2
exit 1
@@ -8,6 +8,14 @@ if [[ -f "$ROOT_DIR/.env" ]]; then
set +a
fi
# Per-instance port overrides: OpenSwarm passes these when the user opens a SECOND instance of the app, so it boots on fresh ports instead of colliding with the primary's .env-pinned ones.
if [[ -n "${OPENSWARM_FORCE_FRONTEND_PORT:-}" ]]; then
export FRONTEND_PORT="$OPENSWARM_FORCE_FRONTEND_PORT"
fi
if [[ -n "${OPENSWARM_FORCE_BACKEND_PORT:-}" ]]; then
export BACKEND_PORT="$OPENSWARM_FORCE_BACKEND_PORT"
fi
# Recursively SIGTERM a pid + all of its descendants. We track FRONTEND_PID
# and BACKEND_PID below, but each of those is a `bash` wrapper that has its
# own grandchildren (vite, uvicorn, npm). A flat `kill $FRONTEND_PID` leaves
+1 -3
View File
@@ -137,7 +137,7 @@ def p_free_port() -> int:
def p_localize_env(folder: str) -> None:
"""Regenerate the workspace .env on the importer's machine: a fresh port plus
this install's absolute template/debugger paths (the source's were dropped)."""
this install's absolute template path (the source's was dropped)."""
env_path = os.path.join(folder, ".env")
example = os.path.join(folder, ".env.example")
if not os.path.exists(env_path):
@@ -147,7 +147,6 @@ def p_localize_env(folder: str) -> None:
return # flat app: no run.sh, no env needed
try:
from backend.apps.outputs.view_builder_templates import (
DEBUGGER_PATH,
TEMPLATE_BACKEND_PATH,
link_node_modules,
patch_env_port,
@@ -157,7 +156,6 @@ def p_localize_env(folder: str) -> None:
return
patch_env_port(env_path, "FRONTEND_PORT", str(p_free_port()))
patch_env_port(env_path, "OPENSWARM_TEMPLATE_BACKEND_PATH", TEMPLATE_BACKEND_PATH)
patch_env_port(env_path, "OPENSWARM_DEBUGGER_PATH", DEBUGGER_PATH)
try:
patch_env_port(env_path, "OPENSWARM_BACKEND_VENV_CACHE", warm_venv_dir())
except Exception:
+11 -5
View File
@@ -36,12 +36,15 @@ class DashboardExportable:
if bid:
cards[bid] = {**card, "session_id": bid}
view_cards = {}
for oid, card in (layout.get("view_cards") or {}).items():
for key, card in (layout.get("view_cards") or {}).items():
# Keys are output_id for the primary card, `output_id#N` for extra instances of the same app; resolve the bundle id off the bare output id and rebuild the suffix.
oid = str(card.get("output_id") or key).split("#")[0]
bid = ctx.bundle_id_for(EntityType.app, oid)
if bid:
inst = int(card.get("instance") or 1)
# parent_session_id tethers the app card to the agent that built it; it's a session id, so it remaps like spawned_by on browser cards.
parent = card.get("parent_session_id")
view_cards[bid] = {
view_cards[bid if inst <= 1 else f"{bid}#{inst}"] = {
**card, "output_id": bid,
"parent_session_id": ctx.bundle_id_for(EntityType.session, parent) if parent else None,
}
@@ -64,7 +67,8 @@ class DashboardExportable:
def dependencies(self) -> list[DepRef]:
layout = self.p_data.get("layout") or {}
deps = [DepRef(EntityType.session, sid, "has_agent") for sid in (layout.get("cards") or {})]
deps += [DepRef(EntityType.app, oid, "has_app") for oid in (layout.get("view_cards") or {})]
p_view_oids = {str(card.get("output_id") or key).split("#")[0] for key, card in (layout.get("view_cards") or {}).items()}
deps += [DepRef(EntityType.app, oid, "has_app") for oid in sorted(p_view_oids)]
return deps
def requirements(self) -> list[Requirement]:
@@ -80,11 +84,13 @@ class DashboardExportable:
if nsid:
cards[nsid] = {**card, "session_id": nsid}
view_cards = {}
for bid, card in (layout.get("view_cards") or {}).items():
for key, card in (layout.get("view_cards") or {}).items():
bid = str(card.get("output_id") or key).split("#")[0]
noid = remap.local(bid)
if noid:
inst = int(card.get("instance") or 1)
parent = card.get("parent_session_id")
view_cards[noid] = {
view_cards[noid if inst <= 1 else f"{noid}#{inst}"] = {
**card, "output_id": noid,
"parent_session_id": remap.local(parent) if parent else None,
}
-4
View File
@@ -17,16 +17,12 @@ from fastapi import HTTPException
from pydantic import BaseModel, Field
from typeguard import typechecked
import debug
from backend.config.Apps import SubApp
@asynccontextmanager
async def web_lifespan():
debug("START")
yield
debug("END")
web = SubApp("web", web_lifespan)
+2 -9
View File
@@ -2,7 +2,6 @@ import os
import time
from fastapi import FastAPI, APIRouter
import debug
from uuid import uuid4
from typing import List
from contextlib import asynccontextmanager
@@ -12,28 +11,23 @@ from typing import Callable
class SubApp:
def __init__(self, name:str, lifespan:Callable):
debug("START", name)
self.id = uuid4()
self.name = name
self.prefix = f"/api/{name}"
self.lifespan = lifespan
self.router = APIRouter()
debug("END")
def __str__(self):
return f"SubApp(name={self.name}, prefix={self.prefix}, id={self.id})"
class MainApp:
def __init__(self, sub_apps: List[SubApp]):
debug("START")
@asynccontextmanager
async def lifespan(app: FastAPI):
async with AsyncExitStack() as stack:
# [perf] per-lifespan boot timing. debug() is a no-op in the packaged build, so without this the packaged backend.log has no per-SubApp markers and a cold-start stall can only be guessed at. One perf_counter + flushed print per app pins exactly which lifespan (or the cold first-touch I/O entering it) dominates.
# [perf] per-lifespan boot timing. Without this the packaged backend.log has no per-SubApp markers and a cold-start stall can only be guessed at. One perf_counter + flushed print per app pins exactly which lifespan (or the cold first-touch I/O entering it) dominates.
p_boot_t0 = time.perf_counter()
for sub_app in sub_apps:
debug(sub_app.name)
p_t0 = time.perf_counter()
await stack.enter_async_context(sub_app.lifespan())
p_dt = (time.perf_counter() - p_t0) * 1000
@@ -51,5 +45,4 @@ class MainApp:
sub_app.router,
prefix=sub_app.prefix,
tags=[sub_app.name]
)
debug("END")
)
+4 -3
View File
@@ -253,16 +253,17 @@ def p_ws_auth_ok(websocket: WebSocket) -> bool:
@app.websocket("/ws/outputs/runtime/{workspace_id}/logs")
async def websocket_runtime_logs(websocket: WebSocket, workspace_id: str):
async def websocket_runtime_logs(websocket: WebSocket, workspace_id: str, instance: int = 1):
"""Stream the persistent app-backend's stdout/stderr to the Terminal
pane. On connect we replay the runtime's ring buffer so a Terminal
tab opened mid-session sees the context it missed, then we tail
every subsequent line until disconnect."""
every subsequent line until disconnect. `instance` targets a specific
dashboard card's runtime when the same app is open more than once."""
if not p_ws_auth_ok(websocket):
return
await websocket.accept()
from backend.apps.outputs.runtime import manager as runtime_manager
rt = runtime_manager.get(workspace_id)
rt = runtime_manager.get(workspace_id, instance)
if rt is None:
# No active runtime, surface that to the client and close. The frontend will call /runtime/start and reconnect. Also emit a status frame with is_new_mode (computed from disk) so the preview pane shows the "starting preview…" placeholder for webapp_template workspaces instead of falling back to the legacy /serve/index.html URL (which 404s in new-mode).
try:
-12
View File
@@ -32,18 +32,6 @@ if [[ ! -d "$VENV_DIR" ]]; then
fi
source "$VENV_DIR/bin/activate"
# --- Install custom debugger module if not already installed ---
DEBUGGER_DIR_ABSPATH="$PROJECT_ROOT_ABSPATH/debugger"
if ! pip3 show debug > /dev/null 2>&1; then
echo "Installing debugger module..."
cd "$DEBUGGER_DIR_ABSPATH"
pip3 install -e .
if [[ $? -ne 0 ]]; then
echo "Failed to install debugger module."
exit 1
fi
fi
# --- Install Python dependencies ---
echo "Installing dependencies..."
cd "$BACKEND_DIR_ABSPATH"
+46
View File
@@ -0,0 +1,46 @@
"""'Approve All' mid-run invariant: an allow carrying set_always_allow must make the
NEXT identical tool call auto-approve inside the SAME run. The gate reads the live
builtin_perms dict (effective_policy) and the approval path writes through the same
dict (set_tool_policy), so the policy written at approval time is visible to the very
next call without waiting for the next turn's from-disk reload. Pins the loop the
frontend 'Approve All' buttons now rely on (they send set_always_allow=true)."""
import pytest
from backend.apps.agents.manager.permissions import decision
@pytest.fixture()
def p_isolated_persistence(monkeypatch):
"""Redirect the disk-persistence half of set_tool_policy into memory."""
persisted: dict = {"perms": {}}
monkeypatch.setattr(decision, "load_all_tools", lambda: [])
monkeypatch.setattr(decision, "load_builtin_permissions", lambda: dict(persisted["perms"]))
monkeypatch.setattr(decision, "save_builtin_permissions", lambda perms: persisted.update(perms=dict(perms)))
return persisted
def test_always_allow_applies_to_next_call_same_run(p_isolated_persistence):
live_perms = {"Bash": "ask"}
assert decision.effective_policy("Bash", live_perms, {}) == "ask"
decision.set_tool_policy("Bash", "always_allow", live_perms)
# The very next identical call in the SAME run reads the live dict and auto-approves.
assert decision.effective_policy("Bash", live_perms, {}) == "always_allow"
# And it persisted, so the next turn's from-disk reload keeps it.
assert p_isolated_persistence["perms"]["Bash"] == "always_allow"
def test_always_allow_namespaced_builtin_uses_inner_slot(p_isolated_persistence):
# Our browser/invoke delegation tools live in builtin_permissions under the INNER name; a write through the namespaced name must land where the next read looks.
live_perms: dict = {}
name = "mcp__openswarm-browser-agent__BrowserAgent"
decision.set_tool_policy(name, "always_allow", live_perms)
assert live_perms == {"BrowserAgent": "always_allow"}
assert decision.effective_policy(name, live_perms, {}) == "always_allow"
def test_plain_allow_leaves_policy_untouched(p_isolated_persistence):
# A one-time allow (no set_always_allow) never calls set_tool_policy; the policy stays 'ask' and the next call prompts again. Guards against silently widening plain approves.
live_perms = {"Bash": "ask"}
assert decision.effective_policy("Bash", live_perms, {}) == "ask"
assert p_isolated_persistence["perms"] == {}
+50
View File
@@ -0,0 +1,50 @@
"""CreateApp's backend (/outputs/agent-create): seeds a webapp-template workspace,
registers an Output linked to the calling session, names it (row + meta.json in
agreement), broadcasts the upsert the dashboard listens for, and returns the
workspace path + the App Builder reference the MCP tool hands to the agent."""
import json
import os
import pytest
from backend.apps.outputs.models import AgentCreateAppRequest
from backend.apps.outputs.outputs import agent_create_app
from backend.apps.outputs.workspace_io import load as load_output
@pytest.mark.asyncio
async def test_agent_create_registers_named_output_and_broadcasts(tmp_path, monkeypatch):
import backend.apps.outputs.outputs as outputs_mod
monkeypatch.setattr(outputs_mod, "WORKSPACE_DIR", str(tmp_path / "ws"))
monkeypatch.setattr(outputs_mod, "DATA_DIR", str(tmp_path / "data"))
# workspace_io persists Output rows into its own DATA_DIR; isolate it too.
import backend.apps.outputs.workspace_io as wio
monkeypatch.setattr(wio, "DATA_DIR", str(tmp_path / "data"))
os.makedirs(str(tmp_path / "data"), exist_ok=True)
broadcasts: list = []
from backend.apps.agents.core.ws_manager import ws_manager
async def p_fake_broadcast(event, data):
broadcasts.append((event, data))
monkeypatch.setattr(ws_manager, "broadcast_global", p_fake_broadcast)
res = await agent_create_app(AgentCreateAppRequest(
name="Pomodoro Timer", description="a timer", parent_session_id="sess-1",
))
assert res["ok"] is True
assert os.path.isfile(os.path.join(res["path"], "run.sh"))
# The reference is NOT inlined in the response (context-rot fix) — it's on disk as SKILL.md for the agent to read on demand.
assert "skill" not in res
skill_path = os.path.join(res["path"], "SKILL.md")
assert os.path.isfile(skill_path)
assert len(open(skill_path, encoding="utf-8").read()) > 500
output = load_output(res["output_id"])
assert output.name == "Pomodoro Timer"
assert output.session_id == "sess-1"
with open(os.path.join(res["path"], "meta.json"), encoding="utf-8") as f:
meta = json.load(f)
assert meta["name"] == "Pomodoro Timer"
assert broadcasts and broadcasts[0][0] == "agent:output_upserted"
assert broadcasts[0][1]["output"]["id"] == res["output_id"]
@@ -19,13 +19,14 @@ def test_registers_always_on_and_delegation_servers():
# always-on
assert "openswarm-mcp-meta" in mcp_servers
assert "openswarm-settings-meta" in mcp_servers
assert "openswarm-apps" in mcp_servers
# delegation (not denied)
assert "openswarm-browser-agent" in mcp_servers
assert "openswarm-invoke-agent" in mcp_servers
assert browser_tools == ["CreateBrowserAgent", "BrowserAgent", "BrowserAgents", "AppAgent"]
assert invoke_tools == ["InvokeAgent"]
# Every registered server's script path must resolve to a file that ACTUALLY EXISTS. This is the assertion that catches a moved-caller resolving the wrong agents dir.
for name in ("openswarm-mcp-meta", "openswarm-settings-meta",
for name in ("openswarm-mcp-meta", "openswarm-settings-meta", "openswarm-apps",
"openswarm-browser-agent", "openswarm-invoke-agent"):
script = mcp_servers[name]["args"][0]
assert os.path.isfile(script), f"{name} script does not exist on disk: {script}"
@@ -38,3 +39,4 @@ def test_fully_denied_delegation_servers_are_not_registered():
assert "openswarm-browser-agent" not in mcp_servers # all browser tools denied -> skip
assert "openswarm-invoke-agent" not in mcp_servers
assert "openswarm-mcp-meta" in mcp_servers # always-on regardless
assert "openswarm-apps" in mcp_servers # always-on regardless
+54
View File
@@ -0,0 +1,54 @@
"""Agent self-restart handshake: a workspace's restart.sh touches
.openswarm/restart-requested and the AppRuntimeManager watcher consumes it and
restarts every attached instance of that workspace. This is the only restart
path an agent has (no API token, uvicorn runs without --reload), so pin both
the pickup and the sentinel consumption restart.sh's wait loop depends on."""
import asyncio
import os
import pytest
from backend.apps.outputs import runtime as runtime_mod
from backend.apps.outputs.runtime import AppRuntime, AppRuntimeManager
@pytest.mark.asyncio
async def test_sentinel_restarts_all_attached_instances(tmp_path, monkeypatch):
monkeypatch.setattr(runtime_mod, "RESTART_SENTINEL_POLL_SECONDS", 0.05)
restarted: list[int] = []
async def p_fake_start(self):
return True
async def p_fake_restart(self):
restarted.append(self.instance)
return True
monkeypatch.setattr(AppRuntime, "start", p_fake_start)
monkeypatch.setattr(AppRuntime, "restart", p_fake_restart)
# The stub spawns no process; the watcher only restarts live runtimes, so fake liveness.
monkeypatch.setattr(AppRuntime, "running", property(lambda self: True))
mgr = AppRuntimeManager()
ws = str(tmp_path)
await mgr.attach("ws1", ws, 1)
await mgr.attach("ws1", ws, 2)
os.makedirs(os.path.join(ws, ".openswarm"), exist_ok=True)
sentinel = os.path.join(ws, ".openswarm", "restart-requested")
with open(sentinel, "w", encoding="utf-8") as f:
f.write("")
for _ in range(100):
await asyncio.sleep(0.05)
if len(restarted) >= 2:
break
# Sentinel consumed (restart.sh's wait loop unblocks) and BOTH instances bounced.
assert not os.path.exists(sentinel)
assert sorted(restarted) == [1, 2]
# No sentinel -> no further restarts.
await asyncio.sleep(0.2)
assert sorted(restarted) == [1, 2]
if mgr.restart_watch_task:
mgr.restart_watch_task.cancel()
@@ -0,0 +1,72 @@
"""Multi-instance app runtimes: two dashboard cards of the same app must get fully
independent AppRuntime processes on independent ports. Pins the invariants the
frontend's per-instance attach relies on: instance-suffixed registry keys, no shared
refcount between instances, fresh (non-.env) ports + OPENSWARM_FORCE_* env for
secondaries, and per-instance terminal.log files."""
import os
import pytest
from backend.apps.outputs.runtime import AppRuntime, AppRuntimeManager, runtime_key
def test_runtime_key_primary_is_bare_workspace_id():
assert runtime_key("ws1", 1) == "ws1"
assert runtime_key("ws1", 2) == "ws1#2"
@pytest.mark.asyncio
async def test_attach_two_instances_creates_independent_runtimes(tmp_path, monkeypatch):
# Neither instance should actually spawn a process; pin start() to a no-op.
async def p_fake_start(self):
return True
monkeypatch.setattr(AppRuntime, "start", p_fake_start)
mgr = AppRuntimeManager()
ws = str(tmp_path)
rt1 = await mgr.attach("ws1", ws, 1)
rt2 = await mgr.attach("ws1", ws, 2)
assert rt1 is not rt2
assert rt1.instance == 1 and rt2.instance == 2
assert mgr.get("ws1", 1) is rt1
assert mgr.get("ws1", 2) is rt2
# Detaching one instance must not tear down or refcount-touch the other. (The stub runtime has no live process, so detach reaps it rather than idling it.)
await mgr.detach("ws1", 2)
assert "ws1" in mgr.runtimes
assert mgr.get("ws1", 2) is None
assert mgr.get("ws1", 1) is rt1
@pytest.mark.asyncio
async def test_secondary_instance_uses_fresh_ports_and_force_env(tmp_path, monkeypatch):
# A new-mode workspace with pinned .env ports; the secondary must NOT reuse or rewrite them.
ws = tmp_path / "ws"
ws.mkdir()
(ws / "run.sh").write_text("#!/bin/bash\n")
(ws / ".env").write_text("FRONTEND_PORT=45001\nBACKEND_PORT=45002\n")
captured_env: dict = {}
async def p_fake_exec(*cmd, **kwargs):
captured_env.update(kwargs.get("env") or {})
raise RuntimeError("stop before real spawn")
monkeypatch.setattr("asyncio.create_subprocess_exec", p_fake_exec)
rt = AppRuntime("ws1", str(ws), instance=2)
ok = await rt.start()
# The stubbed spawn raises, so start() reports failure and nulls the ports; the forced env captured at spawn time carries what the secondary would have used.
assert ok is False
forced_fp = int(captured_env["OPENSWARM_FORCE_FRONTEND_PORT"])
forced_bp = int(captured_env["OPENSWARM_FORCE_BACKEND_PORT"])
assert forced_fp != 45001
assert forced_bp != 45002
# .env untouched: the primary still owns its pinned ports.
assert (ws / ".env").read_text() == "FRONTEND_PORT=45001\nBACKEND_PORT=45002\n"
def test_secondary_terminal_log_is_suffixed(tmp_path):
os.makedirs(os.path.join(str(tmp_path), ".openswarm"))
rt1 = AppRuntime("ws1", str(tmp_path), instance=1)
rt2 = AppRuntime("ws1", str(tmp_path), instance=2)
rt1.record_frontend_log("log", "hello from instance 1")
rt2.record_frontend_log("log", "hello from instance 2")
files = sorted(os.listdir(os.path.join(str(tmp_path), ".openswarm")))
assert files == ["terminal-2.log", "terminal.log"]
+1
View File
@@ -0,0 +1 @@
/Users/ericzeng/Downloads/openswarm/electron/node_modules
+2 -1
View File
@@ -192,7 +192,7 @@ try {
};
const onWheelCapture = (e) => {
if (isInteractive) return;
// Canvas zoom is a dashboard-level gesture: forward cmd/ctrl+wheel even in interact mode, matching browser cards (which never set interactive and always zoom the canvas).
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
e.stopPropagation();
@@ -208,6 +208,7 @@ try {
} catch (_) {}
return;
}
if (isInteractive) return;
// Vertical-dominant scroll stays with the page.
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
// Horizontal-dominant: defer to the page if anything inside can absorb
+1
View File
@@ -0,0 +1 @@
/Users/ericzeng/Downloads/openswarm/frontend/node_modules
-5
View File
@@ -30,7 +30,6 @@ import { setPanelMode, disableOnboardingAfterCrash } from '@/shared/state/onboar
const Skills = React.lazy(() => import('./pages/Skills/Skills'));
const Tools = React.lazy(() => import('./pages/Tools/Tools'));
const Modes = React.lazy(() => import('./pages/Modes/Modes'));
const Views = React.lazy(() => import('./pages/Views/Views'));
const Customization = React.lazy(() => import('./pages/Customization/Customization'));
const Analytics = React.lazy(() => import('./pages/Analytics/Analytics'));
const OnboardingRoot = React.lazy(() =>
@@ -60,13 +59,11 @@ if (typeof window !== 'undefined') {
case '/tools': void import('./pages/Tools/Tools'); return;
case '/modes': void import('./pages/Modes/Modes'); return;
case '/views':
case '/apps': void import('./pages/Views/Views'); return;
case '/customization': void import('./pages/Customization/Customization'); return;
case '/analytics': void import('./pages/Analytics/Analytics'); return;
}
};
const prefetchAll = () => {
void import('./pages/Views/Views');
void import('./pages/Skills/Skills');
void import('./pages/Tools/Tools');
void import('./pages/Modes/Modes');
@@ -535,8 +532,6 @@ const ThemedApp: React.FC = () => {
<Route path="/skills" element={<Skills />} />
<Route path="/actions" element={<Tools />} />
<Route path="/modes" element={<Modes />} />
<Route path="/apps" element={<Views />} />
<Route path="/apps/:id" element={<Views />} />
<Route path="/analytics" element={<Analytics />} />
</Route>
</Routes>
+15 -36
View File
@@ -44,7 +44,7 @@ import { shallowEqual } from 'react-redux';
import { fetchDashboards, createDashboard, renameDashboard } from '@/shared/state/dashboardsSlice';
import { Typewriter } from '@/app/components/feedback/Animated';
import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice';
import { addBrowserCard, addBrowserTab, cycleBrowserTab, reopenLastClosed } from '@/shared/state/dashboardLayoutSlice';
import { addBrowserCard, addBrowserTab, cycleBrowserTab, reopenLastClosed, addViewCard } from '@/shared/state/dashboardLayoutSlice';
import { setPendingBrowserUrl } from '@/shared/state/tempStateSlice';
import { fetchOutputs } from '@/shared/state/outputsSlice';
import { setInstalling } from '@/shared/state/updateSlice';
@@ -81,10 +81,6 @@ const AppShell: React.FC = () => {
};
return fn as typeof navigateRaw;
}, [navigateRaw]);
// Navigate to an app instantly on click. The old debounce here swallowed clicks the user could see (felt broken) and never actually fixed the crash, since letting each app load defeats the debounce anyway. The real GPU-churn source (the WebGL loading placeholder) is now CSS, and the 250ms preview gate still skips webviews for apps switched-past too fast, so instant navigation is safe.
const navigateToApp = useCallback((id: string) => {
navigate(`/apps/${id}`);
}, [navigate]);
const location = useLocation();
// React Router (HashRouter) stores a monotonic index in history state. location re-renders on every nav, by which point window.history.state.idx is updated.
const historyIdx = (window.history.state?.idx as number | undefined) ?? 0;
@@ -438,16 +434,24 @@ const AppShell: React.FC = () => {
const isDashboardRoute = location.pathname === '/' || location.pathname.startsWith('/dashboard/');
const isDashboardViewActive = location.pathname.startsWith('/dashboard/');
const isAppsRoute = location.pathname === '/apps' || location.pathname.startsWith('/apps/');
const isAppsRoute = false; // /apps route removed; app cards live on the dashboard now.
const isCustomizationRoute = location.pathname === '/customization' || CUSTOMIZATION_PATHS.has(location.pathname);
const activeDashboardId = location.pathname.startsWith('/dashboard/')
? location.pathname.split('/dashboard/')[1]
: null;
const [lastDashboardId, setLastDashboardId] = useLastDashboardId();
const activeAppId = location.pathname.startsWith('/apps/')
? location.pathname.split('/apps/')[1]
: null;
// Apps no longer have a full-page editor; clicking one in the sidebar drops (or focuses) its live card on the current dashboard. Fold-in of the old App Builder.
const navigateToApp = useCallback((id: string) => {
dispatch(addViewCard({ outputId: id }));
if (lastDashboardId && location.pathname !== `/dashboard/${lastDashboardId}`) {
navigate(`/dashboard/${lastDashboardId}`);
}
}, [dispatch, navigate, lastDashboardId, location.pathname]);
// With the /apps route gone, an app row is "active" when its card is open on the dashboard, not from the URL.
const openViewCardOutputIds = useAppSelector((s) =>
new Set(Object.values(s.dashboardLayout.viewCards).map((vc) => vc.output_id)),
);
const handleDashboardsClick = () => {
if (isDashboardRoute && location.pathname === '/') {
@@ -486,17 +490,7 @@ const AppShell: React.FC = () => {
};
const handleAppsClick = () => {
if (isAppsRoute && location.pathname === '/apps') {
setAppsExpanded((prev) => !prev);
} else {
navigate('/apps');
setAppsExpanded(true);
}
};
const handleCreateApp = (e: React.MouseEvent) => {
e.stopPropagation();
navigate('/apps/new');
setAppsExpanded((prev) => !prev);
};
return (
@@ -1120,21 +1114,6 @@ const AppShell: React.FC = () => {
},
}}
/>
<Tooltip title="New app" placement="right">
<IconButton
size="small"
onClick={handleCreateApp}
sx={{
color: c.text.ghost,
p: 0.25,
mr: 0.25,
borderRadius: 1,
'&:hover': { color: c.accent.primary, bgcolor: `${c.accent.primary}14` },
}}
>
<Plus size={15} />
</IconButton>
</Tooltip>
{appsList.length > 0 && (
<ExpandMoreIcon
sx={{
@@ -1163,7 +1142,7 @@ const AppShell: React.FC = () => {
}}
>
{appsList.map((app) => {
const isActive = activeAppId === app.id;
const isActive = openViewCardOutputIds.has(app.id);
return (
<Box
key={app.id}
@@ -1,6 +1,9 @@
import type { OnboardingStep } from './types';
import { S } from '../selectors';
// App Builder is folded into normal agents now: the user just asks an agent to build
// an app and its live card drops on the canvas. So this step points at the dashboard
// composer instead of the old /apps page. We guide, we don't type or auto-send.
export const step08: OnboardingStep = {
id: 'make_app',
stage: 'learn_features',
@@ -10,34 +13,10 @@ export const step08: OnboardingStep = {
videoSrc: './onboarding-videos/v2/08.mp4',
videoDurationLabel: '0:42',
ops: [
{ kind: 'move_to', target: S.sidebarApps },
{ kind: 'popup', text: 'Swing by Apps.' },
{
kind: 'wait_user',
condition: { kind: 'click_target', target: S.sidebarApps },
},
{ kind: 'move_to', target: S.appsNewButton },
{ kind: 'popup', text: 'Spin up a fresh one.' },
{
kind: 'wait_user',
condition: { kind: 'click_target', target: S.appsNewButton },
},
// Wait for the SCOPED chat-input; survives cold-starts and the StrictMode mount/unmount/remount cycle.
{
kind: 'popup',
text: 'Loading the App Builder...',
},
{
kind: 'wait_for_dom',
css: '[data-onboarding-scope="app-builder"] [data-onboarding="chat-input"]',
timeoutMs: 60000,
},
{ kind: 'delay', ms: 350 },
// Guide, don't commandeer: point at the chat input and let the user describe their OWN app. We never type a canned prompt or auto-send, so the tour doesn't spend a run building something the user didn't choose.
{ kind: 'move_to', target: S.chatInput },
{
kind: 'popup',
text: 'Describe any app you want, a tracker, a viewer, a game, type it here and send.',
text: 'Apps are just something you ask for. Try "build me a habit tracker" — any agent can make one.',
},
{
kind: 'wait_user',
@@ -46,7 +25,7 @@ export const step08: OnboardingStep = {
},
{
kind: 'popup',
text: "Cooking up your app! It'll pop up in a sec. Go explore while it brews.",
text: "Cooking up your app! It'll pop onto the canvas as a live card in a sec. Go explore while it brews.",
},
{ kind: 'delay', ms: 4000 },
{ kind: 'outro' },
@@ -435,7 +435,8 @@ const DynamicIsland: React.FC = () => {
for (const g of groups) {
for (const req of g.approvals) {
if (req.tool_name !== 'AskUserQuestion') {
dispatch(handleApproval({ requestId: req.id, behavior: 'allow' }));
// setAlwaysAllow so the SAME command mid-run stops re-prompting: the backend writes the policy into the live in-run snapshot, a plain allow only clears the pending request.
dispatch(handleApproval({ requestId: req.id, behavior: 'allow', setAlwaysAllow: true }));
}
}
}
@@ -792,7 +793,7 @@ const CompactActionablePill: React.FC<{
</IconButton>
</Tooltip>
{remainingCount > 1 && !isIntervention && (
<Tooltip title={`Approve all ${remainingCount}`} arrow>
<Tooltip title={`Approve all ${remainingCount} (stops re-asking for these tools)`} arrow>
<Box
component="button"
onClick={(e: React.MouseEvent) => { e.stopPropagation(); onApproveAll(); }}
@@ -54,7 +54,6 @@ const ACTIONS: ActionResult[] = [
{ kind: 'action', id: 'go-skills', name: 'Go to Skills', keywords: 'customize skills' },
{ kind: 'action', id: 'go-actions', name: 'Go to Actions', keywords: 'customize tools actions mcp' },
{ kind: 'action', id: 'go-modes', name: 'Go to Modes', keywords: 'customize modes' },
{ kind: 'action', id: 'go-apps', name: 'Go to Apps', keywords: 'apps mini app' },
{ kind: 'action', id: 'all-dashboards', name: 'All dashboards', keywords: 'overview picker browse boards' },
];
@@ -153,7 +152,6 @@ const GlobalSearchPalette: React.FC<Props> = ({ open, onClose }) => {
case 'go-skills': navigate('/skills'); break;
case 'go-actions': navigate('/actions'); break;
case 'go-modes': navigate('/modes'); break;
case 'go-apps': navigate('/apps'); break;
case 'all-dashboards': navigate('/'); break;
}
}, [dispatch, navigate]);
@@ -117,7 +117,8 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
const skills = useAppSelector((state) => state.skills.items);
const modesMap = useAppSelector((state) => state.modes.items);
const modesArr = useMemo(() => Object.values(modesMap), [modesMap]);
// 'view-builder' (App Builder) is folded into normal agents now (any agent builds apps via CreateApp), so it's hidden from the picker. The mode still exists for back-compat with sessions created before the fold-in.
const modesArr = useMemo(() => Object.values(modesMap).filter((m) => m.id !== 'view-builder'), [modesMap]);
const sessionFrameworkOverhead = useAppSelector((state) =>
sessionId ? (state.agents.sessions[sessionId]?.framework_overhead_tokens ?? 0) : 0,
);
@@ -258,7 +259,8 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
.map((el) => el.semanticData!.selectId as string);
const appIds = selectedEls
.filter((el) => el.semanticType === 'view-card' && el.semanticData?.selectId)
.map((el) => el.semanticData!.selectId as string);
// Strip a multi-instance card-key suffix (`output_id#N`): the backend AppAgent gate wants bare output ids.
.map((el) => (el.semanticData!.selectId as string).split('#')[0]);
const settingIds = selectedEls
.filter((el) => el.semanticType === 'settings-option' && el.semanticData?.selectId)
.map((el) => el.semanticData!.selectId as string);
@@ -55,7 +55,7 @@ interface Props {
selectedBrowserIds?: string[],
selectedAppIds?: string[],
) => void;
onAddView: (outputId: string) => void;
onAddView: (outputId: string, opts?: { newInstance?: boolean }) => void;
onHistoryResume: (sessionId: string) => void;
onAddBrowser: () => void;
onAddNote: () => void;
@@ -240,8 +240,9 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
}
}, [historyOpen, viewPickerOpen, onCancel, handleCloseHistory]);
const handleSelectView = useCallback((output: Output) => {
onAddView(output.id);
// Alt/Option-click opens ANOTHER independent instance of an already-open app (its own runtime + ports); plain click focuses the existing card.
const handleSelectView = useCallback((output: Output, newInstance?: boolean) => {
onAddView(output.id, { newInstance });
setViewPickerOpen(false);
setViewSearch('');
}, [onAddView]);
@@ -533,7 +534,7 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
filteredOutputs.map((output) => (
<Box
key={output.id}
onClick={() => handleSelectView(output)}
onClick={(e) => handleSelectView(output, e.altKey)}
sx={{
display: 'flex',
alignItems: 'center',
@@ -81,7 +81,7 @@ interface DashboardCanvasProps {
onStarter: (prompt: string, mode?: string) => void;
toolbarPrefill?: string;
toolbarPrefillMode?: string;
onAddView: (outputId: string) => void;
onAddView: (outputId: string, opts?: { newInstance?: boolean }) => void;
onHistoryResume: (sessionId: string) => void;
onAddBrowser: () => void;
onAddNote: () => void;
@@ -191,12 +191,14 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
);
})}
</AnimatePresence>
{Object.values(viewCards).map((vc) => {
{Object.entries(viewCards).map(([cardKey, vc]) => {
const output = outputs[vc.output_id];
if (!output) return null;
return (
<DashboardViewCard
key={`view-${vc.output_id}`}
key={`view-${cardKey}`}
cardKey={cardKey}
instance={vc.instance ?? 1}
output={output}
cardX={vc.x}
cardY={vc.y}
@@ -207,8 +209,8 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
panX={panX}
panY={panY}
cmdHeld={cmdHeld}
isSelected={selection.isSelected(vc.output_id)}
isHighlighted={highlightedCardId === vc.output_id}
isSelected={selection.isSelected(cardKey)}
isHighlighted={highlightedCardId === cardKey}
multiDragDelta={multiDragDelta}
onCardSelect={onCardSelect}
onDragStart={onDragStart}
@@ -26,14 +26,10 @@ const DashboardEmptyState: React.FC<{
const currentPrompts = currentCategory?.prompts ?? [];
const showChips = !!onLaunch && canRun;
const isAppBuilder = currentCategory?.target === 'app-builder';
// "Build an app" launches a normal agent like every other starter; the agent calls CreateApp and its live card drops on the canvas. No separate App Builder mode/page anymore.
const launch = (prompt: string) => {
if (launching) return;
if (isAppBuilder) {
if (onStarter) onStarter(prompt, 'view-builder');
return;
}
if (onLaunch) {
setLaunching(true);
onLaunch(prompt, mode, model);
@@ -39,7 +39,7 @@ interface DashboardOverlaysProps {
onNewAgent: () => void;
onToolbarCancel: () => void;
onToolbarSend: (...args: any[]) => void;
onAddView: (outputId: string) => void;
onAddView: (outputId: string, opts?: { newInstance?: boolean }) => void;
onHistoryResume: (sessionId: string) => void;
onAddBrowser: () => void;
onAddNote: () => void;
@@ -1165,7 +1165,8 @@ const AgentCard: React.FC<Props> = ({
startIcon={<CheckIcon sx={{ fontSize: '14px !important' }} />}
onClick={() => {
for (const req of session.pending_approvals) {
if (req.tool_name !== 'AskUserQuestion') dispatch(handleApproval({ requestId: req.id, behavior: 'allow' }));
// setAlwaysAllow so the SAME command mid-run stops re-prompting: the backend writes the policy into the live in-run snapshot, a plain allow only clears the pending request.
if (req.tool_name !== 'AskUserQuestion') dispatch(handleApproval({ requestId: req.id, behavior: 'allow', setAlwaysAllow: true }));
}
}}
sx={{
@@ -1,4 +1,5 @@
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { createPortal } from 'react-dom';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
@@ -34,6 +35,7 @@ import {
updateBrowserTabTitle,
updateBrowserTabFavicon,
reorderBrowserTab,
moveBrowserTab,
recordClosedCard,
type BrowserTab,
} from '@/shared/state/dashboardLayoutSlice';
@@ -505,17 +507,22 @@ const BrowserCard: React.FC<Props> = ({
const tabDragRef = useRef<{
tabId: string;
startX: number;
startY: number;
isDragging: boolean;
detached: boolean;
} | null>(null);
const swapCooldown = useRef(false);
const [dragTabId, setDragTabId] = useState<string | null>(null);
const [dragTabOffset, setDragTabOffset] = useState(0);
// Ghost pill following the cursor while a tab is dragged OUT of the strip (Push 6: drop on another card = absorbed, drop on canvas = new browser card).
const [detachGhost, setDetachGhost] = useState<{ x: number; y: number } | null>(null);
const DETACH_PX = 48;
const handleTabPointerDown = useCallback((e: React.PointerEvent) => {
e.stopPropagation();
const tabId = (e.currentTarget as HTMLElement).getAttribute('data-tab-id');
if (!tabId) return;
tabDragRef.current = { tabId, startX: e.clientX, isDragging: false };
tabDragRef.current = { tabId, startX: e.clientX, startY: e.clientY, isDragging: false, detached: false };
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}, []);
@@ -523,9 +530,27 @@ const BrowserCard: React.FC<Props> = ({
const drag = tabDragRef.current;
if (!drag) return;
const dx = e.clientX - drag.startX;
if (!drag.isDragging && Math.abs(dx) < 5) return;
const dy = e.clientY - drag.startY;
if (!drag.isDragging && Math.abs(dx) < 5 && Math.abs(dy) < 5) return;
drag.isDragging = true;
setDragTabId(drag.tabId);
// Pulling clear of the strip detaches the tab; hovering back over the strip re-attaches (Chrome behavior).
const barRect = tabBarRef.current?.getBoundingClientRect();
if (barRect) {
const outside = e.clientY < barRect.top - DETACH_PX || e.clientY > barRect.bottom + DETACH_PX
|| e.clientX < barRect.left - DETACH_PX || e.clientX > barRect.right + DETACH_PX;
const backInside = e.clientY >= barRect.top && e.clientY <= barRect.bottom
&& e.clientX >= barRect.left && e.clientX <= barRect.right;
if (!drag.detached && outside) drag.detached = true;
else if (drag.detached && backInside) drag.detached = false;
}
if (drag.detached) {
setDetachGhost({ x: e.clientX, y: e.clientY });
setDragTabOffset(0);
return;
}
setDetachGhost(null);
setDragTabOffset(dx);
if (swapCooldown.current) return;
@@ -574,12 +599,30 @@ const BrowserCard: React.FC<Props> = ({
if (!drag) return;
if (!drag.isDragging) {
handleSwitchTab(drag.tabId);
} else if (drag.detached) {
// Hit-test the drop point: another browser card absorbs the tab; empty canvas spins off a new card there.
const hit = document.elementsFromPoint(e.clientX, e.clientY)
.map((el) => (el as HTMLElement).closest?.('[data-select-type="browser-card"]') as HTMLElement | null)
.find((el) => el && el.getAttribute('data-select-id') !== browserId);
const targetId = hit?.getAttribute('data-select-id') || null;
if (targetId) {
dispatch(moveBrowserTab({ fromBrowserId: browserId, tabId: drag.tabId, toBrowserId: targetId }));
} else {
// Screen -> canvas: derive the transform origin from this card's own strip (screenX = originX + canvasX * zoom).
const barRect = tabBarRef.current?.getBoundingClientRect();
if (barRect) {
const dropX = (e.clientX - (barRect.left - cardX * zoomRef.current)) / zoomRef.current - 40;
const dropY = (e.clientY - (barRect.top - cardY * zoomRef.current)) / zoomRef.current - 16;
dispatch(moveBrowserTab({ fromBrowserId: browserId, tabId: drag.tabId, x: dropX, y: dropY }));
}
}
}
tabDragRef.current = null;
setDragTabId(null);
setDragTabOffset(0);
setDetachGhost(null);
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}, [handleSwitchTab]);
}, [handleSwitchTab, dispatch, browserId, cardX, cardY]);
const DRAG_THRESHOLD = 3;
const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number; startPanX: number; startPanY: number } | null>(null);
@@ -1537,6 +1580,44 @@ const BrowserCard: React.FC<Props> = ({
/>
))}
{/* Detached-tab ghost: fixed-position pill under the cursor while a tab is dragged out of the strip. pointerEvents none so the drop hit-test sees the cards underneath it. */}
{detachGhost && dragTabId && createPortal(
(() => {
const ghostTab = tabs.find((t) => t.id === dragTabId);
return (
<Box
sx={{
position: 'fixed',
left: detachGhost.x + 10,
top: detachGhost.y + 10,
zIndex: 2147483647,
pointerEvents: 'none',
display: 'flex',
alignItems: 'center',
gap: 0.75,
px: 1.25,
py: 0.5,
maxWidth: 240,
bgcolor: c.bg.elevated,
border: `1px solid ${c.border.medium}`,
borderRadius: `${c.radius.md}px`,
boxShadow: c.shadow.lg,
}}
>
{ghostTab?.favicon ? (
<Box component="img" src={ghostTab.favicon} sx={{ width: 14, height: 14, flexShrink: 0 }} />
) : (
<LanguageIcon sx={{ fontSize: 14, color: c.text.muted, flexShrink: 0 }} />
)}
<Typography sx={{ fontSize: '0.75rem', color: c.text.primary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{ghostTab?.title || ghostTab?.url || 'Tab'}
</Typography>
</Box>
);
})(),
document.body,
)}
</Box>
);
};
@@ -9,6 +9,10 @@ import RefreshIcon from '@mui/icons-material/Refresh';
import RestartAltIcon from '@mui/icons-material/RestartAlt';
import CloseIcon from '@mui/icons-material/Close';
import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded';
import VisibilityRoundedIcon from '@mui/icons-material/VisibilityRounded';
import CodeRoundedIcon from '@mui/icons-material/CodeRounded';
import TerminalRoundedIcon from '@mui/icons-material/TerminalRounded';
import HistoryRoundedIcon from '@mui/icons-material/HistoryRounded';
import { Output, SERVE_BASE } from '@/shared/state/outputsSlice';
import { setViewCardPosition, setViewCardSize, setActiveViewCardId, recordClosedCard } from '@/shared/state/dashboardLayoutSlice';
import { removeViewCardCleanly } from '@/shared/viewTeardown';
@@ -16,12 +20,22 @@ import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { API_BASE, getAuthToken } from '@/shared/config';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import ViewPreview, { ViewPreviewHandle } from '@/app/pages/Views/ViewPreview';
import TerminalPanel, { TerminalLine } from '@/app/pages/Views/TerminalPanel';
import AppCodePanel from '@/app/pages/Views/AppCodePanel';
import HistoryPanel from '@/app/pages/Views/HistoryPanel';
import ShareButton from '@/app/components/share/ShareButton';
import { getDefault } from '@/shared/inputSchemaDefaults';
import { useOverlayScrollPassthrough } from '../hooks/interaction/useOverlayScrollPassthrough';
import {
useRuntimePreviewUrl,
pickPreviewUrl,
RuntimeLogLine,
} from '@/shared/hooks/useRuntimePreviewUrl';
import { postAppConsoleLine, terminalLineFromStream } from '@/shared/appTerminal';
type AppCardView = 'preview' | 'code' | 'terminal' | 'history';
const TERMINAL_BUFFER_CAP = 5000;
type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
@@ -48,6 +62,10 @@ const HANDLE_DEFS: { dir: ResizeDir; sx: Record<string, any> }[] = [
interface Props {
output: Output;
// Record key in dashboardLayout.viewCards (output.id for the primary, `${output.id}#N` for extras); every layout/selection dispatch keys by this.
cardKey?: string;
// Which independent instance of the app this card runs; each instance gets its own runtime + ports.
instance?: number;
cardX: number;
cardY: number;
cardWidth: number;
@@ -104,16 +122,17 @@ const BootingBody: React.FC = () => {
};
const DashboardViewCard: React.FC<Props> = ({
output, cardX, cardY, cardWidth, cardHeight, zoom = 1, panX = 0, panY = 0, cmdHeld = false,
output, cardKey: cardKeyProp, instance = 1, cardX, cardY, cardWidth, cardHeight, zoom = 1, panX = 0, panY = 0, cmdHeld = false,
isSelected = false, isHighlighted = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd,
cardZOrder = 0, onDoubleClick, onBringToFront,
}) => {
const cardKey = cardKeyProp ?? output.id;
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const scrollOverlayRef = useOverlayScrollPassthrough(isSelected);
const previewRef = useRef<ViewPreviewHandle>(null);
const activeViewCardId = useAppSelector((s) => s.dashboardLayout.activeViewCardId);
const interactive = activeViewCardId === output.id;
const interactive = activeViewCardId === cardKey;
// Deselecting the card exits interact mode (click anywhere else on canvas).
useEffect(() => {
@@ -133,6 +152,20 @@ const DashboardViewCard: React.FC<Props> = ({
const [inputData] = useState<Record<string, any>>(() => getDefault(output.input_schema));
const [backendResult] = useState<Record<string, any> | null>(null);
// Preview/Code/Terminal switcher; only new-mode (workspace-backed) apps have code + terminal to show.
const [activeView, setActiveView] = useState<AppCardView>('preview');
const hasWorkspace = !!output.workspace_id;
const [terminalLines, setTerminalLines] = useState<TerminalLine[]>([]);
const terminalLineIdRef = useRef(0);
// Fed by the runtime logs WS (which replays its ring buffer on connect); frontend console lines arrive on the same socket via the console-log beacon echo.
const handleRuntimeLog = useCallback((line: RuntimeLogLine) => {
const fields = terminalLineFromStream(line.stream, line.text);
setTerminalLines((prev) => {
const next = prev.concat({ id: ++terminalLineIdRef.current, ...fields });
return next.length > TERMINAL_BUFFER_CAP ? next.slice(next.length - TERMINAL_BUFFER_CAP) : next;
});
}, []);
// Reload the preview when the session finishes a turn: React holds the ErrorBoundary's snag page until a reload, so without this the user keeps seeing the old error even after the agent fixed it. The overlay lingers through the reload (finishing) so the stale page never flashes.
const linkedStatus = useAppSelector(
(s) => (output.session_id ? s.agents.sessions[output.session_id]?.status : undefined),
@@ -178,8 +211,8 @@ const DashboardViewCard: React.FC<Props> = ({
didDrag.current = false;
setIsDragging(true);
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
onDragStart?.(output.id, 'view');
}, [cardX, cardY, onDragStart, output.id]);
onDragStart?.(cardKey, 'view');
}, [cardX, cardY, onDragStart, cardKey]);
const recomputeDragPos = useCallback(() => {
const ds = dragState.current;
@@ -226,7 +259,7 @@ const DashboardViewCard: React.FC<Props> = ({
finalY = Math.round(finalY / 24) * 24;
}
dispatch(setViewCardPosition({
outputId: output.id,
outputId: cardKey,
x: finalX,
y: finalY,
}));
@@ -239,7 +272,7 @@ const DashboardViewCard: React.FC<Props> = ({
setLocalDragPos(null);
setIsDragging(false);
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}, [dispatch, output.id, onDragEnd]);
}, [dispatch, cardKey, onDragEnd]);
const resizeRef = useRef<{
dir: ResizeDir; startX: number; startY: number;
@@ -293,24 +326,19 @@ const DashboardViewCard: React.FC<Props> = ({
if (!resizeRef.current) return;
const result = computeResize(e);
if (result) {
dispatch(setViewCardPosition({ outputId: output.id, x: result.x, y: result.y }));
dispatch(setViewCardSize({ outputId: output.id, width: result.w, height: result.h }));
dispatch(setViewCardPosition({ outputId: cardKey, x: result.x, y: result.y }));
dispatch(setViewCardSize({ outputId: cardKey, width: result.w, height: result.h }));
}
resizeRef.current = null;
setLocalResize(null);
setIsResizing(false);
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
}, [computeResize, dispatch, output.id]);
}, [computeResize, dispatch, cardKey]);
const handleRemove = (e: React.MouseEvent) => {
e.stopPropagation();
dispatch(recordClosedCard({ kind: 'view', id: output.id }));
void removeViewCardCleanly(output.id, dispatch);
};
const handleRefresh = (e: React.MouseEvent) => {
e.stopPropagation();
previewRef.current?.reload();
dispatch(recordClosedCard({ kind: 'view', id: cardKey }));
void removeViewCardCleanly(cardKey, dispatch);
};
const [reloadMenuRect, setReloadMenuRect] = useState<DOMRect | null>(null);
@@ -323,14 +351,24 @@ const DashboardViewCard: React.FC<Props> = ({
const tok = getAuthToken();
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (tok) headers.Authorization = `Bearer ${tok}`;
await fetch(`${API_BASE}/outputs/workspace/${wsId}/runtime/restart`, {
await fetch(`${API_BASE}/outputs/workspace/${wsId}/runtime/restart?instance=${instance}`, {
method: 'POST',
headers,
});
} catch { /* failures surface via the runtime log WS */ }
}
previewRef.current?.reload();
}, [output.workspace_id]);
}, [output.workspace_id, instance]);
// In Terminal view a soft webview reload is invisible (the terminal is what you're looking at), so the refresh button always hard-reloads there.
const handleRefresh = (e: React.MouseEvent) => {
e.stopPropagation();
if (activeView === 'terminal' && output.workspace_id) {
void handleHardReload(e);
return;
}
previewRef.current?.reload();
};
const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0;
const mdDy = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dy : 0;
@@ -343,16 +381,16 @@ const DashboardViewCard: React.FC<Props> = ({
return (
<Box
data-select-type="view-card"
data-select-id={output.id}
data-select-id={cardKey}
data-select-meta={JSON.stringify({ name: output.name, description: output.description, path: output.workspace_path })}
onPointerDownCapture={() => onBringToFront?.(output.id, 'view')}
onPointerDownCapture={() => onBringToFront?.(cardKey, 'view')}
onClick={(e: React.MouseEvent) => {
if (justDraggedRef.current) return;
onCardSelect?.(output.id, 'view', e.shiftKey);
onCardSelect?.(cardKey, 'view', e.shiftKey);
}}
onDoubleClick={(e: React.MouseEvent) => {
e.stopPropagation();
onDoubleClick?.(output.id, 'view');
onDoubleClick?.(cardKey, 'view');
}}
sx={{
position: 'absolute',
@@ -446,8 +484,58 @@ const DashboardViewCard: React.FC<Props> = ({
>
{output.name}
</Typography>
{instance > 1 && (
<Typography sx={{ fontSize: '0.66rem', fontWeight: 700, color: c.text.ghost, bgcolor: c.bg.page, borderRadius: 999, px: 0.75, py: 0.1, flexShrink: 0 }}>
#{instance}
</Typography>
)}
<Tooltip title="Reload preview; right-click for Hard Reload" placement="top">
{hasWorkspace && (
<Box
onPointerDown={(e) => e.stopPropagation()}
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.25,
bgcolor: c.bg.page,
borderRadius: 999,
p: 0.25,
flexShrink: 0,
}}
>
{([
{ view: 'preview' as const, label: 'Preview', Icon: VisibilityRoundedIcon },
{ view: 'code' as const, label: 'Code', Icon: CodeRoundedIcon },
{ view: 'terminal' as const, label: 'Terminal', Icon: TerminalRoundedIcon },
{ view: 'history' as const, label: 'History', Icon: HistoryRoundedIcon },
]).map(({ view, label, Icon }) => (
<Tooltip key={view} title={label} placement="top">
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); setActiveView(view); }}
sx={{
p: 0.5,
borderRadius: 999,
color: activeView === view ? c.text.primary : c.text.ghost,
bgcolor: activeView === view ? c.bg.elevated : 'transparent',
'&:hover': { color: c.text.primary, bgcolor: activeView === view ? c.bg.elevated : `${c.text.primary}0a` },
}}
>
<Icon sx={{ fontSize: 14 }} />
</IconButton>
</Tooltip>
))}
</Box>
)}
<Box onPointerDown={(e) => e.stopPropagation()} sx={{ display: 'flex', flexShrink: 0 }}>
<ShareButton target={{ kind: 'app', id: output.id, name: output.name }} size="small" iconFontSize={15} />
</Box>
<Tooltip
title={activeView === 'terminal' ? 'Hard reload (restart runtime + reload app)' : 'Reload preview; right-click for Hard Reload'}
placement="top"
>
<IconButton
size="small"
onClick={handleRefresh}
@@ -484,12 +572,33 @@ const DashboardViewCard: React.FC<Props> = ({
<DashboardOutputPreview
previewRef={previewRef}
output={output}
cardKey={cardKey}
instance={instance}
inputData={inputData}
backendResult={backendResult}
interactive={interactive}
onAppClicked={() => dispatch(setActiveViewCardId(output.id))}
onAppClicked={() => dispatch(setActiveViewCardId(cardKey))}
onRuntimeLog={handleRuntimeLog}
/>
<BuildingOverlay show={showBuildingOverlay} />
{/* Code/Terminal overlay the always-mounted preview instead of replacing it: unmounting the webview kills the app's live state and forces a reload on switch-back. */}
{output.workspace_id && activeView !== 'preview' && (
<Box sx={{ position: 'absolute', inset: 0, zIndex: 13, bgcolor: c.bg.surface }}>
{activeView === 'terminal' ? (
<TerminalPanel lines={terminalLines} />
) : activeView === 'history' ? (
<Box sx={{ height: '100%', overflow: 'auto' }}>
<HistoryPanel
outputId={output.id}
isAgentActive={showBuildingOverlay}
onRestored={() => previewRef.current?.reload()}
/>
</Box>
) : (
<AppCodePanel workspaceId={output.workspace_id} onFileSaved={() => previewRef.current?.reload()} />
)}
</Box>
)}
<BuildingOverlay show={showBuildingOverlay && activeView === 'preview'} />
</Box>
{/* Resize handles */}
@@ -608,17 +717,22 @@ const BuildingOverlay: React.FC<{ show: boolean }> = ({ show }) => {
const DashboardOutputPreview: React.FC<{
previewRef: React.Ref<ViewPreviewHandle>;
output: Output;
cardKey?: string;
instance?: number;
inputData: Record<string, any>;
backendResult: any;
interactive: boolean;
onAppClicked: () => void;
}> = ({ previewRef, output, inputData, backendResult, interactive, onAppClicked }) => {
onRuntimeLog?: (line: RuntimeLogLine) => void;
}> = ({ previewRef, output, cardKey, instance = 1, inputData, backendResult, interactive, onAppClicked, onRuntimeLog }) => {
const tokens = useClaudeTokens();
const dispatch = useAppDispatch();
const workspaceId = output.workspace_id ?? null;
const { frontendUrl, isNewMode, isHydrating } = useRuntimePreviewUrl({
workspaceId,
enabled: !!workspaceId,
onLog: onRuntimeLog,
instance,
});
const { url, isBooting } = pickPreviewUrl({
workspaceId,
@@ -634,23 +748,25 @@ const DashboardOutputPreview: React.FC<{
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (tok) headers.Authorization = `Bearer ${tok}`;
if (text.includes('[openswarm:app-ready]')) {
fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/report-ready`, {
fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/report-ready?instance=${instance}`, {
method: 'POST', headers,
}).catch(() => {});
return;
}
// Fold console output into the runtime terminal stream (card Terminal view + agent-readable terminal.log).
postAppConsoleLine(workspaceId, level, text, instance);
if (level !== 'error' || !text.includes('[openswarm:app-error]')) return;
const idx = text.indexOf('[openswarm:app-error]');
const tail = text.slice(idx + '[openswarm:app-error]'.length).trim();
const firstNewline = tail.indexOf('\n');
const message = firstNewline >= 0 ? tail.slice(0, firstNewline).trim() : tail;
const componentStack = firstNewline >= 0 ? tail.slice(firstNewline + 1).trim() : '';
fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/report-error`, {
fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/report-error?instance=${instance}`, {
method: 'POST',
headers,
body: JSON.stringify({ message, componentStack }),
}).catch(() => {});
}, [workspaceId]);
}, [workspaceId, instance]);
// An orphaned record (files deleted on disk) used to render the raw 404 JSON inside the card, or spin on "Starting preview" forever; probe once instead.
const [filesMissing, setFilesMissing] = useState(false);
@@ -688,7 +804,7 @@ const DashboardOutputPreview: React.FC<{
This app's files are missing.
</Typography>
<Typography
onClick={() => void removeViewCardCleanly(output.id, dispatch)}
onClick={() => void removeViewCardCleanly(cardKey ?? output.id, dispatch)}
sx={{
color: tokens.accent.primary,
fontSize: '0.85rem',
@@ -715,7 +831,7 @@ const DashboardOutputPreview: React.FC<{
return (
<ViewPreview
ref={previewRef}
registryId={output.id}
registryId={cardKey ?? output.id}
serveUrl={url}
frontendCode={output.files?.['index.html'] ?? ''}
inputData={inputData}
@@ -723,7 +839,7 @@ const DashboardOutputPreview: React.FC<{
onConsoleMessage={handleConsoleMessage}
interactive={interactive}
onAppClicked={onAppClicked}
agentBrowserId={`app:${output.id}`}
agentBrowserId={instance > 1 ? `app:${output.id}#${instance}` : `app:${output.id}`}
/>
);
};
@@ -44,10 +44,10 @@ const CardSearchPalette: React.FC<Props> = ({
rect: { x: card.x, y: card.y, width: card.width, height: card.height },
});
}
for (const vc of Object.values(viewCards)) {
for (const [key, vc] of Object.entries(viewCards)) {
result.push({
id: vc.output_id,
label: `View: ${vc.output_id.slice(0, 12)}`,
id: key,
label: `View: ${vc.output_id.slice(0, 12)}${(vc.instance ?? 1) > 1 ? ` (#${vc.instance})` : ''}`,
type: 'view',
rect: { x: vc.x, y: vc.y, width: vc.width, height: vc.height },
});
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type
import { report } from '@/shared/serviceClient';
import { useAppDispatch } from '@/shared/hooks';
import { expandSession } from '@/shared/state/agentsSlice';
import { bringToFront } from '@/shared/state/dashboardLayoutSlice';
import { bringToFront, viewCardKey } from '@/shared/state/dashboardLayoutSlice';
import type { CardPosition, ViewCardPosition, BrowserCardPosition, WorkflowCardPosition } from '@/shared/state/dashboardLayoutSlice';
import type { CardType } from '../state/useDashboardSelection';
import type { CanvasActions } from './useCanvasControls';
@@ -45,7 +45,7 @@ export function useArrowNav({
allCardEntries.push({ id: card.session_id, type: 'agent', cx: card.x + card.width / 2, cy: card.y + card.height / 2 });
}
for (const vc of Object.values(viewCards)) {
allCardEntries.push({ id: vc.output_id, type: 'view', cx: vc.x + vc.width / 2, cy: vc.y + vc.height / 2 });
allCardEntries.push({ id: viewCardKey(vc.output_id, vc.instance), type: 'view', cx: vc.x + vc.width / 2, cy: vc.y + vc.height / 2 });
}
for (const bc of Object.values(browserCards)) {
allCardEntries.push({ id: bc.browser_id, type: 'browser', cx: bc.x + bc.width / 2, cy: bc.y + bc.height / 2 });
@@ -14,6 +14,7 @@ import {
type BrowserCardPosition,
} from '@/shared/state/dashboardLayoutSlice';
import type { Output } from '@/shared/state/outputsSlice';
import { store } from '@/shared/state/store';
import { setClipboardCards, getClipboardCards, type ClipboardCard } from '@/shared/dashboardClipboard';
import type { CardType, useDashboardSelection } from '../state/useDashboardSelection';
@@ -134,8 +135,15 @@ export function useDashboardClipboard({
newSelection.set(newId, 'agent');
}
} else if (card.type === 'view') {
dispatch(addViewCard({ outputId: card.id, expandedSessionIds, x: px, y: py, width: card.width, height: card.height }));
newSelection.set(card.id, 'view');
// Pasting an app whose card is already open creates a NEW independent instance (own runtime + ports) instead of no-op'ing.
const outputId = card.id.split('#')[0];
dispatch(addViewCard({ outputId, expandedSessionIds, x: px, y: py, width: card.width, height: card.height, newInstance: true }));
const viewCards = store.getState().dashboardLayout.viewCards;
let pastedKey = outputId;
for (const [key, vc] of Object.entries(viewCards)) {
if (vc.output_id === outputId && (vc.instance ?? 1) >= (viewCards[pastedKey]?.instance ?? 1)) pastedKey = key;
}
newSelection.set(pastedKey, 'view');
} else if (card.type === 'browser') {
const browserId = `browser-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
dispatch(pasteBrowserCard({
@@ -39,13 +39,21 @@ export function useDashboardCardActions({
}: UseDashboardCardActionsArgs) {
const dispatch = useAppDispatch();
const handleAddView = useCallback((outputId: string) => {
dispatch(addViewCard({ outputId, expandedSessionIds }));
const handleAddView = useCallback((outputId: string, opts?: { newInstance?: boolean }) => {
dispatch(addViewCard({ outputId, expandedSessionIds, newInstance: opts?.newInstance }));
setTimeout(() => {
const card = store.getState().dashboardLayout.viewCards[outputId];
// Focus whichever card the dispatch produced: with newInstance that's the highest-numbered instance of this output, else the primary.
const viewCards = store.getState().dashboardLayout.viewCards;
let focusKey = outputId;
if (opts?.newInstance) {
for (const [key, vc] of Object.entries(viewCards)) {
if (vc.output_id === outputId && (vc.instance ?? 1) >= (viewCards[focusKey]?.instance ?? 1)) focusKey = key;
}
}
const card = viewCards[focusKey];
if (card) {
canvasActions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.15, true);
handleHighlightCard(outputId);
handleHighlightCard(focusKey);
}
}, 200);
}, [dispatch, expandedSessionIds, canvasActions, handleHighlightCard]);
@@ -16,6 +16,7 @@ import {
resetLayout,
removeViewCard,
clearPendingFocusBrowserId,
clearPendingFocusViewCardId,
clearPendingFocusWorkflowId,
clearPendingFocusWorkflowsHub,
type ViewCardPosition,
@@ -71,6 +72,7 @@ export function useDashboardLifecycle({
const pendingBrowserUrl = useAppSelector((state) => state.tempState.pendingBrowserUrl);
const pendingFocusAgentId = useAppSelector((state) => state.tempState.pendingFocusAgentId);
const pendingFocusBrowserId = useAppSelector((state) => state.dashboardLayout.pendingFocusBrowserId);
const pendingFocusViewCardId = useAppSelector((state) => state.dashboardLayout.pendingFocusViewCardId);
const pendingFocusWorkflowId = useAppSelector((state) => state.dashboardLayout.pendingFocusWorkflowId);
const pendingFocusWorkflowsHub = useAppSelector((state) => state.dashboardLayout.pendingFocusWorkflowsHub);
@@ -231,6 +233,22 @@ export function useDashboardLifecycle({
}, 200);
}, [isActive, pendingFocusBrowserId, layoutInitialized, dispatch, canvasActions, handleHighlightCard]);
// Auto-focus a view card opened from OUTSIDE the canvas (sidebar app click, toolbar picker). addViewCard sets pendingFocusViewCardId; fit + highlight it, then clear. Mirrors the browser path above so reopening a closed app lands you looking right at it.
useEffect(() => {
if (!isActive) return;
if (!pendingFocusViewCardId || !layoutInitialized) return;
const cardKey = pendingFocusViewCardId;
dispatch(clearPendingFocusViewCardId());
hasFittedRef.current = true;
setTimeout(() => {
const card = store.getState().dashboardLayout.viewCards[cardKey];
if (card) {
canvasActions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.15, true);
handleHighlightCard(cardKey);
}
}, 200);
}, [isActive, pendingFocusViewCardId, layoutInitialized, dispatch, canvasActions, handleHighlightCard]);
// Same pan/highlight choreography for newly-spawned workflow cards.
useEffect(() => {
if (!isActive) return;
@@ -310,8 +328,9 @@ export function useDashboardLifecycle({
if (autoOpenedOutputsRef.current.has(output.id)) continue;
const sid = output.session_id;
if (!sid) continue;
// Any mode: apps are born from normal agents via CreateApp now, not just view-builder sessions.
const sess = sessions[sid];
if (!sess || sess.mode !== 'view-builder') continue;
if (!sess) continue;
if (sess.dashboard_id !== dashboardId) continue;
autoOpenedOutputsRef.current.add(output.id);
if (viewCards[output.id]) continue;
@@ -1,5 +1,6 @@
import { useState, useCallback, useRef, useEffect, RefObject } from 'react';
import type { CardPosition, ViewCardPosition, BrowserCardPosition, NotePosition, WorkflowCardPosition, WorkflowsHubPosition } from '@/shared/state/dashboardLayoutSlice';
import { viewCardKey } from '@/shared/state/dashboardLayoutSlice';
export type { CardType } from '@/shared/state/dashboardLayoutSlice';
import type { CardType } from '@/shared/state/dashboardLayoutSlice';
@@ -75,7 +76,7 @@ export function useDashboardSelection(
const selectAll = useCallback(() => {
const next = new Map<string, CardType>();
for (const card of Object.values(cards)) next.set(card.session_id, 'agent');
for (const vc of Object.values(viewCards)) next.set(vc.output_id, 'view');
for (const vc of Object.values(viewCards)) next.set(viewCardKey(vc.output_id, vc.instance), 'view');
for (const bc of Object.values(browserCards)) next.set(bc.browser_id, 'browser');
for (const n of Object.values(notes)) next.set(n.note_id, 'note');
for (const wc of Object.values(workflowCards)) next.set(wc.workflow_id, 'workflow');
@@ -134,7 +135,7 @@ export function useDashboardSelection(
height: vc.height,
})
) {
intersecting.set(vc.output_id, 'view');
intersecting.set(viewCardKey(vc.output_id, vc.instance), 'view');
}
}
@@ -0,0 +1,136 @@
// Self-contained Code view for the dashboard app card: polls the workspace file
// tree, edits save via the same per-file PUT the full ViewEditor uses. Owns all
// its state so DashboardViewCard mounts it on demand with just a workspaceId.
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { API_BASE } from '@/shared/config';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import CodeEditor from './CodeEditor';
import { FileTreeItem, buildFileTree, getEditorLanguage, isHiddenWorkspacePath } from './AppFileTree';
const POLL_MS = 3000;
const SAVE_DEBOUNCE_MS = 300;
interface Props {
workspaceId: string;
onFileSaved?: () => void;
}
const AppCodePanel: React.FC<Props> = ({ workspaceId, onFileSaved }) => {
const c = useClaudeTokens();
const [files, setFiles] = useState<Record<string, string>>({});
const [oversizeFiles, setOversizeFiles] = useState<Record<string, number>>({});
const [activeFile, setActiveFile] = useState('');
const lastPollRef = useRef('');
const saveTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
// Files the user is mid-editing; the poll must not clobber them with a stale disk read racing the debounced PUT.
const dirtyFilesRef = useRef<Set<string>>(new Set());
useEffect(() => {
let cancelled = false;
const poll = async () => {
try {
const res = await fetch(`${API_BASE}/outputs/workspace/${workspaceId}`);
if (!res.ok || cancelled) return;
const data = await res.json();
const fingerprint = JSON.stringify(data.files ?? {});
if (fingerprint === lastPollRef.current) return;
lastPollRef.current = fingerprint;
setFiles((prev) => {
const next: Record<string, string> = { ...(data.files ?? {}) };
for (const dirty of dirtyFilesRef.current) {
if (prev[dirty] != null) next[dirty] = prev[dirty];
}
return next;
});
setOversizeFiles(data.truncated ?? {});
} catch { /* transient poll failure; next tick retries */ }
};
poll();
const id = setInterval(poll, POLL_MS);
return () => {
cancelled = true;
clearInterval(id);
};
}, [workspaceId]);
const filePaths = useMemo(
() =>
Array.from(new Set([...Object.keys(files), ...Object.keys(oversizeFiles)]))
.filter((p) => p !== 'meta.json' && p !== 'SKILL.md')
.filter((p) => !isHiddenWorkspacePath(p))
.sort(),
[files, oversizeFiles],
);
const fileTree = useMemo(() => buildFileTree(filePaths), [filePaths]);
useEffect(() => {
if (!activeFile || !filePaths.includes(activeFile)) {
setActiveFile(filePaths.find((p) => p.endsWith('.tsx') || p.endsWith('.html')) ?? filePaths[0] ?? '');
}
}, [filePaths, activeFile]);
const updateFile = useCallback((path: string, content: string) => {
if (oversizeFiles[path] != null) return;
dirtyFilesRef.current.add(path);
setFiles((prev) => ({ ...prev, [path]: content }));
const existing = saveTimersRef.current.get(path);
if (existing) clearTimeout(existing);
saveTimersRef.current.set(path, setTimeout(() => {
saveTimersRef.current.delete(path);
dirtyFilesRef.current.delete(path);
fetch(`${API_BASE}/outputs/workspace/${workspaceId}/file/${encodeURIComponent(path)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
})
.then(() => onFileSaved?.())
.catch(() => {});
}, SAVE_DEBOUNCE_MS));
}, [workspaceId, oversizeFiles, onFileSaved]);
useEffect(() => () => {
for (const t of saveTimersRef.current.values()) clearTimeout(t);
}, []);
return (
<Box sx={{ display: 'flex', height: '100%', bgcolor: c.bg.surface }}>
<Box sx={{ width: 168, flexShrink: 0, bgcolor: c.bg.secondary, overflow: 'auto', py: 0.5, borderRight: `1px solid ${c.border.subtle}` }}>
{fileTree.map((node) => (
<FileTreeItem key={node.path} node={node} depth={0} activeFile={activeFile} onSelect={setActiveFile} c={c} />
))}
{filePaths.length === 0 && (
<Typography sx={{ fontSize: '0.72rem', color: c.text.ghost, px: 1.5, py: 1 }}>
Loading files
</Typography>
)}
</Box>
<Box sx={{ flex: 1, overflow: 'hidden' }}>
{activeFile && oversizeFiles[activeFile] != null ? (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', px: 3 }}>
<Typography sx={{ color: c.text.muted, fontSize: '0.8rem', textAlign: 'center', maxWidth: 320, lineHeight: 1.5 }}>
This file is {(oversizeFiles[activeFile] / (1024 * 1024)).toFixed(1)} MB, too large to edit here.
</Typography>
</Box>
) : activeFile && files[activeFile] != null ? (
<CodeEditor
key={activeFile}
value={files[activeFile]}
onChange={(val) => updateFile(activeFile, val)}
language={getEditorLanguage(activeFile)}
placeholder={`// ${activeFile}`}
/>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
<Typography sx={{ color: c.text.ghost, fontSize: '0.8rem' }}>
Select a file to edit
</Typography>
</Box>
)}
</Box>
</Box>
);
};
export default AppCodePanel;
@@ -0,0 +1,194 @@
// Workspace file-tree primitives shared by ViewEditor's Code tab and the dashboard card's AppCodePanel.
import React, { useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Collapse from '@mui/material/Collapse';
import HtmlIcon from '@mui/icons-material/Code';
import PythonIcon from '@mui/icons-material/Terminal';
import SchemaIcon from '@mui/icons-material/DataObject';
import JsIcon from '@mui/icons-material/Javascript';
import CssIcon from '@mui/icons-material/Style';
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
import FolderIcon from '@mui/icons-material/Folder';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
// File-tree noise: filtered by basename anywhere in the path; callers may offer a "show hidden" bypass.
export const HIDDEN_PATH_SEGMENTS = new Set<string>([
'node_modules',
'.vite-cache',
'.vite',
'.git',
'dist',
'.next',
'__pycache__',
'.venv',
'.openswarm',
]);
export function isHiddenWorkspacePath(p: string): boolean {
for (const seg of p.split('/')) {
if (HIDDEN_PATH_SEGMENTS.has(seg)) return true;
}
return false;
}
export function getFileIcon(filename: string): React.ReactNode {
const ext = filename.split('.').pop()?.toLowerCase();
const size = 15;
switch (ext) {
case 'html': case 'htm': return <HtmlIcon sx={{ fontSize: size }} />;
case 'py': return <PythonIcon sx={{ fontSize: size }} />;
case 'json': return <SchemaIcon sx={{ fontSize: size }} />;
case 'js': case 'jsx': case 'ts': case 'tsx': return <JsIcon sx={{ fontSize: size }} />;
case 'css': case 'scss': case 'less': return <CssIcon sx={{ fontSize: size }} />;
default: return <InsertDriveFileIcon sx={{ fontSize: size }} />;
}
}
export function getEditorLanguage(filename: string): 'html' | 'python' | 'json' {
const ext = filename.split('.').pop()?.toLowerCase();
switch (ext) {
case 'py': return 'python';
case 'json': return 'json';
default: return 'html';
}
}
export interface FileTreeNode {
name: string;
path: string;
isDir: boolean;
children?: FileTreeNode[];
}
export function buildFileTree(filePaths: string[]): FileTreeNode[] {
const root: FileTreeNode[] = [];
const sorted = [...filePaths].sort();
for (const fp of sorted) {
const parts = fp.split('/');
let current = root;
let pathSoFar = '';
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
pathSoFar = pathSoFar ? `${pathSoFar}/${part}` : part;
const isLast = i === parts.length - 1;
let existing = current.find(n => n.name === part && n.isDir === !isLast);
if (!existing) {
if (isLast) {
existing = { name: part, path: fp, isDir: false };
} else {
existing = { name: part, path: pathSoFar, isDir: true, children: [] };
}
current.push(existing);
}
if (!isLast) {
current = existing.children!;
}
}
}
return root;
}
interface FileTreeItemProps {
node: FileTreeNode;
depth: number;
activeFile: string;
onSelect: (path: string) => void;
onDelete?: (path: string) => void;
c: ReturnType<typeof useClaudeTokens>;
}
const PROTECTED_FILES = new Set(['index.html', 'schema.json', 'meta.json', 'SKILL.md']);
export const FileTreeItem: React.FC<FileTreeItemProps> = ({ node, depth, activeFile, onSelect, onDelete, c }) => {
const [open, setOpen] = useState(true);
if (node.isDir) {
return (
<>
<Box
onClick={() => setOpen(!open)}
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
pl: 1.5 + depth * 1,
pr: 1,
py: 0.5,
cursor: 'pointer',
'&:hover': { bgcolor: c.bg.surface },
}}
>
<ExpandMoreIcon sx={{ fontSize: 12, color: c.text.ghost, transform: open ? 'rotate(0deg)' : 'rotate(-90deg)', transition: '0.15s' }} />
<FolderIcon sx={{ fontSize: 14, color: c.text.muted }} />
<Typography sx={{ fontSize: '0.74rem', color: c.text.secondary, fontFamily: c.font.mono }}>
{node.name}
</Typography>
</Box>
<Collapse in={open}>
{node.children?.map((child) => (
<FileTreeItem key={child.path} node={child} depth={depth + 1} activeFile={activeFile} onSelect={onSelect} onDelete={onDelete} c={c} />
))}
</Collapse>
</>
);
}
const isActive = activeFile === node.path;
const canDelete = onDelete && !PROTECTED_FILES.has(node.path);
return (
<Box
onClick={() => onSelect(node.path)}
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
pl: 1.5 + depth * 1 + 1.25,
pr: 0.5,
py: 0.5,
cursor: 'pointer',
bgcolor: isActive ? c.bg.elevated : 'transparent',
borderLeft: isActive ? `2px solid ${c.accent.primary}` : '2px solid transparent',
'&:hover': { bgcolor: isActive ? c.bg.elevated : c.bg.surface },
'&:hover .delete-btn': { opacity: 1 },
transition: 'background-color 0.1s',
}}
>
<Box sx={{ color: isActive ? c.accent.primary : c.text.muted, display: 'flex', flexShrink: 0 }}>
{getFileIcon(node.name)}
</Box>
<Typography
sx={{
fontSize: '0.74rem',
fontFamily: c.font.mono,
color: isActive ? c.text.primary : c.text.secondary,
fontWeight: isActive ? 500 : 400,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
}}
>
{node.name}
</Typography>
{canDelete && (
<IconButton
className="delete-btn"
size="small"
onClick={(e) => { e.stopPropagation(); onDelete(node.path); }}
sx={{ opacity: 0, p: 0.25, color: c.text.ghost, '&:hover': { color: '#ef4444' }, transition: 'opacity 0.15s, color 0.15s' }}
>
<DeleteOutlineIcon sx={{ fontSize: 14 }} />
</IconButton>
)}
</Box>
);
};
@@ -1,232 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import TextField from '@mui/material/TextField';
import Switch from '@mui/material/Switch';
import FormControlLabel from '@mui/material/FormControlLabel';
import Select from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import InputLabel from '@mui/material/InputLabel';
import FormControl from '@mui/material/FormControl';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Button from '@mui/material/Button';
import AddIcon from '@mui/icons-material/Add';
import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { getDefault, type SchemaNode } from '@/shared/inputSchemaDefaults';
interface Props {
schema: SchemaNode;
value: any;
onChange: (value: any) => void;
label?: string;
depth?: number;
}
const InputSchemaForm: React.FC<Props> = ({ schema, value, onChange, label, depth = 0 }) => {
const c = useClaudeTokens();
if (schema.enum && schema.enum.length > 0) {
return (
<FormControl fullWidth size="small" sx={{ mb: 1.5 }}>
{label && <InputLabel>{label}</InputLabel>}
<Select
value={value ?? ''}
label={label}
onChange={(e) => onChange(e.target.value)}
sx={{
fontSize: '0.85rem',
'& .MuiOutlinedInput-notchedOutline': { borderColor: c.border.medium },
}}
>
{schema.enum.map((opt) => (
<MenuItem key={opt} value={opt}>{opt}</MenuItem>
))}
</Select>
{schema.description && (
<Typography sx={{ fontSize: '0.7rem', color: c.text.tertiary, mt: 0.25, ml: 0.5 }}>
{schema.description}
</Typography>
)}
</FormControl>
);
}
if (schema.type === 'boolean') {
return (
<Box sx={{ mb: 1 }}>
<FormControlLabel
control={
<Switch
checked={!!value}
onChange={(e) => onChange(e.target.checked)}
size="small"
/>
}
label={
<Typography sx={{ fontSize: '0.85rem', color: c.text.secondary }}>
{label || 'Toggle'}
</Typography>
}
/>
{schema.description && (
<Typography sx={{ fontSize: '0.7rem', color: c.text.tertiary, ml: 0.5 }}>
{schema.description}
</Typography>
)}
</Box>
);
}
if (schema.type === 'number' || schema.type === 'integer') {
return (
<TextField
fullWidth
size="small"
type="number"
label={label}
helperText={schema.description}
value={value ?? 0}
onChange={(e) => onChange(Number(e.target.value))}
sx={{
mb: 1.5,
'& .MuiOutlinedInput-root': { fontSize: '0.85rem' },
'& .MuiFormHelperText-root': { fontSize: '0.7rem' },
}}
/>
);
}
if (schema.type === 'string') {
return (
<TextField
fullWidth
size="small"
label={label}
helperText={schema.description}
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
multiline={(value?.length ?? 0) > 80}
sx={{
mb: 1.5,
'& .MuiOutlinedInput-root': { fontSize: '0.85rem' },
'& .MuiFormHelperText-root': { fontSize: '0.7rem' },
}}
/>
);
}
if (schema.type === 'array' && schema.items) {
const items = Array.isArray(value) ? value : [];
return (
<Box
sx={{
mb: 1.5,
pl: depth > 0 ? 1.5 : 0,
borderLeft: depth > 0 ? `2px solid ${c.border.subtle}` : 'none',
}}
>
{label && (
<Typography
sx={{ fontSize: '0.8rem', fontWeight: 600, color: c.text.secondary, mb: 0.5 }}
>
{label}
</Typography>
)}
{schema.description && (
<Typography sx={{ fontSize: '0.7rem', color: c.text.tertiary, mb: 0.5 }}>
{schema.description}
</Typography>
)}
{items.map((item: any, i: number) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5, mb: 0.5 }}>
<Box sx={{ flex: 1 }}>
<InputSchemaForm
schema={schema.items!}
value={item}
onChange={(newVal) => {
const updated = [...items];
updated[i] = newVal;
onChange(updated);
}}
label={`Item ${i + 1}`}
depth={depth + 1}
/>
</Box>
<IconButton
size="small"
onClick={() => {
const updated = items.filter((_: any, idx: number) => idx !== i);
onChange(updated);
}}
sx={{ color: c.status.error, mt: 0.5 }}
>
<RemoveCircleOutlineIcon sx={{ fontSize: 18 }} />
</IconButton>
</Box>
))}
<Button
size="small"
startIcon={<AddIcon sx={{ fontSize: 14 }} />}
onClick={() => onChange([...items, getDefault(schema.items!)])}
sx={{
fontSize: '0.75rem',
color: c.accent.primary,
textTransform: 'none',
}}
>
Add item
</Button>
</Box>
);
}
if (schema.type === 'object' && schema.properties) {
const obj = typeof value === 'object' && value !== null ? value : {};
return (
<Box
sx={{
mb: 1.5,
pl: depth > 0 ? 1.5 : 0,
borderLeft: depth > 0 ? `2px solid ${c.border.subtle}` : 'none',
}}
>
{label && (
<Typography
sx={{ fontSize: '0.8rem', fontWeight: 600, color: c.text.secondary, mb: 1 }}
>
{label}
</Typography>
)}
{schema.description && (
<Typography sx={{ fontSize: '0.7rem', color: c.text.tertiary, mb: 0.5 }}>
{schema.description}
</Typography>
)}
{Object.entries(schema.properties).map(([key, propSchema]) => (
<InputSchemaForm
key={key}
schema={propSchema}
value={obj[key]}
onChange={(newVal) => onChange({ ...obj, [key]: newVal })}
label={key + (schema.required?.includes(key) ? ' *' : '')}
depth={depth + 1}
/>
))}
</Box>
);
}
return (
<TextField
fullWidth
size="small"
label={label}
value={typeof value === 'string' ? value : JSON.stringify(value ?? '')}
onChange={(e) => onChange(e.target.value)}
sx={{ mb: 1.5, '& .MuiOutlinedInput-root': { fontSize: '0.85rem' } }}
/>
);
};
export default InputSchemaForm;
-176
View File
@@ -1,176 +0,0 @@
import React 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 EditIcon from '@mui/icons-material/Edit';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import HistoryIcon from '@mui/icons-material/History';
import Icon from '@mui/material/Icon';
import { Output } from '@/shared/state/outputsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import ShareButton from '@/app/components/share/ShareButton';
interface Props {
output: Output;
onClick: () => void;
onDelete: () => void;
onRun: () => void;
onHistory: () => void;
}
const ViewCard: React.FC<Props> = ({ output, onClick, onDelete, onRun, onHistory }) => {
const c = useClaudeTokens();
return (
<Box
onClick={onClick}
sx={{
cursor: 'pointer',
borderRadius: 3,
border: `1px solid ${c.border.subtle}`,
bgcolor: c.bg.surface,
overflow: 'hidden',
// Own compositor layer so hover paint stays scoped (same fix as dashboard AgentCard).
willChange: 'transform',
// Animate only transform + border-color; `transition: all` triggers per-frame CPU paint for box-shadow blur.
transition: 'transform 0.15s ease, border-color 0.15s ease',
'&:hover': {
borderColor: c.border.strong,
transform: 'translateY(-2px)',
},
'&:hover .card-actions': { opacity: 1 },
display: 'flex',
flexDirection: 'column',
}}
>
<Box
sx={{
height: 160,
// Whisper of warmth fading into the card surface, no hard tint block, so preview and title read as one continuous panel (matches dashboards).
background: `radial-gradient(120% 90% at 50% 0%, ${c.accent.primary}1F 0%, transparent 62%)`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
overflow: 'hidden',
}}
>
{output.thumbnail ? (
<Box
component="img"
src={output.thumbnail}
alt={`${output.name} preview`}
loading="lazy"
decoding="async"
sx={{
width: '100%',
height: '100%',
objectFit: 'cover',
objectPosition: 'top left',
}}
/>
) : (
<Icon
sx={{
fontSize: 40,
color: c.text.tertiary,
opacity: 0.45,
}}
>
{output.icon}
</Icon>
)}
<Box
className="card-actions"
sx={{
position: 'absolute',
top: 8,
right: 8,
display: 'flex',
gap: 0.5,
opacity: 0,
transition: 'opacity 0.15s',
}}
>
<Tooltip title="Run">
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); onRun(); }}
sx={{
bgcolor: c.bg.surface,
color: c.accent.primary,
boxShadow: c.shadow.sm,
'&:hover': { bgcolor: c.bg.elevated },
}}
>
<PlayArrowIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
<ShareButton target={{ kind: 'app', id: output.id, name: output.name }} tone="chip" iconFontSize={16} />
<Tooltip title="History">
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); onHistory(); }}
sx={{
bgcolor: c.bg.surface,
color: c.text.secondary,
boxShadow: c.shadow.sm,
'&:hover': { bgcolor: c.bg.elevated },
}}
>
<HistoryIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); onDelete(); }}
sx={{
bgcolor: c.bg.surface,
color: c.status.error,
boxShadow: c.shadow.sm,
'&:hover': { bgcolor: c.bg.elevated },
}}
>
<DeleteOutlineIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
</Box>
</Box>
<Box sx={{ p: 2, flex: 1, display: 'flex', flexDirection: 'column', gap: 0.75 }}>
<Typography
sx={{
fontSize: '0.95rem',
fontWeight: 600,
color: c.text.primary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{output.name}
</Typography>
<Typography
sx={{
fontSize: '0.8rem',
color: c.text.muted,
lineHeight: 1.4,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
minHeight: '2.2em',
}}
>
{output.description || 'No description'}
</Typography>
</Box>
</Box>
);
};
// Custom equality: parent re-renders pass new inline callbacks every time, so key on output identity only.
export default React.memo(ViewCard, (prev, next) => prev.output === next.output);
File diff suppressed because it is too large Load Diff
+28 -1
View File
@@ -173,6 +173,33 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
}
}, [windowHidden, onContentLoad]);
// srcdoc iframes swallow wheel events just like webviews, but they're same-origin so the host can forward cmd/ctrl+wheel to the canvas zoom itself (the webview path does this via the preload). Cross-origin URL iframes throw on contentWindow access; the catch leaves them as-is. Listeners die with the document on reload, so this reattaches from onLoad each time.
const attachIframeWheelForwarder = useCallback(() => {
const iframe = iframeRef.current;
if (!iframe) return;
try {
const win = iframe.contentWindow;
if (!win || !win.document) return;
const onWheel = (e: WheelEvent) => {
if (!e.ctrlKey && !e.metaKey) return;
e.preventDefault();
e.stopPropagation();
const rect = iframe.getBoundingClientRect();
const iw = win.innerWidth || 1;
const ih = win.innerHeight || 1;
window.dispatchEvent(new CustomEvent('openswarm:canvas-wheel-zoom', {
detail: {
deltaY: e.deltaY,
deltaMode: e.deltaMode,
clientX: rect.left + Math.max(0, Math.min(1, e.clientX / iw)) * rect.width,
clientY: rect.top + Math.max(0, Math.min(1, e.clientY / ih)) * rect.height,
},
}));
};
win.addEventListener('wheel', onWheel, { capture: true, passive: false });
} catch (_e) { /* cross-origin URL iframe; wheel stays with the page */ }
}, []);
const srcdoc = useMemo(() => {
if (serveUrl || !frontendCode) return undefined;
return buildSrcdoc(frontendCode, inputData, backendResult);
@@ -443,7 +470,7 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
// Key only changes on mode switch (URL vs srcdoc); reloadKey updates the src attribute in place to avoid blank-flash on reload.
key={iframeSrc ? 'url-mode' : 'srcdoc'}
src={effectiveSrc}
onLoad={handleNavigationLoad}
onLoad={() => { handleNavigationLoad(); attachIframeWheelForwarder(); }}
sandbox="allow-scripts allow-same-origin"
style={{
width: '100%',
@@ -1,202 +0,0 @@
import React, { useState, useMemo } from 'react';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
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 WarningAmberIcon from '@mui/icons-material/WarningAmber';
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 from './InputSchemaForm';
import { getDefault } from '@/shared/inputSchemaDefaults';
import ViewPreview from './ViewPreview';
interface Props {
output: Output;
onClose: () => void;
}
const ViewRunDialog: React.FC<Props> = ({ output, onClose }) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const defaultInput = useMemo(() => getDefault(output.input_schema), [output.input_schema]);
const [inputData, setInputData] = useState<Record<string, any>>(defaultInput);
const [result, setResult] = useState<OutputExecuteResult | null>(null);
const [running, setRunning] = useState(false);
const warnings = result?.warnings && result.warnings.length > 0 ? result.warnings : null;
const codePreview = result?.code_preview || null;
const runWith = async (force: boolean) => {
setRunning(true);
try {
const res = await dispatch(
executeOutput({ output_id: output.id, input_data: inputData, force })
).unwrap();
setResult(res);
} finally {
setRunning(false);
}
};
const handleRun = () => runWith(false);
const handleRunAnyway = () => runWith(true);
return (
<Dialog open onClose={onClose} maxWidth="lg" fullWidth>
<DialogTitle sx={{ fontWeight: 600, color: c.text.primary }}>
Run: {output.name}
</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', gap: 3, minHeight: 400 }}>
{/* Input form */}
<Box sx={{ width: 340, flexShrink: 0, overflow: 'auto' }}>
<Typography
sx={{ fontSize: '0.8rem', fontWeight: 600, color: c.text.muted, mb: 1.5 }}
>
Input
</Typography>
<InputSchemaForm
schema={output.input_schema}
value={inputData}
onChange={setInputData}
/>
</Box>
{/* Preview */}
<Box
sx={{
flex: 1,
border: `1px solid ${c.border.subtle}`,
borderRadius: 2,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
<Box
sx={{
px: 1.5,
py: 0.75,
borderBottom: `1px solid ${c.border.subtle}`,
bgcolor: c.bg.secondary,
display: 'flex',
alignItems: 'center',
gap: 1,
}}
>
<Typography sx={{ fontSize: '0.8rem', fontWeight: 600, color: c.text.muted }}>
Preview
</Typography>
{result?.error && (
<Typography sx={{ fontSize: '0.75rem', color: c.status.error }}>
Backend error: {result.error}
</Typography>
)}
</Box>
<Box sx={{ flex: 1, position: 'relative' }}>
{running && (
<Box
sx={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'rgba(0,0,0,0.05)',
zIndex: 1,
}}
>
<CircularProgress size={28} />
</Box>
)}
{warnings && codePreview ? (
<Box sx={{ p: 2, overflow: 'auto', height: '100%', display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<WarningAmberIcon sx={{ color: c.status.warning, fontSize: 22 }} />
<Typography sx={{ fontSize: '0.95rem', fontWeight: 600, color: c.text.primary }}>
Review before running
</Typography>
</Box>
<Typography sx={{ fontSize: '0.8rem', color: c.text.secondary }}>
This Output's backend code does things outside the safe
data-shaping allowlist. Read it and decide whether to run.
</Typography>
<Box
component="ul"
sx={{ m: 0, pl: 2.5, color: c.text.secondary, fontSize: '0.78rem', lineHeight: 1.55 }}
>
{warnings.map((w, i) => (
<li key={i}>{w}</li>
))}
</Box>
<Box
sx={{
flex: 1,
minHeight: 120,
mt: 0.5,
p: 1.25,
borderRadius: 1,
border: `1px solid ${c.border.subtle}`,
bgcolor: c.bg.secondary,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
fontSize: '0.74rem',
lineHeight: 1.5,
color: c.text.primary,
whiteSpace: 'pre',
overflow: 'auto',
}}
>
{codePreview}
</Box>
</Box>
) : result ? (
<ViewPreview
serveUrl={`${SERVE_BASE}/${output.id}/serve/index.html`}
frontendCode={result.frontend_code}
inputData={result.input_data}
backendResult={result.backend_result}
/>
) : (
<ViewPreview
serveUrl={`${SERVE_BASE}/${output.id}/serve/index.html`}
frontendCode={getFrontendCode(output)}
inputData={inputData}
/>
)}
</Box>
</Box>
</Box>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={onClose} sx={{ color: c.text.muted }}>Close</Button>
{warnings ? (
<Button
variant="contained"
onClick={handleRunAnyway}
disabled={running}
sx={{ bgcolor: c.status.warning, '&:hover': { bgcolor: c.status.warning } }}
>
{running ? 'Running...' : 'Run anyway'}
</Button>
) : (
<Button
variant="contained"
onClick={handleRun}
disabled={running}
sx={{ bgcolor: c.accent.primary, '&:hover': { bgcolor: c.accent.hover } }}
>
{running ? 'Running...' : getBackendCode(output) ? 'Execute & Preview' : 'Preview'}
</Button>
)}
</DialogActions>
</Dialog>
);
};
export default ViewRunDialog;
-217
View File
@@ -1,217 +0,0 @@
import React, { useEffect, useState, useMemo, lazy, Suspense } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import Snackbar from '@mui/material/Snackbar';
import Alert from '@mui/material/Alert';
import AddIcon from '@mui/icons-material/Add';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { fetchOutputs, deleteOutput, Output } from '@/shared/state/outputsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { byPreviewRecency } from '@/shared/previewOrder';
import ViewCard from './ViewCard';
import { Skeleton } from '@/app/components/feedback/Loading';
import ViewRunDialog from './ViewRunDialog';
import HistoryPanel from './HistoryPanel';
// Lazy: pulls CodeMirror (~600KB) + 1600 lines of form scaffolding, only needed when an editor opens.
const ViewEditor = lazy(() => import('./ViewEditor'));
const Views: React.FC = () => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const navigate = useNavigate();
const { id: routeId } = useParams<{ id: string }>();
const items = useAppSelector((state) => state.outputs.items);
const loading = useAppSelector((state) => state.outputs.loading);
const loaded = useAppSelector((state) => state.outputs.loaded);
const outputs = useMemo(() => Object.values(items).sort(byPreviewRecency), [items]);
const [editorOpen, setEditorOpen] = useState(false);
const [editingOutput, setEditingOutput] = useState<Output | null>(null);
const [runOutput, setRunOutput] = useState<Output | null>(null);
const [historyOutput, setHistoryOutput] = useState<Output | null>(null);
// Branch closes the history modal, which would unmount the panel before its own flash renders; surface the confirmation at the grid level so it survives.
const [branchToast, setBranchToast] = useState(false);
useEffect(() => {
dispatch(fetchOutputs());
}, [dispatch]);
useEffect(() => {
if (!loaded) return;
if (routeId === 'new') {
setEditingOutput(null);
setEditorOpen(true);
} else if (routeId && items[routeId]) {
setEditingOutput(items[routeId]);
setEditorOpen(true);
} else if (routeId && routeId !== 'new') {
navigate('/apps', { replace: true });
} else if (!routeId) {
setEditorOpen(false);
setEditingOutput(null);
}
}, [routeId, loaded, items, navigate]);
const handleNewView = () => {
navigate('/apps/new');
};
const handleEditView = (output: Output) => {
navigate(`/apps/${output.id}`);
};
const handleDeleteView = (id: string) => {
dispatch(deleteOutput(id));
};
const handleEditorClose = () => {
setEditorOpen(false);
setEditingOutput(null);
dispatch(fetchOutputs());
navigate('/apps');
};
if (editorOpen) {
return (
<Suspense fallback={<Box sx={{ p: 4, color: c.text.muted }}>Loading editor...</Box>}>
<ViewEditor key={editingOutput?.id ?? 'new'} output={editingOutput} onClose={handleEditorClose} />
</Suspense>
);
}
return (
<Box sx={{ height: '100%', overflow: 'auto', p: 4 }}>
<Box
sx={{
maxWidth: 1200,
mx: 'auto',
}}
>
{/* Header */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 3,
}}
>
<Box>
<Typography
variant="h4"
sx={{ fontWeight: 700, color: c.text.primary }}
>
Apps
</Typography>
<Typography sx={{ color: c.text.tertiary, fontSize: '0.9rem', mt: 0.5 }}>
In the past, we used to have to pay for expensive applications. Now, you can prompt them into existence.
</Typography>
</Box>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={handleNewView}
data-onboarding="apps-new-button"
sx={{
bgcolor: c.accent.primary,
borderRadius: 2,
textTransform: 'none',
fontWeight: 500,
px: 2.5,
'&:hover': { bgcolor: c.accent.hover },
}}
>
New app
</Button>
</Box>
{/* Card grid */}
{loading ? (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: 2, py: 2 }}>
{[0, 1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} variant="card" height={140} />
))}
</Box>
) : outputs.length === 0 ? (
<Box
sx={{
textAlign: 'center',
py: 10,
color: c.text.muted,
}}
>
<Typography sx={{ fontSize: '1.1rem', mb: 1 }}>
No apps yet
</Typography>
<Typography sx={{ fontSize: '0.85rem', color: c.text.tertiary }}>
Create your first reusable app
</Typography>
</Box>
) : (
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: 2.5,
}}
>
{outputs.map((output, idx) => (
<Box
key={output.id}
data-onboarding={idx === 0 ? 'app-card-latest' : undefined}
>
<ViewCard
output={output}
onClick={() => handleEditView(output)}
onDelete={() => handleDeleteView(output.id)}
onRun={() => setRunOutput(output)}
onHistory={() => setHistoryOutput(output)}
/>
</Box>
))}
</Box>
)}
</Box>
{runOutput && (
<ViewRunDialog
output={runOutput}
onClose={() => setRunOutput(null)}
/>
)}
{historyOutput && (
<Dialog
open
onClose={() => setHistoryOutput(null)}
PaperProps={{ sx: { borderRadius: 3, bgcolor: c.bg.surface, width: 480, maxWidth: '92vw', height: '72vh' } }}
>
<HistoryPanel
outputId={historyOutput.id}
onBranched={() => { setHistoryOutput(null); setBranchToast(true); }}
/>
</Dialog>
)}
<Snackbar
open={branchToast}
autoHideDuration={3000}
onClose={() => setBranchToast(false)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
onClose={() => setBranchToast(false)}
severity="success"
sx={{ bgcolor: c.status.successBg, color: c.status.success, border: `1px solid rgba(38,91,25,0.25)` }}
>
Saved as a new app. Find it at the top of your apps.
</Alert>
</Snackbar>
</Box>
);
};
export default Views;
+64
View File
@@ -0,0 +1,64 @@
// App terminal plumbing shared by the ViewEditor and dashboard-card Terminal panes:
// a batched beacon that folds webview console lines into the backend runtime stream
// (ring buffer -> WS subscribers -> agent-readable .openswarm/terminal.log), and the
// stream->TerminalLine mapping for lines arriving back over the runtime logs WS.
import { API_BASE, getAuthToken } from '@/shared/config';
export interface AppTerminalLineFields {
source: 'frontend' | 'backend' | 'runtime';
level: string;
text: string;
}
// Batched so a chatty console (tick loops, HMR spam) costs one request/second, not one per line.
const FLUSH_MS = 1000;
const MAX_LINES_PER_FLUSH = 50;
interface PendingConsoleLine { level: string; text: string }
// Queues keyed by `${workspaceId}:${instance}` so two cards of the same app don't cross their console streams.
const pendingByWorkspace = new Map<string, PendingConsoleLine[]>();
const flushTimers = new Map<string, number>();
function flushConsoleLines(workspaceId: string, instance: number): void {
const qKey = `${workspaceId}:${instance}`;
flushTimers.delete(qKey);
const queue = pendingByWorkspace.get(qKey);
if (!queue || queue.length === 0) return;
const batch = queue.splice(0, MAX_LINES_PER_FLUSH);
if (queue.length > 0) {
batch.push({ level: 'warn', text: `[console] dropped ${queue.length} lines (rate cap)` });
queue.length = 0;
}
const tok = getAuthToken();
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (tok) headers.Authorization = `Bearer ${tok}`;
fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/console-log?instance=${instance}`, {
method: 'POST',
headers,
body: JSON.stringify({ lines: batch }),
}).catch(() => {});
}
export function postAppConsoleLine(workspaceId: string, level: string, text: string, instance: number = 1): void {
if (!workspaceId || !text) return;
const qKey = `${workspaceId}:${instance}`;
let queue = pendingByWorkspace.get(qKey);
if (!queue) {
queue = [];
pendingByWorkspace.set(qKey, queue);
}
queue.push({ level, text });
if (!flushTimers.has(qKey)) {
flushTimers.set(qKey, window.setTimeout(() => flushConsoleLines(workspaceId, instance), FLUSH_MS));
}
}
export function terminalLineFromStream(stream: string, text: string): AppTerminalLineFields {
if (stream === 'runtime') return { source: 'runtime', level: 'info', text };
if (stream.startsWith('frontend')) {
const level = stream === 'frontend-warn' ? 'warn' : stream === 'frontend-error' ? 'error' : 'log';
return { source: 'frontend', level, text };
}
return { source: 'backend', level: stream, text };
}
@@ -21,10 +21,12 @@ export interface RuntimePreviewOptions {
/** Gate the spawn so callers can defer paying runtime cost until preview is wanted. */
enabled?: boolean;
onLog?: (line: RuntimeLogLine) => void;
/** Which independent instance of the app to attach (1 = primary). Each instance is its own process on its own ports. */
instance?: number;
}
export function useRuntimePreviewUrl(opts: RuntimePreviewOptions): RuntimePreviewState {
const { workspaceId, enabled = true, onLog } = opts;
const { workspaceId, enabled = true, onLog, instance = 1 } = opts;
const [frontendUrl, setFrontendUrl] = useState<string | null>(null);
const [isNewMode, setIsNewMode] = useState(false);
const [isHydrating, setIsHydrating] = useState(true);
@@ -53,7 +55,7 @@ export function useRuntimePreviewUrl(opts: RuntimePreviewOptions): RuntimePrevie
(async () => {
try {
await fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/start`, {
await fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/start?instance=${instance}`, {
method: 'POST',
headers,
});
@@ -63,7 +65,7 @@ export function useRuntimePreviewUrl(opts: RuntimePreviewOptions): RuntimePrevie
if (cancelled) return;
try {
const wsBase = API_BASE.replace(/^http/, 'ws').replace(/\/api$/, '');
const url = `${wsBase}/ws/outputs/runtime/${workspaceId}/logs?token=${encodeURIComponent(auth || '')}`;
const url = `${wsBase}/ws/outputs/runtime/${workspaceId}/logs?token=${encodeURIComponent(auth || '')}&instance=${instance}`;
ws = new WebSocket(url);
ws.onmessage = (ev) => {
try {
@@ -96,12 +98,12 @@ export function useRuntimePreviewUrl(opts: RuntimePreviewOptions): RuntimePrevie
setIsNewMode(false);
setIsHydrating(true);
// detach is ref-counted on the backend; fire-and-forget.
fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/stop`, {
fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/stop?instance=${instance}`, {
method: 'POST',
headers,
}).catch(() => {});
};
}, [workspaceId, enabled]);
}, [workspaceId, enabled, instance]);
return { frontendUrl, isNewMode, isHydrating };
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { Search, Hammer, Globe, Plug } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
// Two-level starters shared by the empty-state and the first-run welcome chat: pick a category, then a concrete prompt. Chosen to SHOWCASE what only OpenSwarm can do, and to feel PERSONAL: the agents can see the user's own computer/files, drive the browser, plug into their apps (MCPs), build real apps, and run agents in parallel, none of which a plain chatbot can do out of the box. Many prompts deliberately touch the user's own stuff so it matters to them. One-click-runnable (no [placeholders]); reads plainly for a non-dev. target 'app-builder' opens the App Builder (live preview); the rest run as an agent.
// Two-level starters shared by the empty-state and the first-run welcome chat: pick a category, then a concrete prompt. Chosen to SHOWCASE what only OpenSwarm can do, and to feel PERSONAL: the agents can see the user's own computer/files, drive the browser, plug into their apps (MCPs), build real apps, and run agents in parallel, none of which a plain chatbot can do out of the box. Many prompts deliberately touch the user's own stuff so it matters to them. One-click-runnable (no [placeholders]); reads plainly for a non-dev. All run as a normal agent; the 'build' category (target 'app-builder') just prefills the composer in the welcome chat instead of auto-sending, since the agent builds the app in-place (it calls CreateApp and the live card drops on the canvas).
export type StarterCategory = {
id: string;
label: string;
@@ -44,6 +44,8 @@ export interface CardPosition {
export interface ViewCardPosition {
output_id: string;
// Which instance of the app this card is (1 = primary, absent on pre-instance layouts). Each instance is a fully independent runtime on its own ports.
instance?: number;
x: number;
y: number;
width: number;
@@ -52,6 +54,11 @@ export interface ViewCardPosition {
parent_session_id?: string | null;
}
// Record key + card identity for a view card. The primary keeps the bare output_id so persisted layouts and every existing by-output lookup stay valid; secondaries append #N.
export function viewCardKey(outputId: string, instance?: number): string {
return (instance ?? 1) > 1 ? `${outputId}#${instance}` : outputId;
}
export interface BrowserTab {
id: string;
url: string;
@@ -143,6 +150,8 @@ export interface DashboardLayoutState {
initialized: boolean;
/** Transient: new browser card id; Dashboard pans/zooms to it then clears via clearPendingFocusBrowserId. */
pendingFocusBrowserId: string | null;
// Set when a view card is opened from outside the canvas (sidebar app click / toolbar picker) so the dashboard fits+highlights it on arrival; holds the card key.
pendingFocusViewCardId: string | null;
pendingFocusNoteId: string | null;
/** Transient: snapshot stand-ins for off-screen webviews; never rides the layout PUT. */
suspendedBrowserCards: Record<string, { dataUrl: string; capturedAt: number }>;
@@ -189,6 +198,7 @@ const initialState: DashboardLayoutState = {
loading: false,
initialized: false,
pendingFocusBrowserId: null,
pendingFocusViewCardId: null,
pendingFocusNoteId: null,
suspendedBrowserCards: {},
endingBrowserCards: {},
@@ -632,7 +642,7 @@ const dashboardLayoutSlice = createSlice({
const allItems = [
...agentCards.map((c) => ({ kind: 'agent' as const, id: c.session_id, x: c.x, y: c.y, storedW: c.width, storedH: c.height })),
...viewCards.map((c) => ({ kind: 'view' as const, id: c.output_id, x: c.x, y: c.y, storedW: c.width, storedH: c.height })),
...viewCards.map((c) => ({ kind: 'view' as const, id: viewCardKey(c.output_id, c.instance), x: c.x, y: c.y, storedW: c.width, storedH: c.height })),
...bCards.map((c) => ({ kind: 'browser' as const, id: c.browser_id, x: c.x, y: c.y, storedW: c.width, storedH: c.height })),
...wCards.map((c) => ({ kind: 'workflow' as const, id: c.workflow_id, x: c.x, y: c.y, storedW: c.width, storedH: c.height })),
...(hub ? [{ kind: 'workflows-hub' as const, id: 'workflows-hub', x: hub.x, y: hub.y, storedW: hub.width, storedH: hub.height }] : []),
@@ -679,9 +689,16 @@ const dashboardLayoutSlice = createSlice({
outputId: string; expandedSessionIds?: string[];
parentSessionId?: string | null;
x?: number; y?: number; width?: number; height?: number;
// Open ANOTHER independent instance of an already-open app instead of no-op'ing.
newInstance?: boolean;
}>) {
const { outputId, expandedSessionIds, parentSessionId, x, y, width, height } = action.payload;
if (state.viewCards[outputId]) return;
const { outputId, expandedSessionIds, parentSessionId, x, y, width, height, newInstance } = action.payload;
let instance = 1;
if (state.viewCards[outputId]) {
if (!newInstance) return;
instance = 2;
while (state.viewCards[viewCardKey(outputId, instance)]) instance++;
}
const w = width || DEFAULT_VIEW_CARD_W;
const h = height || DEFAULT_VIEW_CARD_H;
let posX: number, posY: number;
@@ -701,8 +718,10 @@ const dashboardLayoutSlice = createSlice({
posY = pos.y;
}
}
state.viewCards[outputId] = {
const cardKey = viewCardKey(outputId, instance);
state.viewCards[cardKey] = {
output_id: outputId,
instance,
x: posX,
y: posY,
width: w,
@@ -710,6 +729,11 @@ const dashboardLayoutSlice = createSlice({
zOrder: state.nextZOrder++,
parent_session_id: parentSessionId || null,
};
state.pendingFocusViewCardId = cardKey;
},
clearPendingFocusViewCardId(state) {
state.pendingFocusViewCardId = null;
},
setViewCardPosition(
@@ -1204,6 +1228,51 @@ const dashboardLayoutSlice = createSlice({
card.tabs.splice(Math.max(0, Math.min(action.payload.toIndex, card.tabs.length)), 0, tab);
},
// Drag a tab OUT of a browser card: into another card (absorbed, appended + activated) or onto empty canvas (spins off a new card at the drop point). Moving the last tab dissolves the source card, Chrome-style.
moveBrowserTab(
state,
action: PayloadAction<{ fromBrowserId: string; tabId: string; toBrowserId?: string; x?: number; y?: number }>
) {
const { fromBrowserId, tabId, toBrowserId, x, y } = action.payload;
if (toBrowserId === fromBrowserId) return;
const source = state.browserCards[fromBrowserId];
if (!source) return;
const idx = source.tabs.findIndex((t) => t.id === tabId);
if (idx === -1) return;
const target = toBrowserId ? state.browserCards[toBrowserId] : undefined;
if (toBrowserId && !target) return;
const [moved] = source.tabs.splice(idx, 1);
// Fresh id: reusing the old one makes the receiving BrowserCard think the tab is already initialized, so its webview never loads the URL and sits at about:blank.
const tab = { ...moved, id: generateTabId() };
if (source.tabs.length === 0) {
delete state.browserCards[fromBrowserId];
} else if (source.activeTabId === tabId) {
const nextActive = source.tabs[Math.min(idx, source.tabs.length - 1)];
source.activeTabId = nextActive.id;
source.url = nextActive.url;
}
if (target) {
target.tabs.push(tab);
target.activeTabId = tab.id;
target.url = tab.url;
target.zOrder = state.nextZOrder++;
} else {
const id = `browser-${Date.now().toString(36)}`;
state.browserCards[id] = {
browser_id: id,
url: tab.url,
tabs: [tab],
activeTabId: tab.id,
x: x ?? source.x + 60,
y: y ?? source.y + 60,
width: source.width,
height: source.height,
zOrder: state.nextZOrder++,
dashboard_id: source.dashboard_id,
};
}
},
moveCards(
state,
action: PayloadAction<{
@@ -1352,7 +1421,7 @@ const dashboardLayoutSlice = createSlice({
if (entry.kind === 'browser') {
state.browserCards[entry.card.browser_id] = { ...entry.card, zOrder, dashboard_id: dashboardId ?? entry.card.dashboard_id };
} else if (entry.kind === 'view') {
state.viewCards[entry.card.output_id] = { ...entry.card, zOrder };
state.viewCards[viewCardKey(entry.card.output_id, entry.card.instance)] = { ...entry.card, zOrder };
} else if (entry.kind === 'workflow') {
state.workflowCards[entry.card.workflow_id] = { ...entry.card, zOrder };
} else if (entry.kind === 'note') {
@@ -1595,6 +1664,7 @@ export const {
updateBrowserTabTitle,
updateBrowserTabFavicon,
reorderBrowserTab,
moveBrowserTab,
moveCards,
setGlowingBrowserCards,
fadeGlowingBrowserCards,
@@ -1604,6 +1674,7 @@ export const {
fadeGlowingAgentCard,
clearGlowingAgentCard,
clearPendingFocusBrowserId,
clearPendingFocusViewCardId,
addWorkflowCard,
setWorkflowCardPosition,
setWorkflowCardSize,
-2
View File
@@ -59,8 +59,6 @@ try {
if ($LASTEXITCODE -ne 0) { throw "pip upgrade failed" }
& $VenvPy -m pip install --quiet -r (Join-Path $ScriptDir 'backend\requirements.txt')
if ($LASTEXITCODE -ne 0) { throw "pip install backend reqs failed" }
& $VenvPy -m pip install --quiet -e (Join-Path $ScriptDir 'debugger')
if ($LASTEXITCODE -ne 0) { throw "pip install debugger failed" }
} finally {
$ErrorActionPreference = $prevEAP
}
-4
View File
@@ -79,10 +79,6 @@ if ($LASTEXITCODE -ne 0) { throw "pip upgrade failed" }
& $PythonBin -m pip install -r (Join-Path $ProjectRoot 'backend\requirements.lock')
if ($LASTEXITCODE -ne 0) { throw "pip install requirements failed" }
Write-Host "Installing debugger module..."
& $PythonBin -m pip install (Join-Path $ProjectRoot 'debugger')
if ($LASTEXITCODE -ne 0) { throw "pip install debugger failed" }
Write-Host "Verifying claude-agent-sdk..."
& $PythonBin -c "import claude_agent_sdk; print('claude-agent-sdk installed')"
if ($LASTEXITCODE -ne 0) { throw "claude-agent-sdk verification failed" }
-4
View File
@@ -89,10 +89,6 @@ echo "Installing backend dependencies (from requirements.lock)..."
"$PYTHON_BIN" -m pip install --upgrade pip
"$PYTHON_BIN" -m pip install -r "$PROJECT_ROOT/backend/requirements.lock"
# Install the debugger module
echo "Installing debugger module..."
"$PYTHON_BIN" -m pip install "$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')"
+88 -37
View File
@@ -3,13 +3,11 @@
#
# Idempotent — wipes the existing vendored dir and re-clones at the pinned ref.
# Strips files we don't want shipped (LICENSE, README.md, .gitignore — we
# author our own minimal .gitignore inside the snapshot). Applies our two
# patches:
# 1. backend/run.sh: pip-install $OPENSWARM_DEBUGGER_PATH if set, before
# the existing `pip install -e .` — resolves the `swarm-debug` dep
# from OpenSwarm's bundled debugger/ package instead of PyPI (where
# it doesn't exist).
# 2. Add our own backend_init.sh at the snapshot root.
# author our own minimal .gitignore inside the snapshot). Applies our
# patches (swarm-debug toggle-on at boot, vite config pinning, .gitignore,
# backend_init.sh). The template's `swarm-debug` dependency now resolves
# from PyPI like any other dep; the old local-debugger injection patches
# (editable-install of the bundled debugger/) are gone.
#
# Update REF to bump the pinned snapshot. CI / a future test could compare
# `git rev-parse HEAD` of a fresh clone against REF and fail on drift.
@@ -36,22 +34,45 @@ mkdir -p "$DEST"
( cd "$TMP/clone" && rm -rf .git LICENSE README.md .gitignore )
cp -R "$TMP/clone/." "$DEST/"
# Patch 1: backend/run.sh installs OpenSwarm's local debugger/ before the
# template's own `pip install -e .` so `from swarm_debug import debug` in
# the template's backend code resolves to our bundled package (the PyPI
# `swarm-debug` doesn't exist — our local package registers as `debug`
# and exposes both `debug` and `swarm_debug` module names via setup.py
# py_modules).
RUN_SH="$DEST/backend/run.sh"
if ! grep -q "OPENSWARM_DEBUGGER_PATH" "$RUN_SH"; then
# Insert the install line just before `pip install -e .`. macOS sed
# vs GNU sed: use a portable awk inline rewrite.
# Patch 1a: root run.sh honors per-instance port overrides. OpenSwarm passes
# OPENSWARM_FORCE_FRONTEND_PORT / OPENSWARM_FORCE_BACKEND_PORT when the user
# opens a SECOND instance of an app; without this the `source .env` above
# them pins every instance to the same ports.
ROOT_RUN_SH="$DEST/run.sh"
if ! grep -q "OPENSWARM_FORCE_FRONTEND_PORT" "$ROOT_RUN_SH"; then
awk '
/pip install -e \./ && !inserted {
print "if [[ -n \"${OPENSWARM_DEBUGGER_PATH:-}\" && -d \"$OPENSWARM_DEBUGGER_PATH\" ]]; then"
print " echo \"Installing OpenSwarm debugger (swarm_debug) from $OPENSWARM_DEBUGGER_PATH\""
print " pip install -e \"$OPENSWARM_DEBUGGER_PATH\""
inserted != 1 && sourced && /^fi$/ {
print
print ""
print "# Per-instance port overrides: OpenSwarm passes these when the user opens a SECOND instance of the app, so it boots on fresh ports instead of colliding with the primary'\''s .env-pinned ones."
print "if [[ -n \"${OPENSWARM_FORCE_FRONTEND_PORT:-}\" ]]; then"
print " export FRONTEND_PORT=\"$OPENSWARM_FORCE_FRONTEND_PORT\""
print "fi"
print "if [[ -n \"${OPENSWARM_FORCE_BACKEND_PORT:-}\" ]]; then"
print " export BACKEND_PORT=\"$OPENSWARM_FORCE_BACKEND_PORT\""
print "fi"
inserted = 1
next
}
/source "\$ROOT_DIR\/.env"/ { sourced = 1 }
{ print }
' "$ROOT_RUN_SH" > "$ROOT_RUN_SH.tmp" && mv "$ROOT_RUN_SH.tmp" "$ROOT_RUN_SH"
chmod +x "$ROOT_RUN_SH"
fi
# Patch 1: backend/run.sh forces all swarm-debug per-file toggles ON at
# every boot (they default OFF, including files the agent creates later),
# so `debug()` output actually lands in the App Builder Terminal. Runs
# from the workspace root because that's uvicorn's cwd = the package's
# per-project data-dir key.
RUN_SH="$DEST/backend/run.sh"
if ! grep -q "swarm-debug gates output" "$RUN_SH"; then
awk '
/^echo "Starting backend server/ && !inserted {
print "# swarm-debug gates output on per-file toggles that default OFF; force all ON each boot so agent-added files show in the Terminal."
print "if [[ \"$IS_WIN\" == \"1\" ]]; then SWARM_DEBUG_BIN=\"$VENV_DIR/Scripts/swarm-debug.exe\"; else SWARM_DEBUG_BIN=\"$VENV_DIR/bin/swarm-debug\"; fi"
print "( cd \"$BACKEND_DIR_ABSPATH/..\" && \"$SWARM_DEBUG_BIN\" toggle on --all >/dev/null 2>&1 ) || true"
print ""
inserted = 1
}
{ print }
@@ -59,16 +80,6 @@ if ! grep -q "OPENSWARM_DEBUGGER_PATH" "$RUN_SH"; then
chmod +x "$RUN_SH"
fi
# Patch 1b: drop `"swarm-debug"` from the template's backend/pyproject.toml
# dependencies. The OpenSwarm debugger gets installed separately via Patch
# 1's `pip install -e $OPENSWARM_DEBUGGER_PATH`. Leaving the dep listed
# would make pip 404 against PyPI (no such package).
PYPROJECT="$DEST/backend/pyproject.toml"
awk '
/^[[:space:]]*"swarm-debug",?[[:space:]]*$/ { next }
{ print }
' "$PYPROJECT" > "$PYPROJECT.tmp" && mv "$PYPROJECT.tmp" "$PYPROJECT"
# Patch 1c: vite.config.ts — pin host to 127.0.0.1 (so our IPv4-only
# bind poller in runtime.py:_await_frontend_bind() actually sees the
# bound socket on macOS, where `localhost` can resolve to ::1), disable
@@ -114,6 +125,7 @@ __pycache__/
*.pyc
dist/
build/
.openswarm/
EOF
# Patch 3: backend_init.sh — copied verbatim into every new workspace.
@@ -129,9 +141,8 @@ cat > "$DEST/backend_init.sh" <<'EOF'
# code — it copies the master template's backend/ into the workspace
# and flips BACKEND_PORT in both .env files to a free port.
#
# After running this, hard-reload the preview (right-click the reload
# button in the App Builder) so the runtime restarts with the new
# BACKEND_PORT and `bash run.sh` brings the backend up.
# After running this, run `bash restart.sh` so the runtime restarts
# with the new BACKEND_PORT and `bash run.sh` brings the backend up.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -161,7 +172,7 @@ if [[ -d ./backend ]]; then
fi
# Resolve master template backend/ path. OPENSWARM_TEMPLATE_BACKEND_PATH
# is written into .env at seed time; OPENSWARM_DEBUGGER_PATH the same.
# is written into .env at seed time.
if [[ -z "${OPENSWARM_TEMPLATE_BACKEND_PATH:-}" ]]; then
echo "ERROR: OPENSWARM_TEMPLATE_BACKEND_PATH not set in .env. This" >&2
echo " workspace was seeded by an older OpenSwarm; ask the" >&2
@@ -200,11 +211,51 @@ fi
echo ""
echo "Backend enabled on port $PORT."
echo "Hard-reload the preview (right-click the reload button in"
echo "the App Builder) to bring it up."
echo "Run 'bash restart.sh' to bring it up (restarts the app runtime)."
EOF
chmod +x "$DEST/backend_init.sh"
# Patch 4: restart.sh — the agent-facing runtime restart. The runtime is
# owned by the OpenSwarm harness, so agents can't bounce it from Bash;
# this writes the sentinel the AppRuntimeManager watcher consumes
# (runtime.py RESTART_SENTINEL_NAME) and waits for pickup.
cat > "$DEST/restart.sh" <<'EOF'
#!/usr/bin/env bash
# Restart this app's runtime (backend + vite), managed by the OpenSwarm harness.
#
# The runtime is spawned and owned by OpenSwarm, so you can't just kill/rerun
# run.sh from here. This script writes a sentinel the harness watches; the
# harness consumes it and restarts the whole runtime. No API token needed.
# Use after `bash backend_init.sh`, after editing `.env`, or whenever the
# backend must reload code/schema (uvicorn runs WITHOUT --reload on purpose).
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
mkdir -p "$HERE/.openswarm"
SENTINEL="$HERE/.openswarm/restart-requested"
touch "$SENTINEL"
echo "Restart requested; waiting for the OpenSwarm harness to pick it up..."
for _ in $(seq 1 30); do
if [[ ! -f "$SENTINEL" ]]; then
echo "Restart under way. The runtime takes a few seconds to come back;"
echo "then check .openswarm/terminal.log for boot output:"
sleep 6
tail -n 20 "$HERE/.openswarm/terminal.log" 2>/dev/null || true
exit 0
fi
sleep 1
done
rm -f "$SENTINEL"
echo "ERROR: the harness didn't pick up the restart within 30s." >&2
echo "The runtime only runs while the app is open in OpenSwarm (preview card or" >&2
echo "App Builder). If you're running this app standalone via 'bash run.sh'," >&2
echo "just Ctrl-C that process and rerun it instead." >&2
exit 1
EOF
chmod +x "$DEST/restart.sh"
echo ""
echo "[fetch-webapp-template] vendored snapshot at $DEST"
echo "[fetch-webapp-template] pinned ref: $REF"