diff --git a/backend/apps/agents/apps_mcp_server.py b/backend/apps/agents/apps_mcp_server.py new file mode 100644 index 00000000..e112f734 --- /dev/null +++ b/backend/apps/agents/apps_mcp_server.py @@ -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() diff --git a/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py b/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py index b89f9a40..6da6fc41 100644 --- a/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py +++ b/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py @@ -73,6 +73,17 @@ def compose_turn_system_prompt( from backend.apps.outputs.view_builder_templates import load_app_builder_skill skill_block = f"\n{load_app_builder_skill()}\n" 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 = ( + "\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" + "" + ) + 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) diff --git a/backend/apps/agents/manager/register_builtin_mcp_servers.py b/backend/apps/agents/manager/register_builtin_mcp_servers.py index dede7770..a954ca13 100644 --- a/backend/apps/agents/manager/register_builtin_mcp_servers.py +++ b/backend/apps/agents/manager/register_builtin_mcp_servers.py @@ -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" diff --git a/backend/apps/dashboard_layout/models.py b/backend/apps/dashboard_layout/models.py index 65193747..89bbbae8 100644 --- a/backend/apps/dashboard_layout/models.py +++ b/backend/apps/dashboard_layout/models.py @@ -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 diff --git a/backend/apps/dashboards/models.py b/backend/apps/dashboards/models.py index 5a6e7fa7..2027648f 100644 --- a/backend/apps/dashboards/models.py +++ b/backend/apps/dashboards/models.py @@ -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 diff --git a/backend/apps/health/health.py b/backend/apps/health/health.py index c24fb463..33749ee6 100644 --- a/backend/apps/health/health.py +++ b/backend/apps/health/health.py @@ -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) diff --git a/backend/apps/outputs/app_builder_skill.md b/backend/apps/outputs/app_builder_skill.md index b53440cb..c4f15882 100644 --- a/backend/apps/outputs/app_builder_skill.md +++ b/backend/apps/outputs/app_builder_skill.md @@ -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": diff --git a/backend/apps/outputs/models.py b/backend/apps/outputs/models.py index 9f64b12a..9a65f53c 100644 --- a/backend/apps/outputs/models.py +++ b/backend/apps/outputs/models.py @@ -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 diff --git a/backend/apps/outputs/outputs.py b/backend/apps/outputs/outputs.py index 3e0a06d1..62dfd17c 100644 --- a/backend/apps/outputs/outputs.py +++ b/backend/apps/outputs/outputs.py @@ -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 /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() diff --git a/backend/apps/outputs/runtime.py b/backend/apps/outputs/runtime.py index 5d449f07..d34095e4 100644 --- a/backend/apps/outputs/runtime.py +++ b/backend/apps/outputs/runtime.py @@ -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: diff --git a/backend/apps/outputs/swarm_debug_skill.md b/backend/apps/outputs/swarm_debug_skill.md index 9237653b..84e34ddb 100644 --- a/backend/apps/outputs/swarm_debug_skill.md +++ b/backend/apps/outputs/swarm_debug_skill.md @@ -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")` | diff --git a/backend/apps/outputs/view_builder_templates.py b/backend/apps/outputs/view_builder_templates.py index 8df78412..0930f16a 100644 --- a/backend/apps/outputs/view_builder_templates.py +++ b/backend/apps/outputs/view_builder_templates.py @@ -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=`. 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= - OPENSWARM_DEBUGGER_PATH= - 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) diff --git a/backend/apps/outputs/webapp_template/.gitignore b/backend/apps/outputs/webapp_template/.gitignore index 637a3dbc..1a9c4b2a 100644 --- a/backend/apps/outputs/webapp_template/.gitignore +++ b/backend/apps/outputs/webapp_template/.gitignore @@ -6,3 +6,4 @@ __pycache__/ *.pyc dist/ build/ +.openswarm/ diff --git a/backend/apps/outputs/webapp_template/backend/pyproject.toml b/backend/apps/outputs/webapp_template/backend/pyproject.toml index e96191a6..8f0d7f51 100644 --- a/backend/apps/outputs/webapp_template/backend/pyproject.toml +++ b/backend/apps/outputs/webapp_template/backend/pyproject.toml @@ -6,6 +6,7 @@ requires-python = ">=3.10" dependencies = [ "fastapi[standard]", "typeguard==4.4.2", + "swarm-debug", ] [tool.setuptools] diff --git a/backend/apps/outputs/webapp_template/backend/run.sh b/backend/apps/outputs/webapp_template/backend/run.sh index 52f7405e..de9b848a 100755 --- a/backend/apps/outputs/webapp_template/backend/run.sh +++ b/backend/apps/outputs/webapp_template/backend/run.sh @@ -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}" diff --git a/backend/apps/outputs/webapp_template/backend_init.sh b/backend/apps/outputs/webapp_template/backend_init.sh index e4bf5c3f..92e87e42 100755 --- a/backend/apps/outputs/webapp_template/backend_init.sh +++ b/backend/apps/outputs/webapp_template/backend_init.sh @@ -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)." diff --git a/backend/apps/outputs/webapp_template/restart.sh b/backend/apps/outputs/webapp_template/restart.sh new file mode 100755 index 00000000..96b75514 --- /dev/null +++ b/backend/apps/outputs/webapp_template/restart.sh @@ -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 diff --git a/backend/apps/outputs/webapp_template/run.sh b/backend/apps/outputs/webapp_template/run.sh index 40ed3fbd..35376b48 100755 --- a/backend/apps/outputs/webapp_template/run.sh +++ b/backend/apps/outputs/webapp_template/run.sh @@ -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 diff --git a/backend/apps/swarm/entities/apps.py b/backend/apps/swarm/entities/apps.py index 648bbb50..409a871f 100644 --- a/backend/apps/swarm/entities/apps.py +++ b/backend/apps/swarm/entities/apps.py @@ -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: diff --git a/backend/apps/swarm/entities/dashboards.py b/backend/apps/swarm/entities/dashboards.py index 357970cc..5b9c9642 100644 --- a/backend/apps/swarm/entities/dashboards.py +++ b/backend/apps/swarm/entities/dashboards.py @@ -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, } diff --git a/backend/apps/web/web.py b/backend/apps/web/web.py index bf632be5..0f2ef54f 100644 --- a/backend/apps/web/web.py +++ b/backend/apps/web/web.py @@ -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) diff --git a/backend/config/Apps.py b/backend/config/Apps.py index 1b739864..989ec45b 100644 --- a/backend/config/Apps.py +++ b/backend/config/Apps.py @@ -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") \ No newline at end of file + ) \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index 7c16cf89..91ca4b38 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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: diff --git a/backend/run.sh b/backend/run.sh index b62bdc31..ffedede8 100755 --- a/backend/run.sh +++ b/backend/run.sh @@ -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" diff --git a/backend/tests/test_accept_all_midrun.py b/backend/tests/test_accept_all_midrun.py new file mode 100644 index 00000000..f9329495 --- /dev/null +++ b/backend/tests/test_accept_all_midrun.py @@ -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"] == {} diff --git a/backend/tests/test_agent_create_app.py b/backend/tests/test_agent_create_app.py new file mode 100644 index 00000000..e2e8aece --- /dev/null +++ b/backend/tests/test_agent_create_app.py @@ -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"] diff --git a/backend/tests/test_register_builtin_mcp_servers.py b/backend/tests/test_register_builtin_mcp_servers.py index 6b664007..1147708b 100644 --- a/backend/tests/test_register_builtin_mcp_servers.py +++ b/backend/tests/test_register_builtin_mcp_servers.py @@ -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 diff --git a/backend/tests/test_restart_sentinel.py b/backend/tests/test_restart_sentinel.py new file mode 100644 index 00000000..c969b2d5 --- /dev/null +++ b/backend/tests/test_restart_sentinel.py @@ -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() diff --git a/backend/tests/test_runtime_multi_instance.py b/backend/tests/test_runtime_multi_instance.py new file mode 100644 index 00000000..72498962 --- /dev/null +++ b/backend/tests/test_runtime_multi_instance.py @@ -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"] diff --git a/electron/node_modules b/electron/node_modules new file mode 120000 index 00000000..59a9e1bc --- /dev/null +++ b/electron/node_modules @@ -0,0 +1 @@ +/Users/ericzeng/Downloads/openswarm/electron/node_modules \ No newline at end of file diff --git a/electron/webview-preload.js b/electron/webview-preload.js index 589878c8..bef6bd47 100644 --- a/electron/webview-preload.js +++ b/electron/webview-preload.js @@ -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 diff --git a/frontend/node_modules b/frontend/node_modules new file mode 120000 index 00000000..ed877299 --- /dev/null +++ b/frontend/node_modules @@ -0,0 +1 @@ +/Users/ericzeng/Downloads/openswarm/frontend/node_modules \ No newline at end of file diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index 49acecb3..2016bac8 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -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 = () => { } /> } /> } /> - } /> - } /> } /> diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index 31b7f487..12aefacd 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -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 = () => { }, }} /> - - - - - {appsList.length > 0 && ( { }} > {appsList.map((app) => { - const isActive = activeAppId === app.id; + const isActive = openViewCardOutputIds.has(app.id); return ( { 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<{ {remainingCount > 1 && !isIntervention && ( - + { e.stopPropagation(); onApproveAll(); }} diff --git a/frontend/src/app/components/overlays/GlobalSearchPalette.tsx b/frontend/src/app/components/overlays/GlobalSearchPalette.tsx index 4b1ab44f..2c86b398 100644 --- a/frontend/src/app/components/overlays/GlobalSearchPalette.tsx +++ b/frontend/src/app/components/overlays/GlobalSearchPalette.tsx @@ -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 = ({ 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]); diff --git a/frontend/src/app/pages/AgentChat/ChatInput.tsx b/frontend/src/app/pages/AgentChat/ChatInput.tsx index de9d1833..a12ff56b 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput.tsx @@ -117,7 +117,8 @@ const ChatInput = forwardRef(({ 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(({ 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); diff --git a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx index 2fb674e8..1b385459 100644 --- a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx +++ b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx @@ -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( } }, [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( filteredOutputs.map((output) => ( handleSelectView(output)} + onClick={(e) => handleSelectView(output, e.altKey)} sx={{ display: 'flex', alignItems: 'center', diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx index b6f5928c..2bd9e73e 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx @@ -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; diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx index 80b15e62..5063b631 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx @@ -191,12 +191,14 @@ const DashboardCardLayer: React.FC = ({ ); })} - {Object.values(viewCards).map((vc) => { + {Object.entries(viewCards).map(([cardKey, vc]) => { const output = outputs[vc.output_id]; if (!output) return null; return ( = ({ 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} diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx index 6d544a83..be471602 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx @@ -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); diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx index 963d23a7..64473f54 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx @@ -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; diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index 99a19c6d..7f585b43 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -1165,7 +1165,8 @@ const AgentCard: React.FC = ({ startIcon={} 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={{ diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index 4aabd64d..f8d8dd3f 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -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 = ({ const tabDragRef = useRef<{ tabId: string; startX: number; + startY: number; isDragging: boolean; + detached: boolean; } | null>(null); const swapCooldown = useRef(false); const [dragTabId, setDragTabId] = useState(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 = ({ 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 = ({ 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 = ({ /> ))} + {/* 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 ( + + {ghostTab?.favicon ? ( + + ) : ( + + )} + + {ghostTab?.title || ghostTab?.url || 'Tab'} + + + ); + })(), + document.body, + )} + ); }; diff --git a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx index a398503b..5ed8224b 100644 --- a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx @@ -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 }[] = [ 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 = ({ - 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(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 = ({ const [inputData] = useState>(() => getDefault(output.input_schema)); const [backendResult] = useState | null>(null); + // Preview/Code/Terminal switcher; only new-mode (workspace-backed) apps have code + terminal to show. + const [activeView, setActiveView] = useState('preview'); + const hasWorkspace = !!output.workspace_id; + const [terminalLines, setTerminalLines] = useState([]); + 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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(null); @@ -323,14 +351,24 @@ const DashboardViewCard: React.FC = ({ const tok = getAuthToken(); const headers: Record = { '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 = ({ return ( 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 = ({ > {output.name} + {instance > 1 && ( + + #{instance} + + )} - + {hasWorkspace && ( + 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 }) => ( + + { 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` }, + }} + > + + + + ))} + + )} + + e.stopPropagation()} sx={{ display: 'flex', flexShrink: 0 }}> + + + + = ({ dispatch(setActiveViewCardId(output.id))} + onAppClicked={() => dispatch(setActiveViewCardId(cardKey))} + onRuntimeLog={handleRuntimeLog} /> - + {/* 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' && ( + + {activeView === 'terminal' ? ( + + ) : activeView === 'history' ? ( + + previewRef.current?.reload()} + /> + + ) : ( + previewRef.current?.reload()} /> + )} + + )} + {/* Resize handles */} @@ -608,17 +717,22 @@ const BuildingOverlay: React.FC<{ show: boolean }> = ({ show }) => { const DashboardOutputPreview: React.FC<{ previewRef: React.Ref; output: Output; + cardKey?: string; + instance?: number; inputData: Record; 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 = { '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. 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 ( 1 ? `app:${output.id}#${instance}` : `app:${output.id}`} /> ); }; diff --git a/frontend/src/app/pages/Dashboard/controls/CardSearchPalette.tsx b/frontend/src/app/pages/Dashboard/controls/CardSearchPalette.tsx index 331a0ea9..b961f40c 100644 --- a/frontend/src/app/pages/Dashboard/controls/CardSearchPalette.tsx +++ b/frontend/src/app/pages/Dashboard/controls/CardSearchPalette.tsx @@ -44,10 +44,10 @@ const CardSearchPalette: React.FC = ({ 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 }, }); diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useArrowNav.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useArrowNav.ts index 44176e24..0b14aa95 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useArrowNav.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useArrowNav.ts @@ -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 }); diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardClipboard.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardClipboard.ts index b0ede6a1..6ab634b4 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardClipboard.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardClipboard.ts @@ -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({ diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardCardActions.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardCardActions.ts index c6f96a97..7b095152 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardCardActions.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardCardActions.ts @@ -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]); diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts index 74f76b90..eb30d497 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts @@ -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; diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelection.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelection.ts index 1b59427a..64b38841 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelection.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelection.ts @@ -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(); 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'); } } diff --git a/frontend/src/app/pages/Views/AppCodePanel.tsx b/frontend/src/app/pages/Views/AppCodePanel.tsx new file mode 100644 index 00000000..a3b79e95 --- /dev/null +++ b/frontend/src/app/pages/Views/AppCodePanel.tsx @@ -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 = ({ workspaceId, onFileSaved }) => { + const c = useClaudeTokens(); + const [files, setFiles] = useState>({}); + const [oversizeFiles, setOversizeFiles] = useState>({}); + const [activeFile, setActiveFile] = useState(''); + const lastPollRef = useRef(''); + const saveTimersRef = useRef>>(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>(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 = { ...(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 ( + + + {fileTree.map((node) => ( + + ))} + {filePaths.length === 0 && ( + + Loading files… + + )} + + + {activeFile && oversizeFiles[activeFile] != null ? ( + + + This file is {(oversizeFiles[activeFile] / (1024 * 1024)).toFixed(1)} MB, too large to edit here. + + + ) : activeFile && files[activeFile] != null ? ( + updateFile(activeFile, val)} + language={getEditorLanguage(activeFile)} + placeholder={`// ${activeFile}`} + /> + ) : ( + + + Select a file to edit + + + )} + + + ); +}; + +export default AppCodePanel; diff --git a/frontend/src/app/pages/Views/AppFileTree.tsx b/frontend/src/app/pages/Views/AppFileTree.tsx new file mode 100644 index 00000000..84b80447 --- /dev/null +++ b/frontend/src/app/pages/Views/AppFileTree.tsx @@ -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([ + '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 ; + case 'py': return ; + case 'json': return ; + case 'js': case 'jsx': case 'ts': case 'tsx': return ; + case 'css': case 'scss': case 'less': return ; + default: return ; + } +} + +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; +} + +const PROTECTED_FILES = new Set(['index.html', 'schema.json', 'meta.json', 'SKILL.md']); + +export const FileTreeItem: React.FC = ({ node, depth, activeFile, onSelect, onDelete, c }) => { + const [open, setOpen] = useState(true); + + if (node.isDir) { + return ( + <> + 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 }, + }} + > + + + + {node.name} + + + + {node.children?.map((child) => ( + + ))} + + + ); + } + + const isActive = activeFile === node.path; + const canDelete = onDelete && !PROTECTED_FILES.has(node.path); + + return ( + 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', + }} + > + + {getFileIcon(node.name)} + + + {node.name} + + {canDelete && ( + { 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' }} + > + + + )} + + ); +}; diff --git a/frontend/src/app/pages/Views/InputSchemaForm.tsx b/frontend/src/app/pages/Views/InputSchemaForm.tsx deleted file mode 100644 index c4efba1c..00000000 --- a/frontend/src/app/pages/Views/InputSchemaForm.tsx +++ /dev/null @@ -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 = ({ schema, value, onChange, label, depth = 0 }) => { - const c = useClaudeTokens(); - - if (schema.enum && schema.enum.length > 0) { - return ( - - {label && {label}} - - {schema.description && ( - - {schema.description} - - )} - - ); - } - - if (schema.type === 'boolean') { - return ( - - onChange(e.target.checked)} - size="small" - /> - } - label={ - - {label || 'Toggle'} - - } - /> - {schema.description && ( - - {schema.description} - - )} - - ); - } - - if (schema.type === 'number' || schema.type === 'integer') { - return ( - onChange(Number(e.target.value))} - sx={{ - mb: 1.5, - '& .MuiOutlinedInput-root': { fontSize: '0.85rem' }, - '& .MuiFormHelperText-root': { fontSize: '0.7rem' }, - }} - /> - ); - } - - if (schema.type === 'string') { - return ( - 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 ( - 0 ? 1.5 : 0, - borderLeft: depth > 0 ? `2px solid ${c.border.subtle}` : 'none', - }} - > - {label && ( - - {label} - - )} - {schema.description && ( - - {schema.description} - - )} - {items.map((item: any, i: number) => ( - - - { - const updated = [...items]; - updated[i] = newVal; - onChange(updated); - }} - label={`Item ${i + 1}`} - depth={depth + 1} - /> - - { - const updated = items.filter((_: any, idx: number) => idx !== i); - onChange(updated); - }} - sx={{ color: c.status.error, mt: 0.5 }} - > - - - - ))} - - - ); - } - - if (schema.type === 'object' && schema.properties) { - const obj = typeof value === 'object' && value !== null ? value : {}; - return ( - 0 ? 1.5 : 0, - borderLeft: depth > 0 ? `2px solid ${c.border.subtle}` : 'none', - }} - > - {label && ( - - {label} - - )} - {schema.description && ( - - {schema.description} - - )} - {Object.entries(schema.properties).map(([key, propSchema]) => ( - onChange({ ...obj, [key]: newVal })} - label={key + (schema.required?.includes(key) ? ' *' : '')} - depth={depth + 1} - /> - ))} - - ); - } - - return ( - onChange(e.target.value)} - sx={{ mb: 1.5, '& .MuiOutlinedInput-root': { fontSize: '0.85rem' } }} - /> - ); -}; - -export default InputSchemaForm; diff --git a/frontend/src/app/pages/Views/ViewCard.tsx b/frontend/src/app/pages/Views/ViewCard.tsx deleted file mode 100644 index 327c9161..00000000 --- a/frontend/src/app/pages/Views/ViewCard.tsx +++ /dev/null @@ -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 = ({ output, onClick, onDelete, onRun, onHistory }) => { - const c = useClaudeTokens(); - - return ( - - - {output.thumbnail ? ( - - ) : ( - - {output.icon} - - )} - - - { e.stopPropagation(); onRun(); }} - sx={{ - bgcolor: c.bg.surface, - color: c.accent.primary, - boxShadow: c.shadow.sm, - '&:hover': { bgcolor: c.bg.elevated }, - }} - > - - - - - - { e.stopPropagation(); onHistory(); }} - sx={{ - bgcolor: c.bg.surface, - color: c.text.secondary, - boxShadow: c.shadow.sm, - '&:hover': { bgcolor: c.bg.elevated }, - }} - > - - - - - { e.stopPropagation(); onDelete(); }} - sx={{ - bgcolor: c.bg.surface, - color: c.status.error, - boxShadow: c.shadow.sm, - '&:hover': { bgcolor: c.bg.elevated }, - }} - > - - - - - - - - - {output.name} - - - {output.description || 'No description'} - - - - ); -}; - -// 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); diff --git a/frontend/src/app/pages/Views/ViewEditor.tsx b/frontend/src/app/pages/Views/ViewEditor.tsx deleted file mode 100644 index 02621bea..00000000 --- a/frontend/src/app/pages/Views/ViewEditor.tsx +++ /dev/null @@ -1,1533 +0,0 @@ -import React, { useState, useMemo, useEffect, useRef, useCallback, PointerEvent as ReactPointerEvent } from 'react'; -import { useNavigate } from 'react-router-dom'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import CircularProgress from '@mui/material/CircularProgress'; -import PixelBlast from '@/app/components/feedback/PixelBlast'; -import Button from '@mui/material/Button'; -import IconButton from '@mui/material/IconButton'; -import TextField from '@mui/material/TextField'; -import Tabs from '@mui/material/Tabs'; -import Tab from '@mui/material/Tab'; -import Tooltip from '@mui/material/Tooltip'; -import Menu from '@mui/material/Menu'; -import MenuItem from '@mui/material/MenuItem'; -import ListItemIcon from '@mui/material/ListItemIcon'; -import ListItemText from '@mui/material/ListItemText'; -import RestartAltIcon from '@mui/icons-material/RestartAlt'; -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 AddIcon from '@mui/icons-material/Add'; -import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; -import VisibilityIcon from '@mui/icons-material/Visibility'; -import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; -import Collapse from '@mui/material/Collapse'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import { useAppDispatch, useAppSelector } from '@/shared/hooks'; -import { store } from '@/shared/state/store'; -import { createDraftSession, removeDraftSession, fetchSession } from '@/shared/state/agentsSlice'; -import { createOutput, updateOutput, upsertOutput, fetchOutputs, captureOutputVersion, Output, SERVE_BASE } from '@/shared/state/outputsSlice'; -import { truncateForTitle } from '@/shared/state/sessionDisplay'; -import { useClaudeTokens } from '@/shared/styles/ThemeContext'; -import ShareButton from '@/app/components/share/ShareButton'; -import AgentChat from '../AgentChat/AgentChat'; -import RefreshIcon from '@mui/icons-material/Refresh'; -import ViewPreview, { ViewPreviewHandle } from './ViewPreview'; -import TerminalPanel, { TerminalLine } from './TerminalPanel'; -import HistoryPanel from './HistoryPanel'; -import { getDefault } from '@/shared/inputSchemaDefaults'; -import CodeEditor from './CodeEditor'; -import { ElementSelectionProvider } from '@/app/components/editor/ElementSelectionContext'; -import { API_BASE, getAuthToken } from '@/shared/config'; -import { onboardingBus } from '@/app/components/Onboarding/eventBus'; - -const WORKSPACE_API = `${API_BASE}/outputs/workspace`; - -// Cold-start splash: same Bayer-dither shader as the template's index.html for visual continuity across boot phases. -const InstallPlaceholder: React.FC = () => { - const c = useClaudeTokens(); - return ( - - - - - What're we brewing? - - - Drop the recipe below. I'll handle the rest. - - - - ); -}; - -// File-tree noise: filtered by basename anywhere in the path; `showHidden` bypasses. -const HIDDEN_PATH_SEGMENTS = new Set([ - 'node_modules', - '.vite-cache', - '.vite', - '.git', - 'dist', - '.next', - '__pycache__', - '.venv', -]); -// Poll fast while agent is writing; slow while idle. A one-shot poll fires on active->idle transition to catch the last write. -const POLL_INTERVAL_ACTIVE_MS = 2000; -const POLL_INTERVAL_IDLE_MS = 15000; -// Settle window after a content change/paint before snapshotting, so the app's JS has a beat to render. -const CAPTURE_SETTLE_MS = 1000; -// Fast app-switching minted a webview renderer per app you blew past, piling them up faster than Electron tore the old ones down (the grey-out / OOM on 8GB machines). Hold the heavy preview until this app has stayed open a beat; apps you fast-switch past unmount first and never spawn a renderer. -const PREVIEW_MOUNT_DEBOUNCE_MS = 250; - -// Fingerprint of the files that actually affect the rendered preview, so renames, schema tweaks, and SKILL.md edits don't trigger a needless re-screenshot. -function previewRenderKey(files: Record): string { - const ignore = new Set(['meta.json', 'schema.json', 'SKILL.md']); - return Object.keys(files) - .filter((k) => !ignore.has(k)) - .sort() - .map((k) => `${k}:${files[k]}`) - .join('\n'); -} - -function getFileIcon(filename: string): React.ReactNode { - const ext = filename.split('.').pop()?.toLowerCase(); - const size = 15; - switch (ext) { - case 'html': case 'htm': return ; - case 'py': return ; - case 'json': return ; - case 'js': case 'jsx': case 'ts': case 'tsx': return ; - case 'css': case 'scss': case 'less': return ; - default: return ; - } -} - -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'; - } -} - -interface FileTreeNode { - name: string; - path: string; - isDir: boolean; - children?: FileTreeNode[]; -} - -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; -} - -const PROTECTED_FILES = new Set(['index.html', 'schema.json', 'meta.json', 'SKILL.md']); - -const FileTreeItem: React.FC = ({ node, depth, activeFile, onSelect, onDelete, c }) => { - const [open, setOpen] = useState(true); - - if (node.isDir) { - return ( - <> - 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 }, - }} - > - - - - {node.name} - - - - {node.children?.map((child) => ( - - ))} - - - ); - } - - const isActive = activeFile === node.path; - const canDelete = onDelete && !PROTECTED_FILES.has(node.path); - - return ( - 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', - }} - > - - {getFileIcon(node.name)} - - - {node.name} - - {canDelete && ( - { 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' }} - > - - - )} - - ); -}; - -interface Props { - output: Output | null; - onClose: () => void; -} - -const ViewEditor: React.FC = ({ output }) => { - const c = useClaudeTokens(); - const dispatch = useAppDispatch(); - const navigate = useNavigate(); - - const [createdId, setCreatedId] = useState(null); - const createdIdRef = useRef(null); - const effectiveId = output?.id ?? createdId; - - const [name, setName] = useState(output?.name || 'Untitled App'); - const [description, setDescription] = useState(output?.description ?? ''); - - const initialFiles = useMemo>(() => { - if (!output) return {}; - const f = { ...output.files }; - if (!f['schema.json'] && output.input_schema) { - f['schema.json'] = JSON.stringify(output.input_schema, null, 2); - } - return f; - }, [output]); - - const [files, setFiles] = useState>(initialFiles); - - const TAB_PREVIEW = 0; - const TAB_CODE = 1; - const TAB_TERMINAL = 2; - const TAB_HISTORY = 3; - - const [activeTab, setActiveTab] = useState(TAB_PREVIEW); - const [activeFile, setActiveFile] = useState('index.html'); - const [showHidden, setShowHidden] = useState(false); - const autoSaveTimerRef = useRef | null>(null); - // Only reload the iframe when index.html actually changed AND the agent has paused writing for 600ms; saves to SKILL.md etc don't flash the preview. - const previewReloadTimerRef = useRef | null>(null); - const lastReloadedIndexHtmlRef = useRef(initialFiles['index.html'] ?? ''); - const PREVIEW_RELOAD_DEBOUNCE_MS = 600; - const savingRef = useRef(false); - // Runtime WS feeds backend stdout/stderr; webview-preload ipc-message feeds frontend console.* into the same chronological buffer. - const [terminalLines, setTerminalLines] = useState([]); - const terminalLineIdRef = useRef(0); - const TERMINAL_BUFFER_CAP = 5000; // trim FIFO past this so we don't grow unbounded - - const previewRef = useRef(null); - // True ~300ms after iframe `load`; keeps placeholder up until SPA actually paints, resets on URL change. - const [iframePainted, setIframePainted] = useState(false); - // Gates the preview webview behind PREVIEW_MOUNT_DEBOUNCE_MS so fast-switched-past apps never mount one. - const [previewSettled, setPreviewSettled] = useState(false); - - // Thumbnail capture state. lastCaptured starts at the current render key when a thumbnail already exists, so merely opening an app doesn't re-shoot (and re-sort) it; null when there's no thumbnail yet, so the first paint backfills one. - const filesRef = useRef(files); - filesRef.current = files; - // Files over the backend poll cap: served out-of-band (path -> bytes), never - // as content, so we show them read-only and keep them out of every save path. - const [oversizeFiles, setOversizeFiles] = useState>({}); - const oversizeFilesRef = useRef(oversizeFiles); - oversizeFilesRef.current = oversizeFiles; - const isAgentActiveRef = useRef(false); - const lastCapturedRenderKeyRef = useRef( - output?.thumbnail ? previewRenderKey(initialFiles) : null, - ); - const captureThumbTimerRef = useRef | null>(null); - - const SIDEBAR_MIN = 280; - const SIDEBAR_MAX = 800; - const [sidebarWidth, setSidebarWidth] = useState(420); - const dragging = useRef(false); - const dragStartX = useRef(0); - const dragStartWidth = useRef(0); - - const onDragStart = useCallback((e: ReactPointerEvent) => { - dragging.current = true; - dragStartX.current = e.clientX; - dragStartWidth.current = sidebarWidth; - (e.target as HTMLElement).setPointerCapture(e.pointerId); - document.body.style.cursor = 'col-resize'; - document.body.style.userSelect = 'none'; - }, [sidebarWidth]); - - const onDragMove = useCallback((e: ReactPointerEvent) => { - if (!dragging.current) return; - const delta = e.clientX - dragStartX.current; - setSidebarWidth(Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, dragStartWidth.current + delta))); - }, []); - - const onDragEnd = useCallback(() => { - dragging.current = false; - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - }, []); - - // Seed from the existing session id so a warm reopen resolves effectiveSessionId on the FIRST render (no "Initializing agent..." blank frame while the mount effect re-derives it). Cold opens still read null until fetchSession lands, because the selector requires the session to actually be in the store. - const [initialDraftId, setInitialDraftId] = useState(output?.session_id ?? null); - const [workspacePath, setWorkspacePath] = useState(null); - // Reuse the Output's workspace_id across remounts so we don't orphan agent edits or chat history. - const [stableWorkspaceId] = useState(() => output?.workspace_id || `ws-${Date.now().toString(36)}`); - const draftCreated = useRef(false); - - // Honor Settings default_model + default_thinking_level (else createDraftSession's hardcoded 'sonnet' wins). - const defaultModel = useAppSelector((s) => s.settings.data.default_model); - const defaultThinkingLevel = useAppSelector((s) => s.settings.data.default_thinking_level); - const settingsLoaded = useAppSelector((s) => s.settings.loaded); - const modelsByProvider = useAppSelector((s) => s.models.byProvider); - const modelsLoaded = useAppSelector((s) => s.models.loaded); - - // Spam-clicking sidebar apps used to fire EVERY app's seed + agent-init + runtime boot on each click, flooding the backend: the "Initializing agent..." stall (the landed app's init can't get through the backlog) and, under enough load, an orderly self-quit. Gate all the heavy per-app work behind sustained focus, it only runs if you STAY on the app ~800ms. A brand-new app (no output id yet) is always a deliberate open, so it settles immediately, keeping the onboarding /apps/new flow snappy. A warm reopen still renders its chat instantly from initialDraftId above, this only delays the background reattach/seed for click-throughs. - const isNewApp = !output?.id; - const [focusSettled, setFocusSettled] = useState(isNewApp); - useEffect(() => { - if (isNewApp) return; - const t = setTimeout(() => setFocusSettled(true), 800); - return () => clearTimeout(t); - }, [isNewApp]); - - useEffect(() => { - if (draftCreated.current) return; - if (!focusSettled) return; - // Wait for settings + models else we'd snapshot Redux's initial 'sonnet' over the user's choice. - if (!settingsLoaded || !modelsLoaded) return; - draftCreated.current = true; - - // Provider map mirrors ChatInput.tsx grouping. - const PROVIDER_MAP: Record = { - anthropic: 'anthropic', - 'openswarm pro': 'anthropic', - openai: 'openai', - google: 'gemini', - xai: 'openrouter', - meta: 'openrouter', - deepseek: 'openrouter', - mistral: 'openrouter', - qwen: 'openrouter', - cohere: 'openrouter', - }; - let resolvedProvider: string | undefined; - for (const [prov, models] of Object.entries(modelsByProvider)) { - if (models.some((m: any) => m.value === defaultModel)) { - resolvedProvider = PROVIDER_MAP[prov.toLowerCase()] || prov.toLowerCase(); - break; - } - } - - (async () => { - // Reattach: Output has an existing session + workspace; skip seeding/draft so we don't clobber agent state. - if (output?.session_id && output?.workspace_id) { - // Mount the chat NOW so a warm conversation renders instantly; holding it behind the verification round-trips blanked the chat for seconds on every reopen. The fetch is load-bearing on cold opens: effectiveSessionId resolves only once the session is IN the store, and AgentChat (the other hydrator) can't mount until then. The stale-id guard below still runs in the background and swaps in a fresh draft on the rare 404. - dispatch(fetchSession(output.session_id)); - setInitialDraftId(output.session_id); - - let resolvedWorkspacePath: string | null = null; - try { - const res = await fetch(`${WORKSPACE_API}/${output.workspace_id}`); - if (res.ok) { - const data = await res.json(); - if (data.path) { - resolvedWorkspacePath = data.path; - setWorkspacePath(data.path); - } - } - } catch { /* path is best-effort */ } - - // Verify session still exists; ids go stale across reinstalls/data wipes and AgentChat would hang on "Initializing agent..." - let sessionStillExists = false; - try { - const sr = await fetch(`${API_BASE}/agents/sessions/${output.session_id}`); - sessionStillExists = sr.ok; - } catch { /* network blip, treat as missing */ } - - if (sessionStillExists) return; - - // Stale link: clear it so future opens skip the 404 round-trip, then swap to a fresh draft. - if (output.id) { - try { - await dispatch(updateOutput({ id: output.id, session_id: null })).unwrap(); - } catch { /* best-effort cleanup */ } - } - const action = dispatch(createDraftSession({ - mode: 'view-builder', - setActive: false, - targetDirectory: resolvedWorkspacePath || undefined, - model: defaultModel || undefined, - provider: resolvedProvider, - thinkingLevel: defaultThinkingLevel || undefined, - })); - setInitialDraftId(action.payload.draftId); - return; - } - - const seedBody: Record = { workspace_id: stableWorkspaceId }; - if (output) { - const seedFiles: Record = { ...output.files }; - if (output.input_schema && !seedFiles['schema.json']) { - seedFiles['schema.json'] = JSON.stringify(output.input_schema, null, 2); - } - seedBody.files = seedFiles; - seedBody.meta = { name: output.name, description: output.description }; - } - try { - const res = await fetch(`${WORKSPACE_API}/seed`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(seedBody), - }); - const data = await res.json(); - setWorkspacePath(data.path); - // Adopt the backend-minted output_id so the Apps sidebar shows the app and later autosaves hit updateOutput. - if (typeof data?.output_id === 'string' && data.output_id) { - createdIdRef.current = data.output_id; - setCreatedId(data.output_id); - dispatch(fetchOutputs()); - // CRITICAL: use window.history.replaceState, NOT navigate(). Views.tsx keys ViewEditor on output id; React Router would unmount/remount, the onboarding wizard would type into a detached input and burn 15s on waitForSelector. - if (window.location.hash.includes('/apps/new')) { - const newHash = window.location.hash.replace( - '/apps/new', - `/apps/${data.output_id}`, - ); - try { - window.history.replaceState(null, '', newHash); - } catch { - // Defensive: history API rejection accepts the remount cost rather than dropping the URL update. - navigate(`/apps/${data.output_id}`, { replace: true }); - } - } - } - const action = dispatch(createDraftSession({ - mode: 'view-builder', - setActive: false, - targetDirectory: data.path, - model: defaultModel || undefined, - provider: resolvedProvider, - thinkingLevel: defaultThinkingLevel || undefined, - })); - setInitialDraftId(action.payload.draftId); - } catch { - const action = dispatch(createDraftSession({ - mode: 'view-builder', - setActive: false, - model: defaultModel || undefined, - provider: resolvedProvider, - thinkingLevel: defaultThinkingLevel || undefined, - })); - setInitialDraftId(action.payload.draftId); - } - })(); - }, [dispatch, output, stableWorkspaceId, settingsLoaded, modelsLoaded, defaultModel, defaultThinkingLevel, modelsByProvider, focusSettled]); - - // Resolve via our own pointers only; falling back to activeSessionId bled unrelated agents' chats into the wrong builder. - const launchedFromDraft = useAppSelector((state) => - initialDraftId ? state.agents.draftLaunchMap[initialDraftId] : undefined, - ); - const effectiveSessionId = useAppSelector((state) => { - if (!initialDraftId) return null; - if (state.agents.sessions[initialDraftId]) return initialDraftId; - const mapped = state.agents.draftLaunchMap[initialDraftId]; - if (mapped && state.agents.sessions[mapped]) return mapped; - return null; - }); - - // Promote draftId to the real session id so we survive draftLaunchMap cleanup. - useEffect(() => { - if (launchedFromDraft && initialDraftId && launchedFromDraft !== initialDraftId) { - setInitialDraftId(launchedFromDraft); - } - }, [launchedFromDraft, initialDraftId]); - - const agentStatus = useAppSelector((state) => { - if (!effectiveSessionId) return null; - return state.agents.sessions[effectiveSessionId]?.status ?? null; - }); - - const isLaunched = !!effectiveSessionId && effectiveSessionId !== initialDraftId; - const isAgentActive = agentStatus === 'running' || agentStatus === 'waiting_approval'; - - // The user's last request labels the version we auto-save when a run finishes. - const lastUserPrompt = useAppSelector((state) => { - const msgs = effectiveSessionId ? state.agents.sessions[effectiveSessionId]?.messages : null; - if (!msgs) return ''; - for (let i = msgs.length - 1; i >= 0; i--) { - if (msgs[i].role === 'user' && typeof msgs[i].content === 'string') return msgs[i].content as string; - } - return ''; - }); - const lastUserPromptRef = useRef(''); - lastUserPromptRef.current = lastUserPrompt; - - const workspaceId = workspacePath ? stableWorkspaceId : null; - const workspaceIdRef = useRef(null); - workspaceIdRef.current = workspaceId; - const wsPushTimers = useRef>>(new Map()); - - const initialContextPaths = useMemo( - () => workspacePath ? [{ path: workspacePath, type: 'directory' as const }] : undefined, - [workspacePath], - ); - - const pollRef = useRef | null>(null); - const lastPollRef = useRef(''); - - // Once true, meta.json syncs stop touching the field so a user rename isn't clobbered. - const nameSetByUserRef = useRef(false); - const descriptionSetByUserRef = useRef(false); - - const [fileVersion, setFileVersion] = useState(0); - - const nameTypewriterCancelRef = useRef<(() => void) | null>(null); - const descTypewriterCancelRef = useRef<(() => void) | null>(null); - - const driveTypewriter = useCallback(( - target: string, - setter: React.Dispatch>, - userTypedRef: React.MutableRefObject, - cancelRef: React.MutableRefObject<(() => void) | null>, - charDelayMs: number = 14, - ) => { - if (cancelRef.current) cancelRef.current(); - let cancelled = false; - let timerId: ReturnType | null = null; - const tick = () => { - if (cancelled) return; - setter((prev) => { - if (userTypedRef.current) { cancelled = true; return prev; } - if (prev === target) { cancelled = true; return prev; } - let commonLen = 0; - while (commonLen < prev.length && commonLen < target.length && prev[commonLen] === target[commonLen]) commonLen++; - const next = prev.length > commonLen - ? prev.substring(0, prev.length - 1) - : target.substring(0, prev.length + 1); - if (next !== target) timerId = setTimeout(tick, charDelayMs); - return next; - }); - }; - timerId = setTimeout(tick, charDelayMs); - cancelRef.current = () => { - cancelled = true; - if (timerId) clearTimeout(timerId); - }; - }, []); - - const driveNameTypewriter = useCallback((target: string) => { - driveTypewriter(target, setName, nameSetByUserRef, nameTypewriterCancelRef); - }, [driveTypewriter]); - const driveDescriptionTypewriter = useCallback((target: string) => { - driveTypewriter(target, setDescription, descriptionSetByUserRef, descTypewriterCancelRef); - }, [driveTypewriter]); - - useEffect(() => () => { - if (nameTypewriterCancelRef.current) nameTypewriterCancelRef.current(); - if (descTypewriterCancelRef.current) descTypewriterCancelRef.current(); - }, []); - - const pollWorkspace = useCallback(async () => { - if (!workspaceId) return; - try { - const res = await fetch(`${WORKSPACE_API}/${workspaceId}`); - if (!res.ok) return; - const data = await res.json(); - const fingerprint = JSON.stringify(data); - if (fingerprint === lastPollRef.current) return; - lastPollRef.current = fingerprint; - - if (data.files) { - setFiles(data.files); - setFileVersion(v => v + 1); - } - setOversizeFiles(data.truncated ?? {}); - - if (data.meta) { - const eid = output?.id ?? createdIdRef.current; - if (data.meta.name && eid && !nameSetByUserRef.current) { - const row = store.getState().outputs.items[eid]; - if (row && row.name !== data.meta.name) { - dispatch(upsertOutput({ ...row, name: data.meta.name })); - driveNameTypewriter(data.meta.name); - } - } - if (data.meta.description && eid && !descriptionSetByUserRef.current) { - const row = store.getState().outputs.items[eid]; - if (row && row.description !== data.meta.description) { - dispatch(upsertOutput({ ...row, description: data.meta.description })); - driveDescriptionTypewriter(data.meta.description); - } - } - } - } catch {} - }, [workspaceId, output?.id, dispatch, driveNameTypewriter, driveDescriptionTypewriter]); - - useEffect(() => { - if (!workspaceId) return; - const interval = isAgentActive ? POLL_INTERVAL_ACTIVE_MS : POLL_INTERVAL_IDLE_MS; - - // Visibility-gate the poll so a hidden tab doesn't keep hammering /api/outputs/workspace and starving the foreground. - const startPoll = () => { - if (pollRef.current) return; - pollWorkspace(); - pollRef.current = setInterval(pollWorkspace, interval); - }; - const stopPoll = () => { - if (pollRef.current) { - clearInterval(pollRef.current); - pollRef.current = null; - } - }; - const onVisibilityChange = () => { - if (document.visibilityState === 'visible') startPoll(); - else stopPoll(); - }; - - if (document.visibilityState === 'visible') startPoll(); - document.addEventListener('visibilitychange', onVisibilityChange); - return () => { - document.removeEventListener('visibilitychange', onVisibilityChange); - stopPoll(); - }; - }, [workspaceId, pollWorkspace, isAgentActive]); - - const prevAgentActive = useRef(false); - useEffect(() => { - if (prevAgentActive.current && !isAgentActive) { - if (workspaceId) setTimeout(pollWorkspace, 500); - // A change just finished: quietly save a version so the user can go back to it. Delayed so files + a fresh preview settle first. Fire-and-forget and error-swallowed; saving history must never disrupt the editor, and the backend dedupes so a run that changed nothing won't pile up a junk version. - const eid = output?.id ?? createdIdRef.current; - if (eid) { - const label = lastUserPromptRef.current.slice(0, 140); - window.setTimeout(async () => { - // A new run started inside the settle window: skip, or we'd snapshot a half-written workspace under the previous run's label. Its own completion will capture the settled state. - if (isAgentActiveRef.current) return; - let thumbnail: string | null = null; - try { thumbnail = (await previewRef.current?.capture()) ?? null; } catch { /* preview not mounted */ } - dispatch(captureOutputVersion({ id: eid, source: 'auto', label, thumbnail })).unwrap().catch(() => {}); - }, 1500); - } - } - prevAgentActive.current = isAgentActive; - }, [isAgentActive, workspaceId, pollWorkspace, output?.id, dispatch]); - - // Ref so the unmount cleanup reads the live status, not a stale closure value. - const sessionStatusRef = useRef(null); - sessionStatusRef.current = agentStatus; - const isLaunchedRef = useRef(false); - isLaunchedRef.current = isLaunched; - isAgentActiveRef.current = isAgentActive; - // Track if the draft has any user messages on it. If it does, the user has interacted (likely a send is in flight) and GC would orphan the backend session that's about to materialize via draftLaunchMap. - const draftMessageCount = useAppSelector((state) => - initialDraftId ? (state.agents.sessions[initialDraftId]?.messages?.length ?? 0) : 0, - ); - const draftHasMessagesRef = useRef(false); - draftHasMessagesRef.current = draftMessageCount > 0; - - useEffect(() => { - return () => { - // GC only truly-abandoned drafts: status still 'draft', never promoted via draftLaunchMap, and the user never sent anything. Sending a message kicks off launchAndSendFirstMessage, which races against unmount; if we GC mid-flight we lose the linkage to the new backend session and the reopen path shows an empty editor. - if ( - initialDraftId - && sessionStatusRef.current === 'draft' - && !isLaunchedRef.current - && !draftHasMessagesRef.current - ) { - dispatch(removeDraftSession(initialDraftId)); - } - }; - }, [initialDraftId, dispatch]); - - // Persist session_id + workspace_id on draft->launched so reopens find the in-progress session; deduped via ref since `output` prop is a stale snapshot. - const persistedLinkageRef = useRef(null); - useEffect(() => { - const eid = output?.id ?? createdId; - if (!eid || !effectiveSessionId || !isLaunched) return; - const fingerprint = `${eid}:${effectiveSessionId}:${stableWorkspaceId}`; - if (persistedLinkageRef.current === fingerprint) return; - persistedLinkageRef.current = fingerprint; - dispatch(updateOutput({ - id: eid, - session_id: effectiveSessionId, - workspace_id: stableWorkspaceId, - })); - }, [effectiveSessionId, isLaunched, output?.id, createdId, stableWorkspaceId, dispatch]); - - const schemaText = files['schema.json'] ?? '{"type":"object","properties":{},"required":[]}'; - - const parsedSchema = useMemo(() => { - try { return JSON.parse(schemaText); } catch { return { type: 'object', properties: {} }; } - }, [schemaText]); - - const testInput = useMemo>(() => getDefault(parsedSchema), [parsedSchema]); - - const savedRef = useRef(!!output); - - const buildBody = () => { - let schema: Record; - try { schema = JSON.parse(schemaText); } catch { schema = { type: 'object', properties: {} }; } - - const outputFiles = { ...files }; - delete outputFiles['meta.json']; - delete outputFiles['schema.json']; - delete outputFiles['SKILL.md']; - // Never persist a file we only have out-of-band (oversize); this also drops - // a legacy truncation marker from a previously-corrupted record on save. - for (const p of Object.keys(oversizeFilesRef.current)) delete outputFiles[p]; - - return { - name: name || 'Untitled App', - description, - icon: 'view_quilt', - input_schema: schema, - files: outputFiles, - }; - }; - - // Snapshot the live preview once content settles. Guards: skip mid-agent-run, skip if nothing visual changed since the last shot, skip until the app row exists. capture() returns null when the preview isn't mounted/ready, so a miss leaves lastCaptured untouched and a later paint can still backfill the thumbnail. - const captureAppThumbnail = useCallback(() => { - if (isAgentActiveRef.current) return; - const eid = output?.id ?? createdIdRef.current; - if (!eid) return; - if (previewRenderKey(filesRef.current) === lastCapturedRenderKeyRef.current) return; - if (captureThumbTimerRef.current) clearTimeout(captureThumbTimerRef.current); - captureThumbTimerRef.current = setTimeout(async () => { - captureThumbTimerRef.current = null; - if (isAgentActiveRef.current) return; - const dataUrl = await previewRef.current?.capture(); - if (!dataUrl) return; - lastCapturedRenderKeyRef.current = previewRenderKey(filesRef.current); - dispatch(updateOutput({ id: eid, thumbnail: dataUrl })); - }, CAPTURE_SETTLE_MS); - }, [output?.id, dispatch]); - - const performSaveRef = useRef<(() => Promise) | null>(null); - - performSaveRef.current = async () => { - if (savingRef.current) return; - savingRef.current = true; - try { - const body = buildBody(); - const eid = output?.id ?? createdIdRef.current; - let savedId: string; - if (eid) { - await dispatch(updateOutput({ id: eid, ...body })).unwrap(); - savedId = eid; - } else { - const created = await dispatch(createOutput(body)).unwrap(); - savedId = created.id; - createdIdRef.current = savedId; - setCreatedId(savedId); - // Step 8 onboarding waits on this; only fires on first create. - onboardingBus.emit('app:generation_done'); - } - savedRef.current = true; - // Reload iframe only when agent has paused AND index.html actually changed; skips "Ready" flash on non-rendered writes. - if (previewReloadTimerRef.current) { - clearTimeout(previewReloadTimerRef.current); - } - previewReloadTimerRef.current = setTimeout(() => { - previewReloadTimerRef.current = null; - const currentHtml = files['index.html'] ?? ''; - if (currentHtml === lastReloadedIndexHtmlRef.current) return; - lastReloadedIndexHtmlRef.current = currentHtml; - previewRef.current?.reload(); - }, PREVIEW_RELOAD_DEBOUNCE_MS); - } catch (err: any) { - console.error('Failed to save output:', err); - } finally { - savingRef.current = false; - } - }; - - const appendTerminalLine = useCallback((source: TerminalLine['source'], level: string, text: string) => { - setTerminalLines((prev) => { - const next = prev.concat({ - id: ++terminalLineIdRef.current, - source, - level, - text, - }); - // FIFO trim: drop the ancient head past TERMINAL_BUFFER_CAP. - if (next.length > TERMINAL_BUFFER_CAP) { - return next.slice(next.length - TERMINAL_BUFFER_CAP); - } - return next; - }); - }, []); - - const handleWebviewConsole = useCallback((level: string, text: string) => { - appendTerminalLine('frontend', level, text); - }, [appendTerminalLine]); - - // Right-click adds Hard Reload (also restarts the backend subprocess for Python-error recovery). - const [reloadMenuAnchor, setReloadMenuAnchor] = useState(null); - const handleHardReload = useCallback(async () => { - setReloadMenuAnchor(null); - if (workspaceId) { - try { - const tok = getAuthToken(); - const headers: Record = { 'Content-Type': 'application/json' }; - if (tok) headers.Authorization = `Bearer ${tok}`; - await fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/restart`, { - method: 'POST', - headers, - }); - } catch { /* failures surface via the runtime log WS */ } - } - previewRef.current?.reload(); - }, [workspaceId]); - - // Runtime lifecycle: /runtime/start, stream stdout/stderr + frontend_url via WS, /runtime/stop on unmount (ref-counted server-side). - const runtimeWsRef = useRef(null); - // New-mode workspaces report frontend_url via runtime:status; fall back to the legacy /serve/ endpoint until it arrives. - const [frontendUrl, setFrontendUrl] = useState(null); - // Track new-mode separately so we can show "Installing..." instead of loading the 404ing legacy /serve/index.html. - const [isNewModeRuntime, setIsNewModeRuntime] = useState(false); - // Latched: only flips true (resets on workspace change). Must be state, not a ref, so the lifecycle effect's deps react to it without depending on activeTab (caused tear-down on tab switch). - const [runtimeShouldRun, setRuntimeShouldRun] = useState(false); - useEffect(() => { - setRuntimeShouldRun(false); - }, [workspaceId]); - - // Two effects so tab switches don't tear down the runtime; depending on activeTab here caused cleanup-on-switch which 404'd the iframe. - useEffect(() => { - if (!workspaceId || !runtimeShouldRun) return; - let cancelled = false; - let ws: WebSocket | null = null; - setFrontendUrl(null); - setIsNewModeRuntime(false); - - const auth = getAuthToken(); - const headers: Record = { 'Content-Type': 'application/json' }; - if (auth) headers.Authorization = `Bearer ${auth}`; - - (async () => { - try { - await fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/start`, { - method: 'POST', - headers, - }); - } catch (_) { /* the runtime endpoints surface errors via the log WS */ } - if (cancelled) return; - try { - const wsBase = API_BASE.replace(/^http/, 'ws').replace(/\/api$/, ''); - const url = `${wsBase}/ws/outputs/runtime/${workspaceId}/logs?token=${encodeURIComponent(auth || '')}`; - ws = new WebSocket(url); - runtimeWsRef.current = ws; - ws.onmessage = (ev) => { - try { - const msg = JSON.parse(ev.data); - if (msg.event === 'runtime:status') { - const fu = msg.data?.frontend_url ?? null; - setFrontendUrl(fu || null); - setIsNewModeRuntime(!!msg.data?.is_new_mode); - } else if (msg.event === 'runtime:log') { - const stream = msg.data?.stream || 'stdout'; - const text = msg.data?.text || ''; - if (stream === 'runtime') { - appendTerminalLine('runtime', 'info', text); - } else { - appendTerminalLine('backend', stream, text); - } - } - } catch (_) {} - }; - } catch (_) {} - })(); - - return () => { - cancelled = true; - try { ws?.close(); } catch (_) {} - runtimeWsRef.current = null; - setFrontendUrl(null); - setIsNewModeRuntime(false); - fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/stop`, { - method: 'POST', - headers, - }).catch(() => {}); - }; - }, [workspaceId, runtimeShouldRun, appendTerminalLine]); - - // One-shot trigger on the same sustained-focus gate as the seed: flips runtimeShouldRun true once this app is the focus pick (focusSettled) and you're on Preview/Terminal, never flips back. workspaceId is already downstream of the focus-gated seed, so this just keeps the intent explicit, an app you click past never boots a vite runtime, only one you settle on. - useEffect(() => { - if (!workspaceId || runtimeShouldRun || !focusSettled) return; - const wantsRuntime = activeTab === TAB_PREVIEW || activeTab === TAB_TERMINAL; - if (!wantsRuntime) return; - setRuntimeShouldRun(true); - }, [workspaceId, activeTab, runtimeShouldRun, focusSettled, TAB_PREVIEW, TAB_TERMINAL]); - - // Prefer the Vite dev server URL; fall back to legacy /serve/. New-mode pre-Vite renders the install placeholder (legacy URL 404s). - const showInstallPlaceholder = isNewModeRuntime && !frontendUrl; - const workspaceServeUrl = showInstallPlaceholder - ? undefined - : (frontendUrl ?? (workspaceId ? `${SERVE_BASE}/workspace/${workspaceId}/serve/index.html` : undefined)); - - // Reset paint tracking on URL change so the placeholder stays up for the new load. - useEffect(() => { - setIframePainted(false); - }, [workspaceServeUrl]); - - // Mount the preview only once this app has been the open one for a beat. The timer is cancelled on unmount, so blowing through apps faster than PREVIEW_MOUNT_DEBOUNCE_MS never spawns their renderers. - useEffect(() => { - const t = window.setTimeout(() => setPreviewSettled(true), PREVIEW_MOUNT_DEBOUNCE_MS); - return () => window.clearTimeout(t); - }, []); - - // 300ms after iframe `load` because SPA bundles need a beat to mount, otherwise the grey flash returns. - const onIframeContentLoad = useCallback(() => { - const t = window.setTimeout(() => setIframePainted(true), 300); - // A full (re)load just painted: covers flat-mode post-reload, tab-switch back to preview, and first-open backfill. - captureAppThumbnail(); - return () => window.clearTimeout(t); - }, [captureAppThumbnail]); - - // Keep the placeholder mounted across transient gate flips so the loading animation doesn't flicker/restart. - const placeholderVisible = showInstallPlaceholder || !iframePainted; - const [placeholderMounted, setPlaceholderMounted] = useState(placeholderVisible); - useEffect(() => { - if (placeholderVisible) { - setPlaceholderMounted(true); - return undefined; - } - const t = window.setTimeout(() => setPlaceholderMounted(false), 400); - return () => window.clearTimeout(t); - }, [placeholderVisible]); - - // VSCode-style files.exclude predicate; single source of truth for list/tree/open-file routing. - const isHiddenPath = useCallback((p: string): boolean => { - if (showHidden) return false; - const segments = p.split('/'); - for (const seg of segments) { - if (HIDDEN_PATH_SEGMENTS.has(seg)) return true; - } - return false; - }, [showHidden]); - - const filePaths = useMemo( - () => - // Oversize files aren't in `files` (omitted by the backend), so union them - // in to keep them listed; they render read-only when selected. - Array.from(new Set([...Object.keys(files), ...Object.keys(oversizeFiles)])) - .filter((p) => p !== 'meta.json' && p !== 'SKILL.md') - .filter((p) => !isHiddenPath(p)) - .sort(), - [files, oversizeFiles, isHiddenPath], - ); - const fileTree = useMemo(() => buildFileTree(filePaths), [filePaths]); - - const updateFile = useCallback((path: string, content: string) => { - // Oversize files are shown read-only; never round-trip the placeholder we - // hold for them back to disk (that's the corruption this whole fix kills). - if (oversizeFilesRef.current[path] != null) return; - setFiles(prev => ({ ...prev, [path]: content })); - const wsId = workspaceIdRef.current; - if (wsId) { - const existing = wsPushTimers.current.get(path); - if (existing) clearTimeout(existing); - wsPushTimers.current.set(path, setTimeout(() => { - wsPushTimers.current.delete(path); - fetch(`${WORKSPACE_API}/${wsId}/file/${encodeURIComponent(path)}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ content }), - }) - .then(() => previewRef.current?.reload()) - .catch(() => {}); - }, 300)); - } - }, []); - - const [newFileName, setNewFileName] = useState(''); - const [showNewFileInput, setShowNewFileInput] = useState(false); - const newFileInputRef = useRef(null); - - useEffect(() => { - if (showNewFileInput) { - setTimeout(() => newFileInputRef.current?.focus(), 50); - } - }, [showNewFileInput]); - - const addFile = useCallback((fileName: string) => { - const trimmed = fileName.trim(); - if (!trimmed || files[trimmed] != null) return; - setFiles(prev => ({ ...prev, [trimmed]: '' })); - setActiveFile(trimmed); - setShowNewFileInput(false); - setNewFileName(''); - if (workspaceId) { - fetch(`${WORKSPACE_API}/${workspaceId}/file/${encodeURIComponent(trimmed)}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ content: '' }), - }).catch(() => {}); - } - }, [files, workspaceId]); - - const deleteFile = useCallback((filePath: string) => { - setFiles(prev => { - const next = { ...prev }; - delete next[filePath]; - return next; - }); - if (activeFile === filePath) { - const remaining = filePaths.filter(p => p !== filePath); - setActiveFile(remaining[0] ?? 'index.html'); - } - if (workspaceId) { - fetch(`${WORKSPACE_API}/${workspaceId}/file/${encodeURIComponent(filePath)}`, { - method: 'DELETE', - }).catch(() => {}); - } - }, [activeFile, filePaths, workspaceId]); - - const activeFileContent = files[activeFile] ?? ''; - - const autoSaveInitRef = useRef(true); - useEffect(() => { - if (autoSaveInitRef.current) { - autoSaveInitRef.current = false; - return; - } - const hasContent = name.trim() || (files['index.html'] ?? '').trim(); - if (!hasContent) return; - if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current); - autoSaveTimerRef.current = setTimeout(() => { - performSaveRef.current?.(); - }, 1500); - return () => { - if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current); - }; - }, [files, name, description]); - - // Live-preview (Vite/HMR) mode patches the webview without a load event, so onContentLoad misses most agent edits. Re-arm capture when files settle or the agent goes idle; captureAppThumbnail debounces and skips mid-run, no-op, and not-yet-saved cases. - useEffect(() => { - if (!frontendUrl) return; - captureAppThumbnail(); - }, [files, isAgentActive, frontendUrl, captureAppThumbnail]); - - useEffect(() => { - return () => { - if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current); - if (previewReloadTimerRef.current) clearTimeout(previewReloadTimerRef.current); - if (captureThumbTimerRef.current) clearTimeout(captureThumbTimerRef.current); - wsPushTimers.current.forEach(t => clearTimeout(t)); - }; - }, []); - - return ( - - - {/* Left panel: AgentChat */} - - {effectiveSessionId ? ( - - ) : ( - - - Initializing agent... - - - )} - - - {/* Resize handle */} - (its own compositor layer that floats above normal DOM and ignores z-index), so a centered line had its right half swallowed by the webview in the body but not in the header, the long-standing thick-at-top look. Anchoring left of the seam keeps the whole line over plain DOM, so it's uniform. - '&::after': { - content: '""', - position: 'absolute', - top: 0, - bottom: 0, - right: '50%', - width: 1, - bgcolor: 'transparent', - transition: 'width 0.15s, background-color 0.15s', - }, - '&:hover::after, &:active::after': { - width: 3, - bgcolor: c.accent.primary, - }, - }} - /> - - {/* Right panel */} - - {/* Header bar */} - - { nameSetByUserRef.current = true; setName(e.target.value); }} - placeholder="Untitled App" - variant="standard" - sx={{ - flex: 1, - maxWidth: 220, - '& .MuiInput-input': { - fontSize: '0.9rem', - fontWeight: 600, - color: c.text.primary, - py: 0.25, - }, - '& .MuiInput-underline:before': { borderColor: 'transparent' }, - '& .MuiInput-underline:hover:before': { borderColor: c.border.medium }, - }} - /> - - { descriptionSetByUserRef.current = true; setDescription(e.target.value); }} - placeholder="Description" - variant="standard" - sx={{ - flex: 2, - '& .MuiInput-input': { - fontSize: '0.82rem', - color: c.text.muted, - // Match the App-name input's padding so baselines align. - py: 0.25, - }, - '& .MuiInput-underline:before': { borderColor: 'transparent' }, - '& .MuiInput-underline:hover:before': { borderColor: c.border.medium }, - }} - /> - - {effectiveId && ( - - - - )} - - - {/* Tab bar */} - - setActiveTab(v)} - // No underline indicator; active state is bg-fill pills. - TabIndicatorProps={{ sx: { display: 'none' } }} - sx={{ - flex: 1, - minHeight: 32, - '& .MuiTabs-flexContainer': { - gap: 0.5, - }, - '& .MuiTab-root': { - minHeight: 32, - minWidth: 'auto', - fontSize: '0.8rem', - textTransform: 'none', - // One weight across states; bumping on select widens glyphs and shifts the whole row. - fontWeight: 600, - color: c.text.tertiary, - px: 1.75, - py: 0, - borderRadius: 999, - transition: c.transition, - '&:hover': { - color: c.text.secondary, - bgcolor: `${c.text.primary}06`, - }, - '&.Mui-selected': { - color: c.text.primary, - bgcolor: c.bg.elevated, - }, - }, - }} - > - - - - - - {activeTab === TAB_PREVIEW && ( - - previewRef.current?.reload()} - onContextMenu={(e) => { - e.preventDefault(); - setReloadMenuAnchor(e.currentTarget as HTMLElement); - }} - sx={{ mr: 1, color: c.text.muted }} - > - - - - )} - setReloadMenuAnchor(null)} - anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} - transformOrigin={{ vertical: 'top', horizontal: 'right' }} - > - - - - - - - - - - {/* Tab content */} - - {activeTab === TAB_PREVIEW && ( - - {/* Render iframe under the placeholder so its first paint completes before we fade the placeholder out. */} - {/* previewSettled gate: a fast-switched-past app unmounts before this flips, so it never mounts a webview renderer. */} - {previewSettled && (workspaceServeUrl || !showInstallPlaceholder) && ( - - )} - {/* previewSettled gate: skip mounting the preview webview for apps the user switches past faster than 250ms, so blowing through the app list never spawns a pile of webview renderers. (The loading placeholder is CSS now, so it no longer churns GL contexts.) */} - {previewSettled && placeholderMounted && ( - - - - )} - - )} - {activeTab === TAB_CODE && ( - - {/* File tree sidebar */} - - - - Files - - - - setShowNewFileInput(true)} - sx={{ p: 0.25, color: c.text.ghost, '&:hover': { color: c.accent.primary } }} - > - - - - - - - {fileTree.map((node) => ( - - ))} - {filePaths.length === 0 && ( - - No files yet - - )} - - - {showNewFileInput && ( - - setNewFileName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { addFile(newFileName); } - if (e.key === 'Escape') { setShowNewFileInput(false); setNewFileName(''); } - }} - onBlur={() => { - if (newFileName.trim()) { addFile(newFileName); } - else { setShowNewFileInput(false); setNewFileName(''); } - }} - placeholder="path/to/file.js" - variant="standard" - fullWidth - autoFocus - sx={{ - '& .MuiInput-input': { - fontSize: '0.74rem', - fontFamily: c.font.mono, - color: c.text.primary, - py: 0.25, - }, - '& .MuiInput-underline:before': { borderColor: c.border.subtle }, - '& .MuiInput-underline:after': { borderColor: c.accent.primary }, - }} - /> - - )} - - {/* Editor area */} - - {activeFile && oversizeFiles[activeFile] != null ? ( - - - This file is {(oversizeFiles[activeFile] / (1024 * 1024)).toFixed(1)} MB, too large to edit here. Open it directly to see the full contents. - - - ) : activeFile && files[activeFile] != null ? ( - updateFile(activeFile, val)} - language={getEditorLanguage(activeFile)} - placeholder={`// ${activeFile}`} - /> - ) : ( - - - Select a file to edit - - - )} - - - )} - {activeTab === TAB_TERMINAL && ( - - )} - {activeTab === TAB_HISTORY && ( - effectiveId ? ( - { lastPollRef.current = ''; pollWorkspace(); previewRef.current?.reload(); }} - /> - ) : ( - - Make a change first, then your versions will show up here. - - ) - )} - - - - - ); -}; - -export default ViewEditor; diff --git a/frontend/src/app/pages/Views/ViewPreview.tsx b/frontend/src/app/pages/Views/ViewPreview.tsx index df519c5c..a5cb35e2 100644 --- a/frontend/src/app/pages/Views/ViewPreview.tsx +++ b/frontend/src/app/pages/Views/ViewPreview.tsx @@ -173,6 +173,33 @@ const ViewPreview = forwardRef(({ } }, [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(({ // 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%', diff --git a/frontend/src/app/pages/Views/ViewRunDialog.tsx b/frontend/src/app/pages/Views/ViewRunDialog.tsx deleted file mode 100644 index 92e2cf60..00000000 --- a/frontend/src/app/pages/Views/ViewRunDialog.tsx +++ /dev/null @@ -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 = ({ output, onClose }) => { - const c = useClaudeTokens(); - const dispatch = useAppDispatch(); - - const defaultInput = useMemo(() => getDefault(output.input_schema), [output.input_schema]); - const [inputData, setInputData] = useState>(defaultInput); - const [result, setResult] = useState(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 ( - - - Run: {output.name} - - - - {/* Input form */} - - - Input - - - - - {/* Preview */} - - - - Preview - - {result?.error && ( - - Backend error: {result.error} - - )} - - - {running && ( - - - - )} - {warnings && codePreview ? ( - - - - - Review before running - - - - This Output's backend code does things outside the safe - data-shaping allowlist. Read it and decide whether to run. - - - {warnings.map((w, i) => ( -
  • {w}
  • - ))} -
    - - {codePreview} - -
    - ) : result ? ( - - ) : ( - - )} -
    -
    -
    -
    - - - {warnings ? ( - - ) : ( - - )} - -
    - ); -}; - -export default ViewRunDialog; diff --git a/frontend/src/app/pages/Views/Views.tsx b/frontend/src/app/pages/Views/Views.tsx deleted file mode 100644 index cecd60ff..00000000 --- a/frontend/src/app/pages/Views/Views.tsx +++ /dev/null @@ -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(null); - const [runOutput, setRunOutput] = useState(null); - const [historyOutput, setHistoryOutput] = useState(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 ( - Loading editor...
    }> - - - ); - } - - return ( - - - {/* Header */} - - - - Apps - - - In the past, we used to have to pay for expensive applications. Now, you can prompt them into existence. - - - - - - {/* Card grid */} - {loading ? ( - - {[0, 1, 2, 3, 4, 5].map((i) => ( - - ))} - - ) : outputs.length === 0 ? ( - - - No apps yet - - - Create your first reusable app - - - ) : ( - - {outputs.map((output, idx) => ( - - handleEditView(output)} - onDelete={() => handleDeleteView(output.id)} - onRun={() => setRunOutput(output)} - onHistory={() => setHistoryOutput(output)} - /> - - ))} - - )} - - - {runOutput && ( - setRunOutput(null)} - /> - )} - - {historyOutput && ( - setHistoryOutput(null)} - PaperProps={{ sx: { borderRadius: 3, bgcolor: c.bg.surface, width: 480, maxWidth: '92vw', height: '72vh' } }} - > - { setHistoryOutput(null); setBranchToast(true); }} - /> - - )} - - setBranchToast(false)} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} - > - 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. - - - - ); -}; - -export default Views; diff --git a/frontend/src/shared/appTerminal.ts b/frontend/src/shared/appTerminal.ts new file mode 100644 index 00000000..03f7f5f7 --- /dev/null +++ b/frontend/src/shared/appTerminal.ts @@ -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(); +const flushTimers = new Map(); + +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 = { '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 }; +} diff --git a/frontend/src/shared/hooks/useRuntimePreviewUrl.ts b/frontend/src/shared/hooks/useRuntimePreviewUrl.ts index 5c076813..c86543b1 100644 --- a/frontend/src/shared/hooks/useRuntimePreviewUrl.ts +++ b/frontend/src/shared/hooks/useRuntimePreviewUrl.ts @@ -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(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 }; } diff --git a/frontend/src/shared/starterCategories.ts b/frontend/src/shared/starterCategories.ts index 911cfe0b..0ac779d8 100644 --- a/frontend/src/shared/starterCategories.ts +++ b/frontend/src/shared/starterCategories.ts @@ -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; diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index 3c787733..9720b790 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -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; @@ -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, diff --git a/run.ps1 b/run.ps1 index 1fd97543..b578ed5e 100644 --- a/run.ps1 +++ b/run.ps1 @@ -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 } diff --git a/scripts/build-python-env-win.ps1 b/scripts/build-python-env-win.ps1 index fe6166ea..f79af25d 100644 --- a/scripts/build-python-env-win.ps1 +++ b/scripts/build-python-env-win.ps1 @@ -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" } diff --git a/scripts/build-python-env.sh b/scripts/build-python-env.sh index c00eca93..9dbe5c90 100755 --- a/scripts/build-python-env.sh +++ b/scripts/build-python-env.sh @@ -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')" diff --git a/scripts/fetch-webapp-template.sh b/scripts/fetch-webapp-template.sh index fd304668..a9dbdbc2 100755 --- a/scripts/fetch-webapp-template.sh +++ b/scripts/fetch-webapp-template.sh @@ -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"