[eric] onboarding 1.0.31 + Settings draft persistence + LM Studio fix

This commit is contained in:
ciregenz
2026-05-10 17:57:01 -07:00
parent 443327d4bd
commit 54be1e8ba1
39 changed files with 1202 additions and 231 deletions
+24 -4
View File
@@ -1161,6 +1161,20 @@ class AgentManager:
_builtin_perms = load_builtin_permissions()
# Per-tool DEFAULT policy (overridden by anything the user has set
# explicitly in builtin_permissions.json). Bash defaults to "ask"
# because every other builtin is sandboxed by domain (Read/Write
# touch files but not the shell, browser tools touch a webview),
# whereas Bash is a full local shell — and the agent receives
# untrusted text from MCP tools (Gmail, WebFetch, browsing) that
# can carry prompt injection. Without this, a poisoned email
# could silently `rm -rf` the user. Users who want the old
# behavior can flip Bash back to always_allow in the UI.
_DEFAULTS = {"Bash": "ask"}
def _default_for(tool_name: str) -> str:
return _DEFAULTS.get(tool_name, "always_allow")
def _get_effective_policy(tool_name: str) -> str:
"""Return 'always_allow', 'deny', or 'ask' for any tool."""
if tool_name in _builtin_perms:
@@ -1170,11 +1184,11 @@ class AgentManager:
bm = _re.match(r"mcp__openswarm-browser-agent__(.+)", tool_name)
if bm:
return _builtin_perms.get(bm.group(1), "always_allow")
return _builtin_perms.get(bm.group(1), _default_for(bm.group(1)))
im = _re.match(r"mcp__openswarm-invoke-agent__(.+)", tool_name)
if im:
return _builtin_perms.get(im.group(1), "always_allow")
return _builtin_perms.get(im.group(1), _default_for(im.group(1)))
m = _re.match(r"mcp__([^_]+(?:-[^_]+)*)__(.+)", tool_name)
if m:
@@ -1184,7 +1198,7 @@ class AgentManager:
continue
if _sanitize_server_name(t.name) == server_slug:
return t.tool_permissions.get(mcp_tool_name, "ask")
return "always_allow"
return _default_for(tool_name)
async def _request_user_approval(tool_name: str, tool_input) -> dict:
"""Send an approval request via WebSocket and wait for the user's decision."""
@@ -1914,7 +1928,13 @@ class AgentManager:
"ENABLE_TOOL_SEARCH": "auto",
}
if cp:
env["OPENAI_API_KEY"] = (cp.api_key or "")
# Local OpenAI-compatible servers (LM Studio, Ollama, ...)
# often run with auth disabled — the user leaves api_key
# blank in Settings. The OpenAI-style SDK insists on a
# non-empty key; substitute a harmless placeholder so the
# CLI can issue requests. Servers that DO check auth always
# have a real key configured.
env["OPENAI_API_KEY"] = (cp.api_key or "").strip() or "no-auth-required"
env["OPENAI_BASE_URL"] = (cp.base_url or "")
# Pin subagent ids — without these, CLI's default Haiku 4.5
# gets sent to the custom provider and 404s.
+39 -1
View File
@@ -992,6 +992,15 @@ async def run_browser_agent(
return task.result()
text_parts = [] # initialized before loop so post-loop summary (line ~1294) has a default
# Circuit breaker for ReportProgress violations. Some models get stuck
# in a loop where they keep calling action tools without the brain-
# state preamble. Each iteration of this loop pumps websocket events
# to the frontend, which fans out to every useSelector subscriber and
# tanks UI responsiveness. After N consecutive violations the agent
# gives up and surfaces an error instead of churning through all
# MAX_TURNS doing the same broken thing.
consecutive_violations = 0
MAX_CONSECUTIVE_VIOLATIONS = 3
try:
for turn in range(MAX_TURNS):
if cancel_event.is_set():
@@ -1075,10 +1084,39 @@ async def run_browser_agent(
# The model MUST articulate its evaluation/memory/goal before acting.
report_progress_violation = has_action_tools and not has_report_progress
if report_progress_violation:
consecutive_violations += 1
logger.warning(
f"[browser-agent {session_id}] ReportProgress violation: "
f"[browser-agent {session_id}] ReportProgress violation "
f"({consecutive_violations}/{MAX_CONSECUTIVE_VIOLATIONS}): "
f"action tools called without brain state"
)
if consecutive_violations >= MAX_CONSECUTIVE_VIOLATIONS:
logger.error(
f"[browser-agent {session_id}] hit "
f"{MAX_CONSECUTIVE_VIOLATIONS} consecutive ReportProgress "
f"violations — aborting to prevent runaway loop"
)
# Surface a user-visible error message so the frontend
# shows something coherent instead of just stopping.
err_msg = Message(
role="assistant",
content=(
"I got stuck repeating the same action without "
"thinking it through. Stopping here so I don't "
"loop. Feel free to ask me to try again."
),
)
session.messages.append(err_msg)
await ws_manager.send_to_session(session_id, "agent:message", {
"session_id": session_id,
"message": err_msg.model_dump(mode="json"),
})
break
else:
# Reset on a clean turn — only CONSECUTIVE violations
# count toward the limit. A single bad turn followed by
# a good one shouldn't kill the agent.
consecutive_violations = 0
# Stable sort: ReportProgress first, then everything else in order.
tool_uses_sorted = sorted(
tool_uses,
+8
View File
@@ -717,6 +717,14 @@ async def sync_custom_providers(providers: list) -> None:
api_key = getattr(cp, "api_key", None) or (cp.get("api_key") if isinstance(cp, dict) else None) or ""
if not name.strip() or not base_url.strip():
continue
# Local OpenAI-compatible servers (LM Studio, Ollama, vLLM, llama.cpp,
# text-generation-webui, etc.) ship with auth disabled by default —
# they ignore the Authorization header entirely. But 9Router still
# creates the connection with `authType: "apikey"` and would send a
# blank Bearer if api_key is "", which some servers reject as a
# malformed header. Substitute a harmless placeholder when blank;
# servers that DO require auth always have api_key set anyway.
api_key = api_key.strip() or "no-auth-required"
slug = _custom_provider_slug(name)
prefix = f"cp-{slug}"
seen_prefixes.add(prefix)
+115 -16
View File
@@ -1,13 +1,92 @@
import ast
import asyncio
import json
import logging
import os
import sys
import tempfile
from dataclasses import dataclass
logger = logging.getLogger(__name__)
TIMEOUT_SECONDS = 30
# Modules backend code is allowed to import. Trade-off: a determined attacker
# can find ways around this (e.g. string-encoded imports via tricks the AST
# validator can't see), but the allowlist kills the easy paths cheaply and
# pairs with cwd=tempdir + minimal env so the blast radius is small even if
# a payload slips past. Keep this list to "data shaping" libraries — no I/O,
# no networking, no subprocess.
_ALLOWED_MODULES = frozenset({
"json", "math", "re", "datetime", "collections", "itertools",
"functools", "statistics", "decimal", "fractions", "random",
"string", "textwrap", "unicodedata", "csv", "copy", "enum",
"dataclasses", "typing", "abc", "numbers", "uuid", "hashlib",
"base64", "binascii", "operator", "heapq", "bisect", "array",
})
# Builtin functions that punch holes through the allowlist or do I/O. Direct
# calls (e.g. `eval(...)`) are caught here. Attribute-style calls
# (`__builtins__.eval(...)`) are blocked by the preamble's `delattr` loop in
# the subprocess.
_BLOCKED_BUILTINS = frozenset({
"exec", "eval", "compile", "__import__", "open", "input",
"breakpoint", "exit", "quit",
})
class UnsafeCodeError(Exception):
"""Raised when AST validation rejects user-supplied backend code."""
def _validate_code_safety(code: str) -> None:
try:
tree = ast.parse(code)
except SyntaxError as e:
raise UnsafeCodeError(f"Backend code has a syntax error: {e}")
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
root = alias.name.split(".")[0]
if root not in _ALLOWED_MODULES:
raise UnsafeCodeError(
f"import of '{alias.name}' is not allowed in backend code "
f"(allowed: {sorted(_ALLOWED_MODULES)})"
)
elif isinstance(node, ast.ImportFrom):
if node.module:
root = node.module.split(".")[0]
if root not in _ALLOWED_MODULES:
raise UnsafeCodeError(
f"from '{node.module}' import ... is not allowed in backend code"
)
elif isinstance(node, ast.Call):
if isinstance(node.func, ast.Name) and node.func.id in _BLOCKED_BUILTINS:
raise UnsafeCodeError(
f"call to builtin '{node.func.id}()' is not allowed in backend code"
)
def _minimal_env() -> dict:
"""Build a stripped-down env for the executor subprocess.
Drops PATH, OPENSWARM_AUTH_TOKEN, OPENAI_API_KEY, ANTHROPIC_API_KEY, and
every other inherited credential. Keeps only what Python itself needs to
boot on each platform — on Windows that's SYSTEMROOT et al, on POSIX
nothing is strictly required.
"""
env = {
"PYTHONDONTWRITEBYTECODE": "1",
"LANG": os.environ.get("LANG", "C.UTF-8"),
"LC_ALL": os.environ.get("LC_ALL", "C.UTF-8"),
}
if sys.platform == "win32":
for k in ("SYSTEMROOT", "WINDIR", "TEMP", "TMP", "USERPROFILE"):
if k in os.environ:
env[k] = os.environ[k]
return env
@dataclass
class BackendExecResult:
@@ -22,10 +101,27 @@ async def execute_backend_code(code: str, input_data: dict) -> BackendExecResult
The code receives ``input_data`` as a global dict and must assign its
result to a global ``result`` dict. User print() calls are captured
separately from the result via an in-process StringIO redirect.
Security boundaries (defense in depth — none alone is sufficient):
1. AST allowlist on imports + blocked-builtin call list.
2. Subprocess cwd = fresh temp dir (not the OpenSwarm process cwd).
3. Subprocess env strips PATH, all *_TOKEN / *_API_KEY inheritance.
4. Preamble scrubs dangerous attrs off `builtins` inside the subprocess
to catch AST-bypass tricks (e.g. metaclass shenanigans).
5. 30s wall-clock timeout, killed on overrun.
"""
_validate_code_safety(code)
preamble = (
"import json, sys, io\n"
"import json, sys, io, builtins\n"
# Defense-in-depth: even with an AST allowlist on the host, scrub
# dangerous attrs off `builtins` here so attribute-style accesses
# (e.g. via metaclass.__subclasses__ chains) can't reach them.
"for _b in ('exec','eval','compile','__import__','open','input',\n"
" 'breakpoint','exit','quit'):\n"
" try: delattr(builtins, _b)\n"
" except AttributeError: pass\n"
"_orig_stdout = sys.stdout\n"
"_capture = io.StringIO()\n"
"sys.stdout = _capture\n"
@@ -38,22 +134,25 @@ async def execute_backend_code(code: str, input_data: dict) -> BackendExecResult
)
wrapper = preamble + code + postamble
proc = await asyncio.create_subprocess_exec(
sys.executable, "-c", wrapper,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(
proc.communicate(input=json.dumps(input_data).encode()),
timeout=TIMEOUT_SECONDS,
with tempfile.TemporaryDirectory(prefix="openswarm-exec-") as workdir:
proc = await asyncio.create_subprocess_exec(
sys.executable, "-c", wrapper,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=workdir,
env=_minimal_env(),
)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
raise RuntimeError(f"Backend code execution timed out after {TIMEOUT_SECONDS}s")
try:
stdout, stderr = await asyncio.wait_for(
proc.communicate(input=json.dumps(input_data).encode()),
timeout=TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
raise RuntimeError(f"Backend code execution timed out after {TIMEOUT_SECONDS}s")
stderr_text = stderr.decode(errors="replace").strip()
+6 -1
View File
@@ -154,9 +154,14 @@ class OutputExecuteResult(BaseModel):
class AutoRunRequest(BaseModel):
# extra="ignore" so callers that historically sent `backend_code` (the
# field was removed for security — see auto_run_output endpoint) don't
# 422 on the way in. The endpoint silently drops it now; backend code
# only runs via the persisted-Output flow at /api/outputs/execute.
model_config = {"extra": "ignore"}
prompt: str
input_schema: dict[str, Any] = Field(default_factory=dict)
backend_code: Optional[str] = None
context_paths: list[dict[str, str]] = Field(default_factory=list)
forced_tools: list[str] = Field(default_factory=list)
model: str = "sonnet"
+15 -14
View File
@@ -563,20 +563,21 @@ async def auto_run_output(body: AutoRunRequest):
if validation_err:
return {"input_data": input_data, "backend_result": None, "error": validation_err}
backend_result = None
stdout_text = None
stderr_text = None
error = None
if body.backend_code:
try:
exec_result = await execute_backend_code(body.backend_code, input_data)
backend_result = exec_result.result
stdout_text = exec_result.stdout
stderr_text = exec_result.stderr
except Exception as e:
error = str(e)
return {"input_data": input_data, "backend_result": backend_result, "stdout": stdout_text, "stderr": stderr_text, "error": error}
# SECURITY: this endpoint used to accept arbitrary `backend_code` in
# the request body and pass it straight to execute_backend_code —
# which is an unsandboxed `python -c` subprocess. That gave anyone
# holding the install token (which is readable by every process
# running as the same OS user, and is also handed to every agent
# subprocess via OPENSWARM_AUTH_TOKEN) a one-shot RCE primitive.
# The field is now ignored at the model layer; backend code can
# only run via /api/outputs/execute against a persisted Output.
return {
"input_data": input_data,
"backend_result": None,
"stdout": None,
"stderr": None,
"error": None,
}
except json.JSONDecodeError:
return {"error": "Failed to parse generated data as JSON", "input_data": None, "backend_result": None}
except Exception as e:
+6 -1
View File
@@ -100,7 +100,12 @@ def get_provider_credentials(settings: AppSettings, provider: str) -> dict[str,
# Custom provider
for cp in getattr(settings, "custom_providers", []):
if cp.name.lower() == p:
return {"api_key": cp.api_key, "base_url": cp.base_url}
# Substitute a placeholder when the user left api_key blank
# — local OpenAI-compatible servers (LM Studio, Ollama, etc.)
# ignore the Bearer header but downstream callers may insist
# on non-empty values.
key = (cp.api_key or "").strip() or "no-auth-required"
return {"api_key": key, "base_url": cp.base_url}
raise ValueError(f"No credentials for provider: {provider}")
+120
View File
@@ -115,6 +115,126 @@ def get_auth_token() -> str:
return _TOKEN
class _TokenScrubFilter(logging.Filter):
"""Logging filter that redacts the install token from any log record.
The token leaks into logs in a few mundane ways: subprocess env dicts
that get logged when an MCP server fails to spawn, urllib retry logs
that include `?token=...` query strings, exception tracebacks that
print the response body of a failed proxied request. None of those
are intentional but they all happen, and the file ends up in crash
dumps / shared bug reports / hosted log aggregators. This filter is
pure defense in depth — behavior is unchanged when the token is
absent from a record.
"""
_PLACEHOLDER = "<REDACTED:openswarm-token>"
@staticmethod
def _args_might_contain_token(args) -> bool:
"""Cheap pre-check: does any positional arg or dict-arg value mention
the token? Avoids the cost of `record.getMessage()` (which eagerly
does %-formatting) on the >99% of log lines that don't touch it."""
if not args:
return False
items = args if isinstance(args, (tuple, list)) else (args,)
for a in items:
if isinstance(a, str) and _TOKEN in a:
return True
if isinstance(a, dict):
for v in a.values():
if isinstance(v, str) and _TOKEN in v:
return True
return False
def filter(self, record: logging.LogRecord) -> bool: # pragma: no cover (defensive)
if not _TOKEN:
return True
# Fast path: the overwhelming majority of log records don't mention
# the token at all. Two cheap string-in-string scans (raw msg + each
# arg) are far cheaper than forcing record.getMessage(), which would
# do eager %-formatting on every record in the process.
raw_msg = record.msg if isinstance(record.msg, str) else ""
if _TOKEN not in raw_msg and not self._args_might_contain_token(record.args):
return True
# Slow path: token might be present after substitution. Format,
# scrub, and clear args so the formatted msg is what handlers see.
try:
msg = record.getMessage()
if _TOKEN in msg:
record.msg = msg.replace(_TOKEN, self._PLACEHOLDER)
record.args = None
except Exception:
# Never let the scrubber suppress a log line — if formatting
# fails for any reason, fall through and let normal handling
# proceed (worst case the token leaks for that one record).
pass
return True
_scrubber_installed = False
def install_token_scrubber() -> None:
"""Attach the token-scrubbing filter to every log handler in the process.
Why not just `root.addFilter(...)`: filters attached to a Logger only
fire on records emitted DIRECTLY on that logger. Records propagated up
from child loggers (uvicorn.access, uvicorn.error, websockets, etc.)
flow into root's HANDLERS without ever consulting root's logger-level
filters. So a logger-level install silently misses the access log —
which is exactly the line that contains `?token=...` query params.
This implementation:
1. Walks every currently-registered logger (root + everything in
`Logger.manager.loggerDict`) and attaches the scrubber to each
of their handlers.
2. Monkey-patches `Logger.addHandler` so any handler installed
AFTER this call (uvicorn configures its loggers during startup,
after main.py finishes importing) also gets the scrubber.
3. Keeps the root-logger filter as belt-and-suspenders for the
records that ARE emitted directly on root.
Idempotent — repeated calls are a no-op.
"""
global _scrubber_installed
if _scrubber_installed:
return
scrubber = _TokenScrubFilter()
def _attach(handler: logging.Handler) -> None:
if not any(isinstance(f, _TokenScrubFilter) for f in handler.filters):
handler.addFilter(scrubber)
# 1. Existing handlers across every known logger.
loggers: list[logging.Logger] = [logging.getLogger()]
for logger in logging.root.manager.loggerDict.values():
if isinstance(logger, logging.Logger):
loggers.append(logger)
for logger in loggers:
for h in list(logger.handlers):
_attach(h)
# 2. Future handlers — patch Logger.addHandler so anything attached
# after this point (uvicorn finishing its log config, plugins
# that reconfigure logging on their own) also gets scrubbed.
_original_addHandler = logging.Logger.addHandler
def _patched_addHandler(self: logging.Logger, hdlr: logging.Handler) -> None:
_attach(hdlr)
return _original_addHandler(self, hdlr)
logging.Logger.addHandler = _patched_addHandler # type: ignore[assignment]
# 3. Belt-and-suspenders on root logger itself.
root = logging.getLogger()
if not any(isinstance(f, _TokenScrubFilter) for f in root.filters):
root.addFilter(scrubber)
_scrubber_installed = True
# Paths that never require auth. These are the public surface.
_AUTH_EXEMPT_EXACT = {
# External OAuth providers redirect the user's browser here. The
+18 -2
View File
@@ -1,3 +1,4 @@
import html
import logging
import os
from uuid import uuid4
@@ -53,11 +54,16 @@ app = main_app.app
# time any request lands, the token file exists. See backend/auth.py.
from backend.auth import (
init_auth_token,
install_token_scrubber,
is_path_exempt,
request_matches_token,
is_origin_allowed,
)
init_auth_token()
# Install the log scrubber AFTER the token exists so any log line that
# accidentally embeds it (subprocess env dumps, urllib retry traces,
# proxied-request error bodies) gets redacted before hitting handlers.
install_token_scrubber()
# CORS: previously wide open (`allow_origins=["*"]`), which combined with
@@ -335,7 +341,12 @@ async def subscriptions_callback(request: Request):
error = request.query_params.get("error", "")
if error:
desc = request.query_params.get("error_description", error)
# Escape both inputs — `error_description` and `error` are attacker-
# controllable query params and the endpoint is auth-exempt, so an
# unescaped interpolation here is a reflected XSS in the localhost
# origin (loadable inside the Electron app context, where same-origin
# JS has access to the install token).
desc = html.escape(request.query_params.get("error_description", error))
return HTMLResponse(f'<html><body style="background:#1a1a1a;color:#fff;display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif"><div style="text-align:center"><h2>Authorization failed</h2><p style="color:#888">{desc}</p></div></body></html>')
pending = _pending_oauth.pop(state, None)
@@ -354,7 +365,12 @@ async def subscriptions_callback(request: Request):
await exchange_oauth(pending["provider"], code, pending["redirect_uri"], pending["code_verifier"], state)
except Exception as e:
logger.warning(f"OAuth exchange failed for provider={pending.get('provider')}: {e}")
return HTMLResponse(f'<html><body style="background:#1a1a1a;color:#fff;display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif"><div style="text-align:center"><h2>Connection failed</h2><p style="color:#888">{e}</p></div></body></html>')
# Escape the exception message — upstream OAuth provider errors can
# echo back attacker-influenced strings (e.g. error_description from
# the original request URL), and this response is rendered in the
# localhost origin.
safe_e = html.escape(str(e))
return HTMLResponse(f'<html><body style="background:#1a1a1a;color:#fff;display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif"><div style="text-align:center"><h2>Connection failed</h2><p style="color:#888">{safe_e}</p></div></body></html>')
_mark_oauth_completed(state)
logger.info(f"OAuth exchange succeeded for provider={pending.get('provider')}")
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openswarm",
"version": "1.0.30",
"version": "1.0.31",
"description": "OpenSwarm — AI Agent Orchestrator",
"main": "main.js",
"scripts": {
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+59 -41
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import React, { useState, useEffect, useRef, useCallback, startTransition, useMemo } from 'react';
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
import { openSettingsModal } from '@/shared/state/settingsSlice';
import Box from '@mui/material/Box';
@@ -65,7 +65,22 @@ const CUSTOMIZATION_PATHS = new Set(CUSTOMIZATION_ITEMS.map((i) => i.path));
const AppShell: React.FC = () => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const navigate = useNavigate();
const navigateRaw = useNavigate();
// Wrap navigation in startTransition so React treats the route swap
// as non-urgent: the click handler returns immediately and paint
// happens before the heavy unmount-old-page / mount-new-page work
// runs. Eliminates the "click → wait → page appears" gap on slow
// routes (Actions, Apps, Skills) when the main thread is busy with
// agent streaming dispatches. Same call signature as useNavigate's
// return so existing call sites stay untouched.
const navigate = useMemo(() => {
const fn = (...args: Parameters<typeof navigateRaw>) => {
startTransition(() => {
(navigateRaw as any)(...args);
});
};
return fn as typeof navigateRaw;
}, [navigateRaw]);
const location = useLocation();
const [dashboardsExpanded, setDashboardsExpanded] = useState(true);
const [appsExpanded, setAppsExpanded] = useState(true);
@@ -828,48 +843,51 @@ const AppShell: React.FC = () => {
<Collapse in={customizationExpanded} timeout={200}>
<Box sx={{ ml: 2, mt: 0.25, mb: 0.5, borderLeft: `1px solid ${c.border.medium}` }}>
{CUSTOMIZATION_ITEMS.map((item) => (
<NavLink
key={item.path}
to={item.path}
style={{ textDecoration: 'none', color: 'inherit' }}
>
{({ isActive }) => (
<Box
data-onboarding={item.onboarding}
{CUSTOMIZATION_ITEMS.map((item) => {
// Replaced NavLink with a manual click handler so the
// wrapped (startTransition-aware) navigate runs.
// react-router's NavLink calls its own internal
// navigate which doesn't go through our wrapper,
// bypassing the transition optimization that makes
// Actions/Skills/Modes feel instant.
const isActive = location.pathname === item.path;
return (
<Box
key={item.path}
data-onboarding={item.onboarding}
onClick={() => navigate(item.path)}
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
pl: 1.25,
pr: 1,
py: 0.5,
ml: '-0.5px',
cursor: 'pointer',
borderLeft: isActive ? `1.5px solid ${c.accent.primary}` : '1.5px solid transparent',
bgcolor: isActive ? `${c.accent.primary}0C` : 'transparent',
'&:hover': { bgcolor: `${c.text.tertiary}0A` },
transition: 'background-color 0.12s, border-color 0.12s',
}}
>
<Typography
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
pl: 1.25,
pr: 1,
py: 0.5,
ml: '-0.5px',
cursor: 'pointer',
borderLeft: isActive ? `1.5px solid ${c.accent.primary}` : '1.5px solid transparent',
bgcolor: isActive ? `${c.accent.primary}0C` : 'transparent',
'&:hover': { bgcolor: `${c.text.tertiary}0A` },
transition: 'background-color 0.12s, border-color 0.12s',
color: isActive ? c.text.secondary : c.text.ghost,
fontSize: '0.78rem',
fontWeight: isActive ? 500 : 400,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
minWidth: 0,
}}
>
<Typography
sx={{
color: isActive ? c.text.secondary : c.text.ghost,
fontSize: '0.78rem',
fontWeight: isActive ? 500 : 400,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
minWidth: 0,
}}
>
{item.label}
</Typography>
</Box>
)}
</NavLink>
))}
{item.label}
</Typography>
</Box>
);
})}
</Box>
</Collapse>
</Box>
@@ -115,15 +115,27 @@ class OnboardingDirector {
window.addEventListener('hashchange', onRouteChange);
try {
try {
await this.runPreStepHook(step);
} catch (err) {
// Fire the pre-step hook in the BACKGROUND instead of awaiting it.
// For step 6, the hook posts `seed-orchestration-demo` which can
// take 15-30s when Anthropic is rate-limiting (the meta-generation
// it triggers internally hits 429s and retries). Awaiting it
// blocked the entire AC flow — user sees no cursor, no popup,
// appears completely frozen.
//
// Now: hook fires in parallel with AC's intro animation + first
// popup + user clicks. By the time AC reaches the drag_select op
// that actually needs the stub agent (several user interactions
// in), the seed has long since completed. If the seed fails
// outright, drag_select's waitForSelector will time out into the
// normal recovery path — same as any other "target not found"
// case. Never blocks the user-visible startup.
this.runPreStepHook(step).catch((err) => {
console.warn('[onboarding] preStepHook failed', step.id, err);
report('pre_step_hook_failed', {
step_id: step.id,
error: String(err),
});
}
});
await runStep({
step,
@@ -159,8 +171,17 @@ class OnboardingDirector {
* "OpenSwarm research" (the seed endpoint uses that name) and only
* call seed-orchestration-demo when nothing matches so re-running
* step 6 doesn't keep adding stub agents.
*
* In-flight dedup: rapid Show me clicks used to fire N parallel
* seed calls because Redux state didn't update until the FIRST one
* completed (and synced back via websocket). The promise cache
* collapses concurrent callers to a single backend POST.
*/
private seedInFlight: Promise<void> | null = null;
private async ensureStubResearchAgent(): Promise<void> {
if (this.seedInFlight) return this.seedInFlight;
const state = this.store!.getState();
const sessions = (state as any).agents?.sessions ?? {};
const alreadySeeded = Object.values(sessions).some(
@@ -174,16 +195,20 @@ class OnboardingDirector {
null;
if (!dashboardId) return;
try {
await fetch(
`${API_BASE}/dashboards/${dashboardId}/seed-orchestration-demo`,
{ method: 'POST' },
);
report('stub_research_agent_seeded', { step_id: 'agent_control_agents' });
} catch (err) {
// Non-fatal; user just won't see the stub. Better than blocking.
console.warn('[onboarding] seed-orchestration-demo failed', err);
}
this.seedInFlight = (async () => {
try {
await fetch(
`${API_BASE}/dashboards/${dashboardId}/seed-orchestration-demo`,
{ method: 'POST' },
);
report('stub_research_agent_seeded', { step_id: 'agent_control_agents' });
} catch (err) {
console.warn('[onboarding] seed-orchestration-demo failed', err);
} finally {
this.seedInFlight = null;
}
})();
return this.seedInFlight;
}
}
@@ -24,6 +24,7 @@ import { STEPS, findStepById } from './steps';
import { STAGE_LABELS } from './steps/types';
import { onboardingDirector } from './OnboardingDirector';
import { report } from './telemetry';
import { cursorStore } from './ac/cursorStore';
import OnboardingRoadmapModal from './OnboardingRoadmapModal';
const PANEL_WIDTH = 320;
@@ -67,6 +68,10 @@ const OnboardingPanel: React.FC = () => {
// Cursor icon inside the "Show me" button — used to calculate the AC
// spawn point so the cursor visually flies out of this exact icon.
const cursorIconRef = useRef<HTMLSpanElement | null>(null);
// Cooldown for the Show me button so rapid double-clicks don't fire
// multiple parallel step starts (each one re-triggering backend
// seed/launch calls that already have an in-flight predecessor).
const lastShowMeClickRef = useRef<number>(0);
// Resolve current step. Prefer explicit currentStepId; fall back to
// first uncompleted step.
@@ -103,7 +108,31 @@ const OnboardingPanel: React.FC = () => {
const handleShowMe = async () => {
if (!currentStep) return;
if (progress.running) return;
// Click cooldown — without this, rapid double-clicks fire startStep
// twice. Each invocation calls cancelStep() then starts fresh, but
// any in-flight async ops (seed-orchestration-demo, agent launch,
// etc) keep running because cancelStep only aborts the controller,
// not pending backend fetches. Result: multiple stub agents
// created, multiple agents launched, panel state thrashing. 600ms
// is short enough not to feel laggy, long enough to absorb the
// user's "is it broken" reflex re-click.
const now = Date.now();
if (now - lastShowMeClickRef.current < 600) return;
lastShowMeClickRef.current = now;
// If running flag is stuck at true (a prior step's runStep ended
// without resetting it — possible after an unhandled error or HMR
// cycle), forcibly cancel and reset before starting fresh. This
// unsticks the "Show me does nothing" case without forcing the
// user to reload the app.
if (progress.running) {
onboardingDirector.cancelStep();
progress.setRunning(false);
// Yield a tick so the running=false dispatch lands before we
// start the new step (otherwise the runtime's first dispatch
// races with the reset).
await new Promise<void>((r) => window.setTimeout(r, 0));
}
const iconEl = cursorIconRef.current;
const rect = iconEl?.getBoundingClientRect();
// Sanity-check the rect: if the panel is mid-transition (Framer's
@@ -117,6 +146,20 @@ const OnboardingPanel: React.FC = () => {
? { x: rect!.left + rect!.width / 2, y: rect!.top + rect!.height / 2 }
: { x: window.innerWidth - 80, y: 110 };
report('show_me_clicked', { step_id: currentStep.id });
// Watchdog: if AC fails to become visible within 2s of Show me
// (acRef.current was null after an HMR cycle, fadeIn silently
// rejected, etc), the panel stays hidden because nothing resets
// `running`. Check the cursorStore — if visible is still false,
// recover so the panel comes back instead of stranding the user.
const watchedStepId = currentStep.id;
window.setTimeout(() => {
const acVisible = cursorStore.get().visible;
if (!acVisible) {
onboardingDirector.cancelStep();
progress.setRunning(false);
report('show_me_watchdog_recovery', { step_id: watchedStepId });
}
}, 2000);
await onboardingDirector.startStep(currentStep.id, spawnPoint);
};
@@ -763,50 +806,50 @@ const InfoPopover: React.FC<InfoPopoverProps> = ({ stepId, anchorRef, onClose, t
};
const INFO_BY_STEP_ID: Record<string, string> = {
connect_model: `Open Swarm is designed to be model-agnostic so it works with any AI model.
connect_model: `Open Swarm works with any AI model.
If you already have a subscription to ChatGPT, Claude, or Gemini, you can plug those directly into Open Swarm.
If you already have a subscription to ChatGPT, Claude, or Gemini, plug it directly into Open Swarm.
We also offer an Open Swarm subscription that gives you the same usage as these model providers.
We also offer an Open Swarm subscription that gives you the same usage as those providers.
Optionally you can choose to instead use API Keys directly.`,
Or use your own API keys.`,
enable_actions: `Actions are the capabilities available to your AI agents.
Every tool call an agent makes reading a file, sending an email, searching the web is an action.
Every tool an agent uses (reading a file, sending an email, searching the web) is an action.
Every action in Open Swarm has a permission policy that decides if an agent can use it and whether it requires your permission.
Every action has a permission policy. It decides whether an agent can use it on its own or whether it needs your approval first.
The Actions page is where you configure which actions are available, how they're authenticated, and what permissions they require.`,
The Actions page is where you turn integrations on, sign in, and tune those permissions.`,
launch_agent: `An agent in Open Swarm can do anything you can do on your computer.
They can read and write files, run commands, search the web, control a browser, send emails, manage your calendar and handle long-running, multi-step tasks autonomously.
It can read and write files, run commands, search the web, control a browser, send emails, manage your calendar, and handle long, multi step tasks on its own.
Think of each agent as a teammate you can brief on a task and let loose, while you watch it work in real time.`,
use_browser: `Open Swarm has built-in browsers so you never have to jump between apps. Stay in one place, stay in the zone — just one seamless workspace for you and your agents.
use_browser: `Open Swarm has built in browsers so you never have to jump between apps. One place, one workspace, for you and your agents.
The browsers aren't just for you though your agents can use them too. By default an agent can create and use its own browsers as needed.
The browsers aren't just for you. Your agents can use them too. By default an agent can spin up and use its own browser whenever it needs one.
In the next step we'll see how you can have an agent take over a browser that you yourself were using.`,
agent_use_browser: `This video shows how you can have an agent control browsers that already exist in your canvas. In addition to this, agents can create and use their own browsers as needed.
In the next step we'll see how to hand off a browser you're using to an agent.`,
agent_use_browser: `This shows how to give an agent control of a browser already on your canvas. Agents can also spin up their own browsers whenever they need one.
Note: In this demo, we saw you select a single browser and send it to an agent. That said, you can also select multiple browsers.
In this demo you handed off a single browser, but you can also hand off multiple browsers at once.
Under the hood, each browser is controlled by its own specialized agent which communicates with the agent pointing to the browser.`,
agent_control_agents: `Similarly to browsers, while you have the ability to manually select which agents work together, an agent can choose to spawn its own employees as needed.
Under the hood, each browser is run by its own specialized agent that talks back to the agent you handed it to.`,
agent_control_agents: `Just like browsers, you can pick which agents work together. Or an agent can spin up its own helpers whenever it needs to.
When a task gets too complicated for a single agent, it has the ability to spawn its own sub-agents as it deems fit.
When a task gets too big for one agent, it spins up sub agents to share the load.
After a sub-agent has completed, it will collapse back into its parent agent. You can always re-expand a sub-agent from the parent chat by clicking "Reveal in dashboard".`,
install_skill: `An agent on its own is a capable general-purpose reasoner. It can handle a lot but it doesn't know the specifics of your workflows, your output formats, your domain expertise.
When a sub agent finishes, it collapses back into its parent. You can always reopen it from the parent chat by clicking "Reveal in dashboard".`,
install_skill: `An agent on its own is a strong general purpose reasoner. It can do a lot, but it doesn't know the specifics of your workflows, your output formats, or your domain.
Skills fill that gap.
A skill is a set of instructions that teach an agent how to approach a specific type of task. When a skill is active, the agent follows its guidance producing better, more consistent results for that domain than it would on its own.`,
make_app: `Apps are interactive, AI-generated web applications that live inside OpenSwarm.
A skill is a set of instructions that teach an agent how to handle a specific kind of task. When a skill is active, the agent follows its guidance and produces better, more consistent results in that area.`,
make_app: `Apps are interactive, AI generated web applications that live inside OpenSwarm.
Instead of paying for software or spending weeks building UIs, you describe what you want and an agent writes it for you a live, runnable app appears in seconds.
Instead of paying for software or spending weeks building a UI, you describe what you want and an agent writes it for you. A live, runnable app appears in seconds.
After making an App, you can open it in your canvas alongside agents and browsers.`,
Once it's made, you can pop the App into your canvas alongside agents and browsers.`,
};
export default OnboardingPanel;
@@ -82,6 +82,14 @@ export interface TypeIntoOptions {
onTick?: () => void;
}
function readEffectiveText(el: HTMLElement): string {
if (el.isContentEditable) return (el.textContent ?? '').trim();
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
return (el.value ?? '').trim();
}
return (el.textContent ?? '').trim();
}
export async function typeInto(
el: HTMLElement,
text: string,
@@ -102,18 +110,79 @@ export async function typeInto(
opts.onTick?.();
await new Promise((r) => window.setTimeout(r, speed));
}
return;
} else {
let acc = '';
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
acc = el.value ?? '';
}
for (const ch of text) {
acc += ch;
nativeSetValue(el, acc);
dispatchInput(el);
opts.onTick?.();
await new Promise((r) => window.setTimeout(r, speed));
}
}
let acc = '';
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
acc = el.value ?? '';
// Post-type verification. Under heavy main-thread load (many agents
// streaming concurrently), execCommand('insertText') can silently
// no-op while React's reconciler is starved — AC "types" but the
// characters never land in the controlled input. Without this check,
// step 8 (App Builder) would "complete" with an empty draft and the
// user would see no app get built.
//
// After typing, give React up to 500ms to commit, then re-read the
// effective text. If it's missing most of what we typed, fall back
// to a single-shot insert that's much more reliable under load.
const target = text.trim();
if (!target) return;
for (let i = 0; i < 5; i++) {
await new Promise<void>((r) => window.setTimeout(r, 100));
const got = readEffectiveText(el);
if (got.length >= Math.floor(target.length * 0.8)) return;
}
for (const ch of text) {
acc += ch;
nativeSetValue(el, acc);
dispatchInput(el);
opts.onTick?.();
await new Promise((r) => window.setTimeout(r, speed));
// Fallback: nuke contents and insert the full string in one shot.
// Loses the typing animation but preserves the user-visible outcome.
try {
if (el.isContentEditable) {
el.focus();
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
if (sel) {
sel.removeAllRanges();
sel.addRange(range);
}
try {
document.execCommand('delete', false);
} catch {
/* fall through */
}
try {
const ok = document.execCommand('insertText', false, text);
if (!ok) {
el.textContent = text;
el.dispatchEvent(
new InputEvent('input', {
bubbles: true,
data: text,
inputType: 'insertText',
}),
);
}
} catch {
el.textContent = text;
el.dispatchEvent(new Event('input', { bubbles: true }));
}
} else if (
el instanceof HTMLInputElement ||
el instanceof HTMLTextAreaElement
) {
nativeSetValue(el, text);
dispatchInput(el);
}
} catch {
/* best-effort — runtime's wait_user will time out and recover */
}
}
@@ -323,7 +323,52 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
if (isBroken(r, cy)) {
throw new Error(`waitForSelector: "${op.target}" rect did not settle`);
}
// Rect-stability check: when the user clicks "+" to open the
// dock chat, the chat input mounts then nudges into final
// position over a couple frames as siblings render. If we read
// the rect during that window and start the spring immediately,
// the cursor lands on a stale-target location and then the
// tracker has to drag it the remaining ~10-30px — visible as
// a "jump" right after the spring lands. Polling the rect for
// 2 stable consecutive frames (within 1.5px) guarantees we
// start the spring against the FINAL position. Capped at 200ms
// so we never block visibly. Most paths break out in 0-2 frames.
const STABILITY_MAX_MS = 200;
const STABILITY_THRESHOLD_PX = 1.5;
const stabilityStart = performance.now();
let prevCx = cx;
let prevCy = cy;
let stableFrames = 0;
while (
stableFrames < 2 &&
performance.now() - stabilityStart < STABILITY_MAX_MS
) {
await new Promise<void>((res) => requestAnimationFrame(() => res()));
r = el.getBoundingClientRect();
cx = r.left + r.width / 2 + offX;
cy = r.top + r.height / 2 + offY;
if (
Math.abs(cx - prevCx) <= STABILITY_THRESHOLD_PX &&
Math.abs(cy - prevCy) <= STABILITY_THRESHOLD_PX
) {
stableFrames += 1;
} else {
stableFrames = 0;
}
prevCx = cx;
prevCy = cy;
}
await ac.moveTo(cx, cy);
// One-frame yield before handing transform control to the
// sticky-tracker rAF. Without this, the tracker's first tick
// can fire while Framer's spring is still settling the final
// ~10px of the move, and the tracker's controls.set() cancels
// the spring mid-overshoot — visible as the cursor "teleporting"
// or disappearing into the destination. A single rAF lets the
// spring resolve before the tracker starts re-pinning every
// frame, which is when the cursor needs to start tracking
// anyway.
await new Promise<void>((r) => requestAnimationFrame(() => r()));
ac.startTracking(op.target, op.offset);
return;
}
@@ -383,6 +428,45 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
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, op.text, { 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 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 = op.text.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();
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
if (sel) {
sel.removeAllRanges();
sel.addRange(range);
}
try {
document.execCommand('delete', false);
const ok = document.execCommand('insertText', false, op.text);
if (!ok) {
el.textContent = op.text;
el.dispatchEvent(new Event('input', { bubbles: true }));
}
} catch {
el.textContent = op.text;
el.dispatchEvent(new Event('input', { bubbles: true }));
}
}
}
return;
}
case 'click': {
@@ -397,6 +481,29 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
await ac.pressClick();
clickRipple(x, y, accentColor);
if (op.simulate !== false) {
// Disabled-button guard. If the resolved element (or any
// ancestor IconButton/Button wrapper) is in a disabled state
// when we go to fire the synthetic click, the click is a
// no-op AND we silently move on — which is the "AC clicks
// send and nothing happens" bug for step 6 (the contentEditable
// chat input sometimes reverts AC's typed text under load,
// leaving the send button disabled at click time). Detect it
// and try a brief recovery: wait one frame and re-check, in
// case the button just-now-enabled because state landed late.
const isDisabled = (n: HTMLElement | null): boolean => {
while (n) {
if (n.hasAttribute('disabled')) return true;
if (n.getAttribute('aria-disabled') === 'true') return true;
n = n.parentElement;
}
return false;
};
if (isDisabled(el)) {
await new Promise<void>((res) =>
requestAnimationFrame(() => res()),
);
await sleep(120);
}
try {
el.click();
} catch {
@@ -418,7 +525,29 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
if (scrollIntoViewIfNeeded(el)) {
await sleep(180);
}
const r = el.getBoundingClientRect();
// Rect-stability poll. Without this, the dashed selection box is
// drawn at coordinates read mid-animation — e.g. when step 6
// clicks fit-to-view right before this op, the camera is still
// panning and the target's viewport rect changes frame-to-frame.
// Result: a box that's the wrong size or offset from the actual
// card. Wait for 2 stable consecutive frames (within 1.5px) up
// to 500ms before reading the final rect.
let r = el.getBoundingClientRect();
const stableStart = performance.now();
let prevLeft = r.left;
let prevTop = r.top;
let stableFrames = 0;
while (stableFrames < 2 && performance.now() - stableStart < 500) {
await new Promise<void>((res) => requestAnimationFrame(() => res()));
r = el.getBoundingClientRect();
if (Math.abs(r.left - prevLeft) <= 1.5 && Math.abs(r.top - prevTop) <= 1.5) {
stableFrames += 1;
} else {
stableFrames = 0;
}
prevLeft = r.left;
prevTop = r.top;
}
const fromX = r.left - 12;
const fromY = r.top - 12;
const toX = r.right + 12;
@@ -176,9 +176,14 @@ export function resolveSelector(target: string): HTMLElement | null {
// Wait for a selector to appear in the DOM. Resolves with the element, or
// rejects after timeoutMs. Used by acRuntime when a target is expected to
// mount asynchronously (e.g. settings modal, just-spawned card).
//
// Default bumped to 15s because under heavy main-thread load (many agents
// streaming, App Builder /apps/new mounting AgentChat with its own model
// probe + fetches), 8s was sometimes not enough and AC would abort into
// the recovery popup just before the target finally rendered.
export function waitForSelector(
target: string,
timeoutMs = 8000,
timeoutMs = 15000,
): Promise<HTMLElement> {
const existing = resolveSelector(target);
if (existing) return Promise.resolve(existing);
@@ -24,7 +24,12 @@ export const step03: OnboardingStep = {
{
kind: 'type_into',
target: S.chatInput,
text: 'What is this youtube video about: https://youtu.be/_NKj8KQMY-k?si=rEk4KO2bOpa5Vo0z',
// Anti-browser-agent directive: the YouTube summary can be
// answered entirely from the youtube transcript MCP without
// spawning a browser-agent. Browser agents misbehave under
// load (ReportProgress violation loops, rate-limit retries)
// and tank dashboard responsiveness. The transcript is plenty.
text: 'What is this youtube video about: https://youtu.be/_NKj8KQMY-k?si=rEk4KO2bOpa5Vo0z. Do not use browser agents.',
speedMs: 12,
},
// Auto-send the prompt — same pattern as steps 5/6/8. Without this,
@@ -18,11 +18,13 @@ export const step05: OnboardingStep = {
kind: 'wait_user',
condition: { kind: 'click_target', target: S.newAgentButton },
},
// Offset nudge: cursor SVG is asymmetric (tip top-left, body down-right),
// so default rect-center pinning makes the body bleed over the
// paperclip "Attach file" button right next to this icon. Pulling
// the tip ~8px left keeps the body inside this icon's footprint.
{ kind: 'move_to', target: S.elementSelectionToggle, offset: { x: -8, y: 0 } },
// Offset nudge: cursor SVG is asymmetric (tip top-left, body
// extends ~8px right and ~10px down). Default rect-center pinning
// puts the cursor BODY over the adjacent paperclip "Attach file"
// button instead of this icon. Shifting the tip up-and-left by
// (-10, -10) puts the body's visual center over this icon's
// center, where it belongs.
{ kind: 'move_to', target: S.elementSelectionToggle, offset: { x: -10, y: -10 } },
{ kind: 'popup', text: 'Tap here to plug a browser into this chat.' },
{
kind: 'wait_user',
@@ -24,8 +24,9 @@ export const step06: OnboardingStep = {
kind: 'wait_user',
condition: { kind: 'click_target', target: S.newAgentButton },
},
// See step05 — same nudge to keep cursor body off the adjacent paperclip.
{ kind: 'move_to', target: S.elementSelectionToggle, offset: { x: -8, y: 0 } },
// See step05 — same nudge so the cursor's visual body center sits
// over the select-mode icon, not the adjacent paperclip.
{ kind: 'move_to', target: S.elementSelectionToggle, offset: { x: -10, y: -10 } },
{ kind: 'popup', text: 'Tap here to hook in the older chat.' },
{
kind: 'wait_user',
@@ -41,7 +42,7 @@ export const step06: OnboardingStep = {
{ kind: 'drag_select', target: 'agent-card' },
{
kind: 'popup',
text: 'Your turn! Lasso the chat to make it a helper.',
text: 'Now you try! Drag a box around the chat to make it a helper.',
},
{
kind: 'wait_user',
@@ -22,6 +22,22 @@ export const step08: OnboardingStep = {
kind: 'wait_user',
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). Without this delay AC tries to type into an
// input that's either not yet mounted or mounted-but-not-wired
// to the App Builder agent's state machine. Characters land in
// the DOM but get discarded on first render commit.
//
// 1500ms covers the typical mount + model probe round-trip even
// under main-thread starvation from concurrent agent streams.
// waitForSelector below ALSO retries on its own, so this is a
// belt-and-suspenders preflight, not the primary wait.
{
kind: 'popup',
text: 'Loading the App Builder...',
},
{ kind: 'delay', ms: 1500 },
// The App Builder chat lives in the left pane on /apps/new — a
// regular ChatInput instance, so data-onboarding="chat-input"
// resolves to it.
@@ -1348,6 +1348,7 @@ const ToolCallBubble: React.FC<ToolCallBubbleProps> = React.memo(
const revealTargetSessionId = invokedSessionId || createAgentSessionId;
const sessions = useAppSelector((s) => s.agents.sessions);
const expandedSessionIds = useAppSelector((s) => s.agents.expandedSessionIds);
const handleRevealAgent = useCallback(
(e: React.MouseEvent) => {
@@ -1399,6 +1400,7 @@ const ToolCallBubble: React.FC<ToolCallBubbleProps> = React.memo(
y: targetY,
width: DEFAULT_CARD_W,
height: DEFAULT_CARD_H,
expandedSessionIds,
}));
dispatch(expandSession(revealTargetSessionId));
const label = isCreateAgent ? 'Create Agent' : isInvokeAgent ? 'Invoke Agent' : 'Agent';
@@ -639,6 +639,9 @@ const AgentCard: React.FC<Props> = ({
sx={{
position: 'relative',
// contain: streaming chat updates inside don't reflow the dashboard.
// Skipping `paint` here because the highlighted/selected/glow
// boxShadows legitimately extend past the card border — `paint`
// containment would clip those visuals.
contain: 'layout style',
width: localResize ? activeW : Math.max(cardWidth, MIN_W),
height: localResize ? activeH : (expanded ? Math.max(EXPANDED_OVERLAY_H, cardHeight) : 'auto'),
+34 -3
View File
@@ -717,7 +717,19 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
targetY = lowestBottom + GRID_GAP;
}
dispatch(placeCard({ sessionId: sub.id, x: targetX, y: targetY, width: DEFAULT_CARD_W, height: DEFAULT_CARD_H }));
dispatch(placeCard({
sessionId: sub.id,
x: targetX,
y: targetY,
width: DEFAULT_CARD_W,
height: DEFAULT_CARD_H,
// Pass the current expanded-session set so placeCard's
// collision check uses real visual heights (expanded cards
// render ~620px tall instead of their stored collapsed
// height). Without this, sub-agents spawn into space the
// parent card visually occupies.
expandedSessionIds,
}));
dispatch(expandSession(sub.id));
const label = sub.mode === 'sub-agent' ? 'Create Agent' : 'Invoke Agent';
dispatch(setGlowingAgentCard({ sessionId: sub.id, sourceId: sub.parent_session_id!, label }));
@@ -955,7 +967,14 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
const action = await dispatch(duplicateSession({ sessionId: card.id, dashboardId }));
if (duplicateSession.fulfilled.match(action)) {
const newId = action.payload.id;
dispatch(placeCard({ sessionId: newId, x: px, y: py, width: card.width, height: card.height }));
dispatch(placeCard({
sessionId: newId,
x: px,
y: py,
width: card.width,
height: card.height,
expandedSessionIds,
}));
if (card.expanded) {
dispatch(expandSession(newId));
}
@@ -1182,6 +1201,7 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
y: targetY,
width: DEFAULT_CARD_W,
height: DEFAULT_CARD_H,
expandedSessionIds,
}));
if (expandedSessionIds.includes(sourceSessionId)) {
@@ -1256,10 +1276,21 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
if (selectedBrowserIds.length === 1) {
const bc = store.getState().dashboardLayout.browserCards[selectedBrowserIds[0]];
if (bc) {
dispatch(setCardPosition({
// Use placeCard (collision-aware) instead of
// setCardPosition (blind setter). The "left of the
// browser" anchor is the IDEAL spot — but if it's
// already taken by an existing chat (e.g. step 3's
// YouTube agent that's still on canvas when step 5
// creates a new chat for the same browser), placeCard
// cascades to the nearest free cell instead of
// stacking on top.
dispatch(placeCard({
sessionId: realId,
x: bc.x - DEFAULT_CARD_W - GRID_GAP * 12,
y: bc.y,
width: DEFAULT_CARD_W,
height: DEFAULT_CARD_H,
expandedSessionIds,
}));
}
}
@@ -266,14 +266,30 @@ const DashboardViewCard: React.FC<Props> = ({
const res = await dispatch(autoRunOutput({
prompt: config.prompt,
input_schema: output.input_schema,
backend_code: getBackendCode(output) ?? undefined,
context_paths: config.context_paths,
forced_tools: forcedToolNames.length > 0 ? forcedToolNames : undefined,
model: config.model,
})).unwrap();
if (res.input_data) {
setInputData(res.input_data);
setBackendResult(res.backend_result);
// Auto-run no longer executes backend.py inline (the server-side
// endpoint dropped that field — it was a direct RCE primitive).
// Chain a separate executeOutput against the persisted Output so
// backend code still runs for dashboards that need backend_result.
if (getBackendCode(output)) {
try {
const execRes = await dispatch(executeOutput({
output_id: output.id,
input_data: res.input_data,
})).unwrap();
setBackendResult(execRes.backend_result);
} catch {
// Backend execution failure shouldn't break the input render.
setBackendResult(null);
}
} else {
setBackendResult(res.backend_result);
}
}
}
} catch {
+113 -79
View File
@@ -41,7 +41,7 @@ import LinearProgress from '@mui/material/LinearProgress';
import Collapse from '@mui/material/Collapse';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { updateSettings, closeSettingsModal, resetSystemPrompt, disconnectSubscription, signOut, AppSettings, CustomProvider, DEFAULT_SYSTEM_PROMPT } from '@/shared/state/settingsSlice';
import { updateSettings, closeSettingsModal, resetSystemPrompt, disconnectSubscription, signOut, setDraft, clearDraft, AppSettings, CustomProvider, DEFAULT_SYSTEM_PROMPT } from '@/shared/state/settingsSlice';
import { onboardingBus } from '@/app/components/Onboarding/eventBus';
import { resetTour } from '@/app/components/Onboarding/OnboardingProgressSlice';
import { OPENSWARM_DEFAULT_PROXY_URL } from '@/shared/config';
@@ -1352,14 +1352,27 @@ const Settings: React.FC = () => {
const installing = useAppSelector((s) => s.update.installing);
const initialTab = useAppSelector((s) => s.settings.initialTab);
const [activeTab, setActiveTab] = useState<'general' | 'models' | 'usage' | 'commands'>('general');
const [form, setForm] = useState<AppSettings>({ ...settings });
// Persisted in-flight edits — survive modal close so the user can pop
// out to the dashboard / a doc / wherever and pick up where they left
// off without being prompted to "save or discard." Cleared on actual
// save (in the slice's updateSettings.fulfilled) or via the explicit
// "Discard changes" button.
const draft = useAppSelector((s) => s.settings.draft);
const draftTab = useAppSelector((s) => s.settings.draftTab);
const TAB_VALUES = ['general', 'models', 'usage', 'commands'] as const;
type SettingsTab = typeof TAB_VALUES[number];
const isValidTab = (t: string | null | undefined): t is SettingsTab =>
!!t && (TAB_VALUES as readonly string[]).includes(t);
const [activeTab, setActiveTab] = useState<SettingsTab>(
isValidTab(draftTab) ? draftTab : 'general',
);
const [form, setForm] = useState<AppSettings>({ ...settings, ...(draft || {}) });
// When the modal opens with a requested tab (e.g., from the warning
// banner's "Configure models" link), switch to it.
useEffect(() => {
if (initialTab && ['general', 'models', 'usage', 'commands'].includes(initialTab)) {
setActiveTab(initialTab as typeof activeTab);
if (initialTab && (TAB_VALUES as readonly string[]).includes(initialTab)) {
setActiveTab(initialTab as SettingsTab);
}
}, [initialTab]);
const [showApiKey, setShowApiKey] = useState(false);
@@ -1378,14 +1391,14 @@ const Settings: React.FC = () => {
}, [open, dispatch]);
useEffect(() => {
// Reset to the General tab on open, but NOT when the caller has
// explicitly requested a tab via openSettingsModal(<tab>) — e.g. the
// "Configure models" link in the warning banner dispatches
// openSettingsModal('models') and expects to land on Models. The
// separate `initialTab` effect above handles the targeted case;
// without this guard, that effect's write gets clobbered on the same
// render because React runs effects in declaration order.
if (open && !initialTab) setActiveTab('general');
// On open, restore the user's last tab if they had unsaved edits;
// otherwise default to General. The caller's explicit initialTab
// (e.g. openSettingsModal('models') from the warning banner) wins
// over both — the separate initialTab effect above handles that.
if (open && !initialTab) {
setActiveTab(isValidTab(draftTab) ? draftTab : 'general');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, initialTab]);
// Sync form to Redux settings on modal open / first load only — NOT on
@@ -1394,14 +1407,30 @@ const Settings: React.FC = () => {
// fetchSettings poll, the window-focus refetch in SettingsLoader, the
// updateSettings response, etc.) to wipe the user's in-flight edits
// mid-typing — that's the "save button flashes and the key disappears"
// report from issue #25.
// report from issue #25. Spreads any preserved draft over settings so
// unsaved edits resurface after a close → reopen cycle.
useEffect(() => {
if (open && loaded) {
setForm({ ...settings });
setForm({ ...settings, ...(draft || {}) });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, loaded]);
// Persist in-flight edits to Redux so they survive modal close. Compares
// against `settings` rather than the previous draft so closing+reopening
// a clean form doesn't keep a phantom draft alive. Runs after every
// commit where form/activeTab changed; React batches keystrokes so the
// overhead is one dispatch per render, not per character.
useEffect(() => {
if (!open || !loaded) return;
const dirty = JSON.stringify(form) !== JSON.stringify(settings);
if (dirty) {
dispatch(setDraft({ form, tab: activeTab }));
} else if (draft !== null) {
dispatch(clearDraft());
}
}, [form, activeTab, open, loaded, settings, draft, dispatch]);
const handleCheckForUpdates = async () => {
dispatch(setChecking());
const timeout = setTimeout(() => {
@@ -1441,34 +1470,25 @@ const Settings: React.FC = () => {
setSaved(true);
};
// Closing Settings is now non-destructive — the draft persists in
// Redux so unsaved edits resurface on reopen. The old "Save or
// discard?" prompt was dropped because it interrupted the user every
// time they wanted to step out (e.g. to look up a value on the
// dashboard). Explicit discard lives on a button next to Save.
const handleRequestClose = useCallback(() => {
if (hasChanges) {
setConfirmDiscard(true);
} else {
dispatch(closeSettingsModal());
onboardingBus.emit('settings:closed');
}
}, [hasChanges, dispatch]);
dispatch(closeSettingsModal());
onboardingBus.emit('settings:closed');
}, [dispatch]);
// Explicit discard — fires from the "Discard changes" button. Wipes
// the draft so the form snaps back to saved settings; modal stays
// open so the user can verify the reset before closing.
const handleConfirmDiscard = useCallback(() => {
setConfirmDiscard(false);
setForm({ ...settings });
dispatch(closeSettingsModal());
onboardingBus.emit('settings:closed');
dispatch(clearDraft());
}, [settings, dispatch]);
const handleSaveAndClose = useCallback(async () => {
await dispatch(updateSettings(form));
if (form.theme !== settings.theme) {
setThemeMode(form.theme);
}
dispatch(fetchModels());
setSaved(true);
setConfirmDiscard(false);
dispatch(closeSettingsModal());
onboardingBus.emit('settings:closed');
}, [dispatch, form, settings, setThemeMode]);
const fieldSx = {
'& .MuiOutlinedInput-root': {
fontSize: '0.85rem',
@@ -2442,16 +2462,20 @@ const Settings: React.FC = () => {
const filledModelCount = (cp.models || []).filter(m => (m.value || '').trim()).length;
const nameMissing = !cp.name?.trim();
const urlMissing = !cp.base_url?.trim();
const keyMissing = !cp.api_key?.trim();
const modelsMissing = filledModelCount === 0;
const isReady = !nameMissing && !urlMissing && !keyMissing && !modelsMissing;
// api_key is optional — local OpenAI-compatible servers
// (LM Studio, Ollama, llama.cpp, vLLM, etc.) usually run
// without auth. Backend substitutes a placeholder when
// blank so 9Router still gets a valid connection. Only
// hosted providers (Together, Groq, OpenRouter via Custom)
// need a real key.
const isReady = !nameMissing && !urlMissing && !modelsMissing;
const dupeNameWithEarlier = list.findIndex((other, i) =>
i < idx && (other.name || '').trim().toLowerCase() === (cp.name || '').trim().toLowerCase() && (cp.name || '').trim() !== ''
) !== -1;
const missingLabels: string[] = [];
if (nameMissing) missingLabels.push('name');
if (urlMissing) missingLabels.push('base URL');
if (keyMissing) missingLabels.push('API key');
if (modelsMissing) missingLabels.push('a model');
return (
@@ -2532,9 +2556,8 @@ const Settings: React.FC = () => {
onChange={(e) => updateProvider({ api_key: e.target.value })}
size="small"
fullWidth
placeholder="API key"
label="API Key"
required
placeholder="Leave blank for local servers (LM Studio, Ollama, ...)"
label="API Key (optional)"
InputLabelProps={{ shrink: true, sx: { fontSize: '0.72rem', color: c.text.tertiary } }}
sx={{ ...fieldSx, '& .MuiOutlinedInput-root': { ...fieldSx['& .MuiOutlinedInput-root'], fontFamily: c.font.mono } }}
InputProps={{
@@ -2646,30 +2669,46 @@ const Settings: React.FC = () => {
</DialogContent>
{(activeTab === 'general' || activeTab === 'models') && (
<DialogActions sx={{ borderTop: `1px solid ${c.border.subtle}`, px: 3, py: 1.5, justifyContent: 'flex-end' }}>
<Button
onClick={handleRequestClose}
sx={{ color: c.text.muted, textTransform: 'none', fontSize: '0.85rem' }}
>
Cancel
</Button>
<Button
variant="contained"
startIcon={<SaveIcon sx={{ fontSize: 16 }} />}
onClick={handleSave}
disabled={!hasChanges}
sx={{
bgcolor: c.accent.primary,
'&:hover': { bgcolor: c.accent.pressed },
'&.Mui-disabled': { bgcolor: c.bg.secondary, color: c.text.ghost },
textTransform: 'none',
borderRadius: 1.5,
px: 2.5,
fontSize: '0.85rem',
}}
>
Save
</Button>
<DialogActions sx={{ borderTop: `1px solid ${c.border.subtle}`, px: 3, py: 1.5, justifyContent: 'space-between' }}>
{/* Left: explicit "Discard changes" only surfaces when there
are unsaved edits. Closing the modal no longer prompts; the
draft persists in Redux. This button is the only way to
actively wipe the draft. */}
<Box>
{hasChanges && (
<Button
onClick={() => setConfirmDiscard(true)}
sx={{ color: c.status.error, textTransform: 'none', fontSize: '0.85rem' }}
>
Discard changes
</Button>
)}
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
onClick={handleRequestClose}
sx={{ color: c.text.muted, textTransform: 'none', fontSize: '0.85rem' }}
>
Close
</Button>
<Button
variant="contained"
startIcon={<SaveIcon sx={{ fontSize: 16 }} />}
onClick={handleSave}
disabled={!hasChanges}
sx={{
bgcolor: c.accent.primary,
'&:hover': { bgcolor: c.accent.pressed },
'&.Mui-disabled': { bgcolor: c.bg.secondary, color: c.text.ghost },
textTransform: 'none',
borderRadius: 1.5,
px: 2.5,
fontSize: '0.85rem',
}}
>
Save
</Button>
</Box>
</DialogActions>
)}
@@ -2706,38 +2745,33 @@ const Settings: React.FC = () => {
}}
>
<DialogTitle sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem', pb: 0.5, px: 3, pt: 2.5 }}>
Unsaved changes
Discard unsaved changes?
</DialogTitle>
<DialogContent sx={{ px: 3 }}>
<Typography sx={{ color: c.text.muted, fontSize: '0.85rem' }}>
You have unsaved changes. Would you like to save them before closing?
Your in-progress edits will be cleared and the form will revert to your saved settings. This can&apos;t be undone.
</Typography>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2, gap: 1 }}>
<Button
onClick={handleConfirmDiscard}
sx={{ color: c.status.error, textTransform: 'none', fontSize: '0.85rem' }}
>
Discard
</Button>
<Button
onClick={() => setConfirmDiscard(false)}
sx={{ color: c.text.muted, textTransform: 'none', fontSize: '0.85rem' }}
>
Cancel
Keep editing
</Button>
<Button
variant="contained"
onClick={handleSaveAndClose}
onClick={handleConfirmDiscard}
sx={{
bgcolor: c.accent.primary,
'&:hover': { bgcolor: c.accent.pressed },
bgcolor: c.status.error,
color: '#fff',
'&:hover': { bgcolor: c.status.error, filter: 'brightness(0.9)' },
textTransform: 'none',
borderRadius: 1.5,
fontSize: '0.85rem',
}}
>
Save & Close
Discard
</Button>
</DialogActions>
</Dialog>
+8
View File
@@ -3,6 +3,14 @@ import { createRoot } from 'react-dom/client';
import Main from './app/Main';
import ErrorBoundary from './app/components/ErrorBoundary';
import { ensureAuthToken } from './shared/config';
import { runStartupMigrations } from './shared/migrations';
// Run launch-time migrations BEFORE anything else touches localStorage
// or React state. The v1.0.31 migration force-clears auth + onboarding
// state so every user signs in fresh and walks the new tour. Must run
// before ensureAuthToken() reads from localStorage, otherwise the
// stale token survives.
runStartupMigrations();
// Resolve the per-install auth token from Electron BEFORE first render
// so the very first fetch/WS carries the Authorization header. The
+64
View File
@@ -0,0 +1,64 @@
// One-shot launch-time migrations. Runs synchronously before React
// mounts so any state-reset takes effect before the first selector
// reads it.
//
// Each migration is gated by a localStorage flag so it only runs once
// per install. Adding a new migration:
// 1. Append a new entry to MIGRATIONS below with a unique `key`.
// 2. The `run` function should be idempotent in case the flag check
// races with a parallel reload.
interface Migration {
/** Stable localStorage key. Never reused. */
key: string;
/** Human-readable description for telemetry / logs. */
description: string;
run: () => void;
}
const MIGRATIONS: Migration[] = [
{
key: 'openswarm.migrations.v131_force_relogin_and_reonboard',
description:
'1.0.31 — force every user to sign in again and walk the new ' +
'onboarding flow, regardless of prior state',
run: () => {
try {
// Clear the persisted auth token. SignInGate will see no token
// and show the sign-in screen on next render. Electron's main
// process still has a copy, but the renderer will refetch via
// IPC after the user re-authenticates.
window.localStorage.removeItem('openswarm.auth.token');
// Clear onboarding-v2 state so the tour starts fresh from
// step 1 even for users who completed it on a prior version.
// The slice's loadFromStorage() will return null on next
// mount and init() will fire with a clean slate.
window.localStorage.removeItem('openswarm.onboarding.v2');
// Also clear the legacy v1 onboarding flag so v1.0.29-era
// users who never opened v2 get the new flow too.
window.localStorage.removeItem('openswarm_onboarding_seen');
} catch {
// localStorage can throw in private mode / quota-exceeded —
// non-fatal, user will just keep prior state.
}
},
},
];
/**
* Run any migrations that haven't fired on this install yet. Idempotent;
* safe to call on every launch. Errors in individual migrations don't
* block subsequent ones.
*/
export function runStartupMigrations(): void {
if (typeof window === 'undefined') return;
for (const m of MIGRATIONS) {
try {
if (window.localStorage.getItem(m.key) === 'done') continue;
m.run();
window.localStorage.setItem(m.key, 'done');
} catch {
// Don't block other migrations on one failing.
}
}
}
@@ -231,6 +231,74 @@ export function findOpenGridCell(
}
}
// Like findOpenGridCell but biased to stay near a proposed (x,y) anchor.
// Used when the backend hands us a card with a position that's already
// occupied (sub-agent or sub-browser spawning on top of its parent or a
// sibling). Spirals outward from the anchor on a grid, snapping to
// cell-aligned positions so the result still looks intentional, not
// dropped from orbit. Caps the spiral search at ~1000 cells to avoid
// pathological work in adversarial layouts — falls back to
// findOpenGridCell after that.
//
// Cost: O(rects × cells_scanned). Spawn events are rare (not per-frame),
// so this only runs when a new card appears. Typical scan resolves in
// <10 cells, well below the cap. No perf impact on steady-state UI.
export function findOpenSpotNear(
anchorX: number,
anchorY: number,
occupiedRects: Rect[],
newW: number,
newH: number,
): { x: number; y: number } {
const cellW = DEFAULT_CARD_W + GRID_GAP;
const cellH = DEFAULT_CARD_H + GRID_GAP;
// Snap the anchor to the nearest grid cell so all cards align cleanly.
const baseCol = Math.round((anchorX - GRID_ORIGIN.x) / cellW);
const baseRow = Math.round((anchorY - GRID_ORIGIN.y) / cellH);
const cellFree = (col: number, row: number): boolean => {
const x = GRID_ORIGIN.x + col * cellW;
const y = GRID_ORIGIN.y + row * cellH;
const candidate: Rect = { x, y, w: newW, h: newH };
return !occupiedRects.some((r) => rectsOverlap(candidate, r));
};
// Try the anchor itself first.
if (cellFree(baseCol, baseRow)) {
return {
x: GRID_ORIGIN.x + baseCol * cellW,
y: GRID_ORIGIN.y + baseRow * cellH,
};
}
// Spiral search: expand rings around the anchor. Each ring r covers
// the perimeter of a (2r+1)×(2r+1) square. First free cell wins,
// preferring right/down (read order) within each ring for stability.
const MAX_RING = 32;
for (let r = 1; r <= MAX_RING; r++) {
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
// Only perimeter of this ring (interior was scanned in r-1).
if (Math.abs(dx) !== r && Math.abs(dy) !== r) continue;
const col = baseCol + dx;
const row = baseRow + dy;
// Don't place above the grid origin.
if (col < 0 || row < 0) continue;
if (cellFree(col, row)) {
return {
x: GRID_ORIGIN.x + col * cellW,
y: GRID_ORIGIN.y + row * cellH,
};
}
}
}
}
// Pathological — full canvas occupied near anchor. Fall back to the
// global first-empty scan so we never return an overlap.
return findOpenGridCell(occupiedRects, newW, newH);
}
const dashboardLayoutSlice = createSlice({
name: 'dashboardLayout',
initialState,
@@ -261,10 +329,34 @@ const dashboardLayoutSlice = createSlice({
placeCard(
state,
action: PayloadAction<{ sessionId: string; x: number; y: number; width: number; height: number }>
action: PayloadAction<{
sessionId: string;
x: number;
y: number;
width: number;
height: number;
// Optional: which existing sessions are currently expanded
// (showing their full chat history). Without this, the collision
// check uses each card's STORED height — which is the collapsed
// value — even when the card is currently rendering at the
// expanded ~620px. Result: new sub-agent cards spawn into the
// collapsed footprint but overlap the visually expanded one.
// Caller (Dashboard.tsx) passes the current expanded set so
// the collision math matches what the user actually sees.
expandedSessionIds?: string[];
}>
) {
const { sessionId, x, y, width, height } = action.payload;
state.cards[sessionId] = { session_id: sessionId, x, y, width, height, zOrder: state.nextZOrder++ };
const { sessionId, x, y, width, height, expandedSessionIds } = action.payload;
const rects = collectOccupiedRects(state, expandedSessionIds);
const pos = findOpenSpotNear(x, y, rects, width, height);
state.cards[sessionId] = {
session_id: sessionId,
x: pos.x,
y: pos.y,
width,
height,
zOrder: state.nextZOrder++,
};
},
bringToFront(
@@ -453,10 +545,23 @@ const dashboardLayoutSlice = createSlice({
addBrowserCardFromBackend(state, action: PayloadAction<BrowserCardPosition>) {
const card = action.payload;
if (state.browserCards[card.browser_id]) return;
const w = card.width || DEFAULT_BROWSER_CARD_W;
const h = card.height || DEFAULT_BROWSER_CARD_H;
// Collision-resolve the backend-proposed position. Backend agents
// often spawn sub-browsers at the parent's coordinates or at a
// default (0,0) — without this guard, the new card lands on top
// of an existing one and the user sees a single card with
// multiple titles fighting for the z-index. Bias toward the
// proposed position so the spawn still LOOKS related to wherever
// the agent intended.
const rects = collectOccupiedRects(state);
const pos = findOpenSpotNear(card.x, card.y, rects, w, h);
state.browserCards[card.browser_id] = {
...card,
width: card.width || DEFAULT_BROWSER_CARD_W,
height: card.height || DEFAULT_BROWSER_CARD_H,
x: pos.x,
y: pos.y,
width: w,
height: h,
zOrder: card.zOrder || state.nextZOrder++,
};
},
+4 -1
View File
@@ -145,7 +145,10 @@ export interface AutoRunResult {
export const autoRunOutput = createAsyncThunk(
'outputs/autoRun',
async (body: { prompt: string; input_schema: Record<string, any>; backend_code?: string | null; context_paths?: Array<{ path: string; type: string }>; forced_tools?: string[]; model?: string }) => {
// backend_code intentionally NOT in the request shape. The server endpoint
// ignores it now (it was an unsandboxed-RCE primitive); callers that want
// backend execution should chain executeOutput against a persisted Output.
async (body: { prompt: string; input_schema: Record<string, any>; context_paths?: Array<{ path: string; type: string }>; forced_tools?: string[]; model?: string }) => {
const res = await fetch(`${OUTPUTS_API}/auto-run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
+34 -1
View File
@@ -100,6 +100,16 @@ interface SettingsState {
modalOpen: boolean;
/** When non-null, Settings opens to this tab instead of 'general'. */
initialTab: string | null;
/**
* In-flight form edits, preserved across modal close/reopen so the user
* can step away from Settings (browse the dashboard, open a doc, etc.)
* and come back to find their typing intact. `null` means the form is in
* sync with `data` no unsaved edits. Cleared automatically on a
* successful save, or explicitly via clearDraft.
*/
draft: AppSettings | null;
/** Tab the user was on when they closed the modal with unsaved edits. */
draftTab: string | null;
}
const initialState: SettingsState = {
@@ -124,6 +134,8 @@ const initialState: SettingsState = {
loaded: false,
modalOpen: false,
initialTab: null,
draft: null,
draftTab: null,
};
export const fetchSettings = createAsyncThunk('settings/fetch', async () => {
@@ -242,6 +254,21 @@ const settingsSlice = createSlice({
state.modalOpen = false;
state.initialTab = null;
},
/**
* Persist the user's in-flight form edits + active tab so they survive
* modal close. Settings.tsx calls this on every form mutation (React's
* batching keeps it cheap). When the form matches saved data, callers
* pass null/clearDraft to drop the marker `hasChanges` then reads
* false correctly.
*/
setDraft(state, action: PayloadAction<{ form: AppSettings; tab: string }>) {
state.draft = action.payload.form;
state.draftTab = action.payload.tab;
},
clearDraft(state) {
state.draft = null;
state.draftTab = null;
},
},
extraReducers: (builder) => {
builder
@@ -270,12 +297,18 @@ const settingsSlice = createSlice({
})
.addCase(updateSettings.fulfilled, (state, action) => {
state.data = action.payload;
// Save consumes the draft — clear it so the next modal-open
// doesn't restore stale edits over freshly-saved values.
state.draft = null;
state.draftTab = null;
})
.addCase(resetSystemPrompt.fulfilled, (state, action) => {
state.data = action.payload;
state.draft = null;
state.draftTab = null;
});
},
});
export const { openSettingsModal, closeSettingsModal } = settingsSlice.actions;
export const { openSettingsModal, closeSettingsModal, setDraft, clearDraft } = settingsSlice.actions;
export default settingsSlice.reducer;
+48 -1
View File
@@ -1,4 +1,5 @@
import { store } from '../state/store';
import { unstable_batchedUpdates } from 'react-dom';
import {
updateSession,
updateSessionName,
@@ -166,6 +167,43 @@ class WebSocketManager {
// speed (~200 cps comfort threshold), still hides bursty upstream
// cadence, just feels less frantic. Tuned for legibility at speed.
private static TARGET_CHARS_PER_PAINT = 10;
// Frame-aligned message coalescer. Buffers incoming WS messages from
// all WebSocketManager instances and flushes them in ONE batched
// React render per animation frame. Without this, N concurrent agents
// each cause their own renders on every WS message — dozens of full
// app re-renders per second, fanning out to every useSelector. With
// it: max one render per frame regardless of message volume.
private static _messageQueue: Array<{ mgr: WebSocketManager; msg: WSEvent }> = [];
private static _flushScheduled = false;
private static _enqueueMessage(mgr: WebSocketManager, msg: WSEvent) {
WebSocketManager._messageQueue.push({ mgr, msg });
if (WebSocketManager._flushScheduled) return;
WebSocketManager._flushScheduled = true;
requestAnimationFrame(WebSocketManager._flushMessages);
}
private static _flushMessages = () => {
WebSocketManager._flushScheduled = false;
if (WebSocketManager._messageQueue.length === 0) return;
const batch = WebSocketManager._messageQueue;
WebSocketManager._messageQueue = [];
// unstable_batchedUpdates collapses all dispatches inside the
// callback into a single React render. Available in React 17;
// React 18's automatic batching covers this too, but explicit
// wrap remains correct in both and protects against future
// batching-context changes.
unstable_batchedUpdates(() => {
for (const { mgr, msg } of batch) {
try {
mgr.handleMessage(msg);
} catch (e) {
console.warn('[ws] message handler threw', e);
}
}
});
};
// When a backlog accumulates, allow up to this many chars/paint to
// drain it. ~1.6× the target keeps catch-up imperceptible — the
// eye can't tell 10 from 16 in a fluid stream. Caps the worst-case
@@ -359,7 +397,16 @@ class WebSocketManager {
this.ws.onmessage = (event) => {
try {
const msg: WSEvent = JSON.parse(event.data);
this.handleMessage(msg);
// Buffer incoming messages and flush them per animation frame
// in a single React batch. With N concurrent agents/browsers
// streaming, each WS instance used to trigger its own React
// render — dozens per frame, fanning out to every useSelector
// subscriber, starving the main thread. Coalescing flips that
// to ONE batched render per frame regardless of how many
// messages arrived. Stream-chunk dispatches are already paced
// by the interpolator, so this is purely additive throttling
// for non-stream events (status, tool_call, completion, etc).
WebSocketManager._enqueueMessage(this, msg);
} catch {
// ignore malformed messages
}