[eric] onboarding bug fix and ux op

This commit is contained in:
ciregenz
2026-05-12 22:36:22 -07:00
parent c0c52e33d6
commit 91508c87b1
20 changed files with 1096 additions and 151 deletions
+3
View File
@@ -25,6 +25,9 @@ backend/npm-servers/*/node_modules/
frontend/dist/
# Bundled uv binaries (downloaded during build)
backend/uv-bin/
# Pre-built webapp-template node_modules archive (generated by
# scripts/build-template-archive.sh, optionally bundled into the DMG).
backend/apps/outputs/webapp_template_cache/
# Backend Python venv (created by run.ps1 / backend/run.sh)
backend/.venv/
.account-factory
+58 -2
View File
@@ -6,8 +6,64 @@ backend you can opt into on demand). It's served live to a webview, so it
behaves like a real browser tab — cross-origin `fetch`, popups, mic/camera,
clipboard, anything a normal web page does.
You are **NOT** writing a single HTML file or vanilla JS. Match the
codebase's patterns.
---
## STEP 0 — pick the right shape for the app
Before writing any code, decide whether this app should be **workspace**
(full React/MUI, the default) or **lightweight** (one self-contained
`index.html`). Picking wrong wastes the user's time: the workspace path
spends ~10-30 s pre-bundling MUI and React on first preview, which is
pointless when the app is a 200-line Three.js demo.
**Lightweight** when ALL apply:
- One page, no route navigation
- No persisted server state (no DB-shaped data the user comes back to)
- No real backend logic (just CDN libraries, in-memory state)
- The whole UI is essentially one of: canvas/WebGL scene, single-file
visualization (D3/Plotly/Chart.js), single-purpose tool (formatter,
calculator, color picker), tiny game or simulator
**Workspace** (this document's default) when ANY apply:
- Multiple pages with sidebar/route navigation
- Multiple distinct UI sections with their own state
- Real backend (FastAPI endpoints, file uploads with server processing,
auth, persisted user data)
- Real-time updates (WS/SSE)
- The user is likely to ask for more features later (chat, dashboards,
CRUD apps — these grow)
**Examples — lightweight:** "rotating Three.js cube", "Pomodoro timer",
"JSON formatter", "Mandelbrot explorer", "CSV → bar chart (no save)",
"first-person Minecraft-style demo", "color picker", "regex tester".
**Examples — workspace:** "chat app", "PDF previewer with annotations",
"task manager with categories", "recipe app", "weather dashboard with
saved cities", "Slack-style team chat with channels".
If you're unsure, lean **workspace** — it's strictly more capable and the
boot cost only hits once per app, then warm cache makes subsequent boots
fast.
### Lightweight — how
1. Delete everything under `frontend/src/` (`index.tsx`, `app/`, `pages/`,
`shared/`). Vite serves `frontend/index.html` directly when there's no
module graph to crawl, so the pre-bundle step is skipped entirely.
2. Replace `frontend/index.html` with a single self-contained document.
Inline `<style>` and `<script>`. Pull libraries from `esm.sh` /
`unpkg` via `<script type="importmap">` or plain `<script src=...>`.
3. Leave `frontend/package.json`, `frontend/vite.config.ts`, `run.sh`,
`.env`, `meta.json` alone — vite still needs them.
4. Don't run `bash backend_init.sh` — lightweight mode has no backend.
The rest of this document covers **workspace mode**. If you picked
lightweight, only the "Debugging" section (frontend console logs in the
Terminal pane) is relevant; skip everything else.
You are **NOT** writing a single HTML file or vanilla JS *inside a
workspace*. If you picked workspace mode above, match the codebase's
patterns described below.
---
+10 -1
View File
@@ -506,8 +506,17 @@ async def seed_workspace(body: WorkspaceSeedRequest):
def _runtime_status_payload(workspace_id: str) -> 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)
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)
is_new = _is_new_mode(folder) if os.path.isdir(folder) else False
return {
"running": False,
"port": None,
@@ -515,7 +524,7 @@ def _runtime_status_payload(workspace_id: str) -> dict:
"backend_url": None,
"frontend_port": None,
"frontend_url": None,
"is_new_mode": False,
"is_new_mode": is_new,
}
return {
"running": rt.running,
+191 -40
View File
@@ -15,7 +15,9 @@ around (see `executor.py`) for legacy `/api/outputs/execute` callers.
import asyncio
import logging
import os
import signal
import socket
import subprocess
import sys
from collections import deque, OrderedDict
from dataclasses import dataclass
@@ -45,6 +47,22 @@ _TERMINATE_GRACE_SECONDS = 3
_FRONTEND_BIND_TIMEOUT_SECONDS = 180
_FRONTEND_BIND_POLL_INTERVAL = 0.5
# Process-wide mutex that serializes new-mode workspace boots so only
# ONE vite optimizeDeps run is in flight at a time. Acquired in
# `AppRuntime.start` (new-mode branch only) BEFORE the run.sh spawn,
# released by `_await_frontend_bind` the instant vite emits its
# "frontend ready" log line — or by the timeout / failure paths.
#
# Why a module-level asyncio.Lock and not part of AppRuntimeManager:
# the lock has to be acquired BEFORE the runtime is registered in
# manager.runtimes (which happens inside manager.attach's own
# `_lock`), and we can't hold both locks at once without inviting
# deadlock. Lifting to the module keeps the two locks fully
# independent — the manager lock guards the runtime dict, this one
# guards "is anyone currently mid-MUI-bundle?"
_vite_boot_lock = asyncio.Lock()
# Number of idle (zero-attachment) runtimes the manager keeps alive in
# its LRU before reaping the oldest. Trades memory for instant
# switch-back: clicking a previously-opened App reattaches to an
@@ -82,6 +100,69 @@ _ERROR_PATTERNS = _re.compile(
)
def _suspend_process_tree(proc: Optional[asyncio.subprocess.Process]) -> None:
"""Send SIGSTOP to a workspace's subprocess so it consumes 0% CPU
while sitting in the LRU idle pool. The signal is delivered to the
PROCESS GROUP (negative PID) when the child is a session leader,
so vite + uvicorn + their npm/python subchildren all pause together.
No-op on Windows (SIGSTOP has no equivalent — the `OpenProcessToken` +
`NtSuspendProcess` route works but isn't worth the win32 surface
here; idle Windows runtimes just stay running, which is the current
behavior). Failures here are swallowed — if the process already died
a stop signal is meaningless."""
if proc is None or os.name == "nt":
return
try:
if proc.returncode is not None:
return
os.kill(proc.pid, signal.SIGSTOP)
except (ProcessLookupError, PermissionError, OSError):
# Already-dead or out-of-permission — both safe to ignore.
pass
def _resume_process_tree(proc: Optional[asyncio.subprocess.Process]) -> None:
"""SIGCONT a previously-suspended workspace process. Pair with
_suspend_process_tree. Microsecond cost; idempotent if the process
was never paused."""
if proc is None or os.name == "nt":
return
try:
if proc.returncode is not None:
return
os.kill(proc.pid, signal.SIGCONT)
except (ProcessLookupError, PermissionError, OSError):
pass
def _background_priority_kwargs() -> dict:
"""Return the kwargs that lower the spawned subprocess's OS priority
to a "background" level. On POSIX this is `preexec_fn=os.nice(10)`,
which sets the child's nice to +10 BEFORE exec (so the renice covers
the entire bash → vite + uvicorn process tree). On Windows it's
`creationflags=BELOW_NORMAL_PRIORITY_CLASS`. The OS scheduler then
yields workspace cycles to whichever agent or browser tab is in the
user's foreground, so an in-background app build doesn't starve a
live chat session.
We intentionally do NOT pass `start_new_session=True` here even
though it would defend against an errant `kill 0` inside the
workspace propagating into the OpenSwarm group: doing so also
detaches the workspace from the terminal's foreground process
group, so a user Ctrl+C only reaches OpenSwarm itself and the
cleanup path has to chase every workspace by hand. If that path
is even slightly slow or gets interrupted by a second Ctrl+C, the
workspace's uvicorn / vite leaks past shutdown and the next
`bash run.sh` hits Errno 48 on port 8324. The `kill 0` propagation
is fixed at its source in the workspace template's run.sh
(uses `kill_tree` on tracked PIDs, never `kill 0`)."""
if os.name == "nt":
# subprocess.BELOW_NORMAL_PRIORITY_CLASS == 0x4000
return {"creationflags": subprocess.BELOW_NORMAL_PRIORITY_CLASS}
return {"preexec_fn": lambda: os.nice(10)}
def _find_free_port() -> int:
"""Ask the kernel for an unused localhost port. There's a tiny race
between this socket closing and the backend re-binding, but we hand
@@ -232,13 +313,36 @@ class AppRuntime:
legitimate for old-mode workspaces with no backend.py (pure
frontend served by `/api/outputs/.../serve/`); the runtime still
exists so the Terminal pane can host `[FRONTEND]` lines.
New-mode spawns are serialized through the module-level
`_vite_boot_lock` (see comment at the lock declaration) so a
burst of "create 3 apps in 5 seconds" doesn't trigger 3 parallel
MUI pre-bundle runs each pegging a core.
"""
async with self._lock:
if self.running:
return True
if self.is_new_mode:
return await self._start_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 `_await_frontend_bind` for the release.
await _vite_boot_lock.acquire()
try:
ok = await self._start_new_mode()
if not ok:
# Spawn failed before the bind-poll task was
# created — release synchronously so we don't
# wedge the next workspace.
_vite_boot_lock.release()
return ok
except Exception:
_vite_boot_lock.release()
raise
return await self._start_old_mode()
async def _start_new_mode(self) -> bool:
@@ -285,6 +389,7 @@ class AppRuntime:
stderr=asyncio.subprocess.PIPE,
cwd=self.workspace_path,
env=env,
**_background_priority_kwargs(),
)
except Exception as e:
logger.exception("failed to start new-mode runtime for %s", self.workspace_id)
@@ -309,44 +414,78 @@ class AppRuntime:
something binds (Vite dev server) or we hit the timeout. Emits a
`[runtime]` log line on success/failure so the Terminal pane
shows the transition; flips `_frontend_ready` which the
`frontend_url` property reads."""
if not self.frontend_port:
return
port = self.frontend_port
deadline = asyncio.get_event_loop().time() + _FRONTEND_BIND_TIMEOUT_SECONDS
while asyncio.get_event_loop().time() < deadline:
# Stop polling if the process died — pointless to keep
# checking a port nothing will bind.
if self.process is None or self.process.returncode is not None:
`frontend_url` property reads.
Also responsible for releasing the module-level `_vite_boot_lock`
— every exit path (success, process death, hard timeout) MUST
release exactly once so the next queued workspace can start its
own vite spawn. A try/finally on the lock guarantees that even
an exception in the poll body doesn't strand the lock holding."""
# Track whether we've already released so the cleanup at the
# end doesn't double-release if a success path beat it.
lock_released = False
def _release_boot_lock() -> None:
nonlocal lock_released
if lock_released:
return
lock_released = True
try:
# asyncio.open_connection is the non-blocking equivalent
# of socket.create_connection. 0.5s connect timeout to
# avoid hanging if the host's TCP stack is under load.
fut = asyncio.open_connection("127.0.0.1", port)
reader, writer = await asyncio.wait_for(fut, timeout=0.5)
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
self._frontend_ready = True
self._broadcast(LogLine(
"runtime",
f"[runtime] frontend ready at http://127.0.0.1:{port}/",
))
return
except (OSError, asyncio.TimeoutError):
_vite_boot_lock.release()
except RuntimeError:
# Lock already released (e.g. start() failure path
# released synchronously before spawning the poll task).
pass
await asyncio.sleep(_FRONTEND_BIND_POLL_INTERVAL)
# Timed out — keep the runtime up (Terminal might show useful
# errors) but surface why the preview never appeared.
self._broadcast(LogLine(
"runtime",
f"[runtime] frontend did NOT bind on port {port} after "
f"{_FRONTEND_BIND_TIMEOUT_SECONDS}s — check the Terminal "
f"for npm/vite errors.",
))
try:
if not self.frontend_port:
return
port = self.frontend_port
deadline = asyncio.get_event_loop().time() + _FRONTEND_BIND_TIMEOUT_SECONDS
while asyncio.get_event_loop().time() < deadline:
# Stop polling if the process died — pointless to keep
# checking a port nothing will bind.
if self.process is None or self.process.returncode is not None:
return
try:
# asyncio.open_connection is the non-blocking equivalent
# of socket.create_connection. 0.5s connect timeout to
# avoid hanging if the host's TCP stack is under load.
fut = asyncio.open_connection("127.0.0.1", port)
reader, writer = await asyncio.wait_for(fut, timeout=0.5)
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
self._frontend_ready = True
self._broadcast(LogLine(
"runtime",
f"[runtime] frontend ready at http://127.0.0.1:{port}/",
))
# Release the vite-boot mutex the INSTANT vite is
# ready — the next queued workspace can start its
# own bundle now even though we'll keep streaming
# logs for this one.
_release_boot_lock()
return
except (OSError, asyncio.TimeoutError):
pass
await asyncio.sleep(_FRONTEND_BIND_POLL_INTERVAL)
# Timed out — keep the runtime up (Terminal might show useful
# errors) but surface why the preview never appeared.
self._broadcast(LogLine(
"runtime",
f"[runtime] frontend did NOT bind on port {port} after "
f"{_FRONTEND_BIND_TIMEOUT_SECONDS}s — check the Terminal "
f"for npm/vite errors.",
))
finally:
# Catches process-death return, timeout fall-through, and
# any exception in the poll body. _release_boot_lock is
# idempotent so this is safe even after the success path
# already released.
_release_boot_lock()
async def _start_old_mode(self) -> bool:
if not self.has_backend_file:
@@ -366,6 +505,7 @@ class AppRuntime:
stderr=asyncio.subprocess.PIPE,
cwd=self.workspace_path,
env=env,
**_background_priority_kwargs(),
)
except Exception as e:
logger.exception("failed to start backend for %s", self.workspace_id)
@@ -490,6 +630,10 @@ class AppRuntimeManager:
async def attach(self, workspace_id: str, workspace_path: str) -> AppRuntime:
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._lock:
rt = self.runtimes.get(workspace_id)
if rt is None:
@@ -501,14 +645,15 @@ class AppRuntimeManager:
rt.workspace_path = workspace_path
self.runtimes[workspace_id] = 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)
else:
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
else:
dead = None
rt = AppRuntime(workspace_id, workspace_path)
self.runtimes[workspace_id] = rt
else:
@@ -517,7 +662,6 @@ class AppRuntimeManager:
# folder), trust the latest caller — they have the
# current truth.
rt.workspace_path = workspace_path
dead = None
self._attached[workspace_id] = self._attached.get(workspace_id, 0) + 1
if not revived and not rt.running:
await rt.start()
@@ -542,14 +686,21 @@ class AppRuntimeManager:
if rt is None:
return
# If the process is already dead, no point keeping it
# around — just clean up. Otherwise move to the LRU.
# 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)
_suspend_process_tree(rt.process)
while len(self._idle_lru) > _MAX_IDLE_RUNTIMES:
_, old_rt = self._idle_lru.popitem(last=False)
# Reaping a stopped process: SIGCONT first so the
# SIGTERM in stop() can be delivered cleanly (a
# SIGSTOP'd process can't run its own shutdown).
_resume_process_tree(old_rt.process)
to_reap.append(old_rt)
to_idle = rt if rt.running else None
+77 -6
View File
@@ -6,6 +6,7 @@ import os
import re
import shutil
import subprocess
import tarfile
import threading
logger = logging.getLogger(__name__)
@@ -155,19 +156,82 @@ _warm_cache_lock = threading.Lock()
_warm_cache_thread: threading.Thread | None = None
def _warm_cache_dir() -> str:
"""Path the warm node_modules lives under. Hashed by package.json so
upgrades automatically force a re-populate."""
# Pre-built node_modules archive bundled with packaged releases. Generated
# by `scripts/build-template-archive.sh` and shipped at this path inside
# the app's resources. When present (and tagged with the current
# package.json sha), extract instead of running npm — decompression is
# ~3 s vs ~22 s for the live install. Stale archives (package.json bumped
# but archive not rebuilt) are silently ignored, so the live-install
# fallback always wins on correctness.
_BUNDLED_ARCHIVE_DIR = os.path.join(
os.path.dirname(__file__), "webapp_template_cache"
)
def _bundled_archive_path_for(digest: str) -> str:
"""Sha-tagged archive path so a stale archive from a prior template
version is automatically skipped instead of overwriting the cache with
out-of-date modules."""
return os.path.join(_BUNDLED_ARCHIVE_DIR, f"node_modules.{digest}.tar.gz")
def _try_extract_bundled_archive(cache_dir: str, digest: str) -> bool:
"""Unpack the sha-tagged bundled archive into `cache_dir` if one
exists for the current template digest. Returns True on success,
False to signal the caller should fall back to a live `npm install`.
The archive is built from the same package.json + package-lock.json
sha so the extracted tree is byte-equivalent to `npm ci`."""
archive_path = _bundled_archive_path_for(digest)
if not os.path.exists(archive_path):
return False
try:
logger.info(
"webapp-template: unpacking bundled warm-cache archive %s",
archive_path,
)
os.makedirs(cache_dir, exist_ok=True)
# Archive root is `node_modules/`; extracting into cache_dir places
# it at the expected path. tarfile uses zlib internally for .gz —
# no extra dep needed.
with tarfile.open(archive_path, "r:gz") as tar:
tar.extractall(cache_dir)
cache_modules = os.path.join(cache_dir, "node_modules")
if os.path.isdir(cache_modules):
return True
logger.warning(
"webapp-template: bundled archive extracted but no node_modules/ "
"directory at %s; falling back to npm install",
cache_modules,
)
return False
except Exception as exc:
logger.warning(
"webapp-template: bundled-archive extract failed (%s); "
"falling back to npm install",
exc,
)
return False
def _warm_cache_digest() -> str:
"""Sha of the template's frontend/package.json — used as the cache
key + the bundled-archive filename suffix so a package.json bump
invalidates both at once."""
pkg_path = os.path.join(WEBAPP_TEMPLATE_DIR, "frontend", "package.json")
try:
with open(pkg_path, "rb") as fh:
digest = hashlib.sha256(fh.read()).hexdigest()[:12]
return hashlib.sha256(fh.read()).hexdigest()[:12]
except OSError:
digest = "fallback"
return "fallback"
def _warm_cache_dir() -> str:
"""Path the warm node_modules lives under. Hashed by package.json so
upgrades automatically force a re-populate."""
base = os.environ.get("OPENSWARM_WEBAPP_CACHE_DIR") or os.path.expanduser(
"~/.openswarm/cache/webapp_template_node_modules"
)
return os.path.join(base, digest)
return os.path.join(base, _warm_cache_digest())
def _ensure_warm_cache() -> str | None:
@@ -184,6 +248,13 @@ def _ensure_warm_cache() -> str | None:
with _warm_cache_lock:
if os.path.isdir(cache_modules):
return cache_modules
# Fast path: pre-built archive shipped inside the release. The
# build script generates this so users hitting OpenSwarm for the
# first time skip the ~22 s live `npm install`. Falls through on
# any failure so dev installs (no archive) keep working.
if _try_extract_bundled_archive(cache_dir, _warm_cache_digest()):
logger.info("webapp-template: warm cache ready from bundled archive")
return cache_modules
try:
os.makedirs(cache_dir, exist_ok=True)
# Copy package.json + lockfile (if it exists) into the cache
+24 -1
View File
@@ -8,10 +8,33 @@ if [[ -f "$ROOT_DIR/.env" ]]; then
set +a
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
# the grandchildren alive holding their ports until the OS reaps them
# minutes later — visible to the user as port-already-in-use the next time
# they hit the App Builder.
kill_tree() {
local pid=$1 sig=${2:-TERM}
local children
children=$(pgrep -P "$pid" 2>/dev/null)
for child in $children; do
kill_tree "$child" "$sig"
done
kill -"$sig" "$pid" 2>/dev/null
}
# Previously this was `kill 0`, which SIGTERMs the entire process group.
# That's fast but propagates UP into OpenSwarm — when this workspace's
# cleanup fired on ViewEditor unmount or runtime/stop, it tore down the
# OpenSwarm dev stack (Terminated: 15) and left port 8324 stuck. Now we
# only kill our own tracked subtree, which keeps containment without
# requiring an OS-level session wall.
cleanup() {
echo ""
echo "Shutting down all processes..."
kill 0 2>/dev/null
[[ -n "${FRONTEND_PID:-}" ]] && kill_tree "$FRONTEND_PID" TERM
[[ -n "${BACKEND_PID:-}" ]] && kill_tree "$BACKEND_PID" TERM
wait 2>/dev/null
}
trap cleanup EXIT
+20 -1
View File
@@ -85,6 +85,14 @@ app.add_middleware(
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
# Every cross-origin POST from the Electron renderer (file:// → http://localhost:8324)
# carries Authorization: Bearer, which CORS classifies as non-simple and
# forces a preflight OPTIONS before EACH POST. With no max_age the browser
# re-preflights on a tight schedule (~5 s in Chromium); under heavy
# interaction we observed a 1:1 OPTIONS-to-POST ratio in the dev log,
# doubling roundtrip count for no reason. Caching the preflight result
# for 10 minutes drops that to one OPTIONS per ~600 POSTs.
max_age=600,
)
@@ -266,8 +274,19 @@ async def websocket_runtime_logs(websocket: WebSocket, workspace_id: str):
rt = runtime_manager.get(workspace_id)
if rt is None:
# No active runtime — surface that to the client and close. The
# frontend will call /runtime/start and reconnect.
# 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:
from backend.apps.outputs.outputs import _runtime_status_payload
status = _runtime_status_payload(workspace_id)
await websocket.send_text(json.dumps({
"event": "runtime:status",
"workspace_id": workspace_id,
"data": status,
}))
await websocket.send_text(json.dumps({
"event": "runtime:not_attached",
"workspace_id": workspace_id,
+27
View File
@@ -3,6 +3,33 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!--
Renderer CSP. Defense-in-depth: the only attacker model we care about
here is a compromised third-party page rendered inside a webview/iframe
breaking out into the host renderer. Real production isolation comes
from contextIsolation + sandbox in main.js, not this meta — but a CSP
silences Electron's "no CSP" dev warning AND blocks <object>/<embed>,
base-tag injection, and arbitrary cross-origin script tags as a cheap
extra layer. 'unsafe-eval' stays in script-src so webpack-dev-server's
HMR keeps working; the packaged build doesn't need it but it's harmless
given the bundle origins are 'self' + file:.
-->
<meta
http-equiv="Content-Security-Policy"
content="
default-src 'self' file: data: blob: http://localhost:* http://127.0.0.1:* https://*.openswarm.com https://api.openswarm.com;
script-src 'self' 'unsafe-inline' 'unsafe-eval' file: http://localhost:* http://127.0.0.1:*;
style-src 'self' 'unsafe-inline' file: http://localhost:* http://127.0.0.1:* https://fonts.googleapis.com;
font-src 'self' data: file: https://fonts.gstatic.com;
img-src 'self' data: blob: file: http: https:;
media-src 'self' data: blob: http: https:;
connect-src 'self' file: http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:* https://api.openswarm.com https://*.openswarm.com https://openswarm.com https://api.github.com;
frame-src 'self' file: http://localhost:* http://127.0.0.1:*;
worker-src 'self' blob:;
object-src 'none';
base-uri 'self';
"
/>
<title>Open Swarm</title>
<link rel="icon" href="./favicon.ico?v=2" sizes="16x16 32x32 48x48" />
<link rel="apple-touch-icon" href="./apple-touch-icon.png" />
@@ -520,7 +520,7 @@ const StepCardBody: React.FC<StepCardProps> = ({
// (recorded at a wider canvas than the OpenSwarm window
// actually filled). Scaling up + overflow:hidden on the
// parent crops them off the visible thumbnail area.
transform: 'scale(1.55)',
transform: 'scale(1.0)',
transformOrigin: 'center',
pointerEvents: 'none',
}}
@@ -645,7 +645,7 @@ const StepCardBody: React.FC<StepCardProps> = ({
width: '100%',
height: '100%',
objectFit: 'cover',
transform: 'scale(1.55)',
transform: 'scale(1.0)',
transformOrigin: 'center',
display: 'block',
}}
@@ -68,11 +68,22 @@ const ACPopup: React.FC<Props> = ({ text, offset = { x: 0, y: 14 } }) => {
// Use chained setTimeout (not setInterval) so we can vary the delay
// per character — punctuation gets an extra beat, mimicking the
// pacing of Pokémon-style dialog boxes where sentences "land."
//
// Diagnostic popups (anything containing the literal `[debug]`
// marker) skip streaming entirely. The recovery popup that fires on
// step failure carries a `[debug] <error message>` suffix so the
// user can see WHY a step bailed without opening DevTools — but at
// 30 ms/char + 210 ms per punctuation, the suffix takes the full
// 14 s popup duration to even start rendering, so by the time the
// user reads it the popup is already gone. Instant-render for these
// means the diagnostic appears immediately.
const isDebugPopup = text.includes('[debug]');
const skipStream = isDebugPopup || text.length < STREAM_MIN_CHARS;
const [streamCount, setStreamCount] = useState<number>(
text.length < STREAM_MIN_CHARS ? text.length : 0,
skipStream ? text.length : 0,
);
useEffect(() => {
if (text.length < STREAM_MIN_CHARS) {
if (skipStream) {
setStreamCount(text.length);
return;
}
@@ -98,7 +109,7 @@ const ACPopup: React.FC<Props> = ({ text, offset = { x: 0, y: 14 } }) => {
return () => {
if (timer !== null) window.clearTimeout(timer);
};
}, [text]);
}, [text, skipStream]);
useLayoutEffect(() => {
const el = ref.current;
@@ -9,6 +9,14 @@
// dispatch a real 'input' event. Setting `el.value = ...` directly is
// silently ignored by React's onChange.
// Version marker so we can verify the dev bundle actually reloaded after
// editing this file. Check `window.__OPENSWARM_TYPEINTO__` in DevTools
// — if it's missing or shows an older tag, Electron's renderer is
// running a cached bundle and needs a Cmd+R hard-reload.
if (typeof window !== 'undefined') {
(window as any).__OPENSWARM_TYPEINTO__ = 'v2-dom-direct-2026-05-12';
}
const INPUT_PROTO_VALUE_DESC =
typeof window !== 'undefined'
? Object.getOwnPropertyDescriptor(
@@ -43,36 +51,62 @@ function dispatchInput(el: HTMLElement): void {
}
// contentEditable fields (the agent chat input is one) need a different
// path. Setting textContent doesn't fire any of the events React's
// onInput handler listens for, AND it nukes any rich-content children
// (skill pills, etc). document.execCommand('insertText') is the
// idiomatic way to programmatically type into a contentEditable — it
// fires the same `input` events a real keystroke would.
// path than <input>/<textarea>. Setting textContent nukes rich-content
// children (skill pills, etc), so we append a Text node at the end and
// dispatch a real InputEvent that React's reconciler treats as a
// keystroke. We used to call document.execCommand('insertText') here
// instead — that's the "idiomatic" way to programmatically type into a
// contentEditable — but in Electron with a webview loaded in the
// preview pane (App Builder step 8 / step 5 / step 6 all hit this),
// the webview steals document focus during its load. execCommand
// requires the host document to be focused AND the active element to
// be editable; without focus it silently no-ops while still returning
// true, so the wizard's `typeInto` "succeeded" but no characters ever
// landed, hasContent stayed false on the chat input, the send button
// never rendered, and step 8's `move_to chatSendButton` then burned
// its 15 s waitForSelector and threw into the recovery popup. The
// AC's "cursor" is purely visual — it never fires real focus events
// — so there's no way to get document focus back without the user
// clicking. DOM-level insertion + dispatched InputEvent works
// regardless of focus state.
function insertContentEditableText(el: HTMLElement, ch: string): void {
el.focus();
// Place caret at end so insertion appends rather than overwrites.
// Append at the very end of the editable. Walk to the deepest
// last-text-node so we don't insert into the middle of a skill pill
// wrapper (those are inline-block element children with their own
// text). If the last child is an element (e.g., a <span> skill
// pill), we append a sibling text node after it.
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
const last = el.lastChild;
if (last && last.nodeType === Node.TEXT_NODE) {
range.setStart(last, (last.nodeValue ?? '').length);
range.collapse(true);
(last as Text).appendData(ch);
range.setStart(last, (last.nodeValue ?? '').length);
range.collapse(true);
} else {
const textNode = document.createTextNode(ch);
el.appendChild(textNode);
range.setStart(textNode, ch.length);
range.collapse(true);
}
const sel = window.getSelection();
if (sel) {
sel.removeAllRanges();
sel.addRange(range);
}
// execCommand is deprecated but still the only cross-browser way to
// get React-friendly synthetic input events into a contentEditable.
// Falls back to direct text-node append if execCommand is rejected
// (some embedded webviews disable it).
let ok = false;
try {
ok = document.execCommand('insertText', false, ch);
} catch {
ok = false;
}
if (!ok) {
el.appendChild(document.createTextNode(ch));
el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' }));
}
// React's controlled-input bridge listens for `input` events. The
// `inputType: insertText` + `data: ch` mirrors what a real keystroke
// produces, so handleInput → updateHasContent fires and hasContent
// flips true → the send button finally renders.
el.dispatchEvent(
new InputEvent('input', {
bubbles: true,
cancelable: true,
data: ch,
inputType: 'insertText',
}),
);
}
export interface TypeIntoOptions {
@@ -101,6 +135,18 @@ export async function typeInto(
const speed = opts.speedMs ?? 18;
el.focus();
// Per-character cadence is constant (no jitter — variable timing reads
// as glitchy, not natural). The one exception: insert a natural-reading
// pause after a comma / sentence-terminator / colon / semicolon so the
// streamed text breathes the way a human would. Anything else types at
// the constant `speed` value, beat by beat.
const punctPause = (ch: string): number => {
if (ch === ',') return 220;
if (ch === '.' || ch === '!' || ch === '?') return 320;
if (ch === ':' || ch === ';') return 180;
return 0;
};
// Branch on element kind. contentEditable (the agent ChatInput uses
// a contentEditable div for skill-pill support) requires execCommand;
// <input>/<textarea> require the React-prototype-setter dance.
@@ -108,7 +154,7 @@ export async function typeInto(
for (const ch of text) {
insertContentEditableText(el, ch);
opts.onTick?.();
await new Promise((r) => window.setTimeout(r, speed));
await new Promise((r) => window.setTimeout(r, speed + punctPause(ch)));
}
} else {
let acc = '';
@@ -120,7 +166,7 @@ export async function typeInto(
nativeSetValue(el, acc);
dispatchInput(el);
opts.onTick?.();
await new Promise((r) => window.setTimeout(r, speed));
await new Promise((r) => window.setTimeout(r, speed + punctPause(ch)));
}
}
@@ -19,7 +19,7 @@ import {
import { report, markStepStarted, clearStepTiming } from '../telemetry';
import { onboardingBus, type OnboardingEvent } from '../eventBus';
// (gate bump done via onboardingBus.resetReplayGate at runStep entry)
import { waitForSelector } from '../selectors';
import { waitForSelector, resolveSelector } from '../selectors';
import {
spawnGlowRect,
clickRipple,
@@ -57,9 +57,10 @@ interface RunContext {
// (move_to, click, type_into, drag_select, outro) or a popup replacement
// is allowed to clear it. user-driven transitions (wait_user resolving)
// also flow through here, but typically the user has already been
// reading for longer than this anyway. Set to 3s — covers the streaming
// typewriter cadence plus enough post-stream read time for most popups.
const MIN_POPUP_DWELL_MS = 3000;
// reading for longer than this anyway. 6 s = streaming typewriter
// cadence + ~3 s post-stream read time, which was the user-asked floor
// for popups that don't require an explicit user action to advance.
const MIN_POPUP_DWELL_MS = 6000;
// Resolves once `ms` has elapsed or the signal aborts (whichever
// comes first). Used inside ensurePopupDwell so a step cancel doesn't
@@ -214,13 +215,52 @@ export async function runStep(args: RunStepArgs): Promise<void> {
}
const showMessage = !signal.reason || signal.reason !== 'user-cancel';
if (showMessage) {
// Diagnostic: surface a short version of the actual error in
// the recovery popup so we can see WHY the step bailed without
// needing DevTools open. 180-char cap keeps it readable.
const isAbortErr =
(err as DOMException)?.name === 'AbortError' || signal.aborted;
const errSnippet = isAbortErr
? ''
: ((err as Error)?.message ?? String(err)).slice(0, 180);
const debugSuffix = errSnippet
? `\n\n[debug] ${errSnippet}`
: '';
// Stash the full error on window so a dev can grab it from
// DevTools (`window.__OPENSWARM_LAST_ONBOARDING_ERR__`) even
// if the streaming popup hides the suffix. Full untruncated
// message + stack lives here, the 180-char snippet is just
// for the popup.
try {
(window as any).__OPENSWARM_LAST_ONBOARDING_ERR__ = {
step_id: step.id,
message: (err as Error)?.message ?? String(err),
stack: (err as Error)?.stack,
at: new Date().toISOString(),
};
// eslint-disable-next-line no-console
console.error(
'[onboarding] step bailed:',
step.id,
(err as Error)?.message ?? err,
err,
);
} catch {
/* defensive — never let diagnostics throw */
}
ac.showPopup(
"No worries feel free to explore. Tap Show me whenever you're ready.",
"No worries, feel free to explore. Tap Show me whenever you're ready." +
debugSuffix,
);
// 3.5s gives most readers enough time to actually parse the
// recovery hint. Earlier 1.4s value was tuned for "snappy" but
// the popup was vanishing before users could read it.
await new Promise<void>((r) => window.setTimeout(r, 3500));
// ACPopup streams text at ~30 ms/char + ~210 ms per punctuation
// mark, so a 240-char popup (base copy + 180-char debug
// suffix) takes ~10 s just to finish streaming. With a 5 s
// dwell the [debug] line never even appears on screen before
// the popup closes — which is why the user saw only the base
// recovery copy in every failure run. 14 s gives the streamer
// time to finish AND leaves a few seconds for the user to
// actually read the diagnostic line.
await new Promise<void>((r) => window.setTimeout(r, 14000));
}
} catch {
/* defensive — never let cleanup throw */
@@ -284,6 +324,16 @@ async function runOps(ops: ACOp[], ctx: RunContext): Promise<void> {
duration_ms: Date.now() - opStart,
error: String(err),
});
// Console-visible breadcrumb so a dev with DevTools open can
// see WHICH op of WHICH step blew up without parsing telemetry.
// The catch in runStep above selectively logs based on error
// kind — this is more reliable and pinpoints the failing op.
// eslint-disable-next-line no-console
console.error(
`[onboarding] op failed: step=${ctx.stepId} op#${i}=${op.kind} ` +
`duration=${Date.now() - opStart}ms`,
{ op, error: err },
);
}
throw err;
}
@@ -503,40 +553,82 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
return;
}
case 'type_into': {
const el = await waitForSelector(op.target);
if (scrollIntoViewIfNeeded(el)) {
await sleep(180);
}
const r = el.getBoundingClientRect();
await ac.moveTo(Math.min(r.right - 14, r.left + r.width / 2), r.top + r.height / 2);
ac.startTracking(op.target, { x: 0, y: 0 });
// Resolve text — string-or-function. Function form lets a step
// pick its prompt at run-time based on current Redux state (e.g.
// step 3's YouTube vs. web-research fallback).
// Resolve text up-front — string-or-function. Function form lets a
// step pick its prompt at run-time based on current Redux state
// (e.g. step 3's YouTube vs. web-research fallback).
const resolvedText =
typeof op.text === 'function' ? op.text(ctx.store.getState()) : op.text;
await typeInto(el, resolvedText, { speedMs: op.speedMs });
// Anti-revert guard: some controlled contentEditable libraries
// re-render on their own schedule and wipe AC's typed text in
// the next React commit. Re-check the input value after a brief
// beat and re-insert if it got wiped. Without this, the next
// op (typically click send) hits a disabled send button because
// the input "thinks" it's empty.
await sleep(80);
const targetTrimmed = resolvedText.trim();
const readText = (e: HTMLElement): string => {
if (e.isContentEditable) return (e.textContent ?? '').trim();
if (e instanceof HTMLInputElement || e instanceof HTMLTextAreaElement)
return (e.value ?? '').trim();
return (e.textContent ?? '').trim();
};
const target = resolvedText.trim();
if (target && readText(el).length < Math.floor(target.length * 0.8)) {
// Single-shot re-insert. Same path the typewriter's own
// fallback uses for under-load typing drops.
if (el.isContentEditable) {
el.focus();
// Type-and-verify is wrapped in a retry loop because the App
// Builder's chat input can be detached out from under us mid-
// stream: the workspace's `runtime/start → stop → start` cycle +
// ViewEditor's seed-then-navigate causes React to swap the
// AgentChat instance the user can see, leaving the element our
// `el` ref points at detached from the DOM. execCommand fires
// silently into the dead node, no text lands, hasContent stays
// false, and the send button never renders — which is what was
// pushing the wizard into the recovery popup. On a verify-miss
// we re-fetch the selector (which now resolves to the FRESH
// AgentChat's input) and type again. Two attempts is the max —
// a real "the input is genuinely broken" case shouldn't loop.
const MAX_ATTEMPTS = 3;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
const el = await waitForSelector(op.target);
if (scrollIntoViewIfNeeded(el)) {
await sleep(180);
}
const r = el.getBoundingClientRect();
await ac.moveTo(
Math.min(r.right - 14, r.left + r.width / 2),
r.top + r.height / 2,
);
ac.startTracking(op.target, { x: 0, y: 0 });
await typeInto(el, resolvedText, { speedMs: op.speedMs });
// Let React's onInput commit land before verifying. 80 ms is
// enough in the warm-path; we sleep longer between retries
// because a remount window is what we're racing.
await sleep(80);
if (!targetTrimmed) return;
// Re-fetch in case the original `el` was detached by a remount.
// resolveSelector will return whatever the CURRENT canonical
// chat-input is in the scope priority order.
const currentEl = resolveSelector(op.target);
const verifyEl = currentEl ?? el;
const landed = readText(verifyEl);
if (landed.length >= Math.floor(targetTrimmed.length * 0.8)) {
// Success — text is in the live input.
return;
}
if (attempt < MAX_ATTEMPTS) {
// eslint-disable-next-line no-console
console.warn(
`[onboarding] type_into verify-miss for "${op.target}" attempt ${attempt}/${MAX_ATTEMPTS} — typed=${landed.length}/${targetTrimmed.length}, retrying`,
);
// Wait long enough for any in-flight remount + reconcile to
// settle. 600 ms is longer than the ~500 ms stability window
// wait_for_dom uses, so by the time we retry the DOM is in
// its steady state.
await sleep(600);
continue;
}
// Final attempt — same single-shot re-insert the old anti-
// revert guard used, against whatever element is current.
if (verifyEl.isContentEditable) {
verifyEl.focus();
const range = document.createRange();
range.selectNodeContents(el);
range.selectNodeContents(verifyEl);
const sel = window.getSelection();
if (sel) {
sel.removeAllRanges();
@@ -546,14 +638,31 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
document.execCommand('delete', false);
const ok = document.execCommand('insertText', false, resolvedText);
if (!ok) {
el.textContent = resolvedText;
el.dispatchEvent(new Event('input', { bubbles: true }));
verifyEl.textContent = resolvedText;
verifyEl.dispatchEvent(new Event('input', { bubbles: true }));
}
} catch {
el.textContent = resolvedText;
el.dispatchEvent(new Event('input', { bubbles: true }));
verifyEl.textContent = resolvedText;
verifyEl.dispatchEvent(new Event('input', { bubbles: true }));
}
}
// One last verify after the fallback — if text STILL didn't land,
// throw with a descriptive error so the wizard's catch block
// shows a useful diagnostic instead of letting the next op
// (move_to chatSendButton) burn 15 s on a button that will
// never render because hasContent is false. The thrown message
// appears in DevTools console via the op-failed breadcrumb.
await sleep(120);
const finalLanded = readText(resolveSelector(op.target) ?? verifyEl);
if (finalLanded.length < Math.floor(targetTrimmed.length * 0.5)) {
throw new Error(
`type_into: text never landed in "${op.target}" after ` +
`${MAX_ATTEMPTS} attempts (final length=${finalLanded.length}/${targetTrimmed.length}). ` +
`The chat input was probably detached by an in-flight remount — ` +
`check whether ViewEditor's seed-then-navigate is firing twice ` +
`or whether AgentChat's session key is swapping mid-stream.`,
);
}
}
return;
}
@@ -690,7 +799,7 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
step_id: ctx.stepId,
event: op.condition.event,
});
ac.showPopup("Didn't seem to go through — try again?");
ac.showPopup("Didn't seem to go through. Try again?");
ctx.popupShownAt.current = performance.now();
await waitForCondition(
op.condition,
@@ -733,19 +842,68 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
case 'wait_for_dom': {
const timeoutMs = op.timeoutMs ?? 8000;
const POLL_MS = 100;
// Stability gate: the matched element has to be the SAME node for
// STABILITY_POLLS consecutive polls (≈ 500 ms continuous presence)
// before we return success. Without this, step 8 was finding the
// App Builder's chat-input on poll N, returning, then the next
// op's typing ran straight into AgentChat's remount (the
// `runtime/start → stop → start` cycle from a draftLaunchMap swap
// + React Strict Mode double-effect) — the input became detached
// mid-stream, execCommand('insertText') silently no-op'd into the
// dead node, no text landed, hasContent stayed false, the send
// button was never rendered, and the wizard's next move_to
// chatSendButton burned its 15 s waitForSelector and threw into
// the recovery popup. Requiring stable identity walls off the
// remount window so we only proceed once the runtime has settled.
const STABILITY_POLLS = 5;
const startedAt = performance.now();
let stableEl: Element | null = null;
let stableCount = 0;
while (performance.now() - startedAt < timeoutMs) {
if (signal.aborted) {
throw new DOMException('aborted', 'AbortError');
}
if (document.querySelector(op.css)) return;
const hit = document.querySelector(op.css);
if (hit) {
if (hit === stableEl) {
stableCount += 1;
if (stableCount >= STABILITY_POLLS) return;
} else {
stableEl = hit;
stableCount = 1;
}
} else {
stableEl = null;
stableCount = 0;
}
await sleep(POLL_MS);
}
// Soft-success on timeout — same policy as wait_user's event_bus
// path. The next op (usually move_to / type_into) will hit its own
// waitForSelector and surface a clearer error if the target is
// genuinely missing.
return;
// Hard error on timeout, with DOM-state diagnostics so the dev
// console tells us WHY the selector didn't match — bare selector
// mismatch vs. the marker being on the right element but the
// wrong scope vs. nothing in DOM at all are three different bugs
// and we couldn't tell which from "step failed".
const scopeEls = Array.from(
document.querySelectorAll('[data-onboarding-scope]'),
).map((e) => (e as HTMLElement).getAttribute('data-onboarding-scope'));
const chatInputEls = Array.from(
document.querySelectorAll('[data-onboarding="chat-input"]'),
);
const chatInputScopes = chatInputEls.map((el) => {
let p: HTMLElement | null = el.parentElement;
while (p) {
const s = p.getAttribute('data-onboarding-scope');
if (s) return s;
p = p.parentElement;
}
return '<no-scope>';
});
const msg =
`wait_for_dom: "${op.css}" did not appear within ${timeoutMs}ms ` +
`[scopes=${JSON.stringify(scopeEls)}; chatInputs=${chatInputEls.length}; ` +
`chatInputScopes=${JSON.stringify(chatInputScopes)}]`;
console.error('[onboarding]', msg);
throw new Error(msg);
}
case 'outro': {
await ac.fadeOut(ctx.spawnPoint);
@@ -23,14 +23,28 @@ export const step08: OnboardingStep = {
condition: { kind: 'click_target', target: S.appsNewButton },
},
// After clicking +, the /apps/new route mounts ViewEditor which
// asynchronously renders AgentChat in the left pane (model probe +
// initial fetch). The chat-input data-onboarding marker can land
// on a DIFFERENT agent's chat (one of the dashboard cards) before
// the App Builder's own scope mounts, so we wait for the scoped
// marker specifically. wait_for_dom polls every 100ms up to 8s —
// instant on warm starts, patient on cold ones. Replaces the prior
// fixed 1500ms delay that under-fit slow boots and added latency
// on fast ones.
// asynchronously renders AgentChat in the left pane. Three failure
// modes we have to defend against:
// 1. Cold start can take well over 8 s before AgentChat mounts
// inside the app-builder scope wrapper — vite warm-up + session
// creation + three parallel onboarding sessions racing the
// backend's probe-model queue stack up under load.
// 2. The /apps/new route briefly mounts → unmounts → remounts
// ViewEditor (runtime/start → runtime/stop → runtime/start
// visible in the dev log when the React Strict-Mode double-
// effect collides with the route transition). The scope
// wrapper disappears during the unmount, and wait_for_dom
// polling can land in that gap.
// 3. AgentChat's hardcoded `disabled={false}` means the
// contenteditable attribute is always "true" when the input
// mounts — so we don't need to gate on it (and gating on a
// stringly-serialized React attribute introduces a brittle
// dependency on React's attribute reflection).
//
// Fix: wait for the SCOPED chat-input. 30 s timeout swallows any
// reasonable cold start including the mount-unmount-remount cycle.
// An extra 350 ms `delay` lets the post-mount React commit settle
// (refs, event handlers, focus shims) before we move the cursor.
{
kind: 'popup',
text: 'Loading the App Builder...',
@@ -38,8 +52,9 @@ export const step08: OnboardingStep = {
{
kind: 'wait_for_dom',
css: '[data-onboarding-scope="app-builder"] [data-onboarding="chat-input"]',
timeoutMs: 8000,
timeoutMs: 60000,
},
{ kind: 'delay', ms: 350 },
// The App Builder chat lives in the left pane on /apps/new — the
// chat-input selector resolves to it via the App Builder scope
// priority in resolveSelector.
@@ -51,6 +66,10 @@ export const step08: OnboardingStep = {
speedMs: 12,
},
// AC auto-clicks send per spec ("the AC should auto send this").
// Tiny pause first to let onInput's draft-state commit land — the
// send button is disabled-while-empty, so clicking before React's
// next commit sometimes lands on the stale-disabled button.
{ kind: 'delay', ms: 120 },
{ kind: 'move_to', target: S.chatSendButton },
{ kind: 'click', target: S.chatSendButton, simulate: true },
// Wait only for chat:message_sent (the prompt actually going out).
+82 -6
View File
@@ -465,9 +465,37 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
// Replace /apps/new in the URL with /apps/{output_id} so a
// reload (or back-button return) lands back on the same
// workspace instead of spinning up yet another fresh seed.
// Use replace so /apps/new doesn't pile up in history.
//
// CRITICAL: bypass React Router (`navigate()`) and use the
// raw `window.history.replaceState` instead. Views.tsx
// renders <ViewEditor key={editingOutput?.id ?? 'new'} />
// — a React-Router-driven path change from /apps/new to
// /apps/<id> would flip that key, React would UNMOUNT this
// ViewEditor and MOUNT a new one, AgentChat's chat-input DOM
// node would get a new identity, and the onboarding wizard's
// type_into would silently fire into the now-detached old
// input (no text lands, hasContent stays false, send button
// never renders, wizard burns 15 s on waitForSelector and
// throws into the recovery popup). window.history.replaceState
// changes the URL without triggering Views' re-render, so
// ViewEditor stays mounted and the chat-input the wizard
// already found is the same one it types into. On hard
// reload React Router reads the live URL fresh, so the
// back-button / reload behavior is preserved.
if (window.location.hash.includes('/apps/new')) {
navigate(`/apps/${data.output_id}`, { replace: true });
const newHash = window.location.hash.replace(
'/apps/new',
`/apps/${data.output_id}`,
);
try {
window.history.replaceState(null, '', newHash);
} catch {
// Fallback to React Router nav if the history API rejects
// (extremely unusual; mostly defensive). Accepts the
// remount cost in that edge case rather than dropping
// the URL update entirely.
navigate(`/apps/${data.output_id}`, { replace: true });
}
}
}
const action = dispatch(createDraftSession({
@@ -576,11 +604,38 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
useEffect(() => {
if (!workspaceId) return;
pollWorkspace();
const interval = isAgentActive ? POLL_INTERVAL_ACTIVE_MS : POLL_INTERVAL_IDLE_MS;
pollRef.current = setInterval(pollWorkspace, interval);
// Visibility-gate the poll loop. When the App Builder tab is
// hidden (user navigated to Dashboard / Skills / Actions / a
// different Electron window), there's no UI to update — but the
// interval would otherwise keep hitting `/api/outputs/workspace`
// every 2s, blocking the foreground backend's other endpoints.
// On `hidden` we clear the timer entirely; on `visible` we fire
// one immediate poll (to catch up on whatever the agent wrote
// while we were away) then restart the interval. Reuses the
// existing isAgentActive-driven cadence.
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 () => {
if (pollRef.current) clearInterval(pollRef.current);
document.removeEventListener('visibilitychange', onVisibilityChange);
stopPoll();
};
}, [workspaceId, pollWorkspace, isAgentActive]);
@@ -783,8 +838,29 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
// 404s — new-mode workspaces have no `index.html` at root, only
// `frontend/index.html` reachable via Vite).
const [isNewModeRuntime, setIsNewModeRuntime] = useState(false);
// Has runtime/start been fired for this workspace yet? Used by the
// tab-gated lifecycle below so we only POST start the FIRST time the
// user lands on (or switches to) Preview/Terminal. Switching between
// tabs after that is a no-op — the runtime is already up and the
// WS is already streaming. Reset when workspaceId changes so a new
// workspace gets its own one-shot.
const runtimeStartedRef = useRef(false);
useEffect(() => {
runtimeStartedRef.current = false;
}, [workspaceId]);
useEffect(() => {
if (!workspaceId) return;
// Defer the workspace runtime spawn until the user actually wants
// to see/hear from it. Code tab is pure editor — no need to pay
// the ~1-2s vite + uvicorn cold-start until they click Preview or
// Terminal. After the first entry, the runtime stays up (LRU pools
// it on unmount), so subsequent tab flips are free.
const wantsRuntime = activeTab === TAB_PREVIEW || activeTab === TAB_TERMINAL;
if (!wantsRuntime && !runtimeStartedRef.current) return;
if (runtimeStartedRef.current) return;
runtimeStartedRef.current = true;
let cancelled = false;
let ws: WebSocket | null = null;
setFrontendUrl(null); // reset when workspace changes
@@ -846,7 +922,7 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
headers,
}).catch(() => {});
};
}, [workspaceId, appendTerminalLine]);
}, [workspaceId, appendTerminalLine, activeTab]);
// Preview URL: prefer the new-mode Vite dev server when the runtime
// reports one; otherwise fall back to the legacy serve endpoint.
+84 -3
View File
@@ -1,5 +1,7 @@
import React, { useRef, useEffect, useMemo, forwardRef, useImperativeHandle, useState } from 'react';
import React, { useRef, useEffect, useMemo, useCallback, forwardRef, useImperativeHandle, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { Skeleton } from '@/app/components/Loading';
import { useElementSelection } from '@/app/components/ElementSelectionContext';
import { useIframeElementSelector } from './useIframeElementSelector';
import { getAuthToken, ensureAuthToken } from '@/shared/config';
@@ -92,6 +94,50 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
return `${serveUrl}${sep}_d=${encodeURIComponent(dataParam)}&_v=${reloadKey}&token=${encodeURIComponent(authToken)}`;
}, [serveUrl, inputData, backendResult, reloadKey, authToken]);
// Pause the iframe when the Electron window is hidden (minimized, occluded,
// user switched to a different desktop space). Vite's HMR client keeps a
// WS heartbeat open + the app's rAF loops keep running otherwise — pure
// wasted CPU since nobody can see the result. Swap to about:blank, which
// destroys the previous document and closes its HMR connection cleanly.
// Only applies to URL-mode (vite dev server). Srcdoc apps stay put — they
// don't run HMR and pausing them would silently wipe arbitrary in-memory
// user state.
const [windowHidden, setWindowHidden] = useState(
() => typeof document !== 'undefined' && document.visibilityState === 'hidden',
);
useEffect(() => {
const onVis = () => setWindowHidden(document.visibilityState === 'hidden');
document.addEventListener('visibilitychange', onVis);
return () => document.removeEventListener('visibilitychange', onVis);
}, []);
const effectiveSrc = useMemo(() => {
if (!iframeSrc) return iframeSrc;
return windowHidden ? 'about:blank' : iframeSrc;
}, [iframeSrc, windowHidden]);
// "Restoring preview…" overlay covers the gap between window-restore and
// the iframe finishing its second navigation back to the dev server. Set
// on hidden→visible transition; cleared by iframe load (or 5 s safety).
const [restoring, setRestoring] = useState(false);
const wasHiddenRef = useRef(windowHidden);
useEffect(() => {
if (wasHiddenRef.current && !windowHidden && iframeSrc) {
setRestoring(true);
const t = window.setTimeout(() => setRestoring(false), 5000);
wasHiddenRef.current = windowHidden;
return () => window.clearTimeout(t);
}
wasHiddenRef.current = windowHidden;
return undefined;
}, [windowHidden, iframeSrc]);
const handleNavigationLoad = useCallback(() => {
// load fires for both the about:blank pause-step AND the restored URL —
// only the latter should clear the overlay.
if (!windowHidden) setRestoring(false);
}, [windowHidden]);
const srcdoc = useMemo(() => {
if (serveUrl || !frontendCode) return undefined;
return buildSrcdoc(frontendCode, inputData, backendResult);
@@ -169,6 +215,19 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
};
}, [useWebview, onConsoleMessage, iframeSrc]);
// Webviews don't surface a React-style `onLoad` prop; subscribe to the
// Electron-specific `did-finish-load` event to clear the restoring
// overlay after the about:blank→iframeSrc transition completes.
useEffect(() => {
if (!useWebview) return;
const wv = webviewRef.current;
if (!wv) return;
wv.addEventListener?.('did-finish-load', handleNavigationLoad);
return () => {
try { wv.removeEventListener?.('did-finish-load', handleNavigationLoad); } catch (_e) {}
};
}, [useWebview, handleNavigationLoad]);
const hasContent = !!(serveUrl || frontendCode?.trim());
if (!hasContent) {
@@ -224,7 +283,7 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
// — preserves the prior frame's pixels through reload, same
// pattern as the iframe path.
key="url-mode-webview"
src={iframeSrc}
src={effectiveSrc}
// Autoplay is the most common cross-app expectation; matches
// the BrowserCard default. Plugins / nodeintegration stay off.
webpreferences="autoplayPolicy=no-user-gesture-required"
@@ -248,7 +307,8 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
// keeping the prior frame's pixels visible until the new doc
// paints. No flash.
key={iframeSrc ? 'url-mode' : 'srcdoc'}
src={iframeSrc}
src={effectiveSrc}
onLoad={handleNavigationLoad}
sandbox="allow-scripts allow-same-origin"
style={{
width: '100%',
@@ -260,6 +320,27 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
title="App Preview"
/>
)}
{restoring && (
<Box
sx={{
position: 'absolute',
inset: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 1.5,
bgcolor: '#fff',
zIndex: 2,
pointerEvents: 'none',
}}
>
<Skeleton variant="card" width={140} height={14} delayMs={0} />
<Typography sx={{ fontSize: '0.78rem', color: '#888', letterSpacing: '0.01em' }}>
Restoring preview
</Typography>
</Box>
)}
</Box>
);
});
+68 -7
View File
@@ -49,6 +49,23 @@ export function ensureAuthToken(): Promise<string> {
// Covers the analytics, settings, agents, dashboards, etc. fetches.
// Only applies to requests that target our own API_BASE — pass-through
// for every other URL (3rd-party APIs, asset CDNs, etc.).
//
// Layered on top of the auth-injection: a tiny in-flight dedupe + 1s
// success cache for GETs. The onboarding flow + dashboard load fire the
// same `GET /api/agents/sessions/<id>` / `GET /api/skills/list` /
// `GET /api/skills/workspace/<id>` two-to-five times in quick
// succession when components mount near-simultaneously — without
// dedupe we paid a full roundtrip every time. With this in place the
// second-through-Nth call inside a 1 s window either piggybacks on
// the in-flight promise OR reads a freshly-cached Response. Cache is
// keyed by `METHOD URL`, scoped to GET only (mutations always fall
// through), and a Response.clone() per consumer keeps each caller's
// body stream independent. Non-2xx responses are NOT cached so a
// transient 5xx can't poison the next click.
const _inflightFetches = new Map<string, Promise<Response>>();
const _cachedFetches = new Map<string, { resp: Response; expiresAt: number }>();
const _GET_CACHE_TTL_MS = 1000;
function _installAuthFetchInterceptor() {
if ((window as any).__OPENSWARM_FETCH_PATCHED__) return;
(window as any).__OPENSWARM_FETCH_PATCHED__ = true;
@@ -63,16 +80,60 @@ function _installAuthFetchInterceptor() {
// Don't override an explicit Authorization the caller already set.
const existingHeaders = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
if (existingHeaders.has('Authorization') || existingHeaders.has('authorization')) {
return originalFetch(input, init);
const callerSetAuth = existingHeaders.has('Authorization') || existingHeaders.has('authorization');
let finalInit: RequestInit | undefined = init;
if (!callerSetAuth) {
const token = _authTokenCache || (await ensureAuthToken());
if (token) {
existingHeaders.set('Authorization', `Bearer ${token}`);
finalInit = { ...(init ?? {}), headers: existingHeaders };
}
}
const token = _authTokenCache || (await ensureAuthToken());
if (!token) return originalFetch(input, init);
const method = (
finalInit?.method
?? (input instanceof Request ? input.method : 'GET')
).toUpperCase();
existingHeaders.set('Authorization', `Bearer ${token}`);
const newInit: RequestInit = { ...(init ?? {}), headers: existingHeaders };
return originalFetch(input, newInit);
// Only GET is safe to dedupe + cache. POST/PUT/PATCH/DELETE have
// side effects — collapsing two intentional calls (e.g. user
// double-clicked Send) would be wrong, so we always pass through.
if (method !== 'GET') {
return originalFetch(input, finalInit);
}
const cacheKey = `GET ${url}`;
const cached = _cachedFetches.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.resp.clone();
} else if (cached) {
_cachedFetches.delete(cacheKey);
}
const inflight = _inflightFetches.get(cacheKey);
if (inflight) {
const resp = await inflight;
return resp.clone();
}
const promise = originalFetch(input, finalInit).then((resp) => {
if (resp.ok) {
_cachedFetches.set(cacheKey, {
resp: resp.clone(),
expiresAt: Date.now() + _GET_CACHE_TTL_MS,
});
}
return resp;
});
_inflightFetches.set(cacheKey, promise);
try {
const resp = await promise;
return resp.clone();
} finally {
_inflightFetches.delete(cacheKey);
}
} catch {
return originalFetch(input, init);
}
+29 -1
View File
@@ -305,8 +305,17 @@ export interface LaunchAndSendPayload {
export const fetchSession = createAsyncThunk(
'agents/fetchSession',
async (sessionId: string) => {
async (sessionId: string, { rejectWithValue }) => {
const res = await fetch(`${AGENTS_API}/sessions/${sessionId}`);
if (!res.ok) {
// 404 is the common case: AgentChat is rehydrating from a URL hash
// that points at a session the user deleted (or that never made it
// to disk after a crash). Surface a structured rejection so the
// .rejected reducer can purge the stale id from `state.sessions`
// instead of leaving it as a phantom entry that the next mount
// will re-fetch right back into a 404.
return rejectWithValue({ sessionId, status: res.status });
}
const session = await res.json();
return session as AgentSession;
}
@@ -1277,6 +1286,25 @@ const agentsSlice = createSlice({
tool_group_meta: session.tool_group_meta ?? existing?.tool_group_meta ?? {},
};
})
.addCase(fetchSession.rejected, (state, action) => {
// Stale-id cleanup: if the backend returned 404, the session no
// longer exists — strip it from state so AgentChat can short-
// circuit to a "session not found" view instead of looping the
// same dead fetch on every remount. Also clears activeSessionId
// if it was pointing at the dead id, so the dashboard doesn't
// keep highlighting a ghost.
const payload = action.payload as { sessionId?: string; status?: number } | undefined;
const sessionId = payload?.sessionId;
if (!sessionId) return;
if (payload?.status === 404 || payload?.status === 410) {
delete state.sessions[sessionId];
if (state.activeSessionId === sessionId) {
state.activeSessionId = null;
}
state.expandedSessionIds = state.expandedSessionIds.filter((id) => id !== sessionId);
state.trackedNotificationIds = state.trackedNotificationIds.filter((id) => id !== sessionId);
}
})
.addCase(fetchBrowserAgentChildren.fulfilled, (state, action) => {
for (const session of action.payload) {
if (!state.sessions[session.id]) {
+13
View File
@@ -84,6 +84,19 @@ if [ ! -f "$UV_BIN_DIR/uvx" ]; then
rm -rf /tmp/uv-*-apple-darwin
fi
# --- Reap any backend leftover from a prior unclean exit ---
# If the user double-Ctrl+C'd a previous run, or a workspace's signal
# propagation killed the parent before cleanup() ran SIGKILL, uvicorn
# can still be bound to :8324 even though the shell prompt returned.
# That makes the next `bash run.sh` fail with Errno 48 "Address already
# in use" and leaves the user thinking the dev loop is broken. Free the
# port up front instead of asking the user to debug.
if lsof -ti :8324 >/dev/null 2>&1; then
echo -e "${YELLOW}${BOLD}[preflight]${RESET} Port 8324 still bound from a prior run — killing stale process..."
lsof -ti :8324 | xargs kill -9 2>/dev/null || true
sleep 0.3
fi
# --- Start backend ---
# Mark this as a dev launch so backend/run.sh enables --reload. Packaged
# builds never run this top-level script (Electron spawns backend
+13
View File
@@ -346,6 +346,19 @@ else
fi
echo ""
# Step 3c: Pre-build the webapp-template node_modules archive so first-app
# create on a fresh user install decompresses (~3 s) instead of running a
# live `npm install` (~22 s). The backend's _try_extract_bundled_archive
# is sha-tagged + falls through cleanly if the archive is missing or
# stale, so this step is purely an optimization — skip silently if the
# template snapshot or npm aren't available.
if [[ -f "$PROJECT_ROOT/backend/apps/outputs/webapp_template/frontend/package.json" ]] \
&& command -v npm >/dev/null 2>&1; then
echo "[3c/5] Pre-building webapp-template node_modules archive..."
bash "$PROJECT_ROOT/scripts/build-template-archive.sh"
echo ""
fi
# Step 4: Snapshot source directories for packaging
# (Router was already staged in step 3; do not touch STAGING_DIR/router/ here.)
echo "[4/5] Snapshotting source directories..."
+80
View File
@@ -0,0 +1,80 @@
#!/bin/bash
# Build the pre-compressed webapp-template node_modules archive that gets
# bundled into signed releases. Backend code (_try_extract_bundled_archive
# in view_builder_templates.py) unpacks this on first-app create instead
# of running a live `npm install`, dropping cold-start ~22 s → ~3 s.
#
# Run this once before packaging (CI / publish.sh / publish-win.ps1).
# Local dev installs that skip this step transparently fall through to
# live `npm install` — the archive is purely an optimization.
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
TEMPLATE_DIR="$PROJECT_ROOT/backend/apps/outputs/webapp_template"
OUT_DIR="$PROJECT_ROOT/backend/apps/outputs/webapp_template_cache"
if [[ ! -f "$TEMPLATE_DIR/frontend/package.json" ]]; then
echo "ERROR: $TEMPLATE_DIR/frontend/package.json not found"
echo "Run scripts/fetch-webapp-template.sh first to populate the template."
exit 1
fi
# Tag the archive with a sha of package.json so a stale archive from a
# previous template version is automatically skipped at runtime (the
# backend's _bundled_archive_path_for() computes the same digest). The
# 12-char prefix mirrors view_builder_templates._warm_cache_digest().
if command -v shasum >/dev/null 2>&1; then
PKG_DIGEST=$(shasum -a 256 "$TEMPLATE_DIR/frontend/package.json" | awk '{print substr($1,1,12)}')
elif command -v sha256sum >/dev/null 2>&1; then
PKG_DIGEST=$(sha256sum "$TEMPLATE_DIR/frontend/package.json" | awk '{print substr($1,1,12)}')
else
echo "ERROR: neither shasum nor sha256sum found on PATH"
exit 1
fi
OUT_ARCHIVE="$OUT_DIR/node_modules.${PKG_DIGEST}.tar.gz"
# Work in a temp dir so a failed install can't corrupt the template tree.
WORK_DIR=$(mktemp -d -t openswarm-template-archive-XXXXXX)
trap "rm -rf '$WORK_DIR'" EXIT
echo "Building template node_modules archive..."
echo " source : $TEMPLATE_DIR/frontend/"
echo " digest : $PKG_DIGEST"
echo " staging: $WORK_DIR"
echo " output : $OUT_ARCHIVE"
echo ""
cp "$TEMPLATE_DIR/frontend/package.json" "$WORK_DIR/package.json"
if [[ -f "$TEMPLATE_DIR/frontend/package-lock.json" ]]; then
cp "$TEMPLATE_DIR/frontend/package-lock.json" "$WORK_DIR/package-lock.json"
cd "$WORK_DIR"
echo "[npm] running npm ci..."
npm ci --prefer-offline --no-audit --no-fund --loglevel=error
else
cd "$WORK_DIR"
echo "[npm] running npm install (no lockfile)..."
npm install --prefer-offline --no-audit --no-fund --loglevel=error
fi
if [[ ! -d "$WORK_DIR/node_modules" ]]; then
echo "ERROR: npm did not produce node_modules in $WORK_DIR"
exit 1
fi
mkdir -p "$OUT_DIR"
echo ""
echo "[tar] compressing node_modules..."
# `tar -C "$WORK_DIR" node_modules` so the archive root is `node_modules/`,
# matching what _try_extract_bundled_archive expects when extracting into
# cache_dir.
tar -czf "$OUT_ARCHIVE" -C "$WORK_DIR" node_modules
ARCHIVE_SIZE=$(du -h "$OUT_ARCHIVE" | awk '{print $1}')
NM_FILES=$(find "$WORK_DIR/node_modules" -type f | wc -l | tr -d ' ')
echo ""
echo "Done."
echo " archive : $OUT_ARCHIVE"
echo " size : $ARCHIVE_SIZE"
echo " files in : $NM_FILES"